diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index bdfffc7a..361aa8e2 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ 71FAB7848D244A6D2FC1628A /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A8D344945F752C18EF2D9E /* AudioManager.swift */; }; 779118E89F4D38BF960DB3D0 /* PyAnnounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C562D79BB3B6559A1B1EFDA /* PyAnnounce.swift */; }; 7BE550B9F4D32F6346EBD2F2 /* CodecProfileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA6D4C7151A87D862FF9B8A /* CodecProfileInfo.swift */; }; + 80D0868B2491DB301F772D63 /* BackgroundDeliveryPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894C730FEB9AE4CDB4B949F8 /* BackgroundDeliveryPage.swift */; }; 8768F2E6CD7941D82997A1BB /* CallControlButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71922EC204982A357F814F23 /* CallControlButton.swift */; }; 886AB689C7699471510BAF9A /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86E428836698BA8A8973A92F /* CallManager.swift */; }; 8A321B0938566F0D62D64562 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3BEE6E339CC852C9BA8C5D75 /* Python.xcframework */; }; @@ -183,6 +184,7 @@ F4CA7E2F745F05E21D45E11A /* RNSAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 0FD6A68A52A54D21FDB70324 /* RNSAPI */; }; F4E9991226B4D464017DA247 /* Lucide.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA586EB5F8EB62D2579CEAAB /* Lucide.swift */; }; F80B09722B3A7CCA7C99DE0C /* DiscoveredDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9630845DA60F57A34819CC4B /* DiscoveredDevice.swift */; }; + F83A2212E43B329830919AFF /* BackgroundDeliveryGateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56CFC5EC819F4ED82FCC4B07 /* BackgroundDeliveryGateView.swift */; }; FB2F3248AE405614B36BC798 /* RNSAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E049A7D4D6D58690C0B674CE /* RNSAPI */; }; FDE0DB7957C9D877D3E67367 /* AppGroupRNodeSeamWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34E6D26205158570EA4228EF /* AppGroupRNodeSeamWire.swift */; }; NERN2 /* NEReticulumNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = NERN1 /* NEReticulumNode.swift */; }; @@ -254,12 +256,14 @@ 51D8CBCF7E546028A043E1C7 /* PyConversation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PyConversation.swift; path = Python/Models/PyConversation.swift; sourceTree = ""; }; 550749F04079B0D39E4DAD05 /* AppGroupRNodeSeamTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppGroupRNodeSeamTransport.swift; sourceTree = ""; }; 55FE6D8CFA13376BCD23AE86 /* PropagationSeam.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PropagationSeam.swift; sourceTree = ""; }; + 56CFC5EC819F4ED82FCC4B07 /* BackgroundDeliveryGateView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundDeliveryGateView.swift; sourceTree = ""; }; 67B5525A88EBCEAF05E9DE3F /* SyncStatusBottomSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncStatusBottomSheet.swift; sourceTree = ""; }; 6FE282FA3937E59BC026E072 /* AudioManagerConfigChangeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioManagerConfigChangeTests.swift; sourceTree = ""; }; 710EBCE55DCC9E71A9AB0E86 /* PythonBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonBridge.swift; sourceTree = ""; }; 71922EC204982A357F814F23 /* CallControlButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CallControlButton.swift; sourceTree = ""; }; 7529BF99835005DE07E1B65F /* CodecSelectionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CodecSelectionSheet.swift; sourceTree = ""; }; 86E428836698BA8A8973A92F /* CallManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; + 894C730FEB9AE4CDB4B949F8 /* BackgroundDeliveryPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundDeliveryPage.swift; sourceTree = ""; }; 8CAFC5BF3DDB882B1864F1C0 /* PythonRNSBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonRNSBackend.swift; sourceTree = ""; }; 8CBD293157E715F490613984 /* PyMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PyMessage.swift; path = Python/Models/PyMessage.swift; sourceTree = ""; }; 9630845DA60F57A34819CC4B /* DiscoveredDevice.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiscoveredDevice.swift; sourceTree = ""; }; @@ -663,6 +667,8 @@ F050 /* PermissionsPage.swift */, F051 /* CompletePage.swift */, F079 /* OnboardingRestoreSheet.swift */, + 56CFC5EC819F4ED82FCC4B07 /* BackgroundDeliveryGateView.swift */, + 894C730FEB9AE4CDB4B949F8 /* BackgroundDeliveryPage.swift */, ); path = Onboarding; sourceTree = ""; @@ -1218,6 +1224,8 @@ F4E9991226B4D464017DA247 /* Lucide.swift in Sources */, AAD9231170B11C89EF80F9B8 /* PropagationSeam.swift in Sources */, 1FC4483D48CABE8BF49850EF /* SyncStatusBottomSheet.swift in Sources */, + F83A2212E43B329830919AFF /* BackgroundDeliveryGateView.swift in Sources */, + 80D0868B2491DB301F772D63 /* BackgroundDeliveryPage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1352,7 +1360,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT ENABLE_NETWORK_EXTENSION"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT ENABLE_NETWORK_EXTENSION COLUMBA_ONBOARDING_ENABLED"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Sources/PythonBridge/ColumbaPython-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -1398,7 +1406,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT ENABLE_NETWORK_EXTENSION"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT ENABLE_NETWORK_EXTENSION COLUMBA_ONBOARDING_ENABLED"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Sources/PythonBridge/ColumbaPython-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -1924,7 +1932,7 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/torlando-tech/reticulum-swift.git"; requirement = { - branch = "main"; + branch = main; kind = branch; }; }; diff --git a/Sources/ColumbaApp/App/ColumbaApp.swift b/Sources/ColumbaApp/App/ColumbaApp.swift index 98d18541..d6c3545f 100644 --- a/Sources/ColumbaApp/App/ColumbaApp.swift +++ b/Sources/ColumbaApp/App/ColumbaApp.swift @@ -488,6 +488,7 @@ struct RootView: View { OnboardingView( identityManager: identityManager, settingsRepository: settingsRepository, + appServices: appServices, onComplete: { showOnboarding = false identitySwitchTrigger = UUID() @@ -521,7 +522,19 @@ struct RootView: View { // Voice / CallKit removed in the Python RNS migration (Phase 0). // Will return in v2 once canonical Python LXST is ported to iOS audio. } else { + // Not yet initialized. Under Model B, init suspends before the proxy + // backend starts until the NE/VPN tunnel is up; on first launch that + // means showing the background-delivery gate instead of an indefinite + // "Connecting to network…" spinner on a tunnel that doesn't exist yet. + #if ENABLE_NETWORK_EXTENSION + if appServices.needsBackgroundDeliveryApproval { + BackgroundDeliveryGateView(appServices: appServices) + } else { + loadingView + } + #else loadingView + #endif } } .onChange(of: colorScheme) { _, newScheme in diff --git a/Sources/ColumbaApp/Models/TcpCommunityServer.swift b/Sources/ColumbaApp/Models/TcpCommunityServer.swift index 2f328435..661230f7 100644 --- a/Sources/ColumbaApp/Models/TcpCommunityServer.swift +++ b/Sources/ColumbaApp/Models/TcpCommunityServer.swift @@ -33,7 +33,13 @@ extension TcpCommunityServer { // Bootstrap-class servers (well-established, reliable nodes). // Reticulum-Swift does not yet support the bootstrap interface mode, // so the iOS UI surfaces these alongside other community servers. - TcpCommunityServer(name: "Beleth RNS Hub", host: "rns.beleth.net", port: 4242, isBootstrap: true), + // NOTE: Beleth's :4242 is currently unreachable (host up, but the RNS + // port drops connections), so it is NOT flagged bootstrap — otherwise + // `defaultServer` would seed it as the SOLE TCP path on a skipped/empty + // onboarding and the node would come up with no reachable relay. It + // stays in the directory as a selectable option in case it recovers. + // (Diverges intentionally from Android's list until Beleth is back.) + TcpCommunityServer(name: "Beleth RNS Hub", host: "rns.beleth.net", port: 4242, isBootstrap: false), TcpCommunityServer(name: "Quad4 TCP Node 1", host: "rns.quad4.io", port: 4242, isBootstrap: true), TcpCommunityServer(name: "FireZen", host: "firezen.com", port: 4242, isBootstrap: true), diff --git a/Sources/ColumbaApp/Services/AppServices.swift b/Sources/ColumbaApp/Services/AppServices.swift index 2c591919..e3d59728 100644 --- a/Sources/ColumbaApp/Services/AppServices.swift +++ b/Sources/ColumbaApp/Services/AppServices.swift @@ -292,6 +292,19 @@ public final class AppServices { /// Extension frame reader for processing queued frames from the extension. private var extensionFrameReader: ExtensionFrameReader? + + /// Model-B first-launch gate. Set true while `startPythonBackend` suspends, before + /// `backend.start()`, waiting for the user to approve + enable background delivery + /// (the NE owns the node, so its VPN tunnel MUST be up first — on a fresh install it + /// isn't, and proxying to it would otherwise spin for minutes). RootView shows + /// `BackgroundDeliveryGateView` while this is true; `approveBackgroundDelivery()` + /// clears it and resumes init. Persisted approval (returning users) skips the gate. + public var needsBackgroundDeliveryApproval = false + + @ObservationIgnored + private var backgroundDeliveryApprovalContinuation: CheckedContinuation? + + private static let backgroundDeliveryEnabledKey = "background_delivery_enabled" #endif /// Darwin notification name used by on-device test instrumentation to @@ -960,6 +973,123 @@ public final class AppServices { /// `InterfaceEntity` records from `InterfaceRepository`). No host/port /// is hardcoded — if `interfaces` is empty the app starts offline and /// the user adds an interface in Settings → Manage Interfaces. + #if ENABLE_NETWORK_EXTENSION + /// Create + wire the tunnel manager exactly once (idempotent). Called by + /// `initialize()` and by the onboarding background-delivery step, which may bring + /// the tunnel up before `initialize()` runs. + private func ensureTunnelManager() async { + guard tunnelManager == nil else { return } + let tunnel = TunnelManager() + self.tunnelManager = tunnel + // Wire tunnel state -> per-interface tunnel-mode coordination (see initialize()). + tunnel.onStatusChange = { [weak self] newStatus in + guard let self else { return } + Task { @MainActor in + switch newStatus { + case .connected: + await self.applyTunnelModeToInterfaces(active: true) + case .disconnected, .invalid: + await self.applyTunnelModeToInterfaces(active: false) + default: + break + } + } + } + await tunnel.load() + } + + /// Onboarding's in-flow "Enable Background Delivery" step (Model B). Shares the + /// active identity into the NE-readable keychain, brings the VPN tunnel up (the + /// iOS "Allow" prompt fires here), and persists approval so the post-onboarding + /// init takes the silent-reconnect path (no second gate). Returns whether the + /// tunnel connected; the page surfaces an error + retry on `false`. No-op-ish on + /// simulator/unsigned builds (the shared-keychain group is nil and the tunnel + /// won't truly connect there). + @discardableResult + public func enableBackgroundDeliveryForOnboarding(identity: Identity) async -> Bool { + Self.shareIdentityForModelB(identity) + await ensureTunnelManager() + guard let tunnel = tunnelManager else { return false } + do { + try await tunnel.install() + try await tunnel.start() + } catch { + DiagLog.log("[TUNNEL-GATE] onboarding enable failed: \(error)") + return false + } + guard await tunnel.waitUntilConnected(timeoutMs: 25_000) else { + DiagLog.log("[TUNNEL-GATE] onboarding enable: tunnel did not connect (approval denied?)") + return false + } + SharedDefaults.suite.set(true, forKey: Self.backgroundDeliveryEnabledKey) + DiagLog.log("[TUNNEL-GATE] onboarding enable: tunnel connected + approval persisted") + return true + } + + /// Ensure the NE/VPN tunnel is connected before the Model-B proxy backend starts. + /// + /// Returning users (approval persisted) get a SILENT bring-up — `install()` is a + /// no-op re-save, `start()` reconnects, iOS does not re-prompt. First run (or if the + /// silent bring-up can't connect, e.g. the user revoked the VPN in iOS Settings) + /// suspends here and shows `BackgroundDeliveryGateView`; `approveBackgroundDelivery()` + /// resumes us once the tunnel is up. This is what keeps `backend.start()` from + /// spinning minutes on a fresh install. + private func ensureBackgroundDeliveryTunnel() async { + guard let tunnel = tunnelManager else { + DiagLog.log("[TUNNEL-GATE] no tunnel manager — skipping (degraded)") + return + } + if SharedDefaults.suite.bool(forKey: Self.backgroundDeliveryEnabledKey) { + // Returning user: bring the tunnel up without prompting. + try? await tunnel.install() + try? await tunnel.start() + if await tunnel.waitUntilConnected(timeoutMs: 20_000) { + DiagLog.log("[TUNNEL-GATE] returning user — tunnel reconnected") + return + } + DiagLog.log("[TUNNEL-GATE] returning user — silent reconnect failed; showing gate") + } + // First run, or silent reconnect failed → require explicit approval via the gate. + DiagLog.log("[TUNNEL-GATE] awaiting background-delivery approval (showing gate)") + await withCheckedContinuation { (cont: CheckedContinuation) in + backgroundDeliveryApprovalContinuation = cont + needsBackgroundDeliveryApproval = true + } + DiagLog.log("[TUNNEL-GATE] approval received — resuming init") + } + + /// Gate-flow entry point ONLY (called by `BackgroundDeliveryGateView`'s Enable + /// button — `internal` to keep it out of the public API so it can't be invoked + /// outside the gate, where the suspended-init continuation it resumes wouldn't + /// exist). Installs + starts the tunnel (the first `install()` fires the iOS + /// VPN-approval prompt), waits for it to connect, persists the approval, then + /// resumes the suspended init. Returns whether the tunnel connected — the gate + /// surfaces an error + retry on `false` (e.g. the user tapped "Don't Allow"), + /// WITHOUT resuming init, so the user can try again. (The non-gate "enable + /// background delivery" path is the separate `enableBackgroundDeliveryForOnboarding`.) + @discardableResult + func approveBackgroundDelivery() async -> Bool { + guard let tunnel = tunnelManager else { return false } + do { + try await tunnel.install() + try await tunnel.start() + } catch { + DiagLog.log("[TUNNEL-GATE] enable failed: \(error)") + return false + } + guard await tunnel.waitUntilConnected(timeoutMs: 25_000) else { + DiagLog.log("[TUNNEL-GATE] enable: tunnel did not connect (approval denied?)") + return false + } + SharedDefaults.suite.set(true, forKey: Self.backgroundDeliveryEnabledKey) + needsBackgroundDeliveryApproval = false + backgroundDeliveryApprovalContinuation?.resume() + backgroundDeliveryApprovalContinuation = nil + DiagLog.log("[TUNNEL-GATE] enable: tunnel connected + approval persisted") + return true + } + #endif + private func startPythonBackend( identity: Identity, identityHashHex: String, @@ -1052,6 +1182,18 @@ public final class AppServices { let identityBytes = try? identity.exportPrivateKeys() DiagLog.log("[RNS] identityBytes=\(identityBytes?.count ?? -1)") + #if ENABLE_NETWORK_EXTENSION + // Model B: `backend` is the thin-client proxy; `backend.start()` round-trips to + // the NE node over the VPN tunnel session, so the tunnel MUST be connected first. + // A fresh install has no VPN config at all — without this, start() would spin + // ~30×8s on a dead session (the "stuck on Connecting to network… for minutes" + // bug). Bring the tunnel up, gating first-run on the background-delivery approval + // gate so the iOS VPN prompt is a deliberate user step, not a silent hang. + if BackendPreference.modelB { + await ensureBackgroundDeliveryTunnel() + } + #endif + do { DiagLog.log("[RNS] calling backend.start()") let info = try await backend.start( @@ -1871,6 +2013,19 @@ public final class AppServices { /// TCP is unaffected. @MainActor public func applyInterfaceChanges() async { + // Model B: the NE owns the RNS node + all interfaces; the app's `backend` here is + // the thin `ProxyRnsBackend`, whose `addInterface` throws `unsupportedInProxy`. The + // python-shaped hot-add/-remove path below is therefore both wrong (it would error + // on every relay) AND unnecessary — `InterfaceRepository.saveInterfaces()` already + // wrote the shared `interfacesKey` and posted `configChanged`, which the NE observes + // (`startTCPRelayConfigObserver` → `reconcileTCPRelays`) to live-reconcile its + // `ne-tcp-relay-*` interfaces. So a relay add/edit/remove takes effect with NO VPN + // restart. Nothing more to do app-side; bail before the python path. + if BackendPreference.modelB { + DiagLog.log("[RNS-HOT] modelB: interface change handed to NE via configChanged (no app-side hot-add)") + return + } + let fresh = InterfaceRepository().getEnabledInterfaces() let freshById = Dictionary(uniqueKeysWithValues: fresh.map { ($0.id, $0) }) @@ -2487,32 +2642,9 @@ public final class AppServices { reader.startListening() - // 13. Load tunnel manager - let tunnel = TunnelManager() - self.tunnelManager = tunnel - - // Wire tunnel state -> per-interface tunnel-mode coordination. - // When the VPN extension reports `.connected`, switch each - // TCPInterface / AutoInterface into tunnel mode so its - // outbound traffic flows through the extension's authoritative - // socket instead of through a duplicate local NWConnection. - // When the extension goes back to `.disconnected`, restore the - // local NWConnection-managed path. The closure is invoked on - // the main actor by TunnelManager. - tunnel.onStatusChange = { [weak self] newStatus in - guard let self else { return } - Task { @MainActor in - switch newStatus { - case .connected: - await self.applyTunnelModeToInterfaces(active: true) - case .disconnected, .invalid: - await self.applyTunnelModeToInterfaces(active: false) - default: - break - } - } - } - await tunnel.load() + // 13. Tunnel manager (idempotent — the onboarding background-delivery step may + // have already created it and brought the tunnel up). + await ensureTunnelManager() #endif // Start Python RNS backend on the multi-identity path too. @@ -3647,15 +3779,17 @@ public final class AppServices { DiagLog.log("[ANNOUNCE] sent via Python (name=\"\(displayName)\")") } - /// Model B UI helper: the NE owns the TCP relay, so the app has no local - /// `TCPInterface` to report — the interface card would otherwise show a - /// permanent "disconnected". Query the NE (via the proxy `statusSnapshot`) - /// for the relay's connected state so the card reflects reality. Returns - /// `false` off Model B or when the NE isn't reachable. - public func neTcpRelayOnline() async -> Bool { - guard BackendPreference.modelB, let backend = backend else { return false } + /// Per-relay status for the Interface UI. Maps each registered `ne-tcp-relay-` + /// interface back to its entity id (the suffix), with online + last error so the + /// card can show connected / unreachable / connecting per relay. + public func neTcpRelayStatuses() async -> [(entityId: String, online: Bool, lastError: String?)] { + guard BackendPreference.modelB, let backend = backend else { return [] } let snap = await backend.statusSnapshot() - return snap?.interfaces.first { $0.sectionName == "ne-tcp-relay" }?.online ?? false + return (snap?.interfaces ?? []).compactMap { iface in + guard iface.sectionName.hasPrefix("ne-tcp-relay-") else { return nil } + let entityId = String(iface.sectionName.dropFirst("ne-tcp-relay-".count)) + return (entityId: entityId, online: iface.online, lastError: iface.lastError) + } } /// Send both the LXMF delivery announce and the LXST telephony announce. diff --git a/Sources/ColumbaApp/Services/TunnelManager.swift b/Sources/ColumbaApp/Services/TunnelManager.swift index 7c06f557..92902363 100644 --- a/Sources/ColumbaApp/Services/TunnelManager.swift +++ b/Sources/ColumbaApp/Services/TunnelManager.swift @@ -268,6 +268,19 @@ public final class TunnelManager: @unchecked Sendable { status == .connected } + /// Poll until the tunnel is `.connected`, up to `timeoutMs`; returns whether it + /// connected in time. Used by the Model-B first-launch background-delivery gate to + /// confirm the NE is actually up before the app proxies to it (so the proxy's + /// `start()` handshake connects in seconds instead of spinning on a dead session). + /// + /// Delegates to `connectedSession(timeoutMs:)` so the polling logic AND the status + /// source are shared — reading the live `manager.connection.status` rather than the + /// cached `self.status` (which lags one main-actor hop behind the + /// `NEVPNStatusDidChange` observer). + public func waitUntilConnected(timeoutMs: Int) async -> Bool { + await connectedSession(timeoutMs: timeoutMs) != nil + } + /// Remove the VPN configuration entirely. public func uninstall() async throws { guard let manager else { return } diff --git a/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift b/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift index 3005aa02..d6a80931 100644 --- a/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift @@ -136,13 +136,20 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { private var networkStateChangedObserver: NSObjectProtocol? /// Latest NE-derived BLE badge values (Model B only), populated by the - /// event-driven `refreshNEBackedBLEStatus()` and consumed by the status + /// event-driven `refreshNEBackedStatus()` and consumed by the status /// loop / `interfaceStatus` write. Under Model B the BLE radio + interface /// live across the NE seam, so these are the only source of truth for the /// badge; the 1s loop must NOT round-trip the NE to derive them. @MainActor private var modelBBLEPeerCount: Int = 0 @MainActor private var modelBBLEState: InterfaceState = .disconnected + /// Latest NE-derived PER-RELAY status (Model B only), keyed by interface entity id, + /// populated by the event-driven `refreshNEBackedStatus()` and read back by the 1s + /// status loop. Each `ne-tcp-relay-` interface maps to its own badge so one dead + /// relay shows "Unreachable" while a reachable one shows "Connected" — the coarse + /// any-relay-online bool can't express that. NE-push driven (no per-second round-trip). + @MainActor private var modelBRelayStatuses: [String: InterfaceStatus] = [:] + // MARK: - Computed Properties /// Whether we're in edit mode (vs add mode) @@ -414,14 +421,15 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { // Read TCP interface states off main thread var tcpUpdates: [(String, InterfaceStatus, String?)] = [] if BackendPreference.modelB { - // Model B: the app owns no local TCP interface — the NE owns - // the relay socket. Reflect the NE's relay status (via the - // proxy statusSnapshot) so the card isn't stuck "disconnected" - // while the NE relay is actually connected. - let relayOnline = await appSvc.neTcpRelayOnline() - let neStatus: InterfaceStatus = relayOnline ? .connected : .connecting + // Model B: the app owns no local TCP interface — the NE owns each + // relay socket. Read back the PER-RELAY status cached by the + // event-driven `refreshNEBackedStatus()` (NE-push, not a per-second + // round-trip) so each relay's badge reflects its own reachability and + // the card isn't stuck "disconnected" while a relay is actually up. A + // relay the NE hasn't registered yet (just added) defaults to connecting. + let cached = await MainActor.run { self.modelBRelayStatuses } for entity in tcpEntities { - tcpUpdates.append((entity.id, neStatus, nil)) + tcpUpdates.append((entity.id, cached[entity.id] ?? .connecting, nil)) } } else { for entity in tcpEntities { @@ -459,7 +467,7 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { // 1s (`appSvc.getBLEConnectionInfos()`) — part of the ~10/s app↔NE // IPC flood we're eliminating. The badge is now EVENT-DRIVEN: the // NE pushes `networkStateChangedInApp` on change and - // `refreshNEBackedBLEStatus()` fetches once into these cached + // `refreshNEBackedStatus()` fetches once into these cached // values, which we just read back here (no NE I/O on the timer). blePeerCount = await MainActor.run { self.modelBBLEPeerCount } bleState = await MainActor.run { self.modelBBLEState } @@ -593,7 +601,7 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { private func startNetworkStateObserver() { // One initial refresh so the badge is correct before the first push. Task { @MainActor [weak self] in - await self?.refreshNEBackedBLEStatus() + await self?.refreshNEBackedStatus() } // Refresh once per NE push. The NE coalesces state changes and posts @@ -604,39 +612,57 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { queue: .main ) { [weak self] _ in Task { @MainActor in - await self?.refreshNEBackedBLEStatus() + await self?.refreshNEBackedStatus() } } } - /// Fetch the NE-derived BLE badge (`blePeerCount` / `bleState`) exactly once - /// and publish it to `interfaceStatus`. This is the ONLY place that calls the - /// NE's `getBLEConnectionInfos()`; it runs on NE push, never on a timer. + /// Fetch the NE-derived interface badges (BLE peer count + per-relay TCP status) + /// exactly once and publish them to `interfaceStatus`. This is the ONLY place that + /// round-trips the NE for these; it runs on NE push, never on a timer. /// - /// Under Model A the NE doesn't own the BLE interface, so there's nothing to - /// pull over the seam — the local `bleIf` actor read in `startStatusObserver` - /// remains the source of truth and this returns early. + /// Under Model A the NE doesn't own these interfaces, so there's nothing to pull over + /// the seam — the local actors read in `startStatusObserver` remain the source of + /// truth and this returns early. @MainActor - private func refreshNEBackedBLEStatus() async { + private func refreshNEBackedStatus() async { guard BackendPreference.modelB else { return } - // Single NE round-trip (event-driven, replaces the per-second poll). + // --- BLE badge (single NE round-trip; replaces the per-second poll). --- let count = await appServices.getBLEConnectionInfos().count let state: InterfaceState = count > 0 ? .connected : .disconnected - - // Cache for the status loop's BLE-badge write (Model B branch reads these - // back instead of round-tripping the NE). + // Cache for the status loop's BLE-badge write (Model B branch reads these back). modelBBLEPeerCount = count modelBBLEState = state - - // Publish immediately too, so the badge updates on the push rather than - // waiting up to ~1s for the next loop tick. Mirrors the loop's mapping: - // a live BLE peer ⇒ .connected, else .disconnected. + // Publish immediately too, so the badge updates on the push rather than waiting + // up to ~1s for the next loop tick. if let bleEntity = repository.getEnabledInterfaces().first(where: { $0.type == .ble }) { - // `interfaceStatus` values are `InterfaceStatus` (not `InterfaceState`); - // map the same way the status loop does: a live BLE peer ⇒ .connected. interfaceStatus[bleEntity.id] = count > 0 ? .connected : .disconnected } + + // --- Per-relay TCP status (one snapshot covering every `ne-tcp-relay-`). --- + // Map the NE's online + lastError to a per-relay badge: online ⇒ connected; down + // with an error ⇒ error ("Unreachable", honest about a relay that won't connect); + // down with no error yet ⇒ connecting. No per-relay toast — the badge carries it + // (a global error toast flapped between relays every second). + let relayStatuses = await appServices.neTcpRelayStatuses() + var fresh: [String: InterfaceStatus] = [:] + for relay in relayStatuses { + let status: InterfaceStatus + if relay.online { + status = .connected + } else if let err = relay.lastError, !err.isEmpty { + status = .error + } else { + status = .connecting + } + fresh[relay.entityId] = status + } + modelBRelayStatuses = fresh + // Publish immediately so the badges update on the push, not the next tick. + for entity in repository.getEnabledInterfaces() where entity.type == .tcpClient { + interfaceStatus[entity.id] = fresh[entity.id] ?? .connecting + } } // MARK: - Form Helpers diff --git a/Sources/ColumbaApp/ViewModels/NetworkStatusViewModel.swift b/Sources/ColumbaApp/ViewModels/NetworkStatusViewModel.swift index 4cc5f6de..436c9bf0 100644 --- a/Sources/ColumbaApp/ViewModels/NetworkStatusViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/NetworkStatusViewModel.swift @@ -210,12 +210,18 @@ final class NetworkStatusViewModel { else { typeName = Self.displayType(forRaw: iface.typeRaw) } let addr = (iface.peerAddress?.isEmpty == false) ? iface.peerAddress : nil let err = (iface.lastError?.isEmpty == false) ? iface.lastError : nil + // An offline relay that reported an error is `.connectionFailed` (the row shows + // the reason), not a bland `.disconnected` — so a relay that can't reach its + // host is honestly surfaced instead of looking idle. No error yet ⇒ disconnected. + let mappedState: InterfaceState = iface.online + ? .connected + : (err != nil ? .connectionFailed(underlying: err!) : .disconnected) return InterfaceInfo( id: iface.sectionName, name: iface.name, type: typeName, online: iface.online, - state: iface.online ? .connected : .disconnected, + state: mappedState, isAutoInterfacePeer: isAutoPeer, peerAddress: addr, lastErrorDescription: err diff --git a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift index e89fddc1..f5264723 100644 --- a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift @@ -10,6 +10,7 @@ import Foundation import RNSAPI import Observation import UserNotifications +import CoreBluetooth /// Manages onboarding flow state and persists selections on completion. @available(iOS 17.0, macOS 14.0, *) @@ -23,6 +24,13 @@ final class OnboardingViewModel { var selectedInterfaces: Set = [] var selectedTcpServer: TcpCommunityServer? = nil var notificationsGranted: Bool = false + /// Current CoreBluetooth authorization. Under Model B the APP runs the CoreBluetooth + /// host (the NE can't), so the BLE prompt would otherwise fire un-guided after + /// onboarding when `ModelBBLEService` starts — surfacing it on the Permissions page + /// keeps it inside the flow. + var bluetoothAuthorization: CBManagerAuthorization = CBCentralManager.authorization + var bluetoothGranted: Bool { bluetoothAuthorization == .allowedAlways } + @ObservationIgnored private var bluetoothProbe: BluetoothPermissionProbe? var isSaving: Bool = false /// Identity created during onboarding (set by prepareIdentity). @@ -31,7 +39,7 @@ final class OnboardingViewModel { var qrCodeString: String = "" /// Total number of onboarding pages. - static let pageCount = 5 + static let pageCount = 6 // MARK: - Computed @@ -79,6 +87,21 @@ final class OnboardingViewModel { notificationsGranted = settings.authorizationStatus == .authorized } + // MARK: - Bluetooth Permission + + /// Trigger the iOS Bluetooth prompt now (creating a CBCentralManager is what fires + /// it) so the user grants/denies it INSIDE onboarding instead of being surprised by + /// it after, when `ModelBBLEService` starts the app-side CoreBluetooth host. + func requestBluetoothPermission() { + bluetoothProbe = BluetoothPermissionProbe { [weak self] auth in + Task { @MainActor in self?.bluetoothAuthorization = auth } + } + } + + func checkBluetoothStatus() { + bluetoothAuthorization = CBCentralManager.authorization + } + // MARK: - Identity Preparation /// Create the identity eagerly so the QR code is available on the complete page. @@ -161,11 +184,19 @@ final class OnboardingViewModel { await settingsRepository.setDisplayName("Anonymous Peer") + // Model B: the NE node delivers over the first enabled tcpClient relay, so a + // skipped setup must still seed one — otherwise the node comes up with no + // reachable path and the user can't message anyone. (An AutoInterface-only + // seed is a no-op the NE ignores.) let interfaceRepo = InterfaceRepository() + let server = TcpCommunityServer.defaultServer interfaceRepo.addInterface(InterfaceEntity( - name: "Auto Discovery", - type: .autoInterface, - config: .autoInterface(AutoInterfaceConfig()) + name: server.name, + type: .tcpClient, + config: .tcpClient(TCPClientConfig( + targetHost: server.host, + targetPort: server.port + )) )) UserDefaults.standard.set(true, forKey: "has_completed_onboarding") @@ -192,42 +223,39 @@ final class OnboardingViewModel { // MARK: - Private + /// Seed the chosen TCP relay into the SHARED interface store. MUST run before the + /// NE is started (on the Background-Delivery step) — the in-NE node reads its relay + /// from this store ONCE at start (`loadTCPRelayConfig`) and has no observer to pick + /// up a later write, so seeding after the NE boots leaves it "AppGroupBridge only" + /// with no TCP path. Idempotent (it also runs again from completeOnboarding). + func seedInterfaces() { + createInterfaces(in: InterfaceRepository()) + } + private func createInterfaces(in repo: InterfaceRepository) { - for interfaceType in selectedInterfaces { - switch interfaceType { - case .auto: - repo.addInterface(InterfaceEntity( - name: "Auto Discovery", - type: .autoInterface, - config: .autoInterface(AutoInterfaceConfig()) - )) - case .nearby: - repo.addInterface(InterfaceEntity( - name: "Nearby", - type: .multipeer, - config: .multipeer(MultipeerConfig()) - )) - case .ble: - repo.addInterface(InterfaceEntity( - name: "Bluetooth LE", - type: .ble, - config: .ble(BLEConfig()) - )) - case .tcp: - let server = selectedTcpServer ?? TcpCommunityServer.defaultServer - repo.addInterface(InterfaceEntity( - name: server.name, - type: .tcpClient, - config: .tcpClient(TCPClientConfig( - targetHost: server.host, - targetPort: server.port - )) - )) - case .rnode: - // RNode requires separate configuration wizard — skip during onboarding - break + // Model B: the NE node delivers over the first enabled `tcpClient` relay and + // IGNORES auto/multipeer/ble entities (those interfaces, where they exist, are + // owned by the NE process itself, not configured here). So onboarding seeds + // exactly one enabled TCP relay — the user's pick, or the default community + // server — guaranteeing a reachable path even if nothing was explicitly chosen. + let server = selectedTcpServer ?? TcpCommunityServer.defaultServer + // Idempotent: seedInterfaces() (pre-NE) and completeOnboarding both call this; + // don't add a duplicate relay if it's already present. + let alreadySeeded = repo.getEnabledInterfaces().contains { entity in + if case .tcpClient(let cfg) = entity.config { + return cfg.targetHost == server.host && cfg.targetPort == server.port } + return false } + guard !alreadySeeded else { return } + repo.addInterface(InterfaceEntity( + name: server.name, + type: .tcpClient, + config: .tcpClient(TCPClientConfig( + targetHost: server.host, + targetPort: server.port + )) + )) } } @@ -291,3 +319,24 @@ enum OnboardingInterfaceType: String, CaseIterable, Hashable { } } } + +/// Triggers the iOS Bluetooth permission dialog by initializing a CBCentralManager +/// (iOS prompts on first creation when authorization is `.notDetermined`) and reports +/// the resulting authorization. Used by onboarding's Permissions page so the Model-B +/// app-side CoreBluetooth host doesn't surprise-prompt after setup. +private final class BluetoothPermissionProbe: NSObject, CBCentralManagerDelegate { + private var manager: CBCentralManager? + private let onAuthorizationChange: (CBManagerAuthorization) -> Void + + init(onAuthorizationChange: @escaping (CBManagerAuthorization) -> Void) { + self.onAuthorizationChange = onAuthorizationChange + super.init() + manager = CBCentralManager(delegate: self, queue: nil, options: [ + CBCentralManagerOptionShowPowerAlertKey: false + ]) + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + onAuthorizationChange(CBCentralManager.authorization) + } +} diff --git a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift index 15dd911e..404c7ca2 100644 --- a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift @@ -505,18 +505,24 @@ public final class SettingsViewModel { // app-owned radios (Auto / BLE / RNode) are read locally in both models. let modelB = BackendPreference.modelB - let tcpConnected: Bool - if modelB { - tcpConnected = await appServices.neTcpRelayOnline() - } else if let tcp = appServices.tcpInterface { - tcpConnected = await tcp.state == .connected - } else { - tcpConnected = false - } - - // Build connected interface string from all active interfaces + // Build connected interface string from all active interfaces. var activeInterfaces: [String] = [] - if tcpConnected { + + if modelB { + // Model B: the NE owns MULTIPLE relays (one `ne-tcp-relay-` per + // enabled tcpClient). Label the card with the relays that are ACTUALLY online, + // matched by entity id via the per-relay snapshot — NOT the first-configured + // one. The old code labeled with `.first(tcpClient)` whenever ANY relay was up + // (a coarse any-online bool), so a down first relay (e.g. a dead community hub) + // was shown as the connected interface while a different relay carried traffic. + let onlineIds = Set(await appServices.neTcpRelayStatuses().filter { $0.online }.map { $0.entityId }) + let interfaceRepo = InterfaceRepository() + for entity in interfaceRepo.getEnabledInterfaces() where entity.type == .tcpClient { + guard onlineIds.contains(entity.id), case .tcpClient(let config) = entity.config else { continue } + activeInterfaces.append("TCP (\(config.targetHost):\(String(config.targetPort)))") + } + } else if let tcp = appServices.tcpInterface, await tcp.state == .connected { + // Model A: the app owns a single local TCP interface. let interfaceRepo = InterfaceRepository() if let tcpEntity = interfaceRepo.getEnabledInterfaces().first(where: { $0.type == .tcpClient }), case .tcpClient(let config) = tcpEntity.config { diff --git a/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift b/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift new file mode 100644 index 00000000..dc7a7734 --- /dev/null +++ b/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift @@ -0,0 +1,115 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2026 Torlando Tech LLC + +// +// BackgroundDeliveryGateView.swift +// ColumbaApp +// +// First-launch gate for Model B (Network Extension). The NE owns the Reticulum/ +// LXMF node, so the app cannot send or receive until its on-device VPN tunnel is +// installed + running. iOS requires an explicit user approval for a VPN +// configuration, so we present this deliberate step (instead of silently triggering +// the system prompt behind a spinner). Shown by RootView while +// AppServices.needsBackgroundDeliveryApproval is true; tapping Enable installs + +// starts the tunnel and, once it connects, resumes app initialization. +// + +#if ENABLE_NETWORK_EXTENSION +import SwiftUI + +struct BackgroundDeliveryGateView: View { + let appServices: AppServices + + @State private var isWorking = false + @State private var errorMessage: String? + + var body: some View { + ZStack { + Theme.backgroundPrimary.ignoresSafeArea() + + VStack(spacing: 24) { + Spacer() + + ZStack { + Circle() + .fill(Theme.accentColor.opacity(0.15)) + .frame(width: 110, height: 110) + Image(systemName: "bolt.horizontal.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(Theme.accentColor) + } + + VStack(spacing: 10) { + Text("Enable Background Delivery") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(Theme.textPrimary) + .multilineTextAlignment(.center) + + Text("Columba runs a small on-device VPN so it can keep delivering and receiving your messages in the background — even when the app is closed. Your traffic isn't sent to any server; the tunnel only powers Columba's own network node on your device.") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 8) + } + .padding(.horizontal, 24) + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(Theme.error) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + + Spacer() + + Button { + enable() + } label: { + HStack(spacing: 8) { + if isWorking { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + Text(isWorking ? "Connecting…" : (errorMessage == nil ? "Enable" : "Try Again")) + .font(.headline) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Theme.accentColor) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusLarge)) + } + .disabled(isWorking) + .padding(.horizontal, 24) + + Text("iOS will ask you to allow the VPN configuration. Columba can't deliver messages in the background without it.") + .font(.caption2) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .padding(.bottom, 24) + } + } + } + + private func enable() { + guard !isWorking else { return } + isWorking = true + errorMessage = nil + Task { + let ok = await appServices.approveBackgroundDelivery() + isWorking = false + if !ok { + errorMessage = "Couldn't enable background delivery. Make sure you tapped “Allow” on the VPN prompt, then try again." + } + // On success, AppServices clears `needsBackgroundDeliveryApproval` and + // resumes init — RootView swaps this gate out automatically. + } + } +} +#endif diff --git a/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryPage.swift b/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryPage.swift new file mode 100644 index 00000000..f0c9afaf --- /dev/null +++ b/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryPage.swift @@ -0,0 +1,130 @@ +#if COLUMBA_ONBOARDING_ENABLED +// +// BackgroundDeliveryPage.swift +// ColumbaApp +// +// Onboarding step 5 (Model B): enable background delivery by bringing up the +// on-device VPN / Network Extension that owns the Reticulum/LXMF node. +// +// This is a real step IN the flow (before the Complete page): the app creates + +// shares the identity and installs/starts the tunnel here, so by the time the user +// finishes onboarding the node is already up. `onEnable` returns whether the tunnel +// connected (iOS shows its "Allow" VPN prompt during it); on failure the page shows +// an error and lets the user retry rather than advancing. +// + +import SwiftUI + +@available(iOS 17.0, macOS 14.0, *) +struct BackgroundDeliveryPage: View { + /// Performs the enable (create+share identity, install+start tunnel, wait for + /// connect). Returns success; advancing to the next page is the caller's job on + /// `true`. + let onEnable: () async -> Bool + let onBack: () -> Void + + @State private var isWorking = false + @State private var errorMessage: String? + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: 32) + + ZStack { + Circle() + .fill(Theme.accentColor.opacity(0.15)) + .frame(width: 110, height: 110) + Image(systemName: "bolt.horizontal.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(Theme.accentColor) + } + .padding(.bottom, 24) + + Text("Background Delivery") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white) + .padding(.bottom, 8) + + Text("Columba runs a small on-device VPN so it can keep delivering and receiving your messages in the background — even when the app is closed. Your traffic isn't sent to any server; the tunnel only powers Columba's own network node on your device.") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 28) + .padding(.bottom, 16) + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(Theme.error) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + .padding(.bottom, 8) + } + + Text("iOS will ask you to allow the VPN configuration. Columba can't deliver messages in the background without it.") + .font(.caption2) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .padding(.bottom, 16) + } + } + + // Navigation buttons + HStack(spacing: 16) { + Button(action: onBack) { + HStack(spacing: 6) { + Image(systemName: "chevron.left") + Text("Back") + } + .font(.headline) + .foregroundStyle(Theme.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Theme.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(isWorking) + + Button { + enable() + } label: { + HStack(spacing: 8) { + if isWorking { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + Text(isWorking ? "Connecting…" : (errorMessage == nil ? "Enable" : "Try Again")) + } + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Theme.accentGradient) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(isWorking) + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + } + } + + private func enable() { + guard !isWorking else { return } + isWorking = true + errorMessage = nil + Task { + let ok = await onEnable() + isWorking = false + if !ok { + errorMessage = "Couldn't enable background delivery. Make sure you tapped “Allow” on the VPN prompt, then try again." + } + // On success the caller advances to the next page. + } + } +} +#endif diff --git a/Sources/ColumbaApp/Views/Onboarding/ConnectivityPage.swift b/Sources/ColumbaApp/Views/Onboarding/ConnectivityPage.swift index db1a6c85..7e983e23 100644 --- a/Sources/ColumbaApp/Views/Onboarding/ConnectivityPage.swift +++ b/Sources/ColumbaApp/Views/Onboarding/ConnectivityPage.swift @@ -3,26 +3,25 @@ // ConnectivityPage.swift // ColumbaApp // -// Onboarding page 2: Network interface selection with TCP server picker. +// Onboarding page 2 (Model B): pick the community relay the node connects through. +// +// Under Model B the Network Extension owns the node and its interfaces; the only +// user-facing first-run choice that matters is which TCP relay to bootstrap from. +// (The older multi-interface picker + in-app BLE/Bonjour permission probes were +// removed: those interfaces live in the NE's own process, so prompting for them +// here was misleading and the entities were ignored by the node.) // import SwiftUI import RNSAPI -import CoreBluetooth -import Network @available(iOS 17.0, macOS 14.0, *) struct ConnectivityPage: View { - @Binding var selectedInterfaces: Set @Binding var selectedTcpServer: TcpCommunityServer? let onBack: () -> Void let onContinue: () -> Void @State private var showServerPicker = false - @State private var bluetoothAuthorization: CBManagerAuthorization = CBCentralManager.authorization - @State private var bluetoothProbe: BluetoothPermissionProbe? - @State private var localNetworkProbe: LocalNetworkPermissionProbe? - @State private var localNetworkPrompted = false var body: some View { VStack(spacing: 0) { @@ -35,40 +34,24 @@ struct ConnectivityPage: View { .foregroundStyle(Theme.accentColor) .padding(.bottom, 24) - Text("How will you connect?") + Text("Choose a relay") .font(.system(size: 28, weight: .bold)) .foregroundStyle(.white) .padding(.bottom, 8) - Text("Select the networks you'd like to use:") + Text("Columba reaches the wider network through a community relay server. We've picked a good default — you can change it anytime in Settings.") .font(.subheadline) .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 32) .padding(.bottom, 24) - // Interface cards - VStack(spacing: 12) { - ForEach(OnboardingInterfaceType.allCases, id: \.self) { type in - interfaceCard(type) - } - } - .padding(.horizontal, 24) - .padding(.bottom, 12) + tcpServerRow + .padding(.horizontal, 24) + .padding(.bottom, 12) - // TCP server selection - if selectedInterfaces.contains(.tcp) { - tcpServerRow - .padding(.horizontal, 24) - .padding(.bottom, 12) - } - - // Bluetooth permission card - if selectedInterfaces.contains(.ble) { - bluetoothPermissionCard - .padding(.horizontal, 24) - .padding(.bottom, 12) - } - - Text("You can configure these later in Settings") + Text("You can configure connectivity later in Settings") .font(.footnote) .foregroundStyle(Theme.textSecondary) .padding(.bottom, 16) @@ -110,74 +93,14 @@ struct ConnectivityPage: View { serverPickerSheet } .onAppear { - if selectedInterfaces.contains(.auto) && !localNetworkPrompted { - requestLocalNetworkPermission() + // Preselect the default community server so a user who just taps through + // still gets a reachable relay (the NE needs an enabled tcpClient). + if selectedTcpServer == nil { + selectedTcpServer = TcpCommunityServer.defaultServer } } } - // MARK: - Interface Card - - private func interfaceCard(_ type: OnboardingInterfaceType) -> some View { - let isSelected = selectedInterfaces.contains(type) - - return Button { - if isSelected { - selectedInterfaces.remove(type) - if type == .tcp { selectedTcpServer = nil } - } else { - selectedInterfaces.insert(type) - if type == .tcp && selectedTcpServer == nil { - selectedTcpServer = TcpCommunityServer.defaultServer - } - if type == .ble && CBCentralManager.authorization == .notDetermined { - requestBluetoothPermission() - } - if type == .auto && !localNetworkPrompted { - requestLocalNetworkPermission() - } - } - } label: { - HStack(spacing: 14) { - // Checkbox - Image(systemName: isSelected ? "checkmark.square.fill" : "square") - .font(.system(size: 22)) - .foregroundStyle(isSelected ? Theme.accentColor : Theme.textDisabled) - - // Icon - Image(systemName: type.icon) - .font(.system(size: 20)) - .foregroundStyle(isSelected ? Theme.accentColor : Theme.textSecondary) - .frame(width: 28) - - // Text - VStack(alignment: .leading, spacing: 2) { - Text(type.title) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(Theme.textPrimary) - - Text(type.description) - .font(.caption) - .foregroundStyle(Theme.textSecondary) - - Text(type.subtitle) - .font(.caption2) - .foregroundStyle(Theme.textDisabled) - } - - Spacer() - } - .padding(14) - .background(isSelected ? Theme.accentColor.opacity(0.1) : Theme.backgroundSecondary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Theme.accentColor.opacity(0.5) : Theme.divider, lineWidth: 1) - ) - } - .buttonStyle(.plain) - } - // MARK: - TCP Server Row private var tcpServerRow: some View { @@ -187,7 +110,7 @@ struct ConnectivityPage: View { .foregroundStyle(Theme.accentColor) VStack(alignment: .leading, spacing: 2) { - Text("Server") + Text("Relay server") .font(.caption) .foregroundStyle(Theme.textSecondary) Text(selectedTcpServer?.name ?? "Select a server") @@ -260,104 +183,5 @@ struct ConnectivityPage: View { } } } - - // MARK: - Bluetooth Permission Card - - private var bluetoothPermissionCard: some View { - let granted = bluetoothAuthorization == .allowedAlways - - return HStack(spacing: 14) { - Image(systemName: "wave.3.right") - .font(.system(size: 24)) - .foregroundStyle(granted ? Theme.success : Theme.accentColor) - - VStack(alignment: .leading, spacing: 2) { - Text("Bluetooth Access") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(Theme.textPrimary) - Text(granted ? "Enabled" : "Required for BLE mesh networking") - .font(.caption) - .foregroundStyle(Theme.textSecondary) - } - - Spacer() - - if granted { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 24)) - .foregroundStyle(Theme.success) - } else { - Button { - requestBluetoothPermission() - } label: { - Text("Enable") - .font(.subheadline.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Theme.accentColor) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - } - } - .padding(16) - .background(Theme.backgroundSecondary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(granted ? Theme.success.opacity(0.5) : Theme.divider, lineWidth: 1) - ) - } - - private func requestBluetoothPermission() { - bluetoothProbe = BluetoothPermissionProbe { auth in - bluetoothAuthorization = auth - } - } - - private func requestLocalNetworkPermission() { - localNetworkPrompted = true - localNetworkProbe = LocalNetworkPermissionProbe() - } -} - -/// Triggers the iOS Bluetooth permission dialog by initializing a CBCentralManager. -/// iOS shows the permission prompt on first CBCentralManager creation if authorization is .notDetermined. -private class BluetoothPermissionProbe: NSObject, CBCentralManagerDelegate { - private var manager: CBCentralManager? - private let onAuthorizationChange: (CBManagerAuthorization) -> Void - - init(onAuthorizationChange: @escaping (CBManagerAuthorization) -> Void) { - self.onAuthorizationChange = onAuthorizationChange - super.init() - manager = CBCentralManager(delegate: self, queue: nil, options: [ - CBCentralManagerOptionShowPowerAlertKey: false - ]) - } - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - onAuthorizationChange(CBCentralManager.authorization) - } -} - -/// Triggers the iOS local network permission dialog by browsing for a Bonjour service. -/// iOS shows the prompt on first local network access attempt. -private class LocalNetworkPermissionProbe { - private var browser: NWBrowser? - - init() { - let params = NWParameters() - params.includePeerToPeer = true - browser = NWBrowser(for: .bonjour(type: "_reticulum._tcp", domain: nil), using: params) - browser?.stateUpdateHandler = { [weak self] state in - if case .cancelled = state { return } - // Brief browse is enough to trigger the prompt — cancel after 2s - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self?.browser?.cancel() - self?.browser = nil - } - } - browser?.start(queue: .main) - } } #endif diff --git a/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift index 720805c9..cf26ef80 100644 --- a/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift +++ b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift @@ -14,11 +14,14 @@ import RNSAPI struct OnboardingView: View { let identityManager: IdentityManager let settingsRepository: SettingsRepository + let appServices: AppServices let onComplete: () -> Void @State private var viewModel = OnboardingViewModel() + #if COLUMBA_MIGRATION_ENABLED @State private var showRestoreSheet = false @State private var migrationVM: MigrationViewModel? + #endif var body: some View { ZStack { @@ -53,6 +56,7 @@ struct OnboardingView: View { Group { switch viewModel.currentPage { case 0: + #if COLUMBA_MIGRATION_ENABLED WelcomePage( onContinue: { viewModel.nextPage() }, onRestoreFile: { data in @@ -65,6 +69,9 @@ struct OnboardingView: View { Task { await vm.handleImportFile(data: data) } } ) + #else + WelcomePage(onContinue: { viewModel.nextPage() }) + #endif case 1: IdentityPage( displayName: $viewModel.displayName, @@ -73,7 +80,6 @@ struct OnboardingView: View { ) case 2: ConnectivityPage( - selectedInterfaces: $viewModel.selectedInterfaces, selectedTcpServer: $viewModel.selectedTcpServer, onBack: { viewModel.previousPage() }, onContinue: { viewModel.nextPage() } @@ -84,10 +90,43 @@ struct OnboardingView: View { onRequestNotifications: { Task { await viewModel.requestNotificationPermission() } }, + bluetoothGranted: viewModel.bluetoothGranted, + onRequestBluetooth: { viewModel.requestBluetoothPermission() }, onBack: { viewModel.previousPage() }, - onContinue: { viewModel.nextPage() } + onContinue: { + // Guarantee the BLE prompt happens IN-FLOW: the Model-B + // app-side CoreBluetooth host (ModelBBLEService) prompts + // unconditionally after onboarding, so if the user didn't + // tap the card's Enable, fire it now as they leave the + // permissions step rather than surprising them later. + if viewModel.bluetoothAuthorization == .notDetermined { + viewModel.requestBluetoothPermission() + } + viewModel.nextPage() + } ) case 4: + BackgroundDeliveryPage( + onEnable: { + // Create the identity now (idempotent) so the NE can + // load it from the shared keychain, activate it, then + // bring the tunnel up. Only advance on success. + await viewModel.prepareIdentity(identityManager: identityManager) + guard let local = viewModel.createdIdentity, + let result = try? await identityManager.switchToIdentity(local.identityHash) + else { return false } + // Seed the TCP relay into the shared store BEFORE the NE + // is started below — the in-NE node reads its relay once + // at start and won't pick up a later write, so seeding + // after would leave it with no TCP path. + viewModel.seedInterfaces() + let ok = await appServices.enableBackgroundDeliveryForOnboarding(identity: result.1) + if ok { viewModel.nextPage() } + return ok + }, + onBack: { viewModel.previousPage() } + ) + case 5: CompletePage( displayName: viewModel.effectiveDisplayName, interfaceNames: viewModel.selectedInterfaceNames, @@ -128,7 +167,9 @@ struct OnboardingView: View { .animation(.easeInOut(duration: 0.25), value: viewModel.currentPage) .task { await viewModel.checkNotificationStatus() + viewModel.checkBluetoothStatus() } + #if COLUMBA_MIGRATION_ENABLED .sheet(isPresented: $showRestoreSheet) { if let vm = migrationVM { OnboardingRestoreSheet(viewModel: vm) { @@ -144,6 +185,7 @@ struct OnboardingView: View { } } } + #endif } } #endif diff --git a/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift b/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift index 17f78964..6e31645a 100644 --- a/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift +++ b/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift @@ -13,6 +13,8 @@ import RNSAPI struct PermissionsPage: View { let notificationsGranted: Bool let onRequestNotifications: () -> Void + let bluetoothGranted: Bool + let onRequestBluetooth: () -> Void let onBack: () -> Void let onContinue: () -> Void @@ -38,7 +40,7 @@ struct PermissionsPage: View { // Notification features VStack(alignment: .leading, spacing: 14) { notificationRow("New messages arrive") - notificationRow("Incoming voice calls") + notificationRow("Someone you know comes online") } .padding(.horizontal, 40) .padding(.bottom, 32) @@ -86,7 +88,52 @@ struct PermissionsPage: View { .padding(.horizontal, 24) .padding(.bottom, 12) - Text("You can change notification settings anytime in iOS Settings") + // Bluetooth permission card — Model B runs the CoreBluetooth host in the + // app, so granting here avoids a surprise prompt after onboarding. Optional: + // the app still works over the relay without it (BLE adds offline/nearby mesh). + HStack(spacing: 14) { + Image(systemName: "wave.3.right") + .font(.system(size: 24)) + .foregroundStyle(bluetoothGranted ? Theme.success : Theme.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text("Bluetooth") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Theme.textPrimary) + Text(bluetoothGranted ? "Enabled" : "Optional — for nearby / offline mesh") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + + Spacer() + + if bluetoothGranted { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundStyle(Theme.success) + } else { + Button(action: onRequestBluetooth) { + Text("Enable") + .font(.subheadline.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Theme.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + .padding(16) + .background(Theme.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(bluetoothGranted ? Theme.success.opacity(0.5) : Theme.divider, lineWidth: 1) + ) + .padding(.horizontal, 24) + .padding(.bottom, 12) + + Text("You can change these anytime in iOS Settings") .font(.footnote) .foregroundStyle(Theme.textSecondary) .multilineTextAlignment(.center) diff --git a/Sources/ColumbaApp/Views/Onboarding/WelcomePage.swift b/Sources/ColumbaApp/Views/Onboarding/WelcomePage.swift index d3dfafee..1274ea36 100644 --- a/Sources/ColumbaApp/Views/Onboarding/WelcomePage.swift +++ b/Sources/ColumbaApp/Views/Onboarding/WelcomePage.swift @@ -13,7 +13,9 @@ import UniformTypeIdentifiers @available(iOS 17.0, macOS 14.0, *) struct WelcomePage: View { let onContinue: () -> Void - let onRestoreFile: (Data) -> Void + /// Optional — only wired (and the "Restore from backup" affordance only shown) + /// when the migration/restore path is compiled in (`COLUMBA_MIGRATION_ENABLED`). + var onRestoreFile: ((Data) -> Void)? = nil @State private var showingFileImporter = false @@ -72,17 +74,22 @@ struct WelcomePage: View { .clipShape(RoundedRectangle(cornerRadius: 14)) } - Button { - showingFileImporter = true - } label: { - Text("Restore from backup") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary) + #if COLUMBA_MIGRATION_ENABLED + if onRestoreFile != nil { + Button { + showingFileImporter = true + } label: { + Text("Restore from backup") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + } } + #endif } .padding(.horizontal, 24) .padding(.bottom, 16) } + #if COLUMBA_MIGRATION_ENABLED .fileImporter( isPresented: $showingFileImporter, allowedContentTypes: [ @@ -95,10 +102,11 @@ struct WelcomePage: View { let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } if let data = try? Data(contentsOf: url) { - onRestoreFile(data) + onRestoreFile?(data) } } } + #endif } private func privacyRow(_ text: String) -> some View { diff --git a/Sources/ColumbaNetworkExtension/NEReticulumNode.swift b/Sources/ColumbaNetworkExtension/NEReticulumNode.swift index 4691067f..35c46942 100644 --- a/Sources/ColumbaNetworkExtension/NEReticulumNode.swift +++ b/Sources/ColumbaNetworkExtension/NEReticulumNode.swift @@ -167,6 +167,16 @@ actor NEReticulumNode { private var rnodeAddInterfaceTask: Task? private var rnodeConfigObserverRegistered = false + /// Model B TCP relays: the app writes the enabled `tcpClient` relays to the shared + /// `interfacesKey`; the NE brings up one `ne-tcp-relay-` interface each and + /// LIVE-RECONCILES them when the app posts `tcpRelayConfigChanged` (add / edit / remove + /// a relay) — so changing interfaces takes effect WITHOUT a VPN restart. We track the + /// applied endpoint per entity id (the snapshot doesn't expose host/port) so the diff + /// can tell an endpoint EDIT (remove + re-add) from an unchanged relay. Keyed by the + /// stable entity id; the interface id is `ne-tcp-relay-`. + private var registeredRelayEndpoints: [String: (host: String, port: UInt16)] = [:] + private var tcpRelayConfigObserverRegistered = false + /// Model B propagation: the app writes the selected propagation node + sync settings /// to `PropagationSeamConfig`; the NE wires them onto `router` and runs the periodic /// sync here (the in-NE router owns delivery, so the app can't sync directly). @@ -383,28 +393,18 @@ actor NEReticulumNode { // before the interface connects. TCPTransport.bypassTunnelEgress = true - if let tcp = Self.loadTCPRelayConfig() { - do { - let cfg = InterfaceConfig( - id: "ne-tcp-relay", - name: "NE TCP Relay", - type: .tcp, - enabled: true, - mode: .full, - host: tcp.host, - port: tcp.port - ) - let tcpIface = try TCPInterface(config: cfg) - try await tp.addInterface(tcpIface) - // NO-PII: never log tcp.host / tcp.port (the relay endpoint). - ExtensionDiagLog.log("NEReticulumNode: TCP relay interface registered") - } catch { - // Non-fatal: the node can still deliver over the AppGroupBridge - // (radio) even if the relay socket can't be brought up. - ExtensionDiagLog.log("NEReticulumNode: TCP relay addInterface failed (non-fatal): \(String(describing: error))") - } - } else { + // Register EVERY enabled tcpClient relay (not just the first). The node + // delivers over whichever connects, so one dead/unreachable relay (e.g. a + // down community server) doesn't strand the user when they've also configured + // a reachable one (e.g. their own LAN transport node). Each gets a distinct + // `ne-tcp-relay-` id; the reconnect/announce hooks match on the prefix. + let relays = Self.loadTCPRelayConfigs() + if relays.isEmpty { ExtensionDiagLog.log("NEReticulumNode: no TCP relay configured — node running on AppGroupBridge only") + } else { + for tcp in relays { + await registerTCPRelay(tcp, on: tp) + } } // TODO(C3-followup): reconnect parity. The PoC path @@ -423,6 +423,9 @@ actor NEReticulumNode { await setupRNodeInterface() startRNodeConfigObserver() + // Live-reconcile TCP relays when the app edits its interface set — no VPN restart. + startTCPRelayConfigObserver() + isRunning = true ExtensionDiagLog.log("NEReticulumNode: started (delivery dest=\(Self.hashPrefix(dest.hexHash)))") @@ -438,7 +441,7 @@ actor NEReticulumNode { // AutoAnnounceManager "on TCP reconnect" trigger; reticulum-swift // rate-limits announces per interface, so a flapping link can't spam. await tp.setOnInterfaceConnected { [weak self] interfaceId in - guard interfaceId == "ne-tcp-relay" else { return } + guard interfaceId.hasPrefix("ne-tcp-relay") else { return } await self?.onRelayReconnected() } startAnnounceScheduler() @@ -574,6 +577,96 @@ actor NEReticulumNode { ) } + // MARK: - Model B TCP relays + + /// Register one enabled `tcpClient` relay as a reticulum-swift `TCPInterface`, keyed by + /// its stable entity id (`ne-tcp-relay-`). The interface owns its own socket + + /// `ExponentialBackoff` auto-reconnect; we add it to the transport and record the applied + /// endpoint so `reconcileTCPRelays()` can diff later. NO-PII: never logs host/port. + private func registerTCPRelay(_ relay: (id: String, host: String, port: UInt16), on tp: ReticulumTransport) async { + let ifaceId = "ne-tcp-relay-\(relay.id)" + let cfg = InterfaceConfig( + id: ifaceId, + name: "TCP Relay", + type: .tcp, + enabled: true, + mode: .full, + host: relay.host, + port: relay.port + ) + do { + let iface = try TCPInterface(config: cfg) + try await tp.addInterface(iface) + registeredRelayEndpoints[relay.id] = (host: relay.host, port: relay.port) + ExtensionDiagLog.log("NEReticulumNode: TCP relay registered (\(ifaceId))") + } catch { + ExtensionDiagLog.log("NEReticulumNode: TCP relay \(ifaceId) registration failed (non-fatal): \(String(describing: error))") + } + } + + /// Observe the app's `configChanged` Darwin notification → reconcile the live relay + /// set so adding / editing / removing a TCP interface takes effect WITHOUT a tunnel + /// restart (the prior behavior threw `unsupportedInProxy` and required a manual VPN + /// toggle). `InterfaceRepository.saveInterfaces()` is the SINGLE write chokepoint — + /// every add/update/delete/toggle posts this — so observing it here can't miss a + /// mutation path. Reconcile only touches `ne-tcp-relay-*` interfaces, so a non-TCP + /// change (BLE/RNode toggle) fires a harmless no-op diff. Mirrors `startRNodeConfigObserver`. + private func startTCPRelayConfigObserver() { + guard !tcpRelayConfigObserverRegistered else { return } + tcpRelayConfigObserverRegistered = true + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + center, observer, + { _, observer, _, _, _ in + guard let observer else { return } + let node = Unmanaged.fromOpaque(observer).takeUnretainedValue() + Task { await node.reconcileTCPRelays() } + }, + SharedDefaultsConstants.configChangedNotificationName as CFString, + nil, .deliverImmediately + ) + } + + /// Bring the live TCP relay set in line with the app's current `interfacesKey`: add + /// newly-enabled relays, remove deleted/disabled ones, and remove+re-add a relay whose + /// host/port was edited. Idempotent and safe to call repeatedly (the config observer + /// fires it on every interface change). No-op until the node is running. + private func reconcileTCPRelays() async { + guard isRunning, let tp = transport else { return } + let desired = Self.loadTCPRelayConfigs() + let desiredById = Dictionary(desired.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + + // Remove relays that are gone OR whose endpoint changed (so the edited one is + // torn down before the re-add below). `removeInterface` disconnects the socket. + // Collect the doomed entries FIRST (into a copy) — mutating + // `registeredRelayEndpoints` while iterating it is undefined behavior in Swift. + let toRemove = registeredRelayEndpoints.filter { entityId, endpoint in + guard let wanted = desiredById[entityId] else { return true } + return wanted.host != endpoint.host || wanted.port != endpoint.port + } + for (entityId, _) in toRemove { + let ifaceId = "ne-tcp-relay-\(entityId)" + await tp.removeInterface(id: ifaceId) + registeredRelayEndpoints.removeValue(forKey: entityId) + ExtensionDiagLog.log("NEReticulumNode: TCP relay removed (\(ifaceId))") + } + + // Add newly-desired relays (and re-add endpoint-edited ones removed just above). + for relay in desired where registeredRelayEndpoints[relay.id] == nil { + await registerTCPRelay(relay, on: tp) + } + + // Re-assert the reconnect/announce hook (idempotent setter) so a freshly added + // relay re-announces our delivery dest the moment it connects. + await tp.setOnInterfaceConnected { [weak self] interfaceId in + guard interfaceId.hasPrefix("ne-tcp-relay") else { return } + await self?.onRelayReconnected() + } + // Nudge the app UI to re-read network state now that the relay set changed. + NEReticulumNode.postNetworkStateChangedDarwinNotification() + } + // MARK: - Model B propagation (LXMF) /// Wire the app-selected propagation node onto the router (Model B). The app writes @@ -746,6 +839,10 @@ actor NEReticulumNode { CFNotificationCenterRemoveEveryObserver(darwinCenter, Unmanaged.passUnretained(self).toOpaque()) rnodeConfigObserverRegistered = false propagationObserversRegistered = false + tcpRelayConfigObserverRegistered = false + // Drop the applied-endpoint map; a subsequent start() re-registers from scratch and + // a stale entry would otherwise make reconcileTCPRelays() skip a needed re-add. + registeredRelayEndpoints.removeAll() if let br = bridge { await br.disconnect() @@ -985,18 +1082,21 @@ actor NEReticulumNode { /// Read the enabled `tcpClient` relay endpoint from the SHARED App-Group /// UserDefaults (the same `SharedDefaultsConstants.interfacesKey` JSON the PoC /// dumb-pipe parses in `PacketTunnelProvider.loadInterfaceConfigs`). Returns - /// the first enabled TCP relay's `(host, port)`, or `nil` if none is - /// configured. Foundation-only JSON parse — the node does NOT import `Network` - /// (collision rule), so it surfaces a plain `(String, UInt16)` that `start()` - /// feeds to a reticulum-swift `TCPInterface`. NO-PII: never logs host/port. - static func loadTCPRelayConfig() -> (host: String, port: UInt16)? { + /// ALL enabled TCP relays' `(host, port)` (the node brings up an interface per + /// relay so one dead endpoint doesn't strand a configured-and-reachable one). + /// Foundation-only JSON parse — the node does NOT import `Network` (collision + /// rule), so it surfaces plain `(String, UInt16)`s that `start()` feeds to + /// reticulum-swift `TCPInterface`s. NO-PII: never logs host/port. + static func loadTCPRelayConfigs() -> [(id: String, host: String, port: UInt16)] { let defaults = UserDefaults(suiteName: appGroupIdentifier) ?? .standard guard let data = defaults.data(forKey: SharedDefaultsConstants.interfacesKey), let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { - return nil + return [] } + var relays: [(id: String, host: String, port: UInt16)] = [] for entity in array { - guard let enabled = entity["enabled"] as? Bool, enabled, + guard let id = entity["id"] as? String, + let enabled = entity["enabled"] as? Bool, enabled, let configWrapper = entity["config"] as? [String: Any], let type = configWrapper["type"] as? String, type == "tcpClient", let config = configWrapper["config"] as? [String: Any], @@ -1004,14 +1104,15 @@ actor NEReticulumNode { let port = config["targetPort"] as? Int, // Bound the port: `UInt16(truncatingIfNeeded:)` would silently WRAP an // out-of-range value (65536 → 0, 131071 → 65535), so a misconfigured - // relay would dial port 0 / a wrong port instead of being skipped. - // Skip bad entries (and 0) so a later valid relay can still win. + // relay would dial port 0 / a wrong port. Skip bad entries (and 0). port > 0, port <= 65535 else { continue } - return (host: host, port: UInt16(port)) + // Key by the stable entity id (not positional index) so the live-reload + // diff can target a specific relay across add/remove without index shift. + relays.append((id: id, host: host, port: UInt16(port))) } - return nil + return relays } /// Short, NO-PII hash prefix (≤ 8 hex chars) for logging. @@ -1130,6 +1231,9 @@ actor NEReticulumNode { /// delivery destination promptly. No-op once the node is torn down. private func onRelayReconnected() async { guard isRunning else { return } + // Push network-state to the app so the UI flips to "connected" promptly (the + // BLE/RNode seams already do this on connect; the TCP relay didn't). + NEReticulumNode.postNetworkStateChangedDarwinNotification() ExtensionDiagLog.log("NEReticulumNode: relay (re)connected — re-announcing delivery dest") await selfAnnounce() // Pull any propagated mail queued at the PN while we were disconnected. @@ -1144,7 +1248,7 @@ actor NEReticulumNode { let stepMs = 500 while waited < timeoutMs { let snaps = await transport.getInterfaceSnapshots() - if snaps.contains(where: { $0.id == "ne-tcp-relay" && $0.state == .connected }) { + if snaps.contains(where: { $0.id.hasPrefix("ne-tcp-relay") && $0.state == .connected }) { return true } do {