Model-B background Network Extension (RNS/LXMF node in the NE) + reticulum-swift main pin#90
Conversation
…rack A3) Model B's Network Extension must load the SAME RNS identity as the app to sign delivery proofs / decrypt inbound LXMF while the host is suspended. Add a shared keychain group: - Both target entitlements declare keychain-access-groups = [$(AppIdentifierPrefix)network.columba.Columba.shared]. - Identity.save/load/deleteFromKeychain take an optional accessGroup (kSecAttrAccessGroup); existing callers default to nil (behavior unchanged). - AppServices resolves the group at runtime (team-id prefix probed from the keychain, never hardcoded — no deployment PII), threads it through loadOrCreateIdentity, and migrates a legacy default-group identity into the shared group on first launch. Accessibility stays AfterFirstUnlockThisDeviceOnly (NE-readable while locked; no backup/sync). - The unencrypted identity.key fallback is neutralized: removed once the keychain holds the identity, and otherwise written with CompleteUntilFirstUserAuthentication protection. Builds Columba-Swift green. Cross-process keychain sharing is entitlement-enforced only on a signed build — device-verified with the NE wiring (A5/Track C). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…anches The background-LXMF / Model-B work depends on in-development changes in the two Swift libraries (resource segmentation + completion fixes in reticulum-swift; cross-process GRDB store config + durable dedup + .complete-gate in LXMF-swift). Repin both from exactVersion to the feature branches (reticulum-swift perf/resource-disk-streaming, LXMF-swift feat/lxmfdb-appgroup-sharing) so this branch builds against them. Reverts to versions once those land and are released. Columba-Swift builds green against both (API-compatible). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… A0) The UI read the Compat raw-SQLite RNSAPI.LXMFDatabase while the Swift/NE backend writes the GRDB LXMFSwift.LXMFDatabase (configDir/lxmf-swift.db) — so Swift/NE-delivered messages never appeared. Repoint MessageRepository to the GRDB store, keeping every public method returning RNSAPI types via pure static adapters, so the UI/ViewModels are unchanged. Architecture: `import LXMFSwift` is confined to MessageRepository.swift (the adapter boundary). AppServices/ColumbaApp/MapView stay RNSAPI-typed and pass only a String path — they do NOT import LXMFSwift (it transitively exposes ReticulumSwift, whose names collide with RNSAPI's Compat layer; a prior attempt that imported it into AppServices was reverted). AppServices routes Python-path inbound-persist + delivery-state through MessageRepository's RNSAPI-typed methods; the Swift backend's LXMRouter already persists to GRDB itself. Adapters map ConversationRecord/MessageRecord/IconAppearance/LXMessage + state/method enums (Date<-Double, enum<-Int, String<-String?); LXMessage bridges via the LxmfFieldCodec field-map. Tests: MessageRepositoryAdapterTests — TEST SUCCEEDED on iPhone 17 sim; builds green. KNOWN FOLLOW-UPS (tracked — do not lose): 1. IncomingMessageHandler (block_unknown_senders favorite-check, senderIsFavorite) and NotificationService (sender displayName) still read the now-empty Compat `db` — must repoint to the GRDB MessageRepository. block_unknown_senders is off-by-default (low impact) but notification sender-name + favorite gating degrade until fixed. (Next commit.) 2. NE/Swift-backend rows store the full LXMF wire in packed_lxmf while app-written rows store the field-map; LxmfFieldCodec.unpack returns nil on wire bytes, so attachments/icons on NE-delivered rows won't render (text/state/timestamp/direction map fine). Needs a wire-unpack path in the adapter, or the NE storing the field-map. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ue (Track A1)
Model B: the NE is the single RNS node; the app forwards radio (BLE/RNode) frames into the
NE's RNS instance and relays NE-originated frames back out. Add the bridge primitives:
- SharedFrameQueue is now bidirectional: e2a ("frame_queue", NE->app, #57-compatible default)
+ a2e ("frame_queue_a2e", app->NE radio ingest), each with its own backing file + lock.
FrameInterfaceTag gains .bleMesh/.rnode; radioFrameReadyNotificationName (app->NE) added.
- AppGroupBridgeInterface (actor) conforms to ReticulumSwift.NetworkInterface: send() HDLC-frames
+ enqueues to e2a + posts the Darwin notification; setDelegate() stores a weak delegate;
deliverInbound() fires didReceivePacket for app->NE frames. mode=.full (announces propagate
both ways); hwMtu = the radio's negotiated MTU.
Collision-safe: the bridge file imports only ReticulumSwift + Foundation (NOT RNSAPI, whose
Compat layer re-declares NetworkInterface). The NE target doesn't link ReticulumSwift yet, so
the file is in the ColumbaApp target only; A5/C2 add it to the NE once the NE runs the Swift
backend. Registration into the NE transport + the app-side radio relay loop are A5.
Columba-Swift builds green. Runtime (NE using the bridge) is exercised in A5 on-device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(Track C3b)
The destination-hash list was a dead Compat.ReticulumTransport stub returning [] — so the
NE's would-be sniff-only filter + local-destinations publisher never matched real registered
destinations. Promote it to the backend-neutral RnsCore protocol so both backends answer
truthfully:
- RnsCore gains `func registeredDestinationHashes() async -> [String]` (lowercase-hex).
- SwiftRNSBackend returns the lxmf.delivery + lxst.telephony Destination hashes it registered
in start() ([deliveryDestination, telephonyDestination].compactMap { $0?.hexHash }).
- PythonRNSBackend returns the cached delivery hash (localInfo.destinationHash); telephony
isn't surfaced by the Python bridge's LocalInfo — documented, true subset (delivery is the
LXMF-inbound hash the NE filter needs).
- AppServices diagnostic dump rewired from the dead Compat stub to backend.core.
- Deleted Compat.ReticulumTransport.registeredDestinationHashes() (grep-confirmed zero refs;
sibling registeredLinkCallbackHashes() stays — still used).
This is the C3(b) seam the NE full-delivery path (C3c, A5-gated) consumes. Columba-Swift green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion (Track C7)
Lands first per plan: NE diagnostics over WiFi can't use the unified-log relay, so route
them through an App-Group file the host copies out for devicectl retrieval. Unblocks all
on-device NE verification.
- Sources/Shared/ExtensionDiagLog.swift (NEW, Foundation-only -> both targets): append-only
App-Group ext-diag.log, ISO8601 lines, NSLock-serialized, FileProtectionType
.completeUntilFirstUserAuthentication (NE writes while locked-after-first-unlock). NO-PII
contract in the header: envelope/metadata only.
- PacketTunnelProvider: all ~24 NSLog("[EXT]...") migrated to ExtensionDiagLog.log (0 NSLog
remain). Redacted the C5-flagged PII: relay host:port at the old :139/:492 -> "TCP relay
config" (host/port dropped); defense-in-depth on NWConnection.State / multicast-state whose
description can embed host:port -> per-case labels. NWError strings (no endpoint) + groupId
(non-secret) retained. No payload bytes logged.
- DiagLog.copyExtensionDiagToDocuments() (AppServices) copies the App-Group log into Documents,
called on launch in ColumbaApp RootView.initializeServices().
- pbxproj: ExtensionDiagLog.swift in BOTH the NE (ESRCBP) + app (SRCBP) Sources phases, absent
from tests.
Validated via BOTH schemes: Columba-Swift (app) AND ColumbaNetworkExtension (NE) build green.
Note: the app scheme does NOT compile the NE target -- NE-side work must be built via the
ColumbaNetworkExtension scheme.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… C4) NE TCP-relay reliability hardening (PacketTunnelProvider, all on configQueue): - Replace the fixed 5s reconnect with capped exponential backoff: 1->2->4->8->16->32->60s (cap 60), attempt counter reset to base on .ready and on a fresh applyConfigs. A tcpReconnectScheduled guard prevents a storm of .waiting/.failed callbacks stacking overlapping reconnects; a stale-connection guard (=== tcpConnection) stops a late callback from a replaced socket tearing down the live one. Both .failed and .waiting now drive the backoff (previously .waiting only logged). tcpReceiveBuffer is reset on every teardown path so a half-frame from a dead socket can't corrupt the next connection's HDLC framing. - Add NWPathMonitor (started in startTunnel on configQueue, cancelled in stopTunnel): on a satisfied path whose primary interface type actually changed (wifi<->cellular) with an active relay, proactively tear down + re-apply instead of waiting for the dead socket to time out. Guarded against redundant re-applies (baseline sample + real-change only). - NO-PII: all logs via ExtensionDiagLog, coarse interface labels only (wifi/cellular/...), no SSID/address/iface-name, no host:port, no payload. NWPath qualified as Network.NWPath (NetworkExtension also exports a legacy NWPath -> ambiguous). Validated via the ColumbaNetworkExtension scheme. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…parked NE wiring (C2a) Enable the background Network-Extension wiring in the Swift build: - add-swift-backend-config.rb refactored from clone_config (early-returns if the -Swift config exists, so it could never add a NEW condition) to ensure_swift_config: find-or-create the config, then idempotently ensure each Swift compilation condition (token-exact match) on BOTH first create and re-run. APP_CONDITIONS = [COLUMBA_BACKEND_SWIFT, ENABLE_NETWORK_EXTENSION]. Re-running added ENABLE_NETWORK_EXTENSION to the existing Debug-Swift/Release-Swift app configs. - This compiles the ~9 parked #if ENABLE_NETWORK_EXTENSION blocks (TunnelManager, ExtensionFrameReader, AppServices tunnel-mode wiring, 2 Settings views). The flip surfaced exactly one parked-code gap: applyTunnelModeToInterfaces calls beginTunnelMode/endTunnelMode on the Compat AutoInterface, which (unlike Compat TCPInterface) lacked them. Added the same no-op stubs to Compat.AutoInterface (the Compat facade has no underlying ReticulumSwift interface; the real tunnel-mode hook is Swift-backend-side, wired NE-side in A5/C3). Validated: Columba-Swift (gated app code compiles) AND ColumbaNetworkExtension schemes green; pbxproj Xcodeproj round-trip preserved all manual A1/C7 entries (AGBF/EDLF/EDL1B/EDL2B), diff is 7+/7- (conditions only, no reformat). C2(c) on-demand-connect + C2(e) NE lib-linking remain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TunnelManager.install() now sets isOnDemandEnabled = true + onDemandRules = [NEOnDemandRuleConnect()] before saveToPreferences. The NE can't wake itself after iOS terminates it (jetsam under memory pressure, reboot, user toggle), so a connect-always rule (no interface match -> WiFi + cellular) keeps the tunnel up whenever a network path exists, resuming background delivery without the app being foregrounded. This is the deliver-while- locked always-on posture; pairs with C4's reconnect backoff once the socket is up. Gated behind ENABLE_NETWORK_EXTENSION (compiled now that C2a flipped it). Columba-Swift green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…store (A0 follow-up) Post-A0 the canonical conversation store is the GRDB LXMFSwift.LXMFDatabase accessed via MessageRepository: the UI writes favorites/display-names through messageRepository.setFavorite/ ensureConversation (-> GRDB) and the Swift/NE backend persists inbound there too. Two reads in IncomingMessageHandler still queried the OLD Compat raw-SQLite3 `database`, so they'd miss Swift/NE-delivered (and UI-set) contacts -- block-unknown-senders would wrongly drop, and favorite/name lookups would be blank. - block_unknown_senders check: db.getConversation -> messageRepository.fetchConversation (ConversationRecord.isFavorite is Int; != 0 semantics preserved; the optional `let db` guard is gone since messageRepository is non-optional). - notification path: resolve the sender ONCE via messageRepository.fetchConversation, derive senderIsFavorite + the display name, and pass senderName to postMessageNotification. senderName takes precedence over NotificationService's own (stale Compat) displayName lookup (NotificationService.swift:182), so the sole caller (IncomingMessageHandler) now bypasses it. Verified write/read consistency: favorites are written via messageRepository.setFavorite -> GRDB (ContactsViewModel/ChatsViewModel), so reading from the same store is correct, not a store mismatch. Columba-Swift green (pre-existing actor-isolation warnings only). Remaining A0 follow-up: NE-row packed_lxmf attachment unpack (task #9 part 2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…C2e) Model B's NE runs the native RNS+LXMF stack itself (Track A5), so the ColumbaNetworkExtension target must link it. Added support/add-ne-backend-deps.rb (idempotent Xcodeproj script): attaches ReticulumSwift + LXMFSwift as package product dependencies + Frameworks-phase entries on the NE target only, reusing the SAME XCRemoteSwiftPackageReference the app target already pins (no second resolution). The NE's EFWBP Frameworks phase (previously empty) now carries both. Feasibility validated: the ColumbaNetworkExtension scheme builds + LINKS the full Swift RNS+LXMF stack (incl. transitive GRDB) clean -- the packaging fits. Runtime memory footprint under load remains the GATE Phase 1b device measurement. A5 adds the code that instantiates the backend + registers AppGroupBridge on the NE transport. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Model B keystone, part a: the NE can now BE the RNS node. NEReticulumNode (actor) builds the node directly on ReticulumSwift + LXMFSwift (C2e-linked) — mirroring SwiftRNSBackend.start without RNSAPI/RNSBackendSwift (collision-safe; LXMFSwift re-exports ReticulumSwift types rather than redeclaring, unlike RNSAPI's Compat layer): - loadSharedIdentity(): reads the raw 64-byte key from the shared keychain group directly via SecItemCopyMatching (service com.columba.identity / account reticulum-identity / runtime team-prefix group, matching AppServices A3) -> ReticulumSwift.Identity. nil when unsigned. - node: PathTable -> ReticulumTransport -> LXMRouter(identity:databasePath:) -> register the lxmf.delivery Destination -> enable ratchets -> register AppGroupBridgeInterface via addInterface. - NEDeliveryDelegate (LXMRouterDelegate): on inbound delivery (LXMF already persisted) posts a UNUserNotification (8-hex sender prefix + <=80-char preview, gated on existing authorization) + the newMessage Darwin notification so the app refreshes. No plaintext/identity/host in logs. - Gated: starts ONLY under `NEReticulumNode.modelBNodeEnabled` (false) — wired inert in startTunnel so it links without run-conflicting with the live PoC dumb-pipe. C3 flips it + adds the live TCP/relay interface (TODO(C3) marked) replacing the dumb-pipe. pbxproj: NEReticulumNode.swift + AppGroupBridgeInterface.swift added to the NE Sources phase (ESRCBP). Validated via the ColumbaNetworkExtension scheme. A5b (app ProxyRnsBackend IPC) + A5c (outbox) next. NOTE: the NE roots the store under the App-Group container, but A2 hasn't moved the APP off process-local Application Support yet -> they don't converge until that lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…th helper (A2 follow-up) Model B needs the NE and app to share ONE store. A5a rooted the NE's lxmf-swift.db under the App-Group container, but the app's grdbDatabaseFilePath still pointed at process-local Application Support (unreachable by the NE) -> they never converged. Fix the mismatch at its root with a single source of truth: - Sources/Shared/AppGroupPaths.swift (NEW, Foundation-only, both targets): canonical <App-Group container>/Columba/python-<identityHashHex>/lxmf-swift.db (+ ratchets), nil when the container is unavailable (no process-local fallback inside the helper — that's the drift it prevents). - AppServices.grdbDatabaseFilePath -> AppGroupPaths (legacy process-local kept only as the container-unavailable fallback). migrateLXMFDatabaseToAppGroupIfNeeded(): one-time, SharedDefaults flag lxmf_db_migrated_to_appgroup, copies lxmf-swift.db + -wal + -shm (correct snapshot of an unopened WAL DB) before any MessageRepository attaches; called at all 3 repo-open sites; leaves old files as fallback; retries if the container isn't ready yet. Both backends use MessageRepository post-A0, so both relocate. No `import LXMFSwift` added. - NEReticulumNode now delegates its path accessors to AppGroupPaths -> app == NE provably. pbxproj: AppGroupPaths.swift in BOTH SRCBP (app) + ESRCBP (NE). Both schemes build green. Known nuance (single-identity assumption, matches the file): the migration flag is global, so only the first identity's store migrates if multiple ever coexist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Model B send path: in Model B the NE owns the lxmf.delivery destination + node (A5a), so the app becomes a thin client that marshals node-owning ops to the NE over sendProviderMessage. Architecture skeleton (inert: BackendPreference.modelB defaults false): - Sources/Shared/ProxyIPC.swift (NEW, Foundation-only, both targets): magic-byte (0xF5, outside the FrameInterfaceTag space) + version + JSON envelope. ProxyRequest (start/stop/announce/ announceTelephony/statusSnapshot/persist/registeredDestinationHashes/lxmfSend) + ProxyResponse (.ok/.error/.unsupported) + Foundation-only DTOs. isProxyRequest does a cheap first-byte check so non-proxy data falls through to the PoC path. - Sources/RNSBackendProxy/ProxyRnsBackend.swift (NEW, ColumbaApp; imports Foundation+os+RNSAPI ONLY — no ReticulumSwift/LXMFSwift): full RnsBackend conformance. Marshals start/stop/announce/ announceTelephony/statusSnapshot/persist/registeredDestinationHashes/lxmfSend via an injected `send: (Data) async -> Data?`; builds the LXMF field map app-side via LxmfFieldCodec. Link/ interface/reaction/propagation-node/telemetry ops throw BackendError.unsupportedInProxy; sync/ nomadnet/telemetry-config return sensible no-ops. events inert (NE pushes inbound via App-Group + Darwin per A5a); localInfo cached from start. - NE: handleAppMessage gains a leading isProxyRequest branch -> handleProxyRequest -> dispatch to NEReticulumNode (nil node -> .unsupported, the live case while gated) -> encoded ProxyResponse; PoC frame-forwarding untouched below. NEReticulumNode gains Foundation-only *ForIPC dispatch methods (msgpack field-map unpack via reticulum-swift's unpackMsgPack; still no RNSAPI). - BackendPreference.modelB (App-Group key, default false); BackendFactory.make(proxySend:) returns ProxyRnsBackend when modelB (always-the-node invariant: proxy OR destination-owning backend, never both); TunnelManager.proxySend wraps sendProviderMessage in a continuation (not yet wired into make() -> A5c). pbxproj: ProxyIPC in both Sources phases; ProxyRnsBackend in the app phase only (imports RNSAPI, not linked in NE). Both schemes (Columba-Swift + ColumbaNetworkExtension) build green. A5c wires proxySend live + the durable outbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the A5 send path: an app-composed LXMF message must survive the NE being stopped and deliver on the next NE start, not silently drop. - Sources/Shared/OutboxQueue.swift (NEW, Foundation-only, both targets): structural mirror of SharedFrameQueue (flock'd App-Group file `outbox`, [4B length][JSON] framing, append + read-all- and-clear, truncated/undecodable-record tolerant). OutboxEntry mirrors ProxyRequest.lxmfSend (destHashHex/content/method/fieldsData) + optional messageHashHex + createdAt. - ProxyRnsBackend.sendLxmfMessage: when the NE does NOT accept the send (IPC nil/garbled -> ipcFailed, or .error/.unsupported -> node not running), enqueue an OutboxEntry + return .queued (optimistic UI row). .ok unchanged. messageHashHex is nil by design: the canonical LXMF hash is assigned at pack time NE-side, so the RNSAPI-only proxy can't compute it; reconciliation rides the existing NE-delivery -> shared GRDB -> newMessage Darwin refresh. - NEReticulumNode.start(): after the node is fully up, drainOutbox() replays each entry through sendLxmfForIPC. Log+skip on failure (no re-append -> no unbounded requeue loop; a pack/sign failure would just recur). Receiver-side LXMF dedup makes replay safe. - Inert when off: ProxyRnsBackend is constructed only under modelB (false); drain runs only in start() (gated by modelBNodeEnabled=false). With Model B off the outbox file is never touched. pbxproj: OutboxQueue.swift in both Sources phases. Both schemes build green. The A5 keystone is now code-complete (node + store + IPC proxy + outbox); C3 flips it live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…3c/d) Make the in-NE node (A5a-c) the live delivery path when Model B is enabled — additive, PoC dumb-pipe preserved as the default-off fallback: - Unified gate: NEReticulumNode.modelBNodeEnabled now reads the App-Group flag modelBBackgroundNE (same key + accessor as BackendPreference.modelB), default false on absent/non-Bool. App (proxy selection) + NE (node activation) share ONE switch. - Live TCP relay interface (the A5a TODO(C3)): NEReticulumNode.start() reads the App-Group TCP interface config (Foundation-only parse -> plain (host,port), no Network type across the collision boundary) and registers a ReticulumSwift TCPInterface on the node transport alongside the AppGroupBridge — mirrors SwiftRNSBackend.buildAndAdd's .tcpClient case. Auto/multicast + path-change rebind left as TODO(C3-followup) (TCPInterface self-reconnects via its own backoff). - startTunnel branch: Model-B true -> start the node, skip applyConfigs/startPathMonitor/the configChanged observer (PoC-only, avoids a double-bound relay); false -> the PoC path unchanged. Fixed a latent double-bind: wake() now guards `reticulumNode == nil` so it can't spin up a duplicate PoC NWConnection under Model B. stopTunnel tears down whichever is active. - proxySend injected at the BackendFactory.make() call site (AppServices startPythonBackend, under ENABLE_NETWORK_EXTENSION) via the established lazy MainActor tunnelManager read — so the proxy has the live IPC send whenever Model B is exercised; inert otherwise. - C3(d): the throwaway PoC memory-measurement code (measureRNSFootprint/poc*/startNEMemoryPoC/ nepoc-mem/NE-PoC.xcscheme) was already absent from the source tree — nothing to delete. Default stays FALSE (both sides): the committed runtime default is the PoC dumb-pipe; Model B is opt-in for on-device verification. Both schemes build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…llow-up #9p2) Live bug: LXMFSwift.LXMRouter persists the signed LXMF WIRE into packed_lxmf (inbound unpackFromBytes sets .packed=data -> MessageRecord copies it), while the app/Python path stores a MessagePack FIELD-MAP. The chat UI renders via LxmfFieldCodec.unpack(record.packedLxmf), which returns nil on wire bytes -> Swift/NE-backend-delivered attachments + icons silently didn't render. MessageRepository now normalizes both forms (recoverFields + normalizedFieldMap): - Strict WIRE-FIRST discriminator: try LXMFSwift.LXMessage.unpackFromBytes(bytes, sourceIdentity: nil); non-empty .fields -> wire row (extract fields, no sig re-validation — the router already validated at receive time + nil identity still populates .fields); else -> field-map (or empty). Wire-first is load-bearing: the reverse (field-codec first) is unsafe because unpackMsgPack reads byte 0 and IGNORES trailing bytes, so a wire row whose leading hash byte is a fixmap marker (~1/16) would decode a bogus map and drop attachments. unpackFromBytes's size guard (>96) + strict typed-array shape make a field-map-misread-as-wire near-zero. - mapRecord re-packs wire rows to a field map so the UI's LxmfFieldCodec.unpack works uniformly; mapToLXMessage uses recoverFields for .fields + normalizedFieldMap for .packed. Field-map rows pass through verbatim (only the wire branch does extra work). All read paths flow through these. Tests (MessageRepositoryAdapterTests, exercise the real production adapter): a genuine field-map row AND a genuine signed-wire row (real Identity + msg.pack()) both recover image/file/icon through mapRecord + mapToLXMessage. TEST SUCCEEDED (13 tests, 0 failures; existing A0 adapter tests intact). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rack C8) So iOS relaunches the app on BLE events while backgrounded (the NE can't own background BLE): - SwiftBLEBridge creates both managers with restore identifiers (CBCentralManagerOptionRestoreIdentifierKey / CBPeripheralManagerOptionRestoreIdentifierKey; stable "network.columba.ble.central"/".peripheral"). - centralManager(_:willRestoreState:): re-adopts CBCentralManagerRestoredStatePeripheralsKey (strong-refs into discoveredPeripherals/gattClients, re-sets delegate, re-drives discoverServices for already-connected peers so the identity-read handshake re-runs), re-arms the scan from RestoredStateScanServicesKey. - peripheralManager(_:willRestoreState:): re-binds rx/tx/identity chars from RestoredStateServicesKey by wire UUID + sets gattServiceAdded=true so the next poweredOn does NOT re-add() the service (re-adding breaks subscribed centrals), re-arms advertising. - restoreAtLaunch() re-creates both managers with the same identifiers; called early in ColumbaApp.init() (this is a pure-SwiftUI app with no UIApplicationDelegate, so it can't read launchOptions.bluetoothCentrals/.bluetoothPeripherals — called unconditionally; the UIApplicationDelegateAdaptor alternative is noted inline). Info.plist already declares the bluetooth-central/peripheral background modes. CAVEAT (documented inline): the BLE DELIVERY path is Python-coupled — the Swift backend has NO native BLE delivery, so a background BLE wake only completes delivery+notify under the Python backend. Native Swift BLE delivery is a follow-on. Scoped to BLE-direct; RNode wake left as best-effort (SwiftRNodeBridge intentionally not given a restore id). App scheme builds green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In-app UX for the background Network Extension (the C6 App-Store reviewer-note doc already exists in the vault): - BackgroundTransportView.swift (NEW, app target, entire file #if ENABLE_NETWORK_EXTENSION): a Settings sheet explaining the feature in plain language (keeps mesh delivery alive while closed/locked), a live NEVPNStatus card (all 6 cases + transitions), a "VPN badge" explainer (mock status-bar pill + why iOS shows it), an explicit "not a commercial VPN — no traffic proxied/monetized, on-device only" section, an error card, and an Enable/Disable primary action. - Wiring: Enable -> TunnelManager.install() (also arms on-demand per C2c) then start(); Disable -> stop(); status via @observable TunnelManager. - Integration: surfaced as an opt-in "Learn more & set up" sheet from SettingsView's existing ADVANCED backgroundTransportCard — NOT forced into mandatory onboarding (the feature needs a paid dev account + explicit VPN-profile consent). The pre-existing quick toggle was upgraded to install()+start() + full NEVPNStatus status text. Added gated `import NetworkExtension` to SettingsView. pbxproj: BackgroundTransportView.swift in the app Sources phase only (not the extension). App scheme builds green. Visual polish is device/screenshot-verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app target had NO dependency on the NE target and NO Embed-App-Extensions phase — so a device build produced ColumbaApp.app with NO PlugIns/ColumbaNetworkExtension.appex (the tunnel was never actually shipped in the app). The plan assumed this wiring "already present"; it wasn't. support/embed-ne.rb (idempotent Xcodeproj script): adds the app->NE target dependency, an "Embed App Extensions" copy-files phase (dstSubfolderSpec=13 PlugIns, CodeSignOnCopy), and ensures the NE target carries DEVELOPMENT_TEAM + Automatic signing. Verified on a signed device build (generic/platform=iOS, -allowProvisioningUpdates): the NE now builds, embeds into PlugIns/, and code-signs with the correct auto-managed profile — entitlements confirmed: packet-tunnel-provider + App Group group.network.columba.Columba + keychain group <team>.network.columba.Columba.shared (A3). Installed to a physical iPhone 14 via devicectl. Default runtime path remains the PoC dumb-pipe (modelBBackgroundNE=false). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…black-holed) Diagnosed from on-device logs: with background transport enabled and an interface added AFTER the tunnel came up (e.g. switching Auto -> a TCP relay), the new interface stayed in local-socket mode and its traffic was black-holed by the active packet tunnel — the TCP interface showed "connected (rx=0 tx=0)" and announces never flowed in or out. Root cause: applyTunnelModeToInterfaces was only invoked from TunnelManager.onStatusChange (VPN status transitions), never from applyInterfaceChanges (hot add/remove). So any interface added while the tunnel was already up never entered tunnel mode (never bridged through the extension). Fix: track tunnelModeActive (set in applyTunnelModeToInterfaces); applyInterfaceChanges now calls reapplyTunnelModeIfActive() after the hot-add loop, so an interface added while the tunnel is up is brought into tunnel mode immediately. Gated #if ENABLE_NETWORK_EXTENSION. Built + reinstalled to device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ch race) Deeper cause of "announces don't flow with background transport on": the persistent NE can already be `.connected` when the app cold-starts, so TunnelManager.load() fires onStatusChange -> applyTunnelModeToInterfaces(active:true) BEFORE any interface is registered — the log showed "enabled tunnel mode on 0 TCP + 0 Auto", then the TCP interface started in local-socket mode and its traffic was black-holed by the active packet tunnel (connected, rx=0 tx=0). The prior hot-reload fix (9fafd97) didn't cover the launch path (interfaces come up via Step 7 -> connectTCPInterface, not applyInterfaceChanges). Fix: connectTCPInterface and startAutoInterface now call reapplyTunnelModeIfActive() after the interface is registered. tunnelModeActive is set true even when onStatusChange fires with zero interfaces, so the re-assert engages the moment the interface exists — regardless of launch/connect ordering. Combined with onStatusChange (status changes) and applyInterfaceChanges (hot-reload), all three interface-establishment paths now bridge through the extension. Device build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stics
On-device debugging aids (Maestro/idb can't drive this physical iOS 26.5 device; devicectl can
post Darwin notifications):
- Darwin-notification trigger: AppServices observes `network.columba.test.announce` and fires
sendAllAnnounces("") — drive a deterministic announce on the device via
`xcrun devicectl device notification post --name network.columba.test.announce`. Registered
idempotently after backend start in both initialize overloads; unregistered on shutdown.
- Frame-bridge diagnostics (no PII — tags/lengths/counts only) tracing app<->NE<->relay:
[BRIDGE-OUT] at the tunnel-mode outbound hook (iface->sendFrame, tcp/auto) + TunnelManager.
sendFrame (tag/len + session=yes|NIL, plus an explicit DROPPED log when no NETunnelProviderSession);
[BRIDGE] app->NE frame and relay->NE frames-queued in the extension (ExtensionDiagLog);
[BRIDGE-IN] frames-from-queue->transport in ExtensionFrameReader. Makes the previously-invisible
PoC frame bridge observable so we can pinpoint where a frame stops.
Both schemes build green. Diagnostics are low-noise (frames>0 only) and can be pruned post-debug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ identity sharing Model B's NE node now actually starts on-device (verified). Three fixes: - Fixed the keychain bundle-seed-id probe (AppServices.keychainAccessGroupPrefix): it read the access group from SecItemAdd's RESULT (which omits kSecAttrAccessGroup) and added a value-less item — so it always returned nil. A3's shared keychain group was therefore NEVER resolved; the identity silently lived in the app's default group, unreachable by the NE. Now: add a value + read the group back via CopyMatching. - shareIdentityForModelB(): on the active multi-identity init path (initialize(identity:), which never calls loadOrCreateIdentity), resolve the shared group, write it to the App Group (resolvedSharedKeychainGroup) so the NE doesn't have to probe (its probe fails while locked), and persist the identity into the shared keychain group so the NE can load it. - NEReticulumNode.sharedKeychainAccessGroup() reads the app-shared group from the App Group first (probe-free, locked-safe), falling back to the local probe. - TEMP (bring-up): BackendPreference.modelB + NEReticulumNode.modelBNodeEnabled default to true so every launch runs Model B. REVERT to false + add a UI toggle before ship (task #13). Verified on device: NEReticulumNode loads identity=6c7adfca, registers the TCP relay interface, starts with delivery dest=a3979878. Remaining: ProxyRnsBackend IPC start returns ipcFailed; verify NE TCP connects to the relay + inbound/outbound traffic flows (next bring-up iterations). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NE-canonical LXMF node (Model B) working on-device — LXMF messages + announces flow in/out while the app is backgrounded, no APNS: - NEReticulumNode: self-announce on node start + on relay reconnect; heard-announce PathTable snapshot over IPC for the app's network-announce list. - ProxyRnsBackend: poll the NE's heard announces and re-emit them as .announce events (the incoming-announce bridge); start the poller on start(). - TunnelManager.proxySend: await a .connected session before sending, fixing the proxy start/announce launch race (ipcFailed -> transportNotConnected). - ProxyIPC: heardAnnounces op + ProxyHeardAnnounce DTO. - UI: Model B settings toggle (Network Backend card); the interface card and Network Status now reflect the NE relay via the proxy statusSnapshot, since the app owns no TCP interface under Model B. - docs/MODEL_B_BACKGROUND_DELIVERY.md: consolidated as-built architecture doc (linked from ARCHITECTURE.md). Verified on iPhone 14: inbound LXMF delivery (decrypt/persist/notify/delivery proof), announce out + in, the announce button, and Network Status. TEMP for testing: BackendPreference.modelB / NEReticulumNode.modelBNodeEnabled default ON (flip to OFF before ship — the new toggle persists the choice). The build currently depends on a local reticulum-swift bypassTunnelEgress checkout patch (a defensive revert-candidate; fork-commit + SPM pin-bump still pending). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p + name fix Make Model B (NE-owned LXMF node, app-as-proxy) the only architecture on the Swift build, and fix the bring-up race that left it degraded. - BackendPreference.modelB: build-flag constant (#if COLUMBA_BACKEND_SWIFT) — no runtime flag, no Settings toggle. NEReticulumNode.modelBNodeEnabled hardcoded true (the NE only exists on this build). Removes the cross-process flag race that left the NE in sniff mode while the app came up as the proxy (ipcFailed). - ProxyRnsBackend.start: retry the handshake until the NE node is ready (~12s), so a cold start / jetsam relaunch no longer fails ipcFailed/backendNotReady. Verified on-device: clean Model B bring-up every launch, end-to-end DIRECT delivery proven (DELIVERED proof). - Removed the "Background delivery (Model B)" Settings toggle + modelBEnabled / applyModelBSelection. - Display-name fix: when an announce carrying a name is heard, stamp it onto an existing nil-name conversation (the announce arrives after the NE creates the row on inbound, so the title was stuck on the "Peer <hash>" fallback). Verified on-device. - reticulum-swift pin -> 9fa6645 (bypassTunnelEgress merged via PR #18). - docs/MODEL_B_TESTING_TODO.md (radio / lock-screen / under-pressure GATE) + flows/bug1-network-tab-repro.yml. TEMP (remove when the render investigation closes): the [MSG] loadMessages and [DIAG-STORE] announce-read diag logs. The empty-thread-via-Network-tab bug is narrowed to a view/nav issue — cross-process reads are proven fresh (msgs=1), not a read-path problem — so the A5 readonly:true refactor is not needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a sim-reproduced regression test for BUG #1 (empty thread when a conversation is opened via the Network tab), and update the inbound-diag waits to accept both the legacy [PY] and the new [RNS] diag-marker prefix (renamed with the backend abstraction). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…te in UI Run reticulum-swift's BLEInterface in the Network Extension (the NE sandbox can't drive CoreBluetooth) and marshal the BLEDriver + BLEPeerConnection protocol surface across a dedicated App-Group seam to the app, which hosts the real CoreBluetoothBLEDriver -- mirroring the python<->kotlin driver abstraction on Android. Seam: BLEDriverSeam (binary codec), AppGroupBLESeamTransport (App-Group queues + Darwin notifications), AppGroupBLEDriver (NE side), AppGroupBLEServer (app side), ModelBBLEService (app radio host). UI: the BLE connections page, the interface status badge, the Network Status list and the Settings network card all now read the NE's native interfaces -- over a new .bleConnections proxy IPC and a statusSnapshot enriched with type/peer/state -- instead of the app's Compat transport stub, which never holds the NE's real interfaces under Model B. Also trims PacketTunnelProvider to Model-B-only (dead Model A / PoC dumb-pipe removed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… thread Under Model B the NE owns LXMF delivery and signals the app only via a cross-process Darwin notification (the app-local IncomingMessageHandler in-process path never fires). Two gaps left incoming messages AND delivery proofs reaching the NE but never surfacing in an open conversation: - The NE posted the Darwin refresh notification only on inbound delivery (didReceiveMessage), not on delivery-proof / outbound state changes -- so sent-message checkmarks never advanced past the single tick. Post it from didConfirmDelivery / didUpdateMessage / didFailMessage too. - The open thread (MessagingViewModel) only observes the in-process messageReceivedNotification, which is dead under Model B. Re-post that in-process notification from the Darwin observer so the open thread reloads on NE-delivered events -- loadMessages re-reads both new messages and delivery states, covering both symptoms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Log central connect / readIdentity / writeIdentity outcomes (OK/FAILED) across the NE<->app BLE seam for connection visibility. Low-volume (per-connection), pulled via DiagLog. Made the central-handshake stall tractable to diagnose and is useful operationally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The NE writes diagnostics to the App-Group container; the app bridges it into Documents/ext-diag.log so it's retrievable via devicectl (the App-Group container isn't reliably reachable that way). That copy only ran on launch, so the file was a frozen snapshot while the NE kept writing live — NE markers (e.g. inbound delivery) couldn't be tailed in real time. Add a DEBUG-only, self-rescheduling 2s refresh so the copy stays current. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ce lands A manually-saved relay sets autoSelect=false. loadPreferences wires the router at launch when the node isn't in knownNodes yet, so it uses a placeholder stampCost=0. When the node's announce later arrives, processPathEntry only re-wired via autoSelectBestNode() (skipped when autoSelect is off), so the router stayed at stampCost=0 and a stamp-requiring PN rejects the upload — messages queue (nodeFound=false at launch was the tell). Re-wire the selected node when its announce arrives. Note: on-device Model-B propagation is still unimplemented in the NE; this fixes the app-side wiring and helps the python backend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Under Model B the LXMF router lives in the Network Extension, so the old in-app propagation path (Compat router + app-side sync loop) was a no-op: PROPAGATED sends stayed queued and sync never ran. The swift LXMF stack already implements propagation end-to-end — this wires the app's selected propagation node + a sync schedule across the App-Group seam into the NE's router, and mirrors live sync state back for the UI. Seam (Foundation-only, both targets): - PropagationSeam.swift: PropagationSeamConfig (PN hash, stamp cost, interval, periodic flag) app→NE + sync-now trigger; PropagationSyncStateSnapshot NE→app. - 5 SharedDefaultsConstants keys (config / sync-now / sync-state + Darwin names). NE (NEReticulumNode): - applyPropagationConfig wires the PN onto the router (setOutboundPropagationNode + setPropagationStampCost); config + sync-now Darwin observers; a periodic sync scheduler mirroring the announce scheduler; runOneSyncFireAndForget runs syncFromPropagationNode in a detached child with a 150s watchdog + in-flight guard so the 120s SYNC_TIMEOUT never blocks the loop/IPC. One-shot sync on relay reconnect. didUpdateSyncState/didCompleteSyncWithNewMessages write the state snapshot (message-arrival push stays on the existing inbound path only). App: - PropagationNodeManager publishes the seam from save/loadPreferences (modelB- gated); syncNow + startPeriodicSync gain modelB branches (post sync-now / publish, NE owns cadence); tracks + persists stamp cost (SettingsRepository) for a correct cold start. NotificationObserver bridges the NE sync-state channel; the manager mirrors snapshots into syncState. - SettingsViewModel.saveDeliverySettings persists so interval/periodic edits (incl. disable) reach the NE. - SyncStatusBottomSheet (mirrors Columba-Android), auto-shown from Chats while a sync is active. - test-prop-sync (ColumbaTestPropSync) routes through the manager under Model B (backend.propagationSync is a no-op proxy there). Verified on device (iPhone 14, Debug-Swift): propagated_echo smoke PASS — device→PN→echo-bot→PN→device, echo delivered, sync_attempts=1. Both schemes build (Columba python + ColumbaNetworkExtension swift). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`postInboundNotification` logged only the failure/unauthorized paths. Add a success marker (with the message preview) when the local notification reaches the iOS notification center — the user-visible proof that a message arriving while the host app is suspended still notifies. Makes background-delivery notification behavior assertable (the iOS smoke harness's suspended_notification scenario greps this) and aids debugging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… #if DEBUG
The `onOpenURL` trigger for the test deep-link surface was already `#if DEBUG`,
but the matching `addPythonObserver("ColumbaTest*")` registrations in
`startPythonBackend` (+ the `addPythonObserver` helper) compiled into release
builds. They were inert there (nothing posts those NotificationCenter names in
release), but they were dead weight and a latent footgun if any other code ever
posted a `ColumbaTest*` name. Gate the registrations + the helper behind
`#if DEBUG` so the test surface is fully absent from release; `shutdown()` still
tears down the (empty) token array unconditionally.
Debug-Swift + Release-Swift both build clean (no orphaned-helper warning); the
DEBUG smoke surface is unchanged, so the on-device 6/6 suite still applies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…treaming + parity reticulum-swift PR #24 merged to main (a strict superset of the previous feat/rnode-over-ble-ios pin): resource disk-streaming, RNS conformance parity (744->148), the BLE/Model-B work, and a series of concurrency/security hardening fixes (centralized fire-once resource conclusion, outbound register-window/reentrancy guards, and a proactive wire-input-hardening sweep closing single-packet remote-crash vectors in the msgpack/advertisement parsers). API change is additive — no Columba source changes; ColumbaNetworkExtension (Debug-Swift) builds clean against the remote main checkout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the Model-B background-NE branch up to date with main (22 commits) ahead of opening the model-b → main PR. Conflict resolutions (all took main's direction; lost nothing functional): - project.pbxproj: 4 NE build configs — adopt main's CURRENT_PROJECT_VERSION=2 and main's drop of the inline DEVELOPMENT_TEAM (inherited from Signing.xcconfig via baseConfigurationReference on all four configs). - Tests/interop/conftest.py, test_attachments.py: collapse the transitional [PY]|[RNS] dual-marker regexes to main's [RNS]-only form. No [PY] markers are emitted anywhere in Sources (verified), so the dual-accept was dead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR makes Model B the sole architecture for Columba iOS: the full Reticulum + LXMF node is moved into a
Confidence Score: 5/5PR is safe to merge; only P2 hardening gaps remain, no blocking correctness or security issues. All previously identified P0/P1 issues (Darwin observer leaks, double-start re-entrancy, stop-during-start orphaning, RNode frame-stealing, zombie announce poller, port overflow, keychain probe accumulation, ExtensionDiagLog PII) are addressed in this PR. The three remaining findings are P2: an untracked BLE addInterface task and missing deinit safety nets in two seam classes. These are hardening gaps rather than present defects. NEReticulumNode.swift (untracked BLE task), AppGroupBLESeamTransport.swift and AppGroupRNodeSeamWire.swift (missing deinit observer removal) Important Files Changed
Reviews (11): Last reviewed commit: "fix(ne): floor the propagation sync inte..." | Re-trigger Greptile |
…ranch Surfaced by an audit of PR #57's (feat/enable-tunnel-flip-flag) unique background-connectivity commits against the Model-B branch. These three are architecture-independent correctness bugs Model B lacked; the rest of #57 is MOOT (tunnel/interface-bridge plumbing the in-NE node replaces) or already COVERED (the in-NE node's decode + ExtensionDiagLog). The deferred multi-relay capability is tracked separately (issue #91). 1. App cold-start no longer blocks on the notification auth sheet (#57 fc9b0b8). ColumbaApp Step 8 awaited requestPermission() before setting isInitialized, so the OS prompt held RootView setup hostage — and on a fresh-install device the smoke harness (no UI driver) could never tap Allow, so init hung. Now fire-and-forget; the foreground UN delegate is already installed in init(). 2. Register app-side notification/announce defaults at launch (#57 dc1024b). notifications_enabled:true was registered only inside SettingsViewModel .loadLocalSettings() (lazy, on first Settings open), so a fresh install that never opened Settings left it unregistered and NotificationService's `guard bool(forKey:)` suppressed the foreground notification path. Moved to a static registerLocalDefaults() called from App.init(). (Model-B background notifications come from the NE, which gates only on system auth — unaffected.) 3. "Disable Background Transport" now actually disables (#57 38f8d2e). install() arms isOnDemandEnabled + NEOnDemandRuleConnect(); stop() was a bare stopVPNTunnel(), so iOS auto-reconnected the NE via the armed rule — the toggle was a no-op. New TunnelManager.disable() clears on-demand + isEnabled and persists before stopping; both affordances (BackgroundTransportView, SettingsView) call it. Also: applyTunnelModeToInterfaces is now TCP-only — it no longer tunnels the AutoInterface (#57 d3719c2 #3). Forwarding Auto frames to tunnel.sendFrame black-holes them (PacketTunnelProvider drops non-ProxyRequest frames; the NE has no UDP/Auto path), so tunneling Auto could only break background Auto outbound. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ig parse (greptile #90 iter 1) Addresses Greptile's review of PR #90 (4/5). Two P1s were the same bug class — NEReticulumNode.stop() leaking raw passUnretained Darwin observers / seam transports across a same-process NE restart (PacketTunnelProvider builds a fresh node per startTunnel; iOS reuses the process) → frame-stealing + use-after-free. P1 — Darwin observers leaked on stop (use-after-free): stop() never removed the 3 observers self-registered with Unmanaged.passUnretained(self): the RNode-config observer and BOTH propagation observers (config-changed + sync-now). Swept ALL of them (not just the flagged ones) via CFNotificationCenterRemoveEveryObserver(self) and reset both *ObserverRegistered guards so the next start() re-registers cleanly. P1 — RNode seam transport not torn down on stop (frame stealing + dangling): stop() tore down the BLE seam but left rnodeInterface live; its AppGroupRNodeSeamWire keeps a rnodeSeamA2N Darwin observer that, on restart, steals KISS frames from the fresh node and dangles after deinit. Now disconnect()+removeInterface()+nil it while transport is still alive. P2 — TCP relay port silently wrapped: loadTCPRelayConfig used UInt16(truncatingIfNeeded: port) on an unconstrained JSON Int, so 65536→0 / 131071→65535 dialed a wrong/zero port instead of skipping. Now `guard port > 0, port <= 65535` and a checked UInt16(port). (Swept for sibling truncations: the other truncatingIfNeeded uses are intentional MsgPack/LXMF wire decodes; BLE MTU already uses UInt16(clamping:).) P2 — bundleSeedProbe keychain item accumulated: the team-id-prefix probe added a generic-password item and never removed it. Fixed BOTH mirror sites Greptile noted (NEReticulumNode + AppServices) with a SecItemDelete after the read; the probe re-adds cheaply on next resolution. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (greptile #90 iter 2) Greptile P0 (dropped the PR to 2/5): NEDeliveryDelegate.postInboundNotification wrote the resolved sender DISPLAY NAME (PII) and up to 80 chars of DECRYPTED message body (`preview`) to ext-diag.log on every inbound. That file's own header is an explicit NO-PII contract ("ENVELOPE / METADATA ONLY … MUST NOT contain message plaintext") and is device-extractable via `devicectl … copy from`, so any log capture leaked plaintext — defeating LXMF end-to-end encryption. Log the sender HASH PREFIX only now (envelope metadata, matching the adjacent `from=…`/`hash=…` markers). The notification BODY still shows the preview to the USER — that is the intended UX; only the persisted diagnostic log must stay clean. Swept every ExtensionDiagLog.log call in the tree: this was the sole plaintext/PII leak (all other call sites already log hash prefixes / states only). The on-device smoke `suspended_notification` scenario correlated the notification by the nonce-in-preview; updated the harness to correlate by the echo bot's sender-hash prefix instead (requiring a NEW occurrence so stale markers can't false-positive). Re-verified on device: direct_echo clean + suspended_notification PASS (notif_posted=true) against the new envelope-only marker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile P1 — double-start re-entrancy. start() guards on `!isRunning` but only
sets isRunning=true after 10+ awaits (GRDB/LXMRouter/path-table opens,
registerDeliveryDestination, addInterface, …). Swift actors suspend at every
await, so the ProxyRnsBackend `.start` retry storm (PacketTunnelProvider fires
Task { start() }; the app then retries `.start` up to 30×/400ms, each a fresh
Task) slips a second start() past the guard mid-init — opening the same App-Group
GRDB twice and registering a duplicate lxmf.delivery destination on a second
transport, clobbering refs and leaking the orphan. Fixed with an `isStarting`
latch claimed SYNCHRONOUSLY before the first await (defer-released so a failed
start, e.g. identity not yet created, still retries).
Proactive sweep of the same bug class (check-then-act across await on actor
state) found the identical defect, NOT flagged by greptile, in
runOneSyncFireAndForget: it set `syncInFlight = true` only AFTER
`await waitForRelayConnected(2s)`, so the periodic scheduler racing a manual
"Sync Now" Darwin trigger could start an OVERLAPPING sync. Moved the claim
before the await. (The *Scheduler methods already cancel() synchronously before
reassigning their Task — verified safe, no change.)
Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on leak (greptile #90 iter 4) The two non-blocking edge cases noted in greptile's 4/5 summary (both real, both in ProxyRnsBackend): 1. start()/stop() lifecycle race. start() runs a ~12s (30×400ms) handshake retry loop; if stop() is called mid-loop it clears cachedLocalInfo + cancels the poller, but a late-completing start() would then re-cache localInfo and restart the poller — resurrecting a stopped backend. Added a `startGeneration` token: start() snapshots it up front and, under stateLock, refuses to commit if stop() bumped it meanwhile. 2. announce-poller swallowed its own cancellation. `try? await Task.sleep` ate the CancellationError stop() raises, firing one extra `.heardAnnounces` IPC round- trip before the while-guard re-checked. Switched to a throwing sleep that returns on cancel. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). All 6 prior P1s remain fixed; on-device direct_echo + propagated_echo verified green on the preceding revision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#90 iter 5) Follow-on the previous generation-token fix exposed: start() releases stateLock before calling startAnnouncePolling(), so a stop() landing in that window bumps startGeneration and cancels a still-nil poller, after which the late startAnnouncePolling() would create a brand-new Task stop() can never cancel — a zombie poller issuing .heardAnnounces forever whose stale lastSeen then silently drops announces after the next start(). Thread the start()'s generation snapshot into startAnnouncePolling(expectedGeneration:) and re-check `expectedGeneration == startGeneration` UNDER the lock before creating the Task; if stop() bumped it, don't spawn the poller. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eptile #90 iter 6) Greptile use-after-free: stop()'s `guard isRunning else { return }` silently DROPS a stop that arrives mid-start() (isRunning isn't set until the end of the 10+ await init). start() then completes, registers the 3 Darwin observers (passUnretained(self)), and returns — but PacketTunnelProvider already nil'd reticulumNode, so the actor is orphaned, deallocates, and the live observers dangle on freed memory; the next rnodeConfigChanged / propagationConfigChanged fires fromOpaque() on it → crash. Two-pronged: - Root cause: `stopRequested` flag set by stop() unconditionally (before the isRunning guard) and reset at the top of each start(); start() checks it after full init and runs a complete teardown (observers + seam transports) instead of returning a started-but-meant-to-stop node. - Safety net: a `deinit` that unconditionally CFNotificationCenterRemoveEveryObserver for self (same idiom as NotificationObserver.deinit), so the observers can never outlive the actor regardless of orphaning path. Idempotent w.r.t. stop(). Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@greptile review |
…#90 iter 7) Greptile (two threads, one root cause): setupRNodeInterface() sets `rnodeInterface = iface` then registers the interface in a fire-and-forget `Task.detached { tp.addInterface(iface) }` (kept off the setup critical path, since connect() may block on the radio handshake). That task was unmanaged, so: - if stop() runs before it executes, stop()'s `ri.disconnect()` is a no-op (connect() hasn't run), then the detached task later connect()s and registers the rnodeSeamA2N Darwin observer on an orphaned transport; and - a rapid rnodeConfigChanged re-invocation tears down iface_v1 and builds iface_v2, but iface_v1's detached task still connect()s and re-registers its observer. Either way two live wires observe rnodeSeamA2N and steal each other's KISS frames on the next NE restart — the same hazard the BLE-seam teardown already guards. Fix: store the task in `rnodeAddInterfaceTask` and cancel it at the top of setupRNodeInterface (reconfig) and in stop() — mirroring announceTask/ propagationSyncTask. The detached body now checks cancellation before AND after addInterface and, if superseded mid-connect, rolls back (disconnect → removeInterface on the captured tp/iface) so no orphaned seam observer survives. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…greptile #90 iter 8) Greptile: startPropagationSyncScheduler sleeps `cfg.syncInterval` between syncs with no lower bound, so a 0 / near-0 value — reachable via a corrupted or unset App-Group entry — spins the loop at the ~2s relay-recheck floor, generating continuous IPC + relay traffic (battery/network drain) once Model B ships to users who set the interval. Added PropagationSeamConfig.minSyncInterval (30s) + an `effectiveSyncInterval` accessor (max(floor, syncInterval)) and used it in the scheduler sleep + the PN-set log. The floor lives at the point of use, NOT init: `loadFromAppGroup()` decodes via Codable, which bypasses the custom init — an init-time clamp wouldn't catch the corrupted-defaults path. Manual "Sync Now" + reconnect-triggered syncs are unaffected. Proactive sweep of sibling config-driven sleeps: the announce scheduler already floors via configuredAnnounceIntervalHours() (`h > 0 ? h : 3`); the 150s sync watchdog and 300s idle-poll are constants. No other unbounded interval found. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Model-B background Network Extension — the RNS/LXMF node moves into the NE
This makes Model B the sole architecture for Columba-iOS: the full Reticulum + LXMF
node runs inside the Network Extension (
Sources/ColumbaNetworkExtension/NEReticulumNode.swift),and the app is a thin SwiftUI client that talks to it over an App-Group + Darwin-notification
seam. This is what enables background / suspended message delivery on iOS — the node keeps
running and posts notifications even when the app is not foregrounded.
What's in here (43 commits)
NE node core & seam
NetworkInterface+ bidirectional frame queue, shared keychain access-group so app and NE share one identity.ProxyRnsBackendIPC skeleton; durable App-Group outbox for sends issued while the NE is stopped.ext-diag.logstays live for on-device tailing.Background transport reliability
NWPathMonitor; on-demand connect for jetsam/reboot auto-relaunch.RNode-over-BLE (Model B)
LXMF propagation (Model B)
SyncStatusBottomSheet.UI
Dependencies
reticulum-swiftpinned tomain(b366f29) — resource disk-streaming + the full conformance/wire-hardening parity work.LXMF-swift@feat/lxmfdb-appgroup-sharing,LXST-swift@feat/transport-agnostic.Relationship to PR #57
PR #57 (
feat/enable-tunnel-flip-flag) is the earlier tunnel approach — it ran interfacebridges in the NE and tunneled packets while the node stayed in the app. This branch
architecturally supersedes it: the node now lives in the NE outright, so the tunnel/packet-bridge
plumbing is replaced rather than extended. The two branches have diverged (neither is an ancestor of
the other) and overlap heavily on the NE files, so they should not both land. A background audit is
in flight to confirm none of #57's background-hardening fixes (background re-announce, NE lifecycle
reliability, suspended-notification scheduling) need porting before #57 is retired; any genuine gaps
will be added to this PR.
Verification
direct_echoandpropagated_echoboth pass — direct/opportunistic and propagation-node send + sync-back round-trips.origin/mainmerged in (was 22 behind); conflicts resolved toward main (NECURRENT_PROJECT_VERSION=2+ team-via-xcconfig;[RNS]-only interop markers).xcodebuild -resolvePackageDependenciesclean.🤖 Generated with Claude Code