fix(rnode): Model-B RNode-over-BLE connect + hardening pass#101
fix(rnode): Model-B RNode-over-BLE connect + hardening pass#101torlando-tech wants to merge 3 commits into
Conversation
Adding an RNode interface never connected until the app was relaunched: the live-add path persisted the entity but never started the app-side CoreBluetooth radio (the radio lives in the app on Model B; RNS runs in the NE). saveInterface() never called startRNodeInterface(), and applyInterfaceChanges() early-returns on Model B, so only cold-launch brought RNode up. Core fix: - InterfaceManagementViewModel.applyRNodeLiveChange() drives AppServices.startRNodeInterface()/stopRNodeInterface() on save/toggle/delete for .rnode on Model B (starts the app-side AppGroupRNodeServer AND posts the RNodeSeamConfig the NE rebuilds its RNodeInterface from). Device-verified: connects on first add and exchanges LXMF over RNode. Hardening (from a verified audit of the RNode-over-BLE path): - BLOCKER: add COLUMBA_RNODE_ENABLED to the Release-Swift config — the RNode UI was compiled out of Release/Archive builds entirely. - Wizard surfaces Bluetooth off/denied/unsupported with an actionable banner + Open Settings, instead of spinning "Scanning…" forever. - 25s connect watchdog fails a stuck "Connecting…" with a real message. - Thread the failure reason through the RNode seam wire (.stateChanged += reason) so BT/permission/timeout errors surface instead of a bare "Unreachable"; "RNode saved — connecting…" instead of a premature "added", and async connect failures now toast. - NE setupRNodeInterface(): generation token + config-diff guard so re-entrant notifications can't build two seam wires or needlessly bounce the live radio; retry the cross-process config read before bailing "no RNode configured". - Seam send-timeout watchdog so a suspended/jettisoned radio can't silently wedge all outbound TX. - Single-RNode-radio guard (no silent overwrite / cross-kill on disable/delete). - Lock-back the seam transport state/callbacks (reconnect-churn data race). - Reject an empty device name (no silent scan-only "connecting" trap). - SharedFrameQueue.append reports write failures + pins data-protection class. - Drop the dead/ignored NSBluetoothWhenInUseUsageDescription plist key. Landed but GATED off (flip after on-device verification): - AppServices.rnodeBadgeFromNE — NE-authoritative badge + kills the ~10s premature green. - ModelBRNodeService.rnodeBackgroundRestoreEnabled — re-create the RNode central at launch for CoreBluetooth state restoration / background reception. Connect-by-CoreBluetooth-identifier (robust to duplicate/renamed RNodes) lands separately once the reticulum-swift BLETransport change merges and the pin bumps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryThis PR fixes the core bug where adding an RNode interface on Model B never connected without a relaunch, and lands a comprehensive hardening pass over the entire RNode-over-BLE path — live bring-up wiring, generation-guarded NE setup, send-timeout watchdog, BT unavailability UX, seam race fixes, and a new test suite.
Confidence Score: 4/5Safe to merge with one transport lifecycle question worth clarifying before enabling automatic reconnects. The new Sources/Shared/AppGroupRNodeSeamTransport.swift — specifically the Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant UI as InterfaceManagementVM
participant AS as AppServices
participant MRS as ModelBRNodeService
participant AGS as AppGroupRNodeServer
participant BLE as BLETransport
participant Wire as AppGroupRNodeSeamWire
participant NE as NEReticulumNode
participant AGT as AppGroupRNodeSeamTransport
UI->>AS: applyRNodeLiveChange(enabled:true)
AS->>AS: startRNodeInterface(config,name)
AS->>AS: "rnodeInterface.state = .connecting"
AS->>AS: start 25s watchdog
AS->>MRS: start(onLinkStateChange:)
MRS->>AGS: AppGroupRNodeServer.start()
AGS->>Wire: wire.start()
AS->>Wire: RNodeSeamConfig.saveToAppGroup()
Wire-->>NE: rnodeConfigChanged Darwin notify
NE->>NE: setupRNodeInterface() [gen guard + config diff]
NE->>AGT: AppGroupRNodeSeamTransport.connect()
AGT->>Wire: wire.send(.connect(deviceName:))
Wire-->>AGS: inbound .connect
AGS->>BLE: connectRadio(deviceName:)
BLE-->>AGS: onStateChange(.connected)
AGS->>Wire: wire.send(.stateChanged(.connected, nil))
Wire-->>AGT: inbound .stateChanged
AGT->>NE: onStateChange(.connected)
AGS->>AS: onLinkStateChange(.connected, nil)
AS->>AS: applyRNodeLinkState: cancel watchdog
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant UI as InterfaceManagementVM
participant AS as AppServices
participant MRS as ModelBRNodeService
participant AGS as AppGroupRNodeServer
participant BLE as BLETransport
participant Wire as AppGroupRNodeSeamWire
participant NE as NEReticulumNode
participant AGT as AppGroupRNodeSeamTransport
UI->>AS: applyRNodeLiveChange(enabled:true)
AS->>AS: startRNodeInterface(config,name)
AS->>AS: "rnodeInterface.state = .connecting"
AS->>AS: start 25s watchdog
AS->>MRS: start(onLinkStateChange:)
MRS->>AGS: AppGroupRNodeServer.start()
AGS->>Wire: wire.start()
AS->>Wire: RNodeSeamConfig.saveToAppGroup()
Wire-->>NE: rnodeConfigChanged Darwin notify
NE->>NE: setupRNodeInterface() [gen guard + config diff]
NE->>AGT: AppGroupRNodeSeamTransport.connect()
AGT->>Wire: wire.send(.connect(deviceName:))
Wire-->>AGS: inbound .connect
AGS->>BLE: connectRadio(deviceName:)
BLE-->>AGS: onStateChange(.connected)
AGS->>Wire: wire.send(.stateChanged(.connected, nil))
Wire-->>AGT: inbound .stateChanged
AGT->>NE: onStateChange(.connected)
AGS->>AS: onLinkStateChange(.connected, nil)
AS->>AS: applyRNodeLinkState: cancel watchdog
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
- watchdog: explicit guard !Task.isCancelled (don't fire after cancellation) - refreshNEBackedStatus: skip the neRNodeStatus() IPC entirely while the rnodeBadgeFromNE gate is off (was an unconditional round-trip) - AppGroupRNodeSeamTransport: take pending send under a sync helper so the async watchdog never touches NSLock directly (Swift 6 async-context safety) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…watchdog) Adds RNodeSeamTests covering the seam logic that runs real production code via an in-memory RNodeSeamWire loopback (no CoreBluetooth, no app-group container): - RNodeSeamMessage wire round-trip, incl. the new .stateChanged(state:reason:). - SharedFrameQueue append→read round-trip + the new Bool failure return (and the write-failure branch the wire layer now depends on). - AppGroupRNodeServer rejects an empty device name (.failed/reason, no radio built). - AppGroupRNodeSeamTransport send-timeout watchdog: fires → .disconnected, and is a no-op when the real .sendResult arrives first. Small testability seams (no behavior change): - RNodeSeamWire protocol gains start()/stop() (the app-group impl already has them), so the server/transport hold the protocol and tests inject an in-memory wire. - AppGroupRNodeSeamTransport takes an injectable sendTimeoutNanos (default 8s). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Adding an RNode interface never connected until the app was relaunched — the live-add path persisted the entity but never started the app-side CoreBluetooth radio (radio in app, RNS in NE on Model B). This wires up live bring-up and lands a full hardening pass over the RNode-over-BLE path.
Device-verified on a physical iPhone 14: RNode connects on first add (no relaunch) and exchanges LXMF messages over RNode.
Core fix
InterfaceManagementViewModel.applyRNodeLiveChange()drivesstartRNodeInterface()/stopRNodeInterface()on save/toggle/delete for.rnodeon Model B — starts the app-sideAppGroupRNodeServerAND writes theRNodeSeamConfigthe NE rebuilds itsRNodeInterfacefrom.Hardening (from a verified audit)
COLUMBA_RNODE_ENABLEDadded toRelease-Swift— the RNode UI was compiled out of Release/Archive builds..stateChanged+= reason); "RNode saved — connecting…" + async failure toast.setupRNodeInterface()generation + config-diff guard (no double seam-wire / needless radio bounce) + cross-process config read retry.SharedFrameQueue.appendreports failures + pins protection class; dropped the deadNSBluetoothWhenInUseUsageDescriptionkey.Gated off (flip after on-device verification)
AppServices.rnodeBadgeFromNE— NE-authoritative badge / kills ~10s premature-green.ModelBRNodeService.rnodeBackgroundRestoreEnabled— CoreBluetooth state restoration / background reception.Follow-up
Connect-by-CoreBluetooth-identifier (robust to duplicate/renamed RNodes) lands separately once the reticulum-swift
BLETransportchange merges and the pin bumps.🤖 Generated with Claude Code