Connects a SwiftRex app to the
Redux DevTools standalone Electron app
(or any compatible remotedev-server) for action monitoring, state inspection,
and time-travel debugging.
iOS/macOS app ──WebSocket──▶ remotedev-server ◀── Redux DevTools panel
(port 8000) (Electron / browser)
Every dispatched action is forwarded in real time. The devtools panel shows the full action log, a state diff before and after each action, and lets you jump to any point in history.
Phone ──WebSocket──▶ remotedev-server ◀──── Redux DevTools panel
(relay, port 8000) (Electron / browser)
The phone is the WebSocket client. It connects to a running remotedev-server relay
on the Mac. Both the phone and the Redux DevTools panel connect to the same relay;
messages pass through it in both directions.
The devtools panel never initiates the connection — the phone always connects outward.
Configure connectionMode in .live() and dispatch DevToolsAction.activate once at
app launch:
// AppEnvironment
#if DEBUG
var devTools: DevToolsEnvironment = .live(connectionMode: .browseOnLaunch)
#endif
// In @main / App body, after the store is created:
store.dispatch(.devTools(.activate))| Mode | Notes |
|---|---|
.manual |
Dispatch .connect or .startBrowsing explicitly from a debug settings screen |
.autoConnect |
Recommended. Browses Bonjour, connects to the first server found automatically |
.connectOnLaunch(host:port:) |
Fixed IP; good for CI or stable lab networks |
.browseOnLaunch |
Browses Bonjour; DevToolsState.discoveredServices fills; user picks |
devTools: .live(connectionMode: .autoConnect)
// At app launch:
store.dispatch(.devTools(.activate))
// → starts browsing "_reduxdevtools._tcp."
// → connects to the first remotedev-server found
// → stops browsing once connectedNo further action needed. As long as remotedev-server is running on the same network,
the connection happens automatically on every app launch.
devTools: .live(connectionMode: .connectOnLaunch(host: "192.168.1.100", port: 8000))
store.dispatch(.devTools(.activate)) // immediately connectsdevTools: .live(connectionMode: .browseOnLaunch)
store.dispatch(.devTools(.activate)) // starts browsing
// Present DevToolsState.discoveredServices in your debug UI.
// When user selects one:
store.dispatch(.devTools(.connectToService(selectedService)))
// → resolves host:port automatically and connectsdevTools: .live() // connectionMode defaults to .manual
// From a shake gesture, debug settings screen, etc.:
store.dispatch(.devTools(.connect(host: "192.168.1.100", port: 8000)))
// or
store.dispatch(.devTools(.startBrowsing))
store.dispatch(.devTools(.connectToService(selectedService)))- iOS 16 / macOS 13 / tvOS 16 / watchOS 9
- Swift 6.2
- SwiftRex (main branch)
- A running
remotedev-serveron your Mac (or any machine on the same network):Then open the Redux DevTools standalone app.npx @redux-devtools/cli --hostname=0.0.0.0 --port=8000
// Package.swift
.package(url: "https://github.com/SwiftRex/ReduxDevToolsBehavior.git", branch: "master"),
// Target dependency
.product(name: "ReduxDevToolsBehavior", package: "ReduxDevToolsBehavior"),import ReduxDevToolsBehavior
// AppAction — add a devTools case
enum AppAction: Sendable {
case counter(CounterAction)
case settings(SettingsAction)
#if DEBUG
case devTools(DevToolsAction)
#endif
}
// AppState — add a devTools sub-state
struct AppState: Sendable {
var counter: CounterState
var settings: SettingsState
#if DEBUG
var devTools: DevToolsState = .initial
#endif
}
// AppEnvironment — add the devTools environment
struct AppEnvironment: Sendable {
var counter: CounterEnvironment
var settings: SettingsEnvironment
#if DEBUG
var devTools: DevToolsEnvironment = .live()
#endif
}import ReduxDevToolsBehavior
let appBehavior = Behavior.combine(
counterBehavior.lift(
action: AppAction.prism.counter,
state: \AppState.counter,
environment: \AppEnvironment.counter
),
settingsBehavior.lift(
action: AppAction.prism.settings,
state: \AppState.settings,
environment: \AppEnvironment.settings
),
#if DEBUG
DevToolsBehavior.behaviors(
action: AppAction.prism.devTools,
state: \AppState.devTools,
environment: \AppEnvironment.devTools
),
#endif
)Dispatch activate once at app startup. The behavior reads connectionMode from the
environment and starts the appropriate connection flow automatically.
// In @main / App body, after the store is created:
#if DEBUG
store.dispatch(.devTools(.activate))
#endifWith .live() (default .manual mode) this is a no-op — you can still trigger the
connection manually from a debug settings screen:
// Manual connect to known IP
store.dispatch(.devTools(.connect(host: "192.168.1.100", port: 8000)))
// Browse and pick from list
store.dispatch(.devTools(.startBrowsing))
// Then when the user picks a discovered service:
store.dispatch(.devTools(.connectToService(selectedService)))
// Or configure the mode in the environment so activate handles it:
// devTools: .live(connectionMode: .connectOnLaunch(host: "192.168.1.100", port: 8000))
// devTools: .live(connectionMode: .browseOnLaunch)DevToolsBehavior.behaviors(action:state:environment:) composes two behaviors internally:
Behavior<DevToolsAction, DevToolsState, DevToolsEnvironment>
Manages the WebSocket connection lifecycle. When .connect is dispatched it starts a
single long-running effect that:
- Opens the WebSocket.
- Performs the Engine.io v4 + Socket.io v4 handshake.
- Emits
._connectedonce confirmed. - Drives the receive loop — sends PONG for server PINGs, routes
DISPATCHevents from the devtools panel back into the store (JUMP_TO_ACTION,TOGGLE_ACTION,RESET, etc.). - Emits
._connectionLostwhen the server closes the connection.
The effect is scheduled with .replacing(id: "devtools-connection") — re-connecting
automatically cancels any previous session.
Behavior<AppAction, AppState, DevToolsEnvironment>
Observes every dispatched AppAction. For each one it:
- Encodes the action and post-mutation state to JSON using
env.encodeAction/env.encodeState(see Serialization). - Stores the state JSON in a bounded ring buffer inside
DevToolsConnectionManager(max 200 entries by default — see Memory model). - Sends
INITon the first action after a connection is established, thenACTIONfor every subsequent cycle.
When the devtools panel requests time travel (JUMP_TO_ACTION, TOGGLE_ACTION,
IMPORT_STATE), the time machine behavior retrieves the stored JSON at the target index,
decodes it via env.decodeState, and dispatches restoreStateAction(decoded).
Configuration lives entirely in DevToolsEnvironment, not in the behavior call site.
// Automatically uses JSONEncoder for encoding and JSONDecoder for time travel
var devTools: DevToolsEnvironment = .live(for: AppState.self)| Direction | What happens |
|---|---|
| Send to devtools | JSONEncoder (via encodeState closure) |
| Receive / time travel | JSONDecoder (via decodeState closure) |
// MirrorJSON uses Swift Mirror to encode any value to JSON without Encodable
var devTools: DevToolsEnvironment = .live()MirrorJSON automatically uses JSONEncoder for any value that happens to conform to
Encodable, and falls back to Mirror reflection for everything else. Time travel is
disabled (no decodeState).
MirrorJSON.encode(AppAction.increment) // → "\"increment\""
MirrorJSON.encode(AppAction.setText("hi")) // → "{\"setText\":\"hi\"}"
MirrorJSON.encode(AppAction.load(id: 1, force: false)) // → "{\"load\":{\"id\":1,\"force\":false}}"Pass closures to DevToolsEnvironment.live(encodeAction:encodeState:decodeState:):
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
var devTools: DevToolsEnvironment = .live(
for: AppState.self, // auto-wires decodeState
instanceId: "com.myapp",
encodeAction: { action in
(try? encoder.encode(action as! Encodable))
.flatMap { String(data: $0, encoding: .utf8) }
?? MirrorJSON.encode(action)
}
)Or provide full control:
var devTools: DevToolsEnvironment = .live(
instanceId: "com.myapp",
encodeAction: { action in /* ... */ },
encodeState: { state in /* ... */ },
decodeState: { json in /* ... */ }
)Time travel requires:
env.decodeState— decodes state JSON back toAppState(auto-wired by.live(for:))restoreStateAction— anAppActionthat replaces the live stateextractDevToolsAction— lets the time machine intercept devtools commands
// AppAction — add a restore case
enum AppAction: Sendable {
// ...
#if DEBUG
case devTools(DevToolsAction)
case restoreState(AppState) // dispatched during time travel
#endif
}
// Handle it in your Behavior or Reducer:
case .restoreState(let snapshot):
state = snapshot// Environment — auto-wires JSONDecoder
#if DEBUG
var devTools: DevToolsEnvironment = .live(for: AppState.self)
#endif
// Behavior — add extractDevToolsAction + restoreStateAction
#if DEBUG
DevToolsBehavior.behaviors(
action: AppAction.prism.devTools,
state: \AppState.devTools,
environment: \AppEnvironment.devTools,
extractDevToolsAction: { if case .devTools(let dt) = $0 { return dt }; return nil },
restoreStateAction: { .restoreState($0) }
)
#endifProvide decodeState manually in the environment and restoreStateAction in the behavior:
var devTools: DevToolsEnvironment = .live(
instanceId: "my-app",
decodeState: { json in myCustomDecode(json) } // returns AppState?
)
DevToolsBehavior.behaviors(
action: AppAction.prism.devTools,
state: \AppState.devTools,
environment: \AppEnvironment.devTools,
extractDevToolsAction: { if case .devTools(let dt) = $0 { return dt }; return nil },
restoreStateAction: { .restoreState($0) }
)The devtools panel can skip / re-enable individual actions. Since SwiftRex doesn't expose the reducer to external code, toggling approximates full re-computation:
- Skip action N → restore the nearest non-skipped state before N.
- Un-skip action N → restore state N directly.
This is accurate for most debugging sessions. The only deviation from exact re-computation is that actions after a toggled one are not re-run — they remain as originally computed.
When the devtools panel imports a full liftedState blob, the ring buffer and skip set
are replaced. If decodeState is available, the state at currentStateIndex is restored.
State history is stored as JSON strings, not as live AppState objects, in a bounded
ring buffer inside DevToolsConnectionManager. The canonical full history lives in the
devtools panel on the Mac (abundant RAM); the iOS device keeps only a recent window.
| Setting | Default | Configure via |
|---|---|---|
| Max ring buffer size | 200 entries | .live(maxHistorySize: N) |
When the buffer is full the oldest entry is evicted. JUMP_TO_ACTION for evicted indices
is silently ignored (the devtools panel still shows them, but restoration is unavailable).
Typical memory usage: 200 entries × ~5 KB average state JSON ≈ 1 MB.
// Start browsing
store.dispatch(.devTools(.startBrowsing))
// DevToolsState.discoveredServices fills with DiscoveredService values.
// Present them in a picker; on selection:
store.dispatch(.devTools(.stopBrowsing))
let resolved = try await env.resolveService(selectedService).run().get()
store.dispatch(.devTools(.connect(
host: resolved.preferredHost ?? "",
port: resolved.port ?? 8000
)))The default Bonjour service type is "_reduxdevtools._tcp.". Override it:
var devTools: DevToolsEnvironment = .live(bonjourServiceType: "_myapp._tcp.")| Parameter | Type | Default | Notes |
|---|---|---|---|
instanceId |
String |
bundle identifier | Key in the devtools instance list |
instanceName |
String? |
nil → uses instanceId |
Label in the devtools panel |
maxHistorySize |
Int |
200 |
Ring buffer cap on the iOS device |
bonjourServiceType |
String |
"_reduxdevtools._tcp." |
Bonjour service type for browsing |
encodeAction |
(Any) -> String |
MirrorJSON.encode |
Serializes AppAction to JSON |
encodeState |
(Any?) -> String |
MirrorJSON |
Serializes AppState? to JSON |
decodeState |
((String) -> Any?)? |
nil |
Decodes JSON → AppState; enables time travel |
urlSession |
URLSession |
.shared |
Session for WebSocket connections |
live(for: AppState.self) overload — when AppState: Codable, sets encodeState to
JSONEncoder and decodeState to JSONDecoder automatically. Accepts all parameters above
except encodeState and decodeState (they are pre-wired).
| Property | Type | Default |
|---|---|---|
connectionStatus |
.disconnected / .connecting / .connected(host:port:) |
.disconnected |
discoveredServices |
[DiscoveredService] |
[] |
isBrowsing |
Bool |
false |
stateHistory |
[String] — JSON, one per step (from IMPORT_STATE) |
[] |
actionHistory |
[String] — JSON, one per step |
[] |
skippedActionIds |
Set<Int> — actions toggled off in devtools |
[] |
currentActionIndex |
Int? — active time-travel position; nil = live |
nil |
isPaused |
Bool — recording paused from devtools panel |
false |
isLocked |
Bool — state changes locked from devtools panel |
false |
| Case | Dispatched by | Purpose |
|---|---|---|
.connect(host:port:) |
App / UI | Open connection to remotedev-server |
.startBrowsing |
App / UI | Browse Bonjour for servers |
.stopBrowsing |
App / UI | Stop Bonjour browsing |
.disconnect |
App / UI | Close connection |
._connected(host:port:) |
Effect | Handshake completed |
._connectionFailed(Error) |
Effect | Connection attempt failed |
._connectionLost(Error?) |
Effect | Established connection closed |
._serviceFound / _serviceRemoved |
Effect | Bonjour discovery events |
._received(RemoteDevCommand) |
Effect | Raw command from devtools |
.jumpToAction(Int) |
Surfaced command | Time-travel to action at index |
.jumpToState(Int) |
Surfaced command | Time-travel to state at index |
.toggleAction(Int) |
Surfaced command | Skip / re-enable action |
.reset |
Surfaced command | Clear history, restore initial state |
.commit |
Surfaced command | Set current state as new baseline |
.rollback |
Surfaced command | Pop to previous checkpoint |
.importState(ImportedLiftedState) |
Surfaced command | Replace full history from devtools |
.pause / .resume |
Surfaced command | Pause / resume recording |
.lockChanges / .unlockChanges |
Surfaced command | Lock / unlock state changes |
Cases prefixed with _ are internal — do not dispatch them from application code.
ReduxDevToolsBehavior speaks Socket.io v4 over WebSocket, compatible with
remotedev-server out of the box.
Outbound (app → devtools):
42["log",{"type":"INIT","payload":"<stateJSON>","instanceId":"...","name":"..."}]
42["log",{"type":"ACTION","action":"<actionJSON>","payload":"<stateJSON>","instanceId":"..."}]
Inbound (devtools → app):
42["dispatch",{"type":"JUMP_TO_ACTION","actionId":5}]
42["dispatch",{"type":"TOGGLE_ACTION","id":3}]
42["dispatch",{"type":"RESET"}]
42["dispatch",{"type":"IMPORT_STATE","nextLiftedState":{...}}]
Engine.io ping/pong (2 / 3) is handled transparently.
- Phase 1 — action monitoring, connection lifecycle, Bonjour discovery
- Phase 1 —
ConnectionModeenum:.manual,.connectOnLaunch,.browseOnLaunch - Phase 1 —
DevToolsAction.activatereads mode from environment;connectToServiceconvenience - Phase 2 — time travel (
JUMP_TO_ACTION,JUMP_TO_STATE) via JSON ring buffer - Phase 2 —
TOGGLE_ACTIONwith nearest-snapshot approximation - Phase 2 —
IMPORT_STATEfull history import - Phase 2 —
PAUSE_RECORDING/LOCK_CHANGES - Phase 2 — automatic
JSONEncoder/JSONDecoderforAppState: Codable - Phase 2 — dispatch actions from devtools Dispatcher tab (
AppAction: Decodable) - Phase 2 — bounded ring buffer (memory-efficient on iOS)
- Linux support via NIO WebSocket backend