| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
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)))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:
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:
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:
// 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:
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.
| Back | FazBrowse Home | New Git URL |