From 67120abcd276951738914492f25bbceb295973e5 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:12:38 -0400 Subject: [PATCH 1/9] fix(model-b): first-launch background-delivery gate so fresh installs don't hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under Model B the Network Extension IS the node, so `ProxyRnsBackend.start()` round-trips to the NE over the VPN tunnel session. On a fresh install there is no VPN config at all, and nothing on the init path ever installed/started one — the only install()+start() call sites were the Settings/Background-Transport toggles, which are unreachable while the app is stuck on the loading screen behind that very start(). Result: `backend.start()` spun ~30×8s (connectedSession) on a dead session ("Connecting to network…" for minutes), then failed → no node. Bootstrap deadlock; every clean TestFlight install hit it. (It only "worked" in dev because the tunnel was already installed+approved on the device.) Fix — explicit first-run gate (the NE/VPN approval is unavoidable for any NetworkExtension app; make it a deliberate step, not a silent hang): - AppServices.ensureBackgroundDeliveryTunnel() runs right before backend.start() (Model B only). Returning users (approval persisted in the App-Group flag) get a SILENT install→start→waitUntilConnected; first run (or if the silent reconnect can't connect, e.g. VPN revoked in iOS Settings) suspends init on a continuation and sets needsBackgroundDeliveryApproval. - RootView shows BackgroundDeliveryGateView while suspended (instead of an indefinite spinner). Its Enable button → approveBackgroundDelivery(): install()+start() (fires the iOS VPN prompt) → waitUntilConnected → persist approval → resume init, so backend.start() then connects in seconds. Denied/timeout → error + Try Again (init stays suspended; no spin). - TunnelManager.waitUntilConnected(timeoutMs:) added. - All NE-only paths are #if ENABLE_NETWORK_EXTENSION; the Python flavor is untouched. Verified on device (fresh flag): init now logs `[TUNNEL-GATE] awaiting background-delivery approval (showing gate)` and never calls backend.start() until the tunnel is up — the multi-minute hang is gone and the gate is shown immediately. Co-Authored-By: Claude Opus 4.8 --- Columba.xcodeproj/project.pbxproj | 6 +- Sources/ColumbaApp/App/ColumbaApp.swift | 12 ++ Sources/ColumbaApp/Services/AppServices.swift | 86 +++++++++++++ .../ColumbaApp/Services/TunnelManager.swift | 15 +++ .../BackgroundDeliveryGateView.swift | 115 ++++++++++++++++++ 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index bdfffc7a..dbb1056e 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -183,6 +183,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,6 +255,7 @@ 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 = ""; }; @@ -663,6 +665,7 @@ F050 /* PermissionsPage.swift */, F051 /* CompletePage.swift */, F079 /* OnboardingRestoreSheet.swift */, + 56CFC5EC819F4ED82FCC4B07 /* BackgroundDeliveryGateView.swift */, ); path = Onboarding; sourceTree = ""; @@ -1218,6 +1221,7 @@ F4E9991226B4D464017DA247 /* Lucide.swift in Sources */, AAD9231170B11C89EF80F9B8 /* PropagationSeam.swift in Sources */, 1FC4483D48CABE8BF49850EF /* SyncStatusBottomSheet.swift in Sources */, + F83A2212E43B329830919AFF /* BackgroundDeliveryGateView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1924,7 +1928,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..19cc174d 100644 --- a/Sources/ColumbaApp/App/ColumbaApp.swift +++ b/Sources/ColumbaApp/App/ColumbaApp.swift @@ -521,7 +521,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/Services/AppServices.swift b/Sources/ColumbaApp/Services/AppServices.swift index 2c591919..59e12438 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,67 @@ 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 + /// 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") + } + + /// Called by `BackgroundDeliveryGateView`'s Enable button. 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. + @discardableResult + public 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 +1126,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( diff --git a/Sources/ColumbaApp/Services/TunnelManager.swift b/Sources/ColumbaApp/Services/TunnelManager.swift index 7c06f557..770886f2 100644 --- a/Sources/ColumbaApp/Services/TunnelManager.swift +++ b/Sources/ColumbaApp/Services/TunnelManager.swift @@ -268,6 +268,21 @@ public final class TunnelManager: @unchecked Sendable { status == .connected } + /// Poll until the tunnel reports `.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). + public func waitUntilConnected(timeoutMs: Int) async -> Bool { + var waited = 0 + let step = 200 + while true { + if status == .connected { return true } + if waited >= timeoutMs { return false } + try? await Task.sleep(for: .milliseconds(step)) + waited += step + } + } + /// Remove the VPN configuration entirely. public func uninstall() async throws { guard let manager else { return } diff --git a/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift b/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift new file mode 100644 index 00000000..fcdf87e7 --- /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 { + @Bindable var 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 From 8b8db52c8c222fc4e413b9edfe8708d23ab236b5 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:08:37 -0400 Subject: [PATCH 2/9] feat(onboarding): revive the onboarding flow for Model B (enable + clean up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5-page onboarding (Welcome → Identity → Connectivity → Permissions → Complete) existed but had NEVER shipped — every page is wrapped in #if COLUMBA_ONBOARDING_ENABLED and that flag was defined in no build config, so RootView always took the "bypass" branch. It also predated Model B. Enable it (Model-B configs only) and make it correct: - pbxproj: define COLUMBA_ONBOARDING_ENABLED on the ColumbaApp Release-Swift + Debug-Swift configs only (NOT project-level — would leak to the NE/Tests targets — and NOT the Python flavor, which stays bypassed: onboarding ⇒ NE present). - Relay seeding (correctness): under Model B the NE delivers over the first enabled tcpClient relay and ignores auto/multipeer/ble entities. createInterfaces() now always seeds exactly one enabled TCP relay (the pick, or the default community server); skipOnboarding() seeds the default relay instead of an AutoInterface-only (relay-less → unreachable) config. - ConnectivityPage stripped to a single relay picker — removed the multi-interface multi-select and the in-app CoreBluetooth/Bonjour permission probes (those interfaces live in the NE's own process; prompting here was misleading and the entities were no-ops). Default server preselected so tap-through stays reachable. - PermissionsPage: dropped the stale "Incoming voice calls" row (CallKit was removed in the Python-RNS migration). - WelcomePage/OnboardingView: gate the restore-from-backup path behind COLUMBA_MIGRATION_ENABLED (MigrationViewModel + OnboardingRestoreSheet are under that flag) so onboarding compiles with migration off. The NE/VPN step is the EXISTING BackgroundDeliveryGateView, shown by RootView right after onboarding completes while init suspends in ensureBackgroundDeliveryTunnel() — single tunnel owner, no double-gate, no in-pager NE page. Identity-before-NE holds automatically: completeOnboarding() switches to the identity before onComplete fires, and initialize() writes the shared keychain (shareIdentityForModelB) long before the tunnel gate. Existing/returning users skip the pager (migrateExistingUsers back-fills has_completed_onboarding) but still hit the gate once. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 --- Columba.xcodeproj/project.pbxproj | 4 +- .../ViewModels/OnboardingViewModel.swift | 63 ++--- .../Views/Onboarding/ConnectivityPage.swift | 218 ++---------------- .../Views/Onboarding/OnboardingView.swift | 9 +- .../Views/Onboarding/PermissionsPage.swift | 2 +- .../Views/Onboarding/WelcomePage.swift | 24 +- 6 files changed, 73 insertions(+), 247 deletions(-) diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index dbb1056e..91a529f5 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -1356,7 +1356,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; @@ -1402,7 +1402,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; diff --git a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift index e89fddc1..3abe0de1 100644 --- a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift @@ -161,11 +161,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") @@ -193,41 +201,20 @@ final class OnboardingViewModel { // MARK: - Private 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 + repo.addInterface(InterfaceEntity( + name: server.name, + type: .tcpClient, + config: .tcpClient(TCPClientConfig( + targetHost: server.host, + targetPort: server.port + )) + )) } } 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..08864c07 100644 --- a/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift +++ b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift @@ -17,8 +17,10 @@ struct OnboardingView: View { 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 +55,7 @@ struct OnboardingView: View { Group { switch viewModel.currentPage { case 0: + #if COLUMBA_MIGRATION_ENABLED WelcomePage( onContinue: { viewModel.nextPage() }, onRestoreFile: { data in @@ -65,6 +68,9 @@ struct OnboardingView: View { Task { await vm.handleImportFile(data: data) } } ) + #else + WelcomePage(onContinue: { viewModel.nextPage() }) + #endif case 1: IdentityPage( displayName: $viewModel.displayName, @@ -73,7 +79,6 @@ struct OnboardingView: View { ) case 2: ConnectivityPage( - selectedInterfaces: $viewModel.selectedInterfaces, selectedTcpServer: $viewModel.selectedTcpServer, onBack: { viewModel.previousPage() }, onContinue: { viewModel.nextPage() } @@ -129,6 +134,7 @@ struct OnboardingView: View { .task { await viewModel.checkNotificationStatus() } + #if COLUMBA_MIGRATION_ENABLED .sheet(isPresented: $showRestoreSheet) { if let vm = migrationVM { OnboardingRestoreSheet(viewModel: vm) { @@ -144,6 +150,7 @@ struct OnboardingView: View { } } } + #endif } } #endif diff --git a/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift b/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift index 17f78964..26659dd0 100644 --- a/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift +++ b/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift @@ -38,7 +38,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) 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 { From cc60a24cc02c983eca7859f95d011bf9cdf6a472 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:17:05 -0400 Subject: [PATCH 3/9] feat(onboarding): make background-delivery an in-flow step before Finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Tyler: the NE/VPN enable should be a real onboarding step (before the Complete page), not a separate gate shown after Finish. New page order: Welcome → Identity → Relay → Permissions → Background Delivery → Complete. The Background Delivery page (step 5/6) brings the node up in-flow: on Enable it creates + activates the identity (idempotent prepareIdentity + switchToIdentity), shares it to the NE-readable keychain, and installs/starts the VPN tunnel (the iOS "Allow" prompt fires here), waiting for it to connect before advancing. Identity is created HERE rather than on Complete so the NE can load it when the tunnel starts. - AppServices: extracted ensureTunnelManager() (idempotent create+wire+load, used by both initialize() and the onboarding step) and added enableBackgroundDeliveryForOnboarding(identity:) which shares the identity, brings the tunnel up, and persists `background_delivery_enabled`. So the post-onboarding init takes the silent-reconnect path through ensureBackgroundDeliveryTunnel() — reusing the already-up tunnel, NO second gate. - The standalone BackgroundDeliveryGateView remains for users who SKIP onboarding or migrated/returning users (they don't pass through the in-flow page, so the flag is unset and the gate still fires once). - OnboardingViewModel.pageCount 5 → 6; OnboardingView gains an appServices param. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 --- Columba.xcodeproj/project.pbxproj | 4 + Sources/ColumbaApp/App/ColumbaApp.swift | 1 + Sources/ColumbaApp/Services/AppServices.swift | 81 +++++++---- .../ViewModels/OnboardingViewModel.swift | 2 +- .../Onboarding/BackgroundDeliveryPage.swift | 130 ++++++++++++++++++ .../Views/Onboarding/OnboardingView.swift | 17 +++ 6 files changed, 208 insertions(+), 27 deletions(-) create mode 100644 Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryPage.swift diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index 91a529f5..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 */; }; @@ -262,6 +263,7 @@ 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 = ""; }; @@ -666,6 +668,7 @@ F051 /* CompletePage.swift */, F079 /* OnboardingRestoreSheet.swift */, 56CFC5EC819F4ED82FCC4B07 /* BackgroundDeliveryGateView.swift */, + 894C730FEB9AE4CDB4B949F8 /* BackgroundDeliveryPage.swift */, ); path = Onboarding; sourceTree = ""; @@ -1222,6 +1225,7 @@ AAD9231170B11C89EF80F9B8 /* PropagationSeam.swift in Sources */, 1FC4483D48CABE8BF49850EF /* SyncStatusBottomSheet.swift in Sources */, F83A2212E43B329830919AFF /* BackgroundDeliveryGateView.swift in Sources */, + 80D0868B2491DB301F772D63 /* BackgroundDeliveryPage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/ColumbaApp/App/ColumbaApp.swift b/Sources/ColumbaApp/App/ColumbaApp.swift index 19cc174d..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() diff --git a/Sources/ColumbaApp/Services/AppServices.swift b/Sources/ColumbaApp/Services/AppServices.swift index 59e12438..d1bd9189 100644 --- a/Sources/ColumbaApp/Services/AppServices.swift +++ b/Sources/ColumbaApp/Services/AppServices.swift @@ -974,6 +974,58 @@ public final class AppServices { /// 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 @@ -2573,32 +2625,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. diff --git a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift index 3abe0de1..a749f5fd 100644 --- a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift @@ -31,7 +31,7 @@ final class OnboardingViewModel { var qrCodeString: String = "" /// Total number of onboarding pages. - static let pageCount = 5 + static let pageCount = 6 // MARK: - Computed 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/OnboardingView.swift b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift index 08864c07..dfcb91d1 100644 --- a/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift +++ b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift @@ -14,6 +14,7 @@ import RNSAPI struct OnboardingView: View { let identityManager: IdentityManager let settingsRepository: SettingsRepository + let appServices: AppServices let onComplete: () -> Void @State private var viewModel = OnboardingViewModel() @@ -93,6 +94,22 @@ struct OnboardingView: View { onContinue: { 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 } + 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, From 63541638ffe14d676945124389d59ca68eab1ae5 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:14:13 -0400 Subject: [PATCH 4/9] feat(onboarding): request Bluetooth permission in-flow on the Permissions page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BLE permission prompt was appearing AFTER onboarding: under Model B the app runs the CoreBluetooth host (ModelBBLEService — the NE can't run CoreBluetooth), and it inits CB on start during post-onboarding init, firing the iOS prompt un-guided. Surface it inside the flow on the Permissions page: - Added a Bluetooth permission card (mirrors the notification card) — explains it's optional (relay works without it; BLE adds nearby/offline mesh) and lets the user grant it there. OnboardingViewModel gains bluetoothAuthorization/bluetoothGranted + requestBluetoothPermission()/checkBluetoothStatus() via a CBCentralManager probe. - Guarantee in-flow: on leaving the Permissions step, if BLE is still .notDetermined, trigger the prompt then — so the unconditional ModelBBLEService prompt is relocated into onboarding for every user, not just those who tap the card. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 --- .../ViewModels/OnboardingViewModel.swift | 44 +++++++++++++++++ .../Views/Onboarding/OnboardingView.swift | 15 +++++- .../Views/Onboarding/PermissionsPage.swift | 49 ++++++++++++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift index a749f5fd..42e1c2be 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). @@ -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. @@ -278,3 +301,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/Views/Onboarding/OnboardingView.swift b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift index dfcb91d1..afc320e8 100644 --- a/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift +++ b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift @@ -90,8 +90,20 @@ 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( @@ -150,6 +162,7 @@ struct OnboardingView: View { .animation(.easeInOut(duration: 0.25), value: viewModel.currentPage) .task { await viewModel.checkNotificationStatus() + viewModel.checkBluetoothStatus() } #if COLUMBA_MIGRATION_ENABLED .sheet(isPresented: $showRestoreSheet) { diff --git a/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift b/Sources/ColumbaApp/Views/Onboarding/PermissionsPage.swift index 26659dd0..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 @@ -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) From 8f98242a171ff43f0dec7110bd4c6b5639141240 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:17:31 -0400 Subject: [PATCH 5/9] address greptile review feedback (greploop #92 iter 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BackgroundDeliveryGateView: @Bindable → let appServices (no $binding is used; a plain let removes the implicit writable-binding surface). - TunnelManager.waitUntilConnected: delegate to connectedSession(timeoutMs:) so the polling logic AND status source are shared — reads the live manager.connection.status instead of the cached self.status (which lags one main-actor hop behind the NEVPNStatusDidChange observer). - AppServices.approveBackgroundDelivery: public → internal. It's the gate-only entry point (resumes the suspended-init continuation), so keeping it out of the public API prevents a future caller from invoking it outside the gate where no continuation is live. The non-gate enable path is the separate enableBackgroundDeliveryForOnboarding. Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 --- Sources/ColumbaApp/Services/AppServices.swift | 16 +++++++++------ .../ColumbaApp/Services/TunnelManager.swift | 20 +++++++++---------- .../BackgroundDeliveryGateView.swift | 2 +- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/Sources/ColumbaApp/Services/AppServices.swift b/Sources/ColumbaApp/Services/AppServices.swift index d1bd9189..304ec856 100644 --- a/Sources/ColumbaApp/Services/AppServices.swift +++ b/Sources/ColumbaApp/Services/AppServices.swift @@ -1058,13 +1058,17 @@ public final class AppServices { DiagLog.log("[TUNNEL-GATE] approval received — resuming init") } - /// Called by `BackgroundDeliveryGateView`'s Enable button. 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. + /// 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 - public func approveBackgroundDelivery() async -> Bool { + func approveBackgroundDelivery() async -> Bool { guard let tunnel = tunnelManager else { return false } do { try await tunnel.install() diff --git a/Sources/ColumbaApp/Services/TunnelManager.swift b/Sources/ColumbaApp/Services/TunnelManager.swift index 770886f2..92902363 100644 --- a/Sources/ColumbaApp/Services/TunnelManager.swift +++ b/Sources/ColumbaApp/Services/TunnelManager.swift @@ -268,19 +268,17 @@ public final class TunnelManager: @unchecked Sendable { status == .connected } - /// Poll until the tunnel reports `.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 + /// 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 { - var waited = 0 - let step = 200 - while true { - if status == .connected { return true } - if waited >= timeoutMs { return false } - try? await Task.sleep(for: .milliseconds(step)) - waited += step - } + await connectedSession(timeoutMs: timeoutMs) != nil } /// Remove the VPN configuration entirely. diff --git a/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift b/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift index fcdf87e7..dc7a7734 100644 --- a/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift +++ b/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryGateView.swift @@ -21,7 +21,7 @@ import SwiftUI struct BackgroundDeliveryGateView: View { - @Bindable var appServices: AppServices + let appServices: AppServices @State private var isWorking = false @State private var errorMessage: String? From 77c30ff2249696376fc9439af4fcf989ea82bc54 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:55:34 -0400 Subject: [PATCH 6/9] fix(onboarding): seed the TCP relay BEFORE starting the NE (no-TCP regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-flow Background-Delivery step starts the Network Extension during onboarding, but the TCP relay was only seeded later in completeOnboarding (on Finish). The in-NE node reads its relay ONCE at start (loadTCPRelayConfig) and has no observer to pick up a later write, so it booted "no TCP relay configured — AppGroupBridge only" and the device had no TCP path (device log: NE start at 02:07:06, relay written at 02:07:08). Seed the chosen relay into the shared interface store in the Background-Delivery onEnable, BEFORE enableBackgroundDeliveryForOnboarding installs/starts the tunnel, so loadTCPRelayConfig finds it. createInterfaces() is now idempotent (skips a duplicate relay) since completeOnboarding still calls it. Exposed via OnboardingViewModel.seedInterfaces(). (The NE still won't hot-pick-up relay edits made AFTER it's up — that's the deferred TCP-interface observer in issue #91; this fixes the first-run path.) Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 --- .../ViewModels/OnboardingViewModel.swift | 18 ++++++++++++++++++ .../Views/Onboarding/OnboardingView.swift | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift index 42e1c2be..f5264723 100644 --- a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift @@ -223,6 +223,15 @@ 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) { // Model B: the NE node delivers over the first enabled `tcpClient` relay and // IGNORES auto/multipeer/ble entities (those interfaces, where they exist, are @@ -230,6 +239,15 @@ final class OnboardingViewModel { // 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, diff --git a/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift index afc320e8..cf26ef80 100644 --- a/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift +++ b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift @@ -115,6 +115,11 @@ struct OnboardingView: View { 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 From 1f004fe829d0f44cbbaabb8f8a90164104df31de Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:56:54 -0400 Subject: [PATCH 7/9] fix(ne): bring up ALL configured TCP relays, not just the first (issue #91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NE registered a single ne-tcp-relay from loadTCPRelayConfig()'s FIRST enabled tcpClient. With the onboarding-seeded community default (e.g. Beleth) first and dead, the node dialed only that, reported ne-tcp-relay=down, and never tried a second, reachable relay the user had configured (e.g. their own LAN transport node) — leaving the device with no TCP path despite a working relay being right there. Register one TCPInterface per enabled tcpClient (ids ne-tcp-relay-); the node delivers over whichever connects. loadTCPRelayConfig() → loadTCPRelayConfigs() (returns all). The reconnect hook (setOnInterfaceConnected) and the relay-connected wait now match the "ne-tcp-relay" prefix instead of the exact id. (Live add/remove of relays while the NE is already running is still a follow-up — the node reads the relay set once at start; restart picks up changes.) Builds clean (ColumbaNetworkExtension scheme, Debug-Swift, device). Co-Authored-By: Claude Opus 4.8 --- .../NEReticulumNode.swift | 76 +++++++++++-------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/Sources/ColumbaNetworkExtension/NEReticulumNode.swift b/Sources/ColumbaNetworkExtension/NEReticulumNode.swift index 4691067f..27ad6338 100644 --- a/Sources/ColumbaNetworkExtension/NEReticulumNode.swift +++ b/Sources/ColumbaNetworkExtension/NEReticulumNode.swift @@ -383,28 +383,37 @@ 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 (index, tcp) in relays.enumerated() { + let ifaceId = "ne-tcp-relay-\(index)" + do { + let cfg = InterfaceConfig( + id: ifaceId, + name: "NE TCP Relay \(index)", + 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 (\(ifaceId))") + } catch { + // Non-fatal: the node can still deliver over the AppGroupBridge + // (radio) or another relay even if this socket can't be brought up. + ExtensionDiagLog.log("NEReticulumNode: TCP relay \(ifaceId) addInterface failed (non-fatal): \(String(describing: error))") + } + } } // TODO(C3-followup): reconnect parity. The PoC path @@ -438,7 +447,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() @@ -985,16 +994,18 @@ 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() -> [(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: [(host: String, port: UInt16)] = [] for entity in array { guard let enabled = entity["enabled"] as? Bool, enabled, let configWrapper = entity["config"] as? [String: Any], @@ -1004,14 +1015,13 @@ 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)) + relays.append((host: host, port: UInt16(port))) } - return nil + return relays } /// Short, NO-PII hash prefix (≤ 8 hex chars) for logging. @@ -1144,7 +1154,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 { From 7c9cc13fbc59fb08392b3405074ca2ebe0a93bbf Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:47:15 -0400 Subject: [PATCH 8/9] Model B: live-reconcile TCP relays on interface change + per-relay status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interface-management gaps under Model B (NE owns the RNS node): 1. Changing TCP interfaces required a manual VPN restart. The app's `applyInterfaceChanges` ran the python-shaped hot-add path, whose `ProxyRnsBackend.addInterface` throws `unsupportedInProxy`, so a relay add/edit/remove never reached the NE without bouncing the tunnel. The NE now observes the existing `configChanged` Darwin notification (posted at the single `InterfaceRepository.saveInterfaces` chokepoint on every add/edit/delete/toggle) and live-reconciles its `ne-tcp-relay-` sockets — add new, drop removed, remove+re-add on an edited host/port — with no tunnel restart. `applyInterfaceChanges` now early-returns under Model B (the seam handles it). 2. The relay-status UI was stuck "not connected". The multi-relay change renamed interface ids `ne-tcp-relay` -> `ne-tcp-relay-`, but `neTcpRelayOnline()` still exact-matched the old id so it never matched. Now prefix-matches, and a new per-relay `neTcpRelayStatuses()` maps each relay back to its entity id with online + lastError. The Manage Interfaces card shows per-relay status (connected / connecting / "Unreachable"), event-driven off the NE push (no per-second NE round-trip), and Network Status maps an offline-with-error relay to `.connectionFailed` rather than a bland `.disconnected`. Also drop the bootstrap flag from the (currently unreachable) Beleth hub so `TcpCommunityServer.defaultServer` no longer seeds a dead relay as the sole TCP path on a skipped/empty onboarding. NE changes: registerTCPRelay / startTCPRelayConfigObserver / reconcileTCPRelays (mirror the RNode/propagation config-observer pattern), endpoint-tracking map for edit-vs-unchanged diffing, observer flag reset + map clear in stop(). Verified on device (iPhone 14, Model B): all configured relays register by entity id; one relay online and pulling announces; no `unsupportedInProxy` spam; app-side TCP correctly skipped under Model B. Co-Authored-By: Claude Opus 4.8 --- .../Models/TcpCommunityServer.swift | 8 +- Sources/ColumbaApp/Services/AppServices.swift | 32 +++- .../InterfaceManagementViewModel.swift | 82 ++++++---- .../ViewModels/NetworkStatusViewModel.swift | 8 +- .../NEReticulumNode.swift | 144 +++++++++++++++--- 5 files changed, 218 insertions(+), 56 deletions(-) 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 304ec856..b67b0414 100644 --- a/Sources/ColumbaApp/Services/AppServices.swift +++ b/Sources/ColumbaApp/Services/AppServices.swift @@ -2013,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) }) @@ -3774,7 +3787,24 @@ public final class AppServices { public func neTcpRelayOnline() async -> Bool { guard BackendPreference.modelB, let backend = backend else { return false } let snap = await backend.statusSnapshot() - return snap?.interfaces.first { $0.sectionName == "ne-tcp-relay" }?.online ?? false + // The NE registers one interface per relay with id `ne-tcp-relay-` + // (multi-relay), so match the PREFIX — an exact `== "ne-tcp-relay"` never matches + // and made the UI always read "not connected" even with a relay up. `&& online` + // is required so a registered-but-down relay doesn't false-positive. + return snap?.interfaces.contains { $0.sectionName.hasPrefix("ne-tcp-relay") && $0.online } ?? 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 ?? []).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/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/ColumbaNetworkExtension/NEReticulumNode.swift b/Sources/ColumbaNetworkExtension/NEReticulumNode.swift index 27ad6338..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). @@ -392,27 +402,8 @@ actor NEReticulumNode { if relays.isEmpty { ExtensionDiagLog.log("NEReticulumNode: no TCP relay configured — node running on AppGroupBridge only") } else { - for (index, tcp) in relays.enumerated() { - let ifaceId = "ne-tcp-relay-\(index)" - do { - let cfg = InterfaceConfig( - id: ifaceId, - name: "NE TCP Relay \(index)", - 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 (\(ifaceId))") - } catch { - // Non-fatal: the node can still deliver over the AppGroupBridge - // (radio) or another relay even if this socket can't be brought up. - ExtensionDiagLog.log("NEReticulumNode: TCP relay \(ifaceId) addInterface failed (non-fatal): \(String(describing: error))") - } + for tcp in relays { + await registerTCPRelay(tcp, on: tp) } } @@ -432,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)))") @@ -583,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 @@ -755,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() @@ -999,15 +1087,16 @@ actor NEReticulumNode { /// 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() -> [(host: String, port: UInt16)] { + 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 [] } - var relays: [(host: String, port: UInt16)] = [] + 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], @@ -1019,7 +1108,9 @@ actor NEReticulumNode { port > 0, port <= 65535 else { continue } - relays.append((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 relays } @@ -1140,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. From 0e50dd65fb2d96c9c83429dcb526ca6d64cf0799 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:56:02 -0400 Subject: [PATCH 9/9] Model B: Settings network card lists actually-connected relays, not first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings connection card showed "Connected — TCP ()" by picking `getEnabledInterfaces().first(tcpClient)` whenever the coarse any-relay-online bool was true. With multiple relays that mislabels: a down first-configured relay (e.g. a dead community hub) was named as the connected interface while a different relay actually carried traffic. `refreshConnectionState()` now lists only the relays whose entity id is online per the per-relay `neTcpRelayStatuses()` snapshot, so the card names exactly the relay(s) carrying traffic. Removed the now-unused coarse `neTcpRelayOnline()` (the footgun that enabled the mislabel); per-relay status is the single source of truth. Model A path unchanged. A codebase sweep for the same bug class (coarse any-online used to label/count a specific interface, or `.first` picked as "the connected one") found no other instances — the Messaging status dot uses the aggregate `isConnected` but names no specific relay, so it's correct. Co-Authored-By: Claude Opus 4.8 --- Sources/ColumbaApp/Services/AppServices.swift | 15 ---------- .../ViewModels/SettingsViewModel.swift | 28 +++++++++++-------- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/Sources/ColumbaApp/Services/AppServices.swift b/Sources/ColumbaApp/Services/AppServices.swift index b67b0414..e3d59728 100644 --- a/Sources/ColumbaApp/Services/AppServices.swift +++ b/Sources/ColumbaApp/Services/AppServices.swift @@ -3779,21 +3779,6 @@ 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 } - let snap = await backend.statusSnapshot() - // The NE registers one interface per relay with id `ne-tcp-relay-` - // (multi-relay), so match the PREFIX — an exact `== "ne-tcp-relay"` never matches - // and made the UI always read "not connected" even with a relay up. `&& online` - // is required so a registered-but-down relay doesn't false-positive. - return snap?.interfaces.contains { $0.sectionName.hasPrefix("ne-tcp-relay") && $0.online } ?? 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. 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 {