diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index c1688388..1813c085 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -112,6 +112,7 @@ 076B /* SharedFrameQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = F076 /* SharedFrameQueue.swift */; }; 077B /* TunnelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F077 /* TunnelManager.swift */; }; 078B /* ExtensionFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F078 /* ExtensionFrameReader.swift */; }; + TUNB /* TunnelTCPInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = FTUN /* TunnelTCPInterface.swift */; }; 079B /* OnboardingRestoreSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F079 /* OnboardingRestoreSheet.swift */; }; 07AB /* PlatformCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07A /* PlatformCompat.swift */; }; 07CB /* MicronDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07D /* MicronDocument.swift */; }; @@ -123,6 +124,7 @@ 082B /* MicronRenderContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F083 /* MicronRenderContainer.swift */; }; 083B /* MonospaceLineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F084 /* MonospaceLineView.swift */; }; 084B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F085 /* ZoomableScrollView.swift */; }; + 085B /* BackgroundTransportPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F088 /* BackgroundTransportPage.swift */; }; 086B /* TCPClientWizardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F086 /* TCPClientWizardViewModel.swift */; }; 087B /* TCPClientWizard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F087 /* TCPClientWizard.swift */; }; T006 /* TCPClientWizardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT06 /* TCPClientWizardViewModelTests.swift */; }; @@ -130,10 +132,29 @@ TAA0 /* AutoAnnouncePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FTAA /* AutoAnnouncePolicyTests.swift */; }; TPCR /* PeerChildInterfaceRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FTPC /* PeerChildInterfaceRegistryTests.swift */; }; 2F3D64B12F7227E100049252 /* ReticulumSwift in Frameworks */ = {isa = PBXBuildFile; productRef = P004 /* ReticulumSwift */; }; + ERETIC /* ReticulumSwift in Frameworks */ = {isa = PBXBuildFile; productRef = P005 /* ReticulumSwift */; }; E001 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01 /* PacketTunnelProvider.swift */; }; E002 /* SharedFrameQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = F076 /* SharedFrameQueue.swift */; }; + E004 /* ExtensionAutoBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE04 /* ExtensionAutoBridge.swift */; }; + EAPPEX /* ColumbaNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EPROD /* ColumbaNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 088T /* TestController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F088T /* TestController.swift */; }; + 089T /* TestURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F089T /* TestURLHandler.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXCopyFilesBuildPhase section */ + EEMBED /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + EAPPEX /* ColumbaNetworkExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXContainerItemProxy section */ TTPROXY /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -142,6 +163,13 @@ remoteGlobalIDString = TTARG; remoteInfo = ColumbaAppTests; }; + EDPROXY /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = PROJ /* Project object */; + proxyType = 1; + remoteGlobalIDString = ETARG; + remoteInfo = ColumbaNetworkExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -254,6 +282,7 @@ F076 /* SharedFrameQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFrameQueue.swift; sourceTree = ""; }; F077 /* TunnelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManager.swift; sourceTree = ""; }; F078 /* ExtensionFrameReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFrameReader.swift; sourceTree = ""; }; + FTUN /* TunnelTCPInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelTCPInterface.swift; sourceTree = ""; }; F079 /* OnboardingRestoreSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingRestoreSheet.swift; sourceTree = ""; }; F07A /* PlatformCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformCompat.swift; sourceTree = ""; }; F07D /* MicronDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronDocument.swift; sourceTree = ""; }; @@ -265,6 +294,7 @@ F083 /* MicronRenderContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronRenderContainer.swift; sourceTree = ""; }; F084 /* MonospaceLineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonospaceLineView.swift; sourceTree = ""; }; F085 /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = ""; }; + F088 /* BackgroundTransportPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTransportPage.swift; sourceTree = ""; }; F086 /* TCPClientWizardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModel.swift; sourceTree = ""; }; F087 /* TCPClientWizard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizard.swift; sourceTree = ""; }; FT06 /* TCPClientWizardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModelTests.swift; sourceTree = ""; }; @@ -273,7 +303,10 @@ FE01 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; FE02 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FE03 /* ColumbaNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ColumbaNetworkExtension.entitlements; sourceTree = ""; }; + FE04 /* ExtensionAutoBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionAutoBridge.swift; sourceTree = ""; }; PROD /* ColumbaApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ColumbaApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F088T /* TestController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestController.swift; sourceTree = ""; }; + F089T /* TestURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestURLHandler.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -281,6 +314,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + ERETIC /* ReticulumSwift in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -362,6 +396,7 @@ isa = PBXGroup; children = ( FE01 /* PacketTunnelProvider.swift */, + FE04 /* ExtensionAutoBridge.swift */, FE02 /* Info.plist */, FE03 /* ColumbaNetworkExtension.entitlements */, ); @@ -412,6 +447,7 @@ F04F /* ConnectivityPage.swift */, F050 /* PermissionsPage.swift */, F051 /* CompletePage.swift */, + F088 /* BackgroundTransportPage.swift */, F079 /* OnboardingRestoreSheet.swift */, ); path = Onboarding; @@ -513,6 +549,7 @@ F074 /* SharedDefaults.swift */, F077 /* TunnelManager.swift */, F078 /* ExtensionFrameReader.swift */, + FTUN /* TunnelTCPInterface.swift */, F07F /* NomadNetBrowserService.swift */, ); path = Services; @@ -612,10 +649,20 @@ GVIEWS /* Views */, GSVC /* Services */, GRES /* Resources */, + GTEST /* Test */, ); path = Sources/ColumbaApp; sourceTree = ""; }; + GTEST /* Test */ = { + isa = PBXGroup; + children = ( + F088T /* TestController.swift */, + F089T /* TestURLHandler.swift */, + ); + path = Test; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -631,6 +678,9 @@ dependencies = ( ); name = ColumbaNetworkExtension; + packageProductDependencies = ( + P005 /* ReticulumSwift */, + ); productName = ColumbaNetworkExtension; productReference = EPROD /* ColumbaNetworkExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -642,10 +692,12 @@ SRCBP /* Sources */, FWBP /* Frameworks */, RESBP /* Resources */, + EEMBED /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + EDDEP /* PBXTargetDependency */, ); name = ColumbaApp; packageProductDependencies = ( @@ -743,6 +795,11 @@ target = TARG /* ColumbaApp */; targetProxy = TTPROXY /* PBXContainerItemProxy */; }; + EDDEP /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = ETARG /* ColumbaNetworkExtension */; + targetProxy = EDPROXY /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXSourcesBuildPhase section */ @@ -767,6 +824,7 @@ files = ( E001 /* PacketTunnelProvider.swift in Sources */, E002 /* SharedFrameQueue.swift in Sources */, + E004 /* ExtensionAutoBridge.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -831,6 +889,7 @@ 04FB /* ConnectivityPage.swift in Sources */, 050B /* PermissionsPage.swift in Sources */, 051B /* CompletePage.swift in Sources */, + 085B /* BackgroundTransportPage.swift in Sources */, 052B /* OnboardingViewModel.swift in Sources */, 053B /* TcpCommunityServer.swift in Sources */, 054B /* RNodeWizardView.swift in Sources */, @@ -868,6 +927,7 @@ 076B /* SharedFrameQueue.swift in Sources */, 077B /* TunnelManager.swift in Sources */, 078B /* ExtensionFrameReader.swift in Sources */, + TUNB /* TunnelTCPInterface.swift in Sources */, 079B /* OnboardingRestoreSheet.swift in Sources */, 07AB /* PlatformCompat.swift in Sources */, 07CB /* MicronDocument.swift in Sources */, @@ -881,6 +941,8 @@ 084B /* ZoomableScrollView.swift in Sources */, 086B /* TCPClientWizardViewModel.swift in Sources */, 087B /* TCPClientWizard.swift in Sources */, + 088T /* TestController.swift in Sources */, + 089T /* TestURLHandler.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -957,7 +1019,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 29; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sources/ColumbaNetworkExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Columba Transport"; @@ -983,7 +1045,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 29; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sources/ColumbaNetworkExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Columba Transport"; @@ -1066,8 +1128,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Sources/ColumbaApp/Resources/ColumbaApp.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 29; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sources/ColumbaApp/Resources/Info.plist; @@ -1092,6 +1155,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ENABLE_NETWORK_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1104,8 +1168,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Sources/ColumbaApp/Resources/ColumbaApp.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 29; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sources/ColumbaApp/Resources/Info.plist; @@ -1130,6 +1195,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ENABLE_NETWORK_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1141,7 +1207,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 29; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.0; @@ -1161,7 +1227,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 29; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.0; @@ -1247,7 +1313,7 @@ repositoryURL = "https://github.com/torlando-tech/reticulum-swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.3.0; + minimumVersion = 0.3.1; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -1273,6 +1339,11 @@ package = PKGREF4 /* XCRemoteSwiftPackageReference "reticulum-swift" */; productName = ReticulumSwift; }; + P005 /* ReticulumSwift */ = { + isa = XCSwiftPackageProductDependency; + package = PKGREF4 /* XCRemoteSwiftPackageReference "reticulum-swift" */; + productName = ReticulumSwift; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = PROJ /* Project object */; diff --git a/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c732770d..6635bc7c 100644 --- a/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution", "state" : { - "revision" : "40e1a0db6d055abf8a1b6e2f6127a8bb6e895cf8", - "version" : "6.25.1" + "revision" : "be0696007ca8b350faa0e5968c0d6397d59db415", + "version" : "6.26.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/torlando-tech/reticulum-swift.git", "state" : { - "revision" : "034d9c7570c7428ebe5daab1ee1b8d17fc1e9c87", - "version" : "0.3.0" + "revision" : "dc6b0056618e6056ed6911728d46df455529a2b9", + "version" : "0.3.1" } }, { diff --git a/Package.swift b/Package.swift index ef7ac2ce..ce0504b2 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( // library changes" for the exact recipe. .package(url: "https://github.com/torlando-tech/LXMF-swift.git", from: "0.4.0"), .package(url: "https://github.com/torlando-tech/LXST-swift.git", from: "0.2.0"), - .package(url: "https://github.com/torlando-tech/reticulum-swift.git", from: "0.3.0"), + .package(url: "https://github.com/torlando-tech/reticulum-swift.git", from: "0.3.1"), .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution", from: "6.9.0"), ], targets: [ diff --git a/Sources/ColumbaApp/App/ColumbaApp.swift b/Sources/ColumbaApp/App/ColumbaApp.swift index 91f04d67..a9dd830c 100644 --- a/Sources/ColumbaApp/App/ColumbaApp.swift +++ b/Sources/ColumbaApp/App/ColumbaApp.swift @@ -11,6 +11,9 @@ import LXMFSwift import UserNotifications import BackgroundTasks import os +#if canImport(UIKit) +import UIKit +#endif private let logger = Logger(subsystem: "network.columba.Columba", category: "ColumbaApp") @@ -35,6 +38,18 @@ struct ColumbaApp: App { // MARK: - Init init() { + // Register user-facing UserDefaults fallbacks BEFORE + // AppServices spins up. `AutoAnnouncePolicy.current()` reads + // `auto_announce_enabled` and `auto_announce_on_tcp_reconnect` + // via `defaults.bool(forKey:)` which silently returns `false` + // when the key has never been written (and the user hasn't + // opened Settings yet to trigger SettingsViewModel.load). + // Without this, a fresh install never auto-announces on TCP + // reconnect, so rnsd's path table loses the phone's path on + // every socket cycle and bot→phone DIRECT/OPP delivery silently + // drops. Same logic applies for the notification gating keys. + SettingsViewModel.registerLocalDefaults() + #if os(iOS) BGTaskScheduler.shared.register( forTaskWithIdentifier: "network.columba.Columba.sync", @@ -64,6 +79,17 @@ struct ColumbaApp: App { .tint(Theme.accentColor) .id(ThemeManager.shared.themeVersion) .onOpenURL { url in + #if DEBUG + // Debug-only test-harness sibling scheme — `lxma-test://?`. + // See Sources/ColumbaApp/Test/TestURLHandler.swift. Compiled out + // entirely in release builds, AND `lxma-test` is not registered + // in CFBundleURLSchemes — so iOS won't route to this handler in + // release even if the file accidentally shipped. + if url.scheme == "lxma-test" { + _ = TestURLHandler.handle(url: url) + return + } + #endif guard url.scheme == "lxma" else { return } pendingDeepLink = url.absoluteString } @@ -256,6 +282,43 @@ struct RootView: View { #if os(iOS) if newPhase == .background { scheduleBackgroundSync() + #if ENABLE_NETWORK_EXTENSION + // Re-announce via the NE tunnel before iOS tears + // down the foreground TCPInterface socket. rnsd's + // path table is single-path / last-write-wins + // (`AppServices.swift:942`); without this re-pin + // the path drifts to the foreground socket while + // the app is foregrounded and goes dead the moment + // we suspend, leaving the NE-owned socket healthy + // but unrouted. The `beginBackgroundTask` wrap + // gives us the brief window iOS allows to complete + // network I/O across the foreground→background + // transition (announce is a single packet write, + // milliseconds in the happy path). + if appServices.tunnelManager?.isRunning == true { + Task { @MainActor in + let app = UIApplication.shared + var bgTask: UIBackgroundTaskIdentifier = .invalid + bgTask = app.beginBackgroundTask(withName: "tunnel-reannounce") { + if bgTask != .invalid { + app.endBackgroundTask(bgTask) + bgTask = .invalid + } + } + guard bgTask != .invalid else { return } + do { + try await appServices.announceViaTunnel() + DiagLog.log("[TUNNEL] background-transition re-announce sent") + } catch { + DiagLog.log("[TUNNEL] background re-announce failed: \(error.localizedDescription)") + } + if bgTask != .invalid { + app.endBackgroundTask(bgTask) + bgTask = .invalid + } + } + } + #endif } appServices.locationSharingManager?.setBackgroundState(newPhase != .active) #endif @@ -415,12 +478,11 @@ struct RootView: View { let enabledInterfaces = interfaceRepo.getEnabledInterfaces() DiagLog.log("[STARTUP] Step 4: \(enabledInterfaces.count) enabled interfaces") - // 5. Initialize AppServices with identity (TCP connected separately below) + // 5. Initialize AppServices with identity (interfaces connected in Step 7) DiagLog.log("[STARTUP] Step 5: initialize AppServices") try await appServices.initialize( identity: identity, - identityHash: active.identityHash, - tcpServerAddress: "" + identityHash: active.identityHash ) DiagLog.log("[STARTUP] Step 5: AppServices initialized OK") @@ -515,11 +577,33 @@ struct RootView: View { } } - // 8. Request notification permission and install foreground delegate - await NotificationService.shared.requestPermission() + // 8. Request notification permission and install foreground delegate. + // + // Fire-and-forget. When the user has never responded to + // the system prompt, `UNUserNotificationCenter + // .requestAuthorization` doesn't return until they tap + // Allow / Don't Allow — and awaiting that here held the + // rest of init hostage (no `TestURLHandler.bind`, no + // MainTabView, no usable app) until the prompt was + // dismissed. The harness can't tap the prompt, but more + // importantly a real user shouldn't have to either: the + // app should be usable while iOS draws the permission + // sheet on top. + Task { _ = await NotificationService.shared.requestPermission() } self.isInitialized = true + #if DEBUG + // Wire the test-harness surface to the live AppServices. + // No-op in release (TestURLHandler graph is `#if DEBUG`-gated). + // Pass `handler` so the test relay forwards to it rather than + // displacing it — see `TestURLHandler.bind` docs. + TestURLHandler.bind( + appServices: appServices, + incomingMessageHandler: handler + ) + #endif + // DEBUG: Auto-trigger propagation sync on launch for testing if ProcessInfo.processInfo.arguments.contains("--auto-sync") { let services = appServices diff --git a/Sources/ColumbaApp/Models/TcpCommunityServer.swift b/Sources/ColumbaApp/Models/TcpCommunityServer.swift index 3bb3e162..5c4abd28 100644 --- a/Sources/ColumbaApp/Models/TcpCommunityServer.swift +++ b/Sources/ColumbaApp/Models/TcpCommunityServer.swift @@ -38,12 +38,28 @@ extension TcpCommunityServer { // Community servers TcpCommunityServer(name: "g00n.cloud Hub", host: "dfw.us.g00n.cloud", port: 6969, isBootstrap: false), + TcpCommunityServer(name: "interloper node", host: "intr.cx", port: 4242, isBootstrap: false), + TcpCommunityServer( + name: "interloper node (Tor)", + host: "intrcxv4fa72e5ovler5dpfwsiyuo34tkcwfy5snzstxkhec75okowqd.onion", + port: 4242, + isBootstrap: false + ), + TcpCommunityServer(name: "Jon's Node", host: "rns.jlamothe.net", port: 4242, isBootstrap: false), TcpCommunityServer(name: "noDNS1", host: "202.61.243.41", port: 4965, isBootstrap: false), TcpCommunityServer(name: "noDNS2", host: "193.26.158.230", port: 4965, isBootstrap: false), TcpCommunityServer(name: "NomadNode SEAsia TCP", host: "rns.jaykayenn.net", port: 4242, isBootstrap: false), TcpCommunityServer(name: "0rbit-Net", host: "93.95.227.8", port: 49952, isBootstrap: false), TcpCommunityServer(name: "Quad4 TCP Node 2", host: "rns2.quad4.io", port: 4242, isBootstrap: false), + TcpCommunityServer(name: "Quortal TCP Node", host: "reticulum.qortal.link", port: 4242, isBootstrap: false), + TcpCommunityServer(name: "R-Net TCP", host: "istanbul.reserve.network", port: 9034, isBootstrap: false), + TcpCommunityServer(name: "RNS bnZ-NODE01", host: "node01.rns.bnz.se", port: 4242, isBootstrap: false), + TcpCommunityServer(name: "RNS COMSEC-RD", host: "80.78.23.249", port: 4242, isBootstrap: false), + TcpCommunityServer(name: "RNS HAM RADIO", host: "135.125.238.229", port: 4242, isBootstrap: false), + TcpCommunityServer(name: "RNS Testnet StoppedCold", host: "rns.stoppedcold.com", port: 4242, isBootstrap: false), + TcpCommunityServer(name: "RNS_Transport_US-East", host: "45.77.109.86", port: 4965, isBootstrap: false), TcpCommunityServer(name: "SparkN0de", host: "aspark.uber.space", port: 44860, isBootstrap: false), + TcpCommunityServer(name: "Tidudanka.com", host: "reticulum.tidudanka.com", port: 37500, isBootstrap: false), ] /// Default server for first-time connections. diff --git a/Sources/ColumbaApp/Resources/Info.plist b/Sources/ColumbaApp/Resources/Info.plist index 55078880..6203cbd0 100644 --- a/Sources/ColumbaApp/Resources/Info.plist +++ b/Sources/ColumbaApp/Resources/Info.plist @@ -16,6 +16,24 @@ lxma + + + CFBundleURLName + network.columba.Columba.lxma-test + CFBundleURLSchemes + + lxma-test + + NSBluetoothWhenInUseUsageDescription Columba uses Bluetooth for peer-to-peer mesh networking and connecting to RNode radio devices. diff --git a/Sources/ColumbaApp/Services/AppServices.swift b/Sources/ColumbaApp/Services/AppServices.swift index 0a492b6b..236382df 100644 --- a/Sources/ColumbaApp/Services/AppServices.swift +++ b/Sources/ColumbaApp/Services/AppServices.swift @@ -45,6 +45,19 @@ enum DiagLog { } } } + + /// Snapshot the extension's diag log (App Group container) into + /// the app's Documents directory so it can be retrieved via + /// `xcrun devicectl device copy from`. Called from + /// `AppServices.initialize()` and is best-effort. + static func snapshotExtensionLog() { + guard let extPath = ExtensionDiagLog.path, + FileManager.default.fileExists(atPath: extPath), + let extData = try? Data(contentsOf: URL(fileURLWithPath: extPath)) else { return } + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let snapshotURL = docs.appendingPathComponent("ext_diag.log") + try? extData.write(to: snapshotURL) + } } /// Central LXMF service layer for the SwiftUI application. @@ -61,7 +74,8 @@ enum DiagLog { /// Example usage: /// ```swift /// let services = AppServices() -/// try await services.initialize(tcpServerAddress: "tcp://10.0.0.1:4242") +/// try await services.initialize(identity: identity, identityHash: hash) +/// try await services.connectTCPInterface(entityId: id, host: "10.0.0.1", port: 4242) /// /// // Access the router for sending messages /// var message = LXMessage(...) @@ -152,6 +166,48 @@ public final class AppServices { /// Network Extension tunnel manager. public private(set) var tunnelManager: TunnelManager? + /// Debounce-disable task. When the tunnel briefly drops (debug + /// reload, transient drop) we don't want to immediately tear + /// down tunnel mode on the app's interfaces — that re-binds the + /// AutoInterface multicast / data ports while iOS is in the + /// middle of bringing up a fresh extension instance, racing the + /// extension's `NWMulticastGroup` / `NWListener` and producing + /// `EADDRINUSE`. + private var pendingTunnelDisableTask: Task? + + /// Auto-restart state. When the tunnel transitions to + /// `.disconnected` after having been `.connected`, the status + /// observer schedules a restart via + /// `scheduleTunnelRestartIfNeeded()` — iOS kills the extension + /// under memory pressure with no warning and there's otherwise + /// no path back up without the user manually re-toggling. + /// Backoff doubles each attempt, capped at + /// `tunnelRestartMaxBackoffSeconds`; reset to 1s on the next + /// `.connected`. `tunnelHasBeenConnectedOnce` gates restart + /// scheduling so the initial-boot `.disconnected` firing + /// (before auto-start runs) doesn't race the auto-start path. + private var tunnelRestartTask: Task? + private var tunnelRestartBackoffSeconds: TimeInterval = 1.0 + private static let tunnelRestartMaxBackoffSeconds: TimeInterval = 300.0 + private var tunnelHasBeenConnectedOnce: Bool = false + + /// Tracks whether `applyTunnelModeToInterfaces(active: true)` has + /// run. Dead code post-dual-interface refactor (the new + /// architecture doesn't flip the foreground `TCPInterface`'s + /// routing anymore; it registers a separate `TunnelTCPInterface` + /// alongside). Kept around so `applyTunnelModeToInterfaces`'s + /// idempotency guards still compile cleanly while the function + /// itself is no-longer called. + private var isTunnelModeActive: Bool = false + + /// Dual-interface tunnel TCP path. Registered with the transport + /// only when `TunnelManager.status` is `.connected` (the NE is + /// alive). Routes outbound through `TunnelManager.sendFrame(...)` + /// and receives inbound via `ExtensionFrameReader` frames tagged + /// with `TUNNEL_TCP_INTERFACE_ID`. See `TunnelTCPInterface.swift` + /// for the architectural rationale. + private var tunnelTCPInterface: TunnelTCPInterface? + /// Extension frame reader for processing queued frames from the extension. private var extensionFrameReader: ExtensionFrameReader? #endif @@ -369,7 +425,8 @@ public final class AppServices { /// Create uninitialized AppServices. /// - /// Call `initialize(tcpServerAddress:)` to set up all components. + /// Call `initialize(identity:identityHash:)` to set up all components, + /// then connect interfaces via `connectTCPInterface`/`startAutoInterface`/etc. public init() {} // Note: No deinit needed - stateObserverTask is automatically cancelled @@ -467,6 +524,7 @@ public final class AppServices { do { let newInterface = try TCPInterface(config: config) tcpInterfaces["tcp-server"] = newInterface + tcpEndpoints["tcp-server"] = TCPEndpoint(host: host, port: port) try await newTransport.addInterface(newInterface) // Record the applied endpoint only after the interface // has been successfully attached. See the matching catch @@ -509,21 +567,29 @@ public final class AppServices { self.autoAnnounceManager = announceManager announceManager.start() + // Publish the registered destination hashes so the + // `PacketTunnelProvider` can filter inbound frames and post a + // notification when the host app is suspended. Both the + // delivery and (on iOS) telephony destinations have been + // registered with the transport by now. + await publishLocalDestinations() + logger.info("Initialization complete") } /// Initialize all LXMF components with an externally-provided identity. /// /// Used by multi-identity flow where IdentityManager loads the identity - /// from Keychain and passes it in directly. + /// from Keychain and passes it in directly. Interfaces are connected + /// separately via `connectTCPInterface`/`startAutoInterface`/etc. by + /// the app-level Step 7 loop, which iterates `InterfaceRepository`. /// /// - Parameters: /// - identity: Pre-loaded Reticulum identity with private keys /// - identityHash: Hex hash of the identity (used for DB filename) - /// - tcpServerAddress: TCP server address (e.g., "10.0.0.1:4242") - public func initialize(identity: Identity, identityHash: String, tcpServerAddress: String) async throws { + public func initialize(identity: Identity, identityHash: String) async throws { DiagLog.clear() - DiagLog.log("[INIT2] Starting with identity: \(identityHash), tcp: \(tcpServerAddress)") + DiagLog.log("[INIT2] Starting with identity: \(identityHash)") self.identity = identity self.localIdentityHashHex = localIdentityHash.map { String(format: "%02x", $0) }.joined() @@ -582,39 +648,13 @@ public final class AppServices { } #endif - // 8. Parse server address and create TCP interface - if let (host, port) = parseHostPort(tcpServerAddress) { - let config = InterfaceConfig( - id: "tcp-server", - name: "TCP Server", - type: .tcp, - enabled: true, - mode: .full, - host: host, - port: port - ) - do { - let newInterface = try TCPInterface(config: config) - tcpInterfaces["tcp-server"] = newInterface - try await newTransport.addInterface(newInterface) - // Record the applied endpoint only after the interface - // has been successfully attached. See the matching catch - // block below — same rationale as the first overload. - tcpEndpoints["tcp-server"] = TCPEndpoint(host: host, port: port) - } catch { - // Non-fatal: init proceeds without TCP. But roll back - // any partial dictionary writes so a later - // reconnectTCPOnly retry with the same address doesn't - // hit a stuck idempotency guard in connectTCPInterface - // and silently no-op. See the first initialize overload - // for the full rationale. - tcpInterfaces.removeValue(forKey: "tcp-server") - tcpEndpoints.removeValue(forKey: "tcp-server") - logger.warning("TCP interface failed (non-fatal): \(error.localizedDescription, privacy: .public)") - } - } - - // 9. Register delivery destination with router + // 8. Register delivery destination with router. Interface + // connections are owned by the app-level Step 7 loop, which + // iterates `InterfaceRepository.getEnabledInterfaces()` and + // calls `connectTCPInterface`/`startAutoInterface`/etc. We + // deliberately don't synthesize a "tcp-server" interface + // here — doing so created a duplicate alongside the + // repository-managed entity after `switchIdentity`. try await newRouter.registerDeliveryDestination(newDestination) startStateObserver() @@ -637,6 +677,10 @@ public final class AppServices { let regCallbacks = await newTransport.registeredLinkCallbackHashes() DiagLog.log("[INIT2] Registered destinations: \(regDests)") DiagLog.log("[INIT2] Registered link callbacks: \(regCallbacks)") + // Publish to App Group so the extension can filter inbound + // frames against our local destinations and schedule a + // notification when the host app is suspended. + await publishLocalDestinations(hexHashes: regDests) // Apply persisted transport mode setting if SharedDefaults.suite.bool(forKey: "transport_enabled") { await newTransport.setTransportEnabled(true, identity: identity) @@ -648,18 +692,45 @@ public final class AppServices { let reader = ExtensionFrameReader() self.extensionFrameReader = reader - // Wire frame injection: extension sends unframed packets -> transport - let tcpId = await tcpInterface?.id ?? "ext-tcp" - let autoId = await autoInterface?.id ?? "ext-auto" - - reader.onTCPFrameReceived = { [weak self] data in - guard let transport = self?.transport else { return } - Task { await transport.handleReceivedData(data: data, from: tcpId) } + // Wire frame injection: extension sends unframed packets -> transport. + // Resolve the interface IDs lazily inside the callback so that + // auto/TCP interfaces created later (e.g. `startAutoInterface` + // runs after this block) end up tagged with their real IDs + // instead of the "ext-tcp" / "ext-auto" fallbacks. Without + // this the transport never sees Sideband's auto announces + // matched against the registered AutoInterface, and announce + // routing silently drops them. + reader.onTCPFrameReceived = { [weak self] entityId, data in + guard let self else { return } + Task { + // Dual-interface tunnel routing: frames tagged with + // `TUNNEL_TCP_INTERFACE_ID` belong to the + // `TunnelTCPInterface` and are fed to the transport + // against that id so the transport's path-table and + // delegate plumbing treat them as inbound on the + // tunnel path. Frames tagged with any other entity + // ID came from the extension's legacy per-foreground- + // entity tunnels and have no destination in the new + // architecture — the foreground `TCPInterface`s + // receive inbound via their OWN `NWConnection`s, so + // delivering these too would double-process every + // packet. We drop them. + let transportRef = await MainActor.run { self.transport } + guard let transport = transportRef else { return } + if entityId == TUNNEL_TCP_INTERFACE_ID { + await transport.handleReceivedData(data: data, from: TUNNEL_TCP_INTERFACE_ID) + } + // Else: drop. See doc-comment above. + } } reader.onAutoFrameReceived = { [weak self] data in - guard let transport = self?.transport else { return } - Task { await transport.handleReceivedData(data: data, from: autoId) } + guard let self else { return } + Task { + let autoId = await self.autoInterface?.id ?? "ext-auto" + guard let transport = self.transport else { return } + await transport.handleReceivedData(data: data, from: autoId) + } } reader.startListening() @@ -668,77 +739,438 @@ public final class AppServices { 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. + // Wire tunnel state -> dual-interface tunnel coordination. + // + // In the dual-interface model (replaces the old tunnel-mode + // flip), the foreground `TCPInterface`s keep owning their + // own `NWConnection`s and a *separate* `TunnelTCPInterface` + // is registered with the transport when the extension is + // up. The foreground stays foreground; the tunnel is its + // own logical path through the extension. When the host + // app suspends, the foreground socket dies but the tunnel + // socket stays alive, so rnsd can still route to us. + // + // 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) + // Cancel any pending deregister — we're back up + // before the debounce fired. + self.pendingTunnelDisableTask?.cancel() + self.pendingTunnelDisableTask = nil + // Cancel any pending restart and reset backoff — + // the tunnel is up. + self.tunnelRestartTask?.cancel() + self.tunnelRestartTask = nil + self.tunnelRestartBackoffSeconds = 1.0 + self.tunnelHasBeenConnectedOnce = true + await self.registerTunnelInterface() case .disconnected, .invalid: - await self.applyTunnelModeToInterfaces(active: false) + // Debounce deregister: a debug reload (extension + // calls `cancelTunnelWithError`) takes the tunnel + // through `.disconnected` for a couple of seconds + // while iOS spawns the new instance. If we tear + // the tunnel interface down immediately, the + // transport might lose a freshly-learned path + // entry that's still valid via the soon-to-be- + // restored extension. Wait a few seconds; if + // status comes back to `.connected`, the branch + // above cancels us. + self.pendingTunnelDisableTask?.cancel() + self.pendingTunnelDisableTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled else { return } + await self?.deregisterTunnelInterface() + } + // Schedule a restart if we've been `.connected` + // before and the user still wants the tunnel on. + // iOS can kill the extension under memory + // pressure with no recovery; on-demand rules + // cover some cases but not all. + self.scheduleTunnelRestartIfNeeded() default: break } } } await tunnel.load() + + // Auto-restart the tunnel if the user previously enabled + // Background Transport (Settings toggle or onboarding step + // both write this key). Without this the user has to re-flip + // the toggle every relaunch — iOS keeps the VPN profile but + // not the running session across app launches. + let defaults = UserDefaults(suiteName: appGroupIdentifier) + let tunnelShouldStart = defaults?.bool(forKey: SharedDefaultsConstants.tunnelEnabledKey) ?? false + + // Note on app-update behaviour: when the App Store / TestFlight + // replaces the .app bundle, iOS automatically replaces the + // running extension instance with the new .appex binary on + // the next tunnel start. No manual reload is needed in + // production. (Dev installs via `xcrun devicectl` are + // different — iOS keeps the previous extension alive across + // re-deploys; the workaround is to delete and re-add the + // VPN profile in iOS Settings.) + + if tunnelShouldStart && !tunnel.isRunning { + // Kick off the tunnel from the saved pref. We deliberately + // do NOT poll + clear-on-timeout the way an earlier + // version did: a transient launch failure (slow network, + // a brief race during install/save) shouldn't permanently + // disable background transport. Recovery is now carried + // by (a) the on-demand always-connect rule iOS enforces + // on the profile and (b) the status observer's + // `scheduleTunnelRestartIfNeeded()` path. The pref is + // only cleared by explicit user toggle-off. + Task { @MainActor [weak tunnel] in + guard let tunnel else { return } + do { + try await tunnel.start() + DiagLog.log("[TUNNEL] auto-start launched from saved pref") + } catch { + DiagLog.log("[TUNNEL] auto-start threw: \(error.localizedDescription) — restart loop will retry") + } + } + } #endif DiagLog.log("[INIT2] Initialization complete (identity: \(identityHash))") + // Mirror the extension's diag log into the app's Documents on + // cold launch so `xcrun devicectl device copy from` can pull + // it. Gated to debug builds — production users shouldn't have + // connection diagnostics surfaced via File Sharing on every + // launch. + #if DEBUG + DiagLog.snapshotExtensionLog() + #endif } #if ENABLE_NETWORK_EXTENSION - /// Switch every TCPInterface and AutoInterface into or out of - /// tunnel mode in response to the VPN extension's status. + /// Switch every TCPInterface into or out of tunnel mode in + /// response to the VPN extension's status. /// - /// In tunnel mode the interface tears down its own NWConnection + /// In tunnel mode the TCPInterface tears down its own NWConnection /// and routes outbound bytes through `TunnelManager.sendFrame`, /// which the extension forwards on its authoritative socket. /// Inbound continues to flow via `ExtensionFrameReader` → /// `transport.handleReceivedData` regardless. + /// + /// AutoInterface is intentionally NOT tunneled. We tested every + /// API combination iOS exposes for putting AutoInterface in the + /// extension and proved empirically (with Mac-side `tcpdump` and + /// listening sockets) that **NEPacketTunnelProvider extensions + /// cannot send any UDP to the LAN**: + /// - `NWConnection` to link-local IPv6: completion fires + /// `success` but the packet never reaches the wire. + /// - POSIX `sendto`: returns `ENETUNREACH`. + /// - Replies on `NWListener`-accepted connections: fail with + /// `ECANCELED` even though the inbound was received. + /// The sandbox is asymmetric — the extension can RECEIVE UDP + /// (NWListener and POSIX `recvfrom` both work) but cannot SEND. + /// Without outbound UDP we can't multicast HELLO discovery, can't + /// reverse-peer, and can't respond to peers — so AutoInterface + /// in the extension is fundamentally broken regardless of + /// architecture. + /// + /// AutoInterface stays in its local app-process implementation, + /// foreground-only — same behaviour the user had before this + /// PR. TCP — which actually does work outbound from the extension + /// because NWConnection to a global IP relay does transmit — is + /// the background path. That's the win for the lock-screen bug + /// (#54). + // MARK: - Dual-interface tunnel registration + + /// Register a `TunnelTCPInterface` with the transport when the NE + /// tunnel reaches `.connected`. The tunnel interface mirrors the + /// host/port of the first enabled foreground TCP interface — i.e. + /// the same rnsd — but routes its outbound packets through + /// `TunnelManager.sendFrame(_:interfaceTag:entityId:)` to the + /// extension's `NWConnection`. Inbound from the extension is + /// fed in via `ExtensionFrameReader`'s callback when the + /// `entityId` matches `TUNNEL_TCP_INTERFACE_ID`. + /// + /// Idempotent: re-registration is a no-op while the interface + /// is already in place. The complementary deregister method + /// fires on `.disconnected` / `.invalid` (debounced 5s to ride + /// out debug reloads). + @MainActor + private func registerTunnelInterface() async { + guard tunnelTCPInterface == nil else { + DiagLog.log("[TUNNEL] registerTunnelInterface — already registered, skipping") + return + } + guard let tunnel = tunnelManager else { return } + guard let transport = transport else { + DiagLog.log("[TUNNEL] registerTunnelInterface — transport not initialized yet, skipping") + return + } + + // Source host/port from the first enabled foreground TCP + // interface — the tunnel is "another path to the same rnsd", + // not a different relay. If the user has no foreground TCP + // interface, we have no rnsd to tunnel to and skip. + guard let endpoint = tcpEndpoints.values.first else { + DiagLog.log("[TUNNEL] registerTunnelInterface — no foreground TCP endpoint to mirror, skipping") + return + } + + let config = TunnelTCPInterface.makeConfig(host: endpoint.host, port: endpoint.port) + let iface = TunnelTCPInterface( + config: config, + sendHook: { [weak tunnel] framed in + await tunnel?.sendFrame( + framed, + interfaceTag: FrameInterfaceTag.tcp.rawValue, + entityId: TUNNEL_TCP_INTERFACE_ID + ) + } + ) + self.tunnelTCPInterface = iface + + do { + try await transport.addInterface(iface) + try await iface.connect() + } catch { + DiagLog.log("[TUNNEL] registerTunnelInterface failed: \(error)") + self.tunnelTCPInterface = nil + return + } + + // Publish the tunnel endpoint to App Group prefs + post a + // Darwin notif so the extension opens its own `NWConnection` + // to rnsd with `entityId = TUNNEL_TCP_INTERFACE_ID`. The + // extension's inbound from this connection is what + // `ExtensionFrameReader` drains and routes back to this + // interface. + publishTunnelTCPEndpoints([(id: TUNNEL_TCP_INTERFACE_ID, + host: endpoint.host, + port: endpoint.port)]) + + DiagLog.log("[TUNNEL] Registered TunnelTCPInterface at \(endpoint.host):\(endpoint.port)") + + // Trigger an announce so rnsd learns a path-via-tunnel. The + // transport's `send(packet:)` broadcasts to every registered + // interface, so this announce goes via the foreground + // TCPInterface AND the just-registered TunnelTCPInterface. + // BUT rnsd's path table is single-path per destination + // (last-write-wins), and the two announces arrive in + // unpredictable order. To guarantee `path = tunnel` after + // this method returns, follow the broadcast with a tunnel- + // only re-announce. The TunnelTCPInterface's announce will + // then arrive at rnsd most recently and the path-table + // update will pin to the tunnel socket. When the host app + // suspends and the foreground socket dies, rnsd retains + // the tunnel path and keeps routing to us. + do { + try await sendAllAnnounces(displayName: "Columba") + // Tiny delay so rnsd has processed the broadcast pass + // before we re-announce via the tunnel only. + try? await Task.sleep(for: .milliseconds(100)) + try await sendAnnounceViaTunnel(displayName: "Columba") + } catch { + DiagLog.log("[TUNNEL] post-register announce failed: \(error.localizedDescription)") + } + } + + /// Public wrapper for `sendAnnounceViaTunnel`. Callable from + /// outside `AppServices` — most notably the `.background` + /// scenePhase handler in `ColumbaApp`, which fires a tunnel-only + /// re-announce just before iOS tears down the foreground + /// `TCPInterface` socket so rnsd's last-write-wins path table + /// lands on the still-alive NE-owned socket. Uses the same + /// `"Columba"` display name as `registerTunnelInterface`. + @MainActor + public func announceViaTunnel() async throws { + try await sendAnnounceViaTunnel(displayName: "Columba") + } + + /// Build an announce packet and ship it ONLY via the + /// `TunnelTCPInterface` so rnsd's path table ends up pointing + /// at the tunnel socket. Mirrors the encryption + ratchet logic + /// of `sendAnnounce(displayName:)` but skips the broadcast + /// `transport.send(packet:)` path in favour of a direct + /// `interface.send(_:)` against the tunnel interface. + @MainActor + private func sendAnnounceViaTunnel(displayName: String) async throws { + guard let destination = deliveryDestination else { + throw AppServicesError.identityNotInitialized + } + guard let tunnelIface = tunnelTCPInterface else { + DiagLog.log("[TUNNEL] sendAnnounceViaTunnel — no tunnel interface registered") + return + } + + destination.appData = displayName.data(using: .utf8) + + // Build the same announce packet shape as `sendAnnounce`. + var ratchetPub: Data? = nil + if let mgr = destination.ratchetManager { + await mgr.rotateIfNeeded() + ratchetPub = await mgr.currentRatchetPublicBytes() + } + let announce = Announce(destination: destination, ratchet: ratchetPub) + let packet = try announce.buildPacket() + + // The TunnelTCPInterface's `send` HDLC-frames and routes + // straight through the extension — no transport broadcast, + // no other interfaces touched. + try await tunnelIface.send(packet.encode()) + DiagLog.log("[TUNNEL] Sent tunnel-only announce for dest \(destination.hexHash)") + } + + /// Tear down the tunnel `NetworkInterface` registration when the + /// NE tunnel drops. The foreground `TCPInterface`s are + /// unaffected — they keep their app-process `NWConnection`s and + /// continue working in foreground. + @MainActor + private func deregisterTunnelInterface() async { + guard let iface = tunnelTCPInterface else { + DiagLog.log("[TUNNEL] deregisterTunnelInterface — not registered, skipping") + return + } + await iface.disconnect() + await transport?.removeInterface(id: TUNNEL_TCP_INTERFACE_ID) + self.tunnelTCPInterface = nil + + // Clear the App Group endpoint so the extension stops trying + // to open the tunnel TCP socket on its next config reload. + publishTunnelTCPEndpoints([]) + + DiagLog.log("[TUNNEL] Deregistered TunnelTCPInterface") + } + + /// Schedule a tunnel restart after a `.disconnected` transition. + /// + /// Gated on (a) having been `.connected` at least once this + /// session — so the initial-boot `.disconnected` firing doesn't + /// race the auto-start path — and (b) the user's + /// `tunnelEnabledKey` pref still being true (so an explicit + /// toggle-off doesn't get fought). Backoff doubles each attempt + /// and is capped at `tunnelRestartMaxBackoffSeconds`; it resets + /// when the tunnel next reaches `.connected`. + /// + /// On-demand always-connect (set in `TunnelManager.install()`) + /// covers many restart cases for free, but iOS doesn't always + /// re-fire on-demand promptly — this loop is the belt to that + /// suspenders. + @MainActor + private func scheduleTunnelRestartIfNeeded() { + guard tunnelHasBeenConnectedOnce else { return } + let defaults = UserDefaults(suiteName: appGroupIdentifier) + guard defaults?.bool(forKey: SharedDefaultsConstants.tunnelEnabledKey) == true else { + return + } + tunnelRestartTask?.cancel() + let delay = tunnelRestartBackoffSeconds + tunnelRestartBackoffSeconds = min(delay * 2, Self.tunnelRestartMaxBackoffSeconds) + tunnelRestartTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + // Re-check pref in case the user toggled off during the wait. + guard let mgr = self.tunnelManager, + UserDefaults(suiteName: appGroupIdentifier)? + .bool(forKey: SharedDefaultsConstants.tunnelEnabledKey) == true + else { return } + DiagLog.log("[TUNNEL] auto-restart after .disconnected (backoff \(delay)s)") + do { + try await mgr.start() + } catch { + DiagLog.log("[TUNNEL] auto-restart start() threw: \(error.localizedDescription)") + // start() never reached the system, so the status + // observer won't re-fire on its own — schedule another + // attempt explicitly. + self.scheduleTunnelRestartIfNeeded() + } + } + } + + /// Write the dual-interface tunnel TCP endpoint list to App + /// Group prefs + post a Darwin notif so the extension picks up + /// the new config without a tunnel restart. Mirrors the existing + /// `localDestinationsKey` + `configChangedNotificationName` + /// pattern. + private func publishTunnelTCPEndpoints(_ endpoints: [(id: String, host: String, port: UInt16)]) { + let jsonArray: [[String: Any]] = endpoints.map { entry in + ["id": entry.id, "host": entry.host, "port": Int(entry.port)] + } + if let data = try? JSONSerialization.data(withJSONObject: jsonArray) { + SharedDefaults.suite.set(data, forKey: SharedDefaultsConstants.tunnelTCPEndpointsKey) + } else { + SharedDefaults.suite.removeObject(forKey: SharedDefaultsConstants.tunnelTCPEndpointsKey) + } + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(SharedDefaultsConstants.tunnelTCPEndpointsChangedNotificationName as CFString), + nil, nil, true + ) + DiagLog.log("[TUNNEL] Published \(endpoints.count) tunnel TCP endpoint(s) to App Group") + } + @MainActor private func applyTunnelModeToInterfaces(active: Bool) async { guard let tunnel = tunnelManager else { return } if active { - for (_, iface) in tcpInterfaces { - await iface.beginTunnelMode { [weak tunnel] frame in - await tunnel?.sendFrame(frame, interfaceTag: FrameInterfaceTag.tcp.rawValue) - } + // Symmetric idempotency guard with the `active: false` + // branch below. iOS routinely reports the VPN-status + // sequence `.connecting → .connected → .reasserting → + // .connected` during routing setup, which fires our + // `onStatusChange` handler twice for `.connected`. Without + // this guard each interface's `beginTunnelMode` got called + // twice; the second call tears down the freshly-installed + // outbound hook + transport pair and re-installs them, + // racing any in-flight LXMessage send (the LXMRouter sees + // the interface flap state=connected→reconnecting→connected + // mid-link-establishment and the resulting `LINKREQUEST` + // gets dropped). With the guard the second `.connected` + // notification is a no-op. + guard !isTunnelModeActive else { + DiagLog.log("[TUNNEL] skipping enable — already active (likely .connected redundancy from iOS VPN-status churn)") + return } - if let auto = autoInterface { - await auto.beginTunnelMode { [weak tunnel] frame in - await tunnel?.sendFrame(frame, interfaceTag: FrameInterfaceTag.auto.rawValue) + for (entityId, iface) in tcpInterfaces { + await iface.beginTunnelMode { [weak tunnel, entityId] frame in + await tunnel?.sendFrame(frame, interfaceTag: FrameInterfaceTag.tcp.rawValue, entityId: entityId) } } - DiagLog.log("[TUNNEL] enabled tunnel mode on \(self.tcpInterfaces.count) TCP + \(self.autoInterface != nil ? 1 : 0) Auto interface(s)") + isTunnelModeActive = true + DiagLog.log("[TUNNEL] enabled tunnel mode on \(self.tcpInterfaces.count) TCP interface(s); Auto stays local (foreground-only)") } else { + // Only undo if we previously did it. `endTunnelMode()` on + // reticulum-swift's TCPInterface is NOT idempotent — see + // the doc on `isTunnelModeActive` for why this guard + // exists. Without it, the initial `.invalid` notification + // from VPN status on cold start (which fires regardless + // of whether the user has enabled Background Transport) + // would tear down every live TCP NWConnection seconds + // after Step 7 brought them up, blocking all outbound + // sends. + guard isTunnelModeActive else { + DiagLog.log("[TUNNEL] skipping disable — tunnel mode was never active (likely initial .invalid VPN state)") + return + } for (_, iface) in tcpInterfaces { await iface.endTunnelMode() } - if let auto = autoInterface { - await auto.endTunnelMode() - } - DiagLog.log("[TUNNEL] disabled tunnel mode; interfaces resuming local connections") + isTunnelModeActive = false + DiagLog.log("[TUNNEL] disabled tunnel mode; TCP interfaces resuming local connections") } } #endif /// Switch to a different identity, tearing down and re-initializing the full stack. /// + /// Interface connections are not started here — the caller's + /// `onIdentitySwitch` hook re-runs the app's Step 7 loop, which + /// iterates `InterfaceRepository` and connects every enabled + /// interface against the new transport. + /// /// - Parameters: /// - newIdentity: The identity to switch to (already loaded from Keychain) /// - identityHash: Hex hash of the new identity - /// - tcpServerAddress: TCP server address to reconnect to - public func switchIdentity(to newIdentity: Identity, identityHash: String, tcpServerAddress: String) async throws { + public func switchIdentity(to newIdentity: Identity, identityHash: String) async throws { logger.info("Switching identity to: \(identityHash)") // Tear down current stack @@ -752,7 +1184,7 @@ public final class AppServices { try? await Task.sleep(for: .milliseconds(200)) // Re-initialize with new identity - try await initialize(identity: newIdentity, identityHash: identityHash, tcpServerAddress: tcpServerAddress) + try await initialize(identity: newIdentity, identityHash: identityHash) logger.info("Identity switch complete: \(identityHash)") } @@ -1221,6 +1653,56 @@ public final class AppServices { self.autoAnnounceManager = announceManager announceManager.start() } + + // Republish in case the base-stack rebuild introduced + // destinations that weren't in the App Group before (e.g. a + // late CallManager spin-up after the first initialize() path + // skipped it). + await publishLocalDestinations() + } + + // MARK: - Local Destinations (App Group) + + /// Republish the set of locally-registered destination hashes to + /// the App Group so the `PacketTunnelProvider` extension can + /// match inbound packets' `destination_hash` field against them + /// and schedule a user-visible `UNUserNotificationCenter` + /// notification under the host app's bundle identity when the + /// host app is suspended. + /// + /// Stored as `[String]` of hex-encoded 16-byte truncated hashes + /// — same shape `ReticulumTransport.registeredDestinationHashes()` + /// already returns, so callers don't need to re-encode. + /// + /// Idempotent and cheap; safe to call after any change to the + /// registered-destination set. Posts a Darwin notification so + /// the extension reloads without restarting the tunnel. + /// + /// Crypto and full LXMF decode stay in the host app — the + /// extension only checks the unencrypted destination-hash header + /// field, equivalent to looking at an envelope's "To:" line. + /// + /// - Parameter hexHashes: Pre-fetched hash list to publish. When + /// nil, the helper queries the transport itself. Passing the + /// value lets `initialize(identity:identityHash:)` reuse the + /// `registeredDestinationHashes()` call it already makes for + /// diagnostics. + private func publishLocalDestinations(hexHashes: [String]? = nil) async { + let hashes: [String] + if let hexHashes { + hashes = hexHashes + } else if let transport { + hashes = await transport.registeredDestinationHashes() + } else { + return + } + SharedDefaults.suite.set(hashes, forKey: SharedDefaultsConstants.localDestinationsKey) + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(SharedDefaultsConstants.localDestinationsChangedNotificationName as CFString), + nil, nil, true + ) + DiagLog.log("[LOCAL_DESTS] Published \(hashes.count) hash(es) to App Group: \(hashes)") } // MARK: - BLE Connection Info @@ -1362,6 +1844,27 @@ public final class AppServices { await transport.setTransportEnabled(true, identity: identity) } + // Post-dual-interface refactor: foreground `TCPInterface`s + // are NEVER flipped into tunnel mode. They keep their + // app-process `NWConnection`s for as long as the host app + // is alive. The separate `TunnelTCPInterface` (registered + // by `registerTunnelInterface()` on tunnel-status + // .connected) carries the background-survival path. See + // `TunnelTCPInterface.swift` for the architectural details. + // + // If we late-added this TCP entity AFTER the tunnel was + // already up (cold-start race), we also need to refresh + // the tunnel's view — the tunnel TCP mirrors the foreground + // entity's host/port, so a new foreground entity changes + // what the tunnel should target. The simplest approach is + // to re-run register; it's idempotent for the same endpoint + // and otherwise re-publishes with the new endpoint. + #if ENABLE_NETWORK_EXTENSION + if let tunnel = tunnelManager, tunnel.isRunning, tunnelTCPInterface == nil { + await registerTunnelInterface() + } + #endif + startStateObserver() } @@ -1465,6 +1968,7 @@ public final class AppServices { ) let newInterface = try TCPInterface(config: config) tcpInterfaces["tcp-server"] = newInterface + tcpEndpoints["tcp-server"] = TCPEndpoint(host: host, port: port) // Add interface to transport (connects it) do { diff --git a/Sources/ColumbaApp/Services/ExtensionFrameReader.swift b/Sources/ColumbaApp/Services/ExtensionFrameReader.swift index ec6110c5..0f61804c 100644 --- a/Sources/ColumbaApp/Services/ExtensionFrameReader.swift +++ b/Sources/ColumbaApp/Services/ExtensionFrameReader.swift @@ -27,8 +27,12 @@ public final class ExtensionFrameReader: @unchecked Sendable { private let frameQueue: SharedFrameQueue private let logger = Logger(subsystem: "network.columba.Columba", category: "ExtensionFrameReader") - /// Callback to inject a TCP frame into transport - public var onTCPFrameReceived: ((Data) -> Void)? + /// Callback to inject a TCP frame into transport. The first + /// argument is the source `InterfaceEntity.id` so the receiver can + /// route the frame to the correct `TCPInterface` when multiple TCP + /// connections are tunneled simultaneously. Empty string for + /// legacy single-TCP frames or where the source is unknown. + public var onTCPFrameReceived: ((String, Data) -> Void)? /// Callback to inject an Auto frame into transport public var onAutoFrameReceived: ((Data) -> Void)? @@ -87,7 +91,7 @@ public final class ExtensionFrameReader: @unchecked Sendable { for frame in frames { switch frame.interfaceTag { case FrameInterfaceTag.tcp.rawValue: - onTCPFrameReceived?(frame.data) + onTCPFrameReceived?(frame.entityId, frame.data) case FrameInterfaceTag.auto.rawValue: onAutoFrameReceived?(frame.data) default: diff --git a/Sources/ColumbaApp/Services/NotificationService.swift b/Sources/ColumbaApp/Services/NotificationService.swift index f180165c..d6097b32 100644 --- a/Sources/ColumbaApp/Services/NotificationService.swift +++ b/Sources/ColumbaApp/Services/NotificationService.swift @@ -234,6 +234,22 @@ public final class NotificationService: Sendable { trigger: nil // Deliver immediately ) + // Dedupe against the Network Extension's placeholder banner. + // + // When the host app is background-running (not yet suspended), + // both notification paths are live for the same arriving + // packet: the extension posts a generic "New message" banner + // via `ExtensionNotifications.postMessageArrived` keyed on the + // recipient's destination hash, and the host app posts the + // rich per-conversation banner here keyed on the LXMF message + // hash. Without dedupe the user sees two banners for one + // message. Now that this rich notification is about to fire + // for the same destination, clear any pending/delivered + // extension placeholders for that destination — the rich + // notification is the authoritative one. + let destHashHex = message.destinationHash.map { String(format: "%02x", $0) }.joined() + await removeExtensionPlaceholders(forDestinationHashHex: destHashHex) + do { try await UNUserNotificationCenter.current().add(request) } catch { @@ -241,6 +257,38 @@ public final class NotificationService: Sendable { } } + /// Remove any pending or delivered placeholder notifications posted + /// by `ExtensionNotifications.postMessageArrived` (in + /// `PacketTunnelProvider.swift`) that match the given destination + /// hash. Identifiers used by the extension: + /// - `ext--` for DATA (OPPORTUNISTIC) + /// - `ext-linkreq-` for LINKREQUEST (DIRECT) + /// + /// `UNUserNotificationCenter` only supports exact-match removal, so + /// fetch the current pending + delivered lists and match by prefix. + private func removeExtensionPlaceholders(forDestinationHashHex destHashHex: String) async { + guard !destHashHex.isEmpty else { return } + let center = UNUserNotificationCenter.current() + let dataPrefix = "ext-\(destHashHex)-" + let linkReqId = "ext-linkreq-\(destHashHex)" + + let pending = await center.pendingNotificationRequests() + let pendingIds = pending + .map(\.identifier) + .filter { $0 == linkReqId || $0.hasPrefix(dataPrefix) } + if !pendingIds.isEmpty { + center.removePendingNotificationRequests(withIdentifiers: pendingIds) + } + + let delivered = await center.deliveredNotifications() + let deliveredIds = delivered + .map(\.request.identifier) + .filter { $0 == linkReqId || $0.hasPrefix(dataPrefix) } + if !deliveredIds.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: deliveredIds) + } + } + // MARK: - Badge Management /// Clear the badge count (call when app becomes active). diff --git a/Sources/ColumbaApp/Services/PropagationNodeManager.swift b/Sources/ColumbaApp/Services/PropagationNodeManager.swift index 3107a021..3f38f36a 100644 --- a/Sources/ColumbaApp/Services/PropagationNodeManager.swift +++ b/Sources/ColumbaApp/Services/PropagationNodeManager.swift @@ -162,6 +162,20 @@ public final class PropagationNodeManager { logger.info("Discovered propagation node: \(node.resolvedDisplayName) (\(hex.prefix(16))) hops=\(node.hopCount)") + // If this node is the currently-selected one, re-apply its + // announce-derived stamp cost to the router. selectNode runs + // before any announce has been received in the smoke-test flow + // (set_prop_node fires immediately after add_tcp_client, which + // is before the path entry / announce arrives), so the initial + // selectNode call sees stampCost=0 and ships it to the router. + // Without this re-apply on later announces, sendPropagated + // ends up generating a random 32-byte stamp that lxmd rejects + // with ERROR_INVALID_STAMP. + if selectedNodeHash == node.hash { + await appServices?.router?.setPropagationStampCost(node.info.stampCost) + logger.info("Re-applied propagation stamp cost \(node.info.stampCost) for selected node from announce") + } + // Auto-select if enabled if autoSelectEnabled { await autoSelectBestNode() @@ -190,13 +204,33 @@ public final class PropagationNodeManager { /// Disables auto-select when called manually. public func selectNode(hash: Data) async { selectedNodeHash = hash - let node = knownNodes.first(where: { $0.hash == hash }) + var node = knownNodes.first(where: { $0.hash == hash }) selectedNodeName = node?.resolvedDisplayName // Compute delivery hash for this identity so we can match against saved contacts. // Relay announces use lxmf.propagation aspect; contacts use lxmf.delivery aspect. - if let entry = await appServices?.pathTable?.lookup(destinationHash: hash), - entry.publicKeys.count >= 64 { + var pathEntry = await appServices?.pathTable?.lookup(destinationHash: hash) + + // Brief wait for the announce-derived path entry to arrive. + // Without this, set_prop_node calls fired immediately after + // adding an interface (the smoke-test flow) race the path + // request: the announce hasn't been processed yet when + // selectNode runs, so neither knownNodes nor pathTable has + // any data. Result: stampCost=0 → router ships random stamp + // → lxmd rejects with ERROR_INVALID_STAMP. Up to ~5 second + // wait — production UI flows usually have the announce well + // before user-triggered selection, so this is mostly a + // smoke-test hot path; the timeout is bounded so it can't + // stall a UI thread. + if pathEntry == nil && node == nil { + for _ in 0..<25 { + try? await Task.sleep(for: .milliseconds(200)) + pathEntry = await appServices?.pathTable?.lookup(destinationHash: hash) + node = knownNodes.first(where: { $0.hash == hash }) + if pathEntry != nil || node != nil { break } + } + } + if let entry = pathEntry, entry.publicKeys.count >= 64 { let identityHash = Hashing.truncatedHash(entry.publicKeys) let nameHash = Hashing.destinationNameHash(appName: "lxmf", aspects: ["delivery"]) var combined = nameHash @@ -206,12 +240,28 @@ public final class PropagationNodeManager { selectedNodeDeliveryHash = nil } - // Wire to router (awaited directly, not fire-and-forget) - let stampCost = node?.info.stampCost ?? 0 + // Resolve stamp cost. Prefer knownNodes (set by processPathEntry + // when the announce was processed), but fall back to parsing the + // pathEntry's appData directly. The fallback exists for the + // race where selectNode is called immediately after the path + // arrives but before processPathEntry's async listener has + // populated knownNodes yet — without it the router stays at + // cost=0 and sendPropagated ships a random stamp that lxmd + // rejects with ERROR_INVALID_STAMP. Mirrors the same pathEntry + // -> PropagationNodeInfo.parse path that processPathEntry uses. + let stampCost: Int + if let cost = node?.info.stampCost { + stampCost = cost + } else if let appData = pathEntry?.appData, + let info = PropagationNodeInfo.parse(from: appData) { + stampCost = info.stampCost + } else { + stampCost = 0 + } await appServices?.router?.setOutboundPropagationNode(hash) await appServices?.router?.setPropagationStampCost(stampCost) - logger.info("Selected propagation node: \(self.selectedNodeName ?? "unknown")") + logger.info("Selected propagation node: \(self.selectedNodeName ?? "unknown") stampCost=\(stampCost)") await savePreferences() } diff --git a/Sources/ColumbaApp/Services/TunnelManager.swift b/Sources/ColumbaApp/Services/TunnelManager.swift index ba2f7918..f0613997 100644 --- a/Sources/ColumbaApp/Services/TunnelManager.swift +++ b/Sources/ColumbaApp/Services/TunnelManager.swift @@ -35,6 +35,25 @@ public final class TunnelManager: @unchecked Sendable { /// The loaded tunnel provider manager private var manager: NETunnelProviderManager? + /// Fetch the last disconnect error from the underlying VPN + /// connection, if any. `startVPNTunnel()` is fire-and-forget — + /// when the tunnel fails to connect (airplane mode, routing + /// failure, extension crash) the launch call returns successfully + /// and the failure is reported asynchronously. The Settings toggle + /// uses this after a polling timeout to surface a meaningful + /// reason instead of silently bouncing. + /// + /// `fetchLastDisconnectError` is iOS 16+; we already require + /// iOS 17. + public func lastFailureReason() async -> String? { + guard let connection = manager?.connection else { return nil } + return await withCheckedContinuation { continuation in + connection.fetchLastDisconnectError { error in + continuation.resume(returning: (error as NSError?)?.localizedDescription) + } + } + } + /// Called whenever the tunnel's VPN status changes. /// /// `AppServices` uses this to coordinate transitioning each @@ -43,6 +62,15 @@ public final class TunnelManager: @unchecked Sendable { /// duplicate local NWConnection). public var onStatusChange: (@Sendable (NEVPNStatus) -> Void)? + /// Called once just before `startVPNTunnel()` fires, so the app + /// can put its interfaces in tunnel mode (release UDP sockets, + /// install the outbound hook) before the extension starts and + /// tries to bind the same ports. Without this, the extension's + /// `NWMulticastGroup` and `NWListener` race the app for the + /// AutoInterface multicast / data ports and fail with + /// `EADDRINUSE`. + public var onWillStart: (@Sendable () async -> Void)? + private let logger = Logger(subsystem: "network.columba.Columba", category: "TunnelManager") // MARK: - Lifecycle @@ -60,10 +88,16 @@ public final class TunnelManager: @unchecked Sendable { logger.info("No tunnel config found") } - // Observe status changes + // Observe status changes. Bind to `object: nil` (all VPN + // status notifications) so a later `install()` / fresh + // profile after a delete-and-re-add doesn't leave the + // observer stuck on the old connection — we re-resolve + // `self.manager?.connection.status` inside the callback, + // which always reflects whichever manager we currently + // hold. NotificationCenter.default.addObserver( forName: .NEVPNStatusDidChange, - object: manager?.connection, + object: nil, queue: .main ) { [weak self] _ in guard let self else { return } @@ -108,6 +142,14 @@ public final class TunnelManager: @unchecked Sendable { mgr.localizedDescription = "Columba Background Transport" mgr.isEnabled = true + // On-demand always-connect: lets iOS keep the tunnel up + // across wake/sleep, network changes, and restart it after + // the system tears it down under memory pressure. Without + // this the extension stays up only until something kills + // it and never recovers on its own. + mgr.isOnDemandEnabled = true + mgr.onDemandRules = [NEOnDemandRuleConnect()] + try await mgr.saveToPreferences() try await mgr.loadFromPreferences() @@ -119,41 +161,156 @@ public final class TunnelManager: @unchecked Sendable { /// Start the tunnel extension. public func start() async throws { + // Refresh from system preferences in case the user deleted + // the VPN profile from iOS Settings while we held a cached + // reference. Without this, `startVPNTunnel()` would fail with + // `NEVPNErrorConfigurationInvalid` (error 1) on the stale + // manager and the toggle would never recover. + if let managers = try? await NETunnelProviderManager.loadAllFromPreferences() { + if let existing = managers.first { + manager = existing + } else { + manager = nil + } + } + guard let manager else { try await install() try await start() return } + // Reflect user intent on the observable up-front so re-enable + // paths (where `disable()` previously cleared `self.isEnabled`) + // don't leave the published value stale while the profile is + // being re-saved. + isEnabled = true + + // Re-assert profile flags — handles two cases: + // 1. Migration: profiles installed before on-demand was + // added need the rules applied on the next start. + // 2. Re-enable after explicit disable(): that path clears + // isEnabled AND isOnDemandEnabled; both must come back. + var needsSave = false if !manager.isEnabled { manager.isEnabled = true + needsSave = true + } + if !manager.isOnDemandEnabled { + manager.isOnDemandEnabled = true + needsSave = true + } + if manager.onDemandRules?.isEmpty ?? true { + manager.onDemandRules = [NEOnDemandRuleConnect()] + needsSave = true + } + if needsSave { try await manager.saveToPreferences() } + // Bail before firing `startVPNTunnel()` if our caller's Task + // was cancelled during the awaited install/save above — + // otherwise a rapid OFF tap during a still-running ON would + // still bring the tunnel up despite the user's last intent. + try Task.checkCancellation() + + // Release any per-interface UDP sockets the app holds before + // the extension launches and tries to bind the same ports. + // The hook is awaited so we don't return from `start()` + // until interfaces are fully in tunnel mode. + if let willStart = onWillStart { + await willStart() + } + try manager.connection.startVPNTunnel() logger.info("Tunnel started") } - /// Stop the tunnel extension. - public func stop() { - manager?.connection.stopVPNTunnel() - logger.info("Tunnel stopped") + /// Disable the tunnel: stop the VPN session and clear `isEnabled` + /// in the saved profile so iOS releases routing fully. + /// + /// Calling `stopVPNTunnel()` alone leaves `manager.isEnabled == true`, + /// which iOS treats as "the tunnel can be auto-resumed" and can + /// keep parts of the routing table installed. That is what caused + /// the previous "toggle off but TCP stays broken" report — the + /// profile was still partially live. + /// + /// Use `uninstall()` instead if the user wants to remove the + /// VPN profile from iOS Settings entirely. + public func disable() async throws { + guard let manager else { return } + // Reflect user intent on the observable before any throwing + // call so observers see "off" even if `saveToPreferences()` + // fails. Without this, a thrown save leaves `self.isEnabled` + // stuck at `true` while the profile is partially mutated. + isEnabled = false + manager.connection.stopVPNTunnel() + var needsSave = false + if manager.isEnabled { + manager.isEnabled = false + needsSave = true + } + // Clear on-demand too — otherwise iOS auto-resumes the + // tunnel after `stopVPNTunnel()` per the always-connect + // rule and the toggle silently bounces back on. + if manager.isOnDemandEnabled { + manager.isOnDemandEnabled = false + needsSave = true + } + if needsSave { + try await manager.saveToPreferences() + } + logger.info("Tunnel disabled") + } + + /// Tell the extension to terminate its current session via + /// `cancelTunnelWithError`, forcing iOS to spawn a fresh + /// extension process the next time the tunnel starts. Used by + /// `tools/auto-test/run_test.sh` to reload the extension binary + /// after a build without the user manually deleting and + /// re-adding the VPN profile in iOS Settings. + public func debugReloadExtension() async { + guard let session = manager?.connection as? NETunnelProviderSession else { + return + } + let message = Data([0xFE]) // DEBUG_RELOAD_COMMAND in extension + do { + try session.sendProviderMessage(message) { _ in } + logger.info("Debug reload requested") + } catch { + logger.error("Debug reload failed: \(error)") + } } /// Send a raw frame to the extension for transmission. /// /// The extension will route this to the appropriate NWConnection - /// based on the interface tag. + /// based on the interface tag and entity ID. + /// + /// Wire format (matches `PacketTunnelProvider.handleAppMessage`): + /// `[1B tag][1B idLen][N idBytes][M frameData]` /// /// - Parameters: /// - data: Raw frame data (already HDLC-framed for TCP) /// - interfaceTag: Which interface to send on (TCP=0x01, Auto=0x02) - public func sendFrame(_ data: Data, interfaceTag: UInt8) async { + /// - entityId: Identifier of the source `TCPInterface` so the + /// extension picks the right `NWConnection` when multiple TCP + /// interfaces are tunneled simultaneously. Empty string keeps + /// the legacy behaviour where the extension routes to its sole + /// connection (used by Auto and by single-TCP fallbacks). + public func sendFrame(_ data: Data, interfaceTag: UInt8, entityId: String = "") async { guard let session = manager?.connection as? NETunnelProviderSession else { return } - var message = Data([interfaceTag]) + let idBytes = Array(entityId.utf8.prefix(255)) + var message = Data() + message.reserveCapacity(2 + idBytes.count + data.count) + message.append(interfaceTag) + message.append(UInt8(idBytes.count)) + if !idBytes.isEmpty { + message.append(contentsOf: idBytes) + } message.append(data) do { diff --git a/Sources/ColumbaApp/Services/TunnelTCPInterface.swift b/Sources/ColumbaApp/Services/TunnelTCPInterface.swift new file mode 100644 index 00000000..17f5477a --- /dev/null +++ b/Sources/ColumbaApp/Services/TunnelTCPInterface.swift @@ -0,0 +1,203 @@ +// +// TunnelTCPInterface.swift +// ColumbaApp +// +// A `NetworkInterface` that routes outbound traffic through the +// Columba Network Extension's `PacketTunnelProvider` instead of +// through an app-owned `NWConnection`. Inbound packets are fed in +// from `ExtensionFrameReader`'s drain of `SharedFrameQueue`. +// +// Architectural rationale (the dual-interface model that obsoletes +// the old tunnel-mode flip): +// +// Previously, Columba had ONE `TCPInterface` per configured TCP +// relay. When the user enabled Background Transport, that interface +// was flipped into "tunnel mode" mid-session: it gave up its +// local `NWConnection` and started routing outbound through the +// extension instead. This worked in steady state but had a fatal +// edge case at the handoff itself — when tunnel mode engaged, the +// app-owned socket closed, rnsd noticed the disconnect and removed +// the path-table entry pointing at it, and the new extension-owned +// socket had no announce yet because `TCPInterface.beginTunnelMode` +// keeps state `.connected` (no `notifyStateChange`, so the +// `auto_announce_on_tcp_reconnect` callback never fires). Bot → +// phone packets then bounced off rnsd as +// `Got packet in transport, but no known path to final destination` +// for the entire suspend window. +// +// The new model: TWO interfaces are registered with the transport +// when Background Transport is on. The original `TCPInterface` +// stays foreground-only (owns its own `NWConnection`, no tunnel +// mode); this `TunnelTCPInterface` carries the tunneled path. +// rnsd sees them as two separate clients with two independent +// path-table entries to ``. When the app suspends, the +// foreground socket dies and rnsd removes its path, but the tunnel +// path stays because the extension's `NWConnection` keeps running. +// Bot → phone packets route via the tunnel path → extension → +// `PacketTunnelProvider.maybeScheduleNotification(for:)` → UN +// notification fires under the host app's bundle identity. +// +// Outbound: `send(_:)` HDLC-frames the data and hands the framed +// bytes to `TunnelManager.sendFrame(_:interfaceTag:entityId:)`, +// which posts them through `sendProviderMessage` to the extension. +// The extension's `handleAppMessage` forwards them on its own +// `NWConnection` to rnsd. +// +// Inbound: when the extension reads a frame from rnsd, it deframes +// HDLC and writes the packet to `SharedFrameQueue` tagged with the +// source `entityId`. `ExtensionFrameReader` drains the queue and +// invokes `onTCPFrameReceived(entityId, data)`. For frames from +// the tunnel's entityId, the callback routes them via +// `transport.handleReceivedData(data:, from: self.id)` — which is +// how the transport handles inbound for any `NetworkInterface`. +// The interface itself doesn't need to actively receive; the +// transport's routing layer does that. +// + +#if ENABLE_NETWORK_EXTENSION + +import Foundation +import ReticulumSwift + +/// Fixed interface ID used by the tunnel TCP path. The extension +/// publishes inbound frames with this entityId and the +/// `TunnelManager.sendFrame` outbound call tags frames with it. +public let TUNNEL_TCP_INTERFACE_ID = "tcp-tunnel" + +@available(iOS 17.0, macOS 14.0, *) +public actor TunnelTCPInterface: @preconcurrency NetworkInterface { + + // MARK: - NetworkInterface conformance + + /// Fixed to `TUNNEL_TCP_INTERFACE_ID` — there is only ever one + /// tunnel TCP interface per app process. The extension routes + /// inbound frames to this id and the outbound `sendFrame` tags + /// with it. + public nonisolated let id: String = TUNNEL_TCP_INTERFACE_ID + + /// Synthesized config so the transport's interface bookkeeping + /// has something to introspect. The `host`/`port` mirror the + /// foreground TCP entity so the user-facing identity (relay + /// address) is the same across both interfaces — they're just + /// two paths to the same rnsd. + public nonisolated let config: InterfaceConfig + + /// Matches `TCPInterface.hwMtu` — TCP has no practical MTU limit + /// at the application layer. + public nonisolated var hwMtu: Int { 262144 } + + public private(set) var state: InterfaceState = .disconnected + + private var delegateRef: WeakDelegate? + + public var delegate: InterfaceDelegate? { + get { delegateRef?.delegate } + } + + public func setDelegate(_ delegate: InterfaceDelegate) async { + delegateRef = WeakDelegate(delegate) + } + + // MARK: - Outbound + + /// Closure called for each HDLC-framed outbound packet. Bound at + /// init time to `TunnelManager.sendFrame(_:interfaceTag:entityId:)` + /// (with `interfaceTag = .tcp` and `entityId = TUNNEL_TCP_INTERFACE_ID`) + /// so this interface doesn't need a direct dependency on + /// `TunnelManager`'s type — keeps the interface compilable + /// outside the `ENABLE_NETWORK_EXTENSION` target if we ever + /// move it. + private let sendHook: @Sendable (Data) async -> Void + + // MARK: - Init + + public init( + config: InterfaceConfig, + sendHook: @escaping @Sendable (Data) async -> Void + ) { + self.config = config + self.sendHook = sendHook + } + + // MARK: - Connection lifecycle + + /// Mark the interface connected. The actual TCP socket is owned + /// by the extension's `PacketTunnelProvider`, so all this method + /// does is flip our local state + notify the delegate. The + /// caller (AppServices' tunnel-status observer) ensures this is + /// only invoked once the underlying VPN tunnel has reached + /// `.connected`. + public func connect() async throws { + guard state != .connected else { return } + state = .connecting + notifyStateChange() + state = .connected + notifyStateChange() + } + + /// Mark the interface disconnected. Mirrors `connect()` — the + /// actual socket teardown happens in the extension when the VPN + /// session ends. We just notify the transport so the + /// path-routing logic stops trying to use this interface. + public func disconnect() async { + guard state != .disconnected else { return } + state = .disconnected + notifyStateChange() + } + + // MARK: - Send + + public func send(_ data: Data) async throws { + guard state == .connected else { + throw InterfaceError.notConnected + } + let framed = HDLC.frame(data) + await sendHook(framed) + } + + // MARK: - Inbound (from ExtensionFrameReader) + + /// Feed an unframed packet (already HDLC-deframed by the + /// extension's `extractHDLCFrames`) up to the transport via + /// the standard delegate channel. Called by AppServices's + /// `ExtensionFrameReader.onTCPFrameReceived` handler when the + /// inbound frame's entityId matches `TUNNEL_TCP_INTERFACE_ID`. + public func receivePacket(_ data: Data) async { + delegateRef?.delegate?.interface(id: id, didReceivePacket: data) + } + + // MARK: - Private + + private func notifyStateChange() { + delegateRef?.delegate?.interface(id: id, didChangeState: state) + } + + /// Convenience for AppServices to synthesize an `InterfaceConfig` + /// from a foreground TCP entity's host/port. Reuses the same + /// host/port so the tunnel interface is logically "another path + /// to the same relay". + public static func makeConfig(host: String, port: UInt16) -> InterfaceConfig { + InterfaceConfig( + id: TUNNEL_TCP_INTERFACE_ID, + name: "TCP Tunnel", + type: .tcp, + enabled: true, + mode: .full, + host: host, + port: port, + ifac: nil + ) + } +} + +/// Weak delegate wrapper. Mirrors the same pattern in +/// `reticulum-swift/TCPInterface.swift` so delegate retention +/// matches across interface types. +private final class WeakDelegate: @unchecked Sendable { + weak var delegate: InterfaceDelegate? + init(_ delegate: InterfaceDelegate) { + self.delegate = delegate + } +} + +#endif // ENABLE_NETWORK_EXTENSION diff --git a/Sources/ColumbaApp/Test/TestController.swift b/Sources/ColumbaApp/Test/TestController.swift new file mode 100644 index 00000000..1af7ba67 --- /dev/null +++ b/Sources/ColumbaApp/Test/TestController.swift @@ -0,0 +1,1163 @@ +// +// TestController.swift +// ColumbaApp +// +// Debug-only test surface for the columba-iOS phone harness. +// +// Mirror of `app/src/debug/java/network/columba/app/test/TestController.kt` +// on the Android side. Lazy-initialized on the first URL action received +// by [TestURLHandler]. Binds to the live `AppServices` (router, interface +// repository) supplied at injection time, then subscribes to the inbound +// message and delivery-status callbacks. Each handler logs a structured +// `event=… key=value` line via `os_log` under the dedicated +// `network.columba.app.test` subsystem so `idevicesyslog` can filter +// cleanly. +// +// This file lives under `Sources/ColumbaApp/Test/` and the entire +// contents are wrapped in `#if DEBUG`, so it never compiles into a +// Release `.ipa`. Defense in depth: every entry point also calls +// `assertionFailure("must not run in release")` (debug-build assertion +// which is a no-op in release-config — but we never get here in a +// release config because the file is fully ifdef'd out). +// + +#if DEBUG + +import Foundation +import os.log +import OSLog +import LXMFSwift +import UserNotifications +#if canImport(UIKit) +import UIKit +#endif + +// MARK: - Logging + +/// Dedicated subsystem for the test harness. The original design called +/// for `idevicesyslog` to filter by (process, subsystem, category) for +/// real-time tailing, but iOS 17+ moved the syslog stream behind the new +/// CoreDevice / RemoteXPC protocol that libimobiledevice can't speak, +/// and `pymobiledevice3` requires a developer-tunnel daemon to bridge it. +/// Rather than maintain that fragile pairing, the orchestrator now polls +/// a structured file at `Documents/test_log.txt` (pulled via +/// `xcrun devicectl device copy from --domain-type appDataContainer`). +/// `os_log` writes are kept as-is for human / Console.app readers; the +/// file is the contract the harness consumes. +public enum TestLog { + public static let subsystem = "network.columba.app.test" + public static let category = "harness" + public static let logger = Logger(subsystem: subsystem, category: category) + + /// Per-launch monotonically-increasing line number, so a harness that + /// pulls the log file mid-run can detect "did any new lines arrive + /// since the last poll" without relying on file-size deltas (which + /// can race with append-writes mid-flight). + private static var sequence: UInt64 = 0 + private static let sequenceLock = NSLock() + + /// File-descriptor cache. Opened lazily, kept open for the app + /// lifetime so each emit() is a write+fsync, not an open+write+close. + private static var fileHandle: FileHandle? + private static let handleLock = NSLock() + + /// Resolved path to the log file inside the app's sandbox Documents + /// dir. Computed once on first use. + public static let logFilePath: String = { + let docs = NSSearchPathForDirectoriesInDomains( + .documentDirectory, .userDomainMask, true + ).first ?? NSTemporaryDirectory() + return (docs as NSString).appendingPathComponent("test_log.txt") + }() + + /// All harness output goes through this single sink so the Python + /// orchestrator's regex sees one consistent shape. + /// + /// Emits to BOTH: + /// - `os_log` for live Console.app / Xcode console viewing + /// - `Documents/test_log.txt` (newline-terminated) for the + /// orchestrator's `devicectl copy from`-based poller + /// + /// Each line is prefixed `seq= ts= ` so the harness can + /// detect new lines after a poll and reason about ordering. + public static func emit(_ line: String) { + os_log("%{public}@", log: OSLog(subsystem: subsystem, category: category), + type: .info, line) + + sequenceLock.lock() + sequence &+= 1 + let seq = sequence + sequenceLock.unlock() + + let ts = ISO8601DateFormatter().string(from: Date()) + let prefixed = "seq=\(seq) ts=\(ts) \(line)\n" + + handleLock.lock() + defer { handleLock.unlock() } + if fileHandle == nil { + let path = logFilePath + // Truncate on first write of each app launch so the harness + // doesn't have to reason about cross-launch line numbers. + // The file is bounded by the harness's own retry-cap anyway. + FileManager.default.createFile(atPath: path, contents: nil, attributes: nil) + fileHandle = FileHandle(forWritingAtPath: path) + } + if let fh = fileHandle, let data = prefixed.data(using: .utf8) { + try? fh.write(contentsOf: data) + // Don't fsync per write — it'd serialize all emit() calls and + // wreck the log under bursty events. The harness polls every + // ~250ms; OS page-cache flush easily keeps up. + } + } +} + +// MARK: - Whitespace escape (matches the Android TestController exactly) + +/// Escape any whitespace so the value is always a single `\S+` token in +/// the harness's `key=value` format. Mirrors the Android side's +/// `escape()` helper byte-for-byte. +/// +/// ' ' (0x20) → '␣' (U+2423 OPEN BOX) +/// '\n' (0x0A) → '⏎' (U+23CE RETURN SYMBOL) +/// '\r' (0x0D) → '␍' (U+240D SYMBOL FOR CARRIAGE RETURN) +/// '\t' (0x09) → '␉' (U+2409 SYMBOL FOR HORIZONTAL TABULATION) +/// +/// Caps the escaped output at 1024 chars so a runaway message body can't +/// blow up the log line size. Long values are truncated with a trailing +/// `…` sentinel. +public func testHarnessEscape(_ s: String) -> String { + var out = s.replacingOccurrences(of: " ", with: "\u{2423}") + out = out.replacingOccurrences(of: "\n", with: "\u{23CE}") + out = out.replacingOccurrences(of: "\r", with: "\u{240D}") + out = out.replacingOccurrences(of: "\t", with: "\u{2409}") + if out.count > 1024 { + out = String(out.prefix(1024)) + "…" + } + return out +} + +// MARK: - Hex helpers + +private func toHex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() +} + +private func fromHex(_ s: String) -> Data? { + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count % 2 == 0 else { return nil } + var out = Data() + out.reserveCapacity(trimmed.count / 2) + var i = trimmed.startIndex + while i < trimmed.endIndex { + let next = trimmed.index(i, offsetBy: 2) + guard let byte = UInt8(trimmed[i..? + private let screenshotIntervalSec: UInt64 = 2 + private var screenshotSeq: UInt64 = 0 + private static let screenshotsDirName = "screenshots" + private static let maxScreenshots = 30 + + private init() {} + + // MARK: - Init / bind + + /// Bind to the live AppServices instance and register receive + + /// delivery-status observers. Idempotent — repeat calls re-bind + /// against the new AppServices (a no-op for the production code path, + /// but useful in tests where AppServices is reconstructed). + public func bind( + appServices: AnyObject, + router: LXMRouter, + interfaceRepo: InterfaceRepository, + destHash: Data + ) { + assertionFailure_releaseGuard() + self.appServices = appServices + self.routerRef = router + self.interfaceRepoRef = interfaceRepo + self.destHashCached = destHash + if !initialized { + // Install harness-side LXMRouterDelegate observer. The app's + // primary delegate is `IncomingMessageHandler`; we don't want + // to displace it, so we register a TestRelayDelegate that + // forwards to the original delegate AND records into our rx + // queue + delivery state map. Wired by TestURLHandler at + // bind time (see `attachDelegate()`). + initialized = true + TestLog.emit("controller_ready") + startDiagnosticTicker() + registerLifecycleObservers() + } else { + TestLog.emit("controller_rebound") + } + } + + // MARK: - Diagnostic ticker (screenshot + lifecycle) + // + // The harness wedge surfaces as "lxma-test:// URLs stop reaching the + // URL handler" — but URL handler dispatch requires the app to be + // foreground-active, so the natural hypothesis is iOS deactivating / + // backgrounding the app between runs. Pure log files can't disprove + // that (URL events stop because the cause stops dispatch). This + // ticker is driven by an internal Task, NOT URL dispatch — so it + // keeps emitting even when the URL handler is wedged. If the ticker + // events also stop, the app is suspended/killed (a stronger signal + // than wedged-URL-handler alone). If ticks keep coming but + // `applicationState != .active`, that's the smoking gun: app went + // to .inactive/.background. + + private func startDiagnosticTicker() { + #if canImport(UIKit) + diagnosticTickTask = Task { [weak self] in + while !Task.isCancelled { + guard let self = self else { return } + await self.tickOnce() + try? await Task.sleep( + nanoseconds: self.screenshotIntervalSec * 1_000_000_000 + ) + } + } + #endif + } + + #if canImport(UIKit) + private func tickOnce() async { + screenshotSeq &+= 1 + let seq = screenshotSeq + + let state = UIApplication.shared.applicationState + let stateStr: String + switch state { + case .active: stateStr = "active" + case .inactive: stateStr = "inactive" + case .background: stateStr = "background" + @unknown default: stateStr = "unknown" + } + + var path: String? = nil + // Only snap when active — UIWindowScene is foregrounded only + // when active, and a snapshot from a non-active scene is either + // empty or stale and would mislead diagnosis. + if state == .active { + path = captureKeyWindowSnapshot(seq: seq) + rotateScreenshots() + } + + TestLog.emit( + "diag_tick seq=\(seq) state=\(stateStr) snapshot=\(path ?? "")" + ) + } + + /// Capture the current key window's contents as a PNG into + /// `Documents/screenshots/.png`. Returns the on-device path on + /// success. + private func captureKeyWindowSnapshot(seq: UInt64) -> String? { + let scenes = UIApplication.shared.connectedScenes + guard let scene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else { + return nil + } + guard let window = scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first else { + return nil + } + + let bounds = window.bounds + let renderer = UIGraphicsImageRenderer(bounds: bounds) + let image = renderer.image { _ in + window.drawHierarchy(in: bounds, afterScreenUpdates: false) + } + guard let png = image.pngData() else { return nil } + + let dir = Self.screenshotsDir() + try? FileManager.default.createDirectory( + atPath: dir, withIntermediateDirectories: true, attributes: nil + ) + let filename = String(format: "diag-%06llu.png", seq) + let path = (dir as NSString).appendingPathComponent(filename) + do { + try png.write(to: URL(fileURLWithPath: path), options: .atomic) + return path + } catch { + return nil + } + } + + private func rotateScreenshots() { + let dir = Self.screenshotsDir() + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: dir) else { return } + let pngs = entries + .filter { $0.hasSuffix(".png") } + .sorted() + guard pngs.count > Self.maxScreenshots else { return } + for old in pngs.prefix(pngs.count - Self.maxScreenshots) { + let p = (dir as NSString).appendingPathComponent(old) + try? FileManager.default.removeItem(atPath: p) + } + } + + private static func screenshotsDir() -> String { + let docs = NSSearchPathForDirectoriesInDomains( + .documentDirectory, .userDomainMask, true + ).first ?? NSTemporaryDirectory() + return (docs as NSString).appendingPathComponent(screenshotsDirName) + } + + private func registerLifecycleObservers() { + let nc = NotificationCenter.default + let pairs: [(Notification.Name, String)] = [ + (UIApplication.didBecomeActiveNotification, "did_become_active"), + (UIApplication.willResignActiveNotification, "will_resign_active"), + (UIApplication.didEnterBackgroundNotification, "did_enter_background"), + (UIApplication.willEnterForegroundNotification, "will_enter_foreground"), + (UIApplication.willTerminateNotification, "will_terminate"), + ] + for (name, label) in pairs { + nc.addObserver(forName: name, object: nil, queue: .main) { _ in + TestLog.emit("lifecycle event=\(label)") + } + } + } + #else + private func registerLifecycleObservers() {} + #endif + + /// Attach the harness's relay delegate, preserving the original. + /// Called by [TestURLHandler] right after `bind` to wire in + /// observation of received messages + delivery state changes. + public func attachDelegate(to router: LXMRouter, originalDelegate: LXMRouterDelegate?) async { + let relay = TestRelayDelegate( + wrapped: originalDelegate, + controller: self + ) + // Pin the relay to TestController BEFORE handing it to the router. + // Router holds the delegate weakly; without this strong reference + // the relay deallocates as soon as this function returns. + attachedDelegate = relay + await router.setDelegate(relay) + } + + /// Append an inbound message to the rx queue. Called by + /// [TestRelayDelegate] on the main actor. + fileprivate func recordReceived(_ message: LXMessage) { + let rec = TestRxRecord( + sourceHash: toHex(message.sourceHash), + messageHash: toHex(message.hash), + content: String(data: message.content, encoding: .utf8) ?? "" + ) + rxQueue.append(rec) + TestLog.emit( + "rx_msg source=stream from=\(rec.sourceHash) " + + "id=\(rec.messageHash) content=\(testHarnessEscape(rec.content))" + ) + } + + /// Record a delivery-state transition. Called by + /// [TestRelayDelegate] on the main actor. + fileprivate func recordDeliveryState(messageHash: Data, state: String) { + let idHex = toHex(messageHash) + deliveryStates[idHex] = state + TestLog.emit("msg_state id=\(idHex) state=\(state)") + } + + // MARK: - Action handlers (mirror TestController.kt) + + public func handleGetDest() { + assertionFailure_releaseGuard() + guard initialized, let hash = destHashCached else { + TestLog.emit("dest_err reason=not_ready") + return + } + TestLog.emit("dest=\(toHex(hash))") + } + + public func handleHasPath(toHex hex: String) { + assertionFailure_releaseGuard() + guard initialized else { + TestLog.emit("has_path to=\(hex) result=err msg=not_ready") + return + } + guard let toBytes = fromHex(hex) else { + TestLog.emit("has_path to=\(hex) result=err_bad_hex") + return + } + // ReticulumSwift's PathTable lookup is async. Run on the same + // actor; emit the result line when done. + Task { + let has = await checkPath(to: toBytes) + TestLog.emit("has_path to=\(hex) result=\(has ? 1 : 0)") + } + } + + private func checkPath(to: Data) async -> Bool { + // Walk through AppServices.pathTable. We hold AppServices via + // `appServices` (AnyObject) to avoid a hard import dependency + // here; resolve via the typed bridge in TestURLHandler. + guard let bridge = TestPathBridge.hasPath else { return false } + return await bridge(to) + } + + public func handleSend(method: LXDeliveryMethod, toHex hex: String, text: String) { + assertionFailure_releaseGuard() + guard initialized, let router = routerRef else { + TestLog.emit("msg_send_err method=\(methodName(method)) reason=not_ready") + return + } + guard let toBytes = fromHex(hex) else { + TestLog.emit("msg_send_err method=\(methodName(method)) reason=bad_hex to=\(hex)") + return + } + Task { + do { + let messageHash = try await TestPathBridge.send?(toBytes, text, method) + if let h = messageHash { + let idHex = toHex(h) + TestLog.emit("msg_sent id=\(idHex) method=\(methodName(method)) to=\(hex)") + if deliveryStates[idHex] == nil { + deliveryStates[idHex] = "OUTBOUND" + } + } else { + TestLog.emit("msg_send_err method=\(methodName(method)) to=\(hex) reason=no_send_bridge") + } + } catch { + TestLog.emit( + "msg_send_err method=\(methodName(method)) to=\(hex) " + + "reason=\(testHarnessEscape(error.localizedDescription))" + ) + } + _ = router // silence unused warning when send path is bridged + } + } + + public func handleGetMsgState(idHex: String) { + assertionFailure_releaseGuard() + let state = deliveryStates[idHex] ?? "UNKNOWN" + TestLog.emit("msg_state id=\(idHex) state=\(state)") + } + + public func handleGetRx() { + assertionFailure_releaseGuard() + let drained = rxQueue + rxQueue.removeAll(keepingCapacity: false) + for rec in drained { + TestLog.emit( + "rx_msg source=drain from=\(rec.sourceHash) " + + "id=\(rec.messageHash) content=\(testHarnessEscape(rec.content))" + ) + } + TestLog.emit("rx_drain count=\(drained.count)") + } + + public func handleRxClear() { + assertionFailure_releaseGuard() + rxQueue.removeAll(keepingCapacity: false) + TestLog.emit("rx_cleared") + } + + public func handleAnnounce() { + assertionFailure_releaseGuard() + guard initialized, let hash = destHashCached else { + TestLog.emit("announce_err reason=no_active_destination") + return + } + Task { + do { + try await TestPathBridge.announce?() + TestLog.emit("announced dest=\(toHex(hash))") + } catch { + TestLog.emit( + "announce_err dest=\(toHex(hash)) " + + "reason=\(testHarnessEscape(error.localizedDescription))" + ) + } + } + } + + /// Dump the iOS unified log for the LXMF/propagation subsystems + /// into test_log.txt so the harness can see what's happening + /// inside the library on failure. iOS 17+ moved live syslog behind + /// the developer tunnel (libimobiledevice/idevicesyslog can't + /// reach it) so we pull from OSLogStore in-process and forward + /// each entry as a `lib_log` event line. + /// + /// Filtered to the subsystems we know LXMFSwift / ColumbaApp use: + /// - com.columba.core (propLogger, syncLogger, routerLogger) + /// - net.reticulum.lxmf (default routerLogger in LXMRouter.swift) + public func handleDumpLog( + sinceSeconds: Double = 120.0, + categoryFilter: String? = nil + ) { + assertionFailure_releaseGuard() + Task { + do { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let cutoff = store.position(date: Date().addingTimeInterval(-sinceSeconds)) + // Stream entries WITHOUT a predicate (NSPredicate against + // OSLogStore doesn't support category-level filtering on all + // OS versions; do it in-loop for portability) and filter by + // (subsystem, category) ourselves. Default: only the + // LXMFSwift propagation/sync/router categories that matter + // for the bug we're chasing. + let entries = try store.getEntries(at: cutoff) + let allowedSubsystems: Set = [ + "com.columba.core", + "net.reticulum.lxmf", + "net.reticulum", // Link, Transport, Packet routing + "network.columba.Columba", // app-side managers + ] + let allowedCategoriesDefault: Set = [ + "Propagation", "Sync", "LXMRouter", "Stamper", "Identity", + "PropagationNodeManager", + "Link", // ← Link state machine + processProof + "Transport", // packet dispatch / routing + "Packet", + ] + let allowedCategories: Set? = categoryFilter + .map { Set($0.split(separator: ",").map(String.init)) } + var count = 0 + for entry in entries { + guard let logEntry = entry as? OSLogEntryLog else { continue } + let subsys = logEntry.subsystem + let cat = logEntry.category + if !allowedSubsystems.contains(subsys) { continue } + if let allowed = allowedCategories, + !allowed.contains(cat) { continue } + if allowedCategories == nil, + !allowedCategoriesDefault.contains(cat) { continue } + let level = String(describing: logEntry.level) + let msg = testHarnessEscape(logEntry.composedMessage) + // Emit the entry's ACTUAL OS-recorded timestamp as + // an extra `entry_ts=` field. The seq=N ts=... prefix + // emitted by TestLog is the dump-time (when this loop + // ran), not the log-time, so the harness needs the + // entry timestamp to reason about ordering across + // events that happened during the smoke run. + let entryTs = ISO8601DateFormatter().string(from: logEntry.date) + TestLog.emit( + "lib_log entry_ts=\(entryTs) subsys=\(subsys) cat=\(cat) " + + "level=\(level) msg=\(msg)" + ) + count += 1 + if count > 500 { break } // higher cap now that we filter + } + TestLog.emit("lib_log_done count=\(count) since_sec=\(Int(sinceSeconds))") + } catch { + TestLog.emit( + "lib_log_err reason=\(testHarnessEscape(error.localizedDescription))" + ) + } + } + } + + // ─── interface management ────────────────────────────────────────── + + public func handleListInterfaces() { + assertionFailure_releaseGuard() + guard let repo = interfaceRepoRef else { + TestLog.emit("interface_list_done count=0") + return + } + let rows = repo.interfaces + for e in rows { + TestLog.emit( + "interface id=\(e.id) name=\(testHarnessEscape(e.name)) " + + "type=\(e.type.rawValue) enabled=\(e.enabled)" + ) + } + TestLog.emit("interface_list_done count=\(rows.count)") + } + + public func handleDisableAllInterfaces() { + assertionFailure_releaseGuard() + guard let repo = interfaceRepoRef else { + TestLog.emit("interfaces_disabled count=0 applied=false err=no_repo") + return + } + var disabled = 0 + for e in repo.interfaces where e.enabled { + repo.toggleInterface(id: e.id, enabled: false) + disabled += 1 + } + applyAndLog(event: "interfaces_disabled", extras: "count=\(disabled)") + } + + public func handleSetInterfaceEnabled(name: String, enabled: Bool) { + assertionFailure_releaseGuard() + guard let repo = interfaceRepoRef else { + TestLog.emit("interface_\(enabled ? "enable" : "disable")_err name=\(testHarnessEscape(name)) reason=no_repo") + return + } + guard let e = repo.interfaces.first(where: { $0.name == name }) else { + TestLog.emit( + "interface_\(enabled ? "enable" : "disable")_err " + + "name=\(testHarnessEscape(name)) reason=not_found" + ) + return + } + repo.toggleInterface(id: e.id, enabled: enabled) + applyAndLog( + event: enabled ? "interface_enabled" : "interface_disabled", + extras: "name=\(testHarnessEscape(name)) id=\(e.id)" + ) + } + + public func handleAddTcpClient(name: String, host: String, port: Int) { + assertionFailure_releaseGuard() + guard let repo = interfaceRepoRef else { + TestLog.emit("interface_add_err reason=no_repo") + return + } + guard port > 0, port < 65536 else { + TestLog.emit("interface_add_err reason=bad_port port=\(port)") + return + } + // Replace-on-existing for idempotent re-runs (matches the + // Android side's delete-then-insert behavior). + if let existing = repo.interfaces.first(where: { $0.name == name }) { + repo.deleteInterface(id: existing.id) + } + let cfg = TCPClientConfig(targetHost: host, targetPort: UInt16(port)) + let entity = InterfaceEntity( + name: name, + type: .tcpClient, + enabled: true, + mode: .full, + config: .tcpClient(cfg) + ) + repo.addInterface(entity) + applyAndLog( + event: "interface_added", + extras: "name=\(testHarnessEscape(name)) id=\(entity.id) " + + "type=TCPClient host=\(testHarnessEscape(host)) port=\(port)" + ) + } + + public func handleRemoveInterface(name: String) { + assertionFailure_releaseGuard() + guard let repo = interfaceRepoRef else { + TestLog.emit("interface_remove_err name=\(testHarnessEscape(name)) reason=no_repo") + return + } + guard let e = repo.interfaces.first(where: { $0.name == name }) else { + TestLog.emit("interface_remove_err name=\(testHarnessEscape(name)) reason=not_found") + return + } + repo.deleteInterface(id: e.id) + applyAndLog( + event: "interface_removed", + extras: "name=\(testHarnessEscape(name)) id=\(e.id)" + ) + } + + public func handleSetPropNode(hex: String) { + assertionFailure_releaseGuard() + guard let router = routerRef else { + TestLog.emit("prop_node_err reason=no_router") + return + } + let bytes = hex.isEmpty ? nil : fromHex(hex) + if !hex.isEmpty && bytes == nil { + TestLog.emit("prop_node_err reason=bad_hex hex=\(hex)") + return + } + // Read the bridge on the MainActor (where this method already + // runs) before hopping into the detached Task. The Task body + // is non-MainActor and can't observe @MainActor static vars. + let select = TestPathBridge.selectPropNode + Task { + // Prefer the manager so stamp cost gets wired alongside the + // outbound-node hash. Fall back to the router-level setter + // only when the bridge isn't populated (defensive — bind() + // installs it under DEBUG). + if let bytes = bytes, let select = select { + await select(bytes) + } else { + await router.setOutboundPropagationNode(bytes) + } + TestLog.emit("prop_node_set hex=\(bytes == nil ? "(cleared)" : hex)") + } + } + + public func handleSyncProp() { + assertionFailure_releaseGuard() + guard let router = routerRef else { + TestLog.emit("prop_sync_err reason=no_router") + return + } + Task { + do { + try await router.syncFromPropagationNode() + TestLog.emit("prop_sync_started state=0 messages_received=0") + } catch { + TestLog.emit( + "prop_sync_err reason=\(testHarnessEscape(error.localizedDescription))" + ) + } + } + } + + /// Dump the full LXMF DB conversation list and per-conversation + /// message metadata into the test log. Used to diagnose user- + /// observed UI grouping bugs ("PROP messages appear in a separate + /// conversation from DIRECT/OPP", "no inbound PROP visible") where + /// the answer depends on what the DB actually has — the iOS UI + /// faithfully renders whatever the conversations + messages tables + /// contain, so DB-level inspection is the source of truth. + /// + /// Output shape (one line each): + /// conv hash=<32hex> display= last_ts= unread= + /// msg conv=<32hex> id=<32hex> dir= method= state= ts= from=<32hex> to=<32hex> + /// + /// `method` and `state` are raw `LXDeliveryMethod` / + /// `LXMessageState` enum values — the harness or a human reader + /// translates via the LXMF source. Per-conversation message dump + /// is capped at 50 most-recent rows. + public func handleDumpDb() { + assertionFailure_releaseGuard() + guard let appServices = self.appServices as? AppServices, + let database = appServices.database else { + TestLog.emit("dump_db_err reason=no_db") + return + } + Task { + do { + let conversations = try await database.getConversations(limit: 1000, offset: 0) + TestLog.emit("dump_db_begin convs=\(conversations.count)") + for conv in conversations { + let hashHex = conv.destinationHash.map { String(format: "%02x", $0) }.joined() + let nameStr = (conv.displayName ?? "").isEmpty + ? "" + : testHarnessEscape(conv.displayName ?? "") + TestLog.emit( + "conv hash=\(hashHex) " + + "display=\(nameStr) " + + "last_ts=\(conv.lastMessageTimestamp) " + + "unread=\(conv.unreadCount)" + ) + let records = try await database.getMessageRecords( + forConversation: conv.destinationHash, + limit: 50, offset: 0 + ) + for r in records { + let convHex = r.conversationHash.map { String(format: "%02x", $0) }.joined() + let idHex = (r.messageId ?? Data()).map { String(format: "%02x", $0) }.joined() + let srcHex = r.sourceHash.map { String(format: "%02x", $0) }.joined() + let dstHex = r.destinationHash.map { String(format: "%02x", $0) }.joined() + let dir = r.incoming ? "in" : "out" + TestLog.emit( + "msg conv=\(convHex) " + + "id=\(idHex) " + + "dir=\(dir) " + + "method=\(r.method) " + + "state=\(r.state) " + + "ts=\(r.timestamp) " + + "from=\(srcHex) " + + "to=\(dstHex)" + ) + } + } + TestLog.emit("dump_db_done") + } catch { + TestLog.emit( + "dump_db_err reason=\(testHarnessEscape(error.localizedDescription))" + ) + } + } + } + + /// Query the iOS notification center for delivered notifications. + /// Emits one `notif` line per delivered notification, including its + /// `delivery_ts` (ISO8601 seconds), `thread`, `id`, and a + /// length-limited preview of the body. Used by the Phase 3 + /// `suspended_notification` smoke scenario to prove whether a + /// system-level notification was posted while the app was + /// suspended (delivery_ts < foreground_ts → notification fired + /// during suspension; otherwise it only fired on resume or not + /// at all). + /// + /// `UNUserNotificationCenter.deliveredNotifications` is iOS-only; + /// this handler is a no-op on macOS-Catalyst builds (which don't + /// participate in the phone smoke pipeline anyway). + public func handleGetNotifications() { + assertionFailure_releaseGuard() + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + Task { + let notifs = await UNUserNotificationCenter.current().deliveredNotifications() + TestLog.emit("notif_begin count=\(notifs.count) query_ts=\(Self.iso8601Now())") + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + for n in notifs { + let id = testHarnessEscape(n.request.identifier) + let thread = testHarnessEscape(n.request.content.threadIdentifier) + let title = testHarnessEscape(n.request.content.title) + let bodyRaw = n.request.content.body + // Cap preview at 80 chars so a malicious sender can't + // inflate the log line beyond `os_log` truncation. + let bodyPreview = bodyRaw.count > 80 + ? String(bodyRaw.prefix(80)) + "…" + : bodyRaw + let body = testHarnessEscape(bodyPreview) + let ts = iso.string(from: n.date) + let sourceHash = (n.request.content.userInfo["sourceHash"] as? String) ?? "" + TestLog.emit( + "notif id=\(id) thread=\(thread) title=\(title) " + + "delivery_ts=\(ts) source_hash=\(sourceHash) " + + "body=\(body)" + ) + } + TestLog.emit("notif_end count=\(notifs.count)") + } + #else + TestLog.emit("notif_unsupported platform=non_ios") + #endif + } + + /// ISO8601 timestamp of `Date()` with fractional seconds, used by + /// `handleGetNotifications` to stamp the query time so the smoke + /// orchestrator can compare `delivery_ts < query_ts` to determine + /// whether the notification was posted before or after the + /// foregrounding that triggered the query. + private static func iso8601Now() -> String { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f.string(from: Date()) + } + + /// Emit current iOS notification authorization status + Columba's + /// own `notifications_enabled` UserDefaults pref. Used by the + /// suspended_notification smoke scenario to detect "permission + /// not granted" up-front rather than diagnose from a 0/0 + /// suspended/post_foreground count ambiguously. + /// + /// Emits a single line: + /// `notif_status auth= alert= badge= sound= notifications_enabled=` + /// where `auth` is one of: `notDetermined`, `denied`, + /// `authorized`, `provisional`, `ephemeral`, `unknown`. + public func handleGetNotifStatus() { + assertionFailure_releaseGuard() + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + Task { + let settings = await UNUserNotificationCenter.current().notificationSettings() + let authStr: String + switch settings.authorizationStatus { + case .notDetermined: authStr = "notDetermined" + case .denied: authStr = "denied" + case .authorized: authStr = "authorized" + case .provisional: authStr = "provisional" + case .ephemeral: authStr = "ephemeral" + @unknown default: authStr = "unknown" + } + let alert = settings.alertSetting == .enabled ? 1 : 0 + let badge = settings.badgeSetting == .enabled ? 1 : 0 + let sound = settings.soundSetting == .enabled ? 1 : 0 + let notifEnabled = UserDefaults.standard.object(forKey: "notifications_enabled") as? Bool ?? false + TestLog.emit( + "notif_status auth=\(authStr) alert=\(alert) " + + "badge=\(badge) sound=\(sound) " + + "notifications_enabled=\(notifEnabled)" + ) + } + #else + TestLog.emit("notif_status_unsupported platform=non_ios") + #endif + } + + /// Request iOS notification permission (`UNUserNotificationCenter + /// .requestAuthorization`) AND set Columba's `notifications_enabled` + /// UserDefaults pref to `true`. Used by the `suspended_notification` + /// smoke scenario to bootstrap permission state on first run. + /// + /// On a phone that has NEVER seen the request prompt for Columba, + /// iOS will display the system "Allow notifications?" UI. The + /// orchestrator can't tap "Allow" automatically (no system-UI + /// interaction permitted from `xcrun devicectl`), so the first run + /// after a fresh install requires Tyler to tap Allow manually. + /// Subsequent runs reuse the persisted grant. + /// + /// Emits: `notif_request granted= error=`. + /// Enable the Network Extension tunnel: persist the + /// `tunnelEnabledKey` so future cold-starts auto-start the + /// tunnel, kick `TunnelManager.start()`, and emit the eventual + /// `tunnel_state state=` line. Used by the + /// `suspended_notification` smoke scenario to guarantee the + /// extension is alive across the suspend window — otherwise the + /// inbound TCP connection dies the moment the host app suspends + /// and Phase B's destination filter never sees a frame. + public func handleEnableTunnel() { + assertionFailure_releaseGuard() + Task { + do { + let state = try await TestTunnelBridge.enableTunnel?() ?? "no_bridge" + TestLog.emit("tunnel_enable state=\(state)") + } catch { + TestLog.emit("tunnel_enable error=\(testHarnessEscape(error.localizedDescription))") + } + } + } + + /// Emit the current `TunnelManager.status` as a string. Lets the + /// harness assert the tunnel is `connected` before backgrounding + /// the app for a suspend test. + public func handleGetTunnelStatus() { + assertionFailure_releaseGuard() + Task { + let state = await TestTunnelBridge.getTunnelStatus?() ?? "no_bridge" + TestLog.emit("tunnel_status state=\(state)") + } + } + + public func handleRequestNotifPermission() { + assertionFailure_releaseGuard() + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + // Set Columba's own pref true up-front; UN auth is the gate + // we can't bypass from code, but the pref check at + // NotificationService.postMessageNotification line 166 also + // has to pass. + UserDefaults.standard.set(true, forKey: "notifications_enabled") + UserDefaults.standard.set(true, forKey: "notify_received_message") + Task { + do { + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .badge, .sound]) + TestLog.emit("notif_request granted=\(granted) error=nil") + } catch { + TestLog.emit("notif_request granted=false error=\(testHarnessEscape(error.localizedDescription))") + } + } + #else + TestLog.emit("notif_request_unsupported platform=non_ios") + #endif + } + + // MARK: - Helpers + + private func methodName(_ m: LXDeliveryMethod) -> String { + switch m { + case .opportunistic: return "OPPORTUNISTIC" + case .direct: return "DIRECT" + case .propagated: return "PROPAGATED" + case .paper: return "PAPER" + @unknown default: return "UNKNOWN" + } + } + + /// On iOS there's no separate `interfaceConfigManager.applyChanges()` + /// step — InterfaceRepository's `saveInterfaces()` already posts a + /// CFNotificationCenter Darwin notification that the network + /// extension picks up to apply the diff. So `applied=true` is always + /// emitted (matching the Android contract); the harness can't + /// distinguish "applied" vs "saved" here without round-tripping + /// through the extension, which is out of scope for v1. + private func applyAndLog(event: String, extras: String) { + TestLog.emit("\(event) \(extras) applied=true") + } + + /// Defense-in-depth: this whole file should be excluded from release + /// via `#if DEBUG`, but if a build-config misconfig somehow includes + /// it, every entry trips this assertion. `assertionFailure` is + /// stripped in release-config builds, so a real release-build that + /// got here would silently no-op rather than crash — which is + /// exactly why the file ALSO ships under `#if DEBUG` (this assertion + /// is the inner of two layers). + /// Defense-in-depth runtime guard: if some build-config or compile- + /// conditions misconfiguration ever lets this code run in a non-DEBUG + /// build, crash hard at the first invocation rather than silently + /// expose the test surface. In normal DEBUG builds this is a no-op. + /// + /// (Was previously calling `assertionFailure(...)` unconditionally — + /// which is exactly the wrong direction. `assertionFailure` ALWAYS + /// crashes in DEBUG builds, so every test entry-point crashed the app + /// on the guard before reaching any actual logic. Mirrors the Android + /// side's `check(BuildConfig.DEBUG)` semantics: throw only when DEBUG + /// is FALSE.) + private func assertionFailure_releaseGuard() { + #if !DEBUG + fatalError( + "TestController must not run in release builds — " + + "this is a debug-only test surface; non-debug invocation " + + "indicates a build-config or compile-conditions misconfiguration" + ) + #endif + } +} + +// MARK: - Inbound record + +private struct TestRxRecord { + let sourceHash: String + let messageHash: String + let content: String +} + +// MARK: - Relay delegate (forwards to original + records into TestController) + +/// Forwards every LXMRouterDelegate callback to the wrapped delegate +/// (so the app's normal flow keeps working), AND records the relevant +/// signals into [TestController] for the harness to observe. +@MainActor +private final class TestRelayDelegate: LXMRouterDelegate { + private let wrapped: LXMRouterDelegate? + private weak var controller: TestController? + + init(wrapped: LXMRouterDelegate?, controller: TestController) { + self.wrapped = wrapped + self.controller = controller + } + + func router(_ router: LXMRouter, didReceiveMessage message: LXMessage) { + controller?.recordReceived(message) + wrapped?.router(router, didReceiveMessage: message) + } + + func router(_ router: LXMRouter, didUpdateMessage message: LXMessage) { + let stateName: String + switch message.state { + case .generating: stateName = "GENERATING" + case .outbound: stateName = "OUTBOUND" + case .sending: stateName = "SENDING" + case .sent: + // SENT after a PROPAGATED send means the propagation node + // accepted the LXMF resource transfer — which is the signal + // the Android harness's `state=PROPAGATED` matches on. Emit + // the Android-shaped token so cross-platform regexes hold. + stateName = (message.method == .propagated) + ? "PROPAGATED" + : "SENT" + case .delivered: stateName = "DELIVERED" + case .rejected: stateName = "REJECTED" + case .cancelled: stateName = "CANCELLED" + case .failed: stateName = "FAILED" + @unknown default: stateName = "UNKNOWN" + } + controller?.recordDeliveryState(messageHash: message.hash, state: stateName) + wrapped?.router(router, didUpdateMessage: message) + } + + func router(_ router: LXMRouter, didFailMessage message: LXMessage, reason: LXMFError) { + controller?.recordDeliveryState(messageHash: message.hash, state: "FAILED") + wrapped?.router(router, didFailMessage: message, reason: reason) + } + + func router(_ router: LXMRouter, didConfirmDelivery messageHash: Data) { + controller?.recordDeliveryState(messageHash: messageHash, state: "DELIVERED") + wrapped?.router(router, didConfirmDelivery: messageHash) + } + + func router(_ router: LXMRouter, didUpdateSyncState state: PropagationTransferState) { + wrapped?.router(router, didUpdateSyncState: state) + } + + func router(_ router: LXMRouter, didCompleteSyncWithNewMessages newMessages: Int) { + wrapped?.router(router, didCompleteSyncWithNewMessages: newMessages) + } +} + +// MARK: - Bridge for actions that need AppServices internals + +/// Slim bridge so [TestController] can avoid a hard import of +/// `AppServices` (which would otherwise force the whole app object graph +/// into the test surface). [TestURLHandler] populates these closures at +/// bind time. +public enum TestPathBridge { + /// `(destHash) -> Bool` — does the path table know a route to the + /// given destination? + @MainActor public static var hasPath: ((Data) async -> Bool)? + + /// `(destHash, text, method) async throws -> messageHash` — send an + /// LXMF message via the live router with the requested delivery + /// method. Returns the canonical message hash on success. + @MainActor public static var send: ((Data, String, LXDeliveryMethod) async throws -> Data)? + + /// `() async throws -> Void` — force-announce the local LXMF + /// destination. Maps to AppServices.sendAnnounce(...). + @MainActor public static var announce: (() async throws -> Void)? + + /// `(hash) async -> Void` — fully select a propagation node by + /// going through `PropagationNodeManager.selectNode`. That call + /// pushes BOTH the outbound-node hash AND the announce-derived + /// stamp cost into the router. Bypassing it via the bare + /// `router.setOutboundPropagationNode(hash)` leaves the cost at + /// 0, which makes `LXMRouter.sendPropagated` ship a random stamp + /// that lxmd then rejects with `ERROR_INVALID_STAMP` (the symptom + /// observed during the iOS PROPAGATED smoke run on 2026-05-10). + @MainActor public static var selectPropNode: ((Data) async -> Void)? +} + +// MARK: - Bridge for Network Extension tunnel control + +/// Slim bridge so [TestController] can drive `TunnelManager` without +/// importing it directly (TunnelManager only exists under +/// `ENABLE_NETWORK_EXTENSION`, so the test surface stays buildable on +/// configurations where the extension is compiled out). Populated by +/// [TestURLHandler.bind] under the same compile-flag guard. +public enum TestTunnelBridge { + /// Persist `tunnelEnabledKey`, kick `TunnelManager.start()`, and + /// wait up to 30s for `status` to reach `.connected`. Returns the + /// final status string for diagnostics. Throws if the bridge has + /// no `TunnelManager` to drive. + @MainActor public static var enableTunnel: (() async throws -> String)? + + /// Return the current `TunnelManager.status` as a string. Used by + /// the harness to assert tunnel readiness before a suspend test. + @MainActor public static var getTunnelStatus: (() async -> String)? +} + +#endif // DEBUG diff --git a/Sources/ColumbaApp/Test/TestURLHandler.swift b/Sources/ColumbaApp/Test/TestURLHandler.swift new file mode 100644 index 00000000..1999a89f --- /dev/null +++ b/Sources/ColumbaApp/Test/TestURLHandler.swift @@ -0,0 +1,421 @@ +// +// TestURLHandler.swift +// ColumbaApp +// +// Debug-only URL-scheme dispatcher for the iOS phone harness. +// +// The Android side uses an explicit BroadcastReceiver. iOS doesn't have +// a runtime-broadcast surface, so we register a sibling URL scheme +// (`lxma-test://`) and route inside the existing `.onOpenURL { … }` in +// `ColumbaApp.swift` to this dispatcher. +// +// Wrapped in `#if DEBUG` so the entire dispatcher is compiled out of +// release builds. The release Info.plist also does NOT register +// `lxma-test` (see Resources/Info.plist) — the scheme is added at +// runtime in handleURL() only when DEBUG is set, by way of the URL +// handler itself being a no-op compile-out. iOS won't route to this +// handler in release because: +// 1. The scheme isn't in CFBundleURLSchemes (no system route). +// 2. Even if a misconfigured plist included it, this whole file is +// not compiled, so nothing in the app binary handles the scheme. +// + +#if DEBUG + +import Foundation +import os.log +import LXMFSwift +#if ENABLE_NETWORK_EXTENSION +import NetworkExtension +#endif + +/// Top-level dispatcher invoked from `ColumbaApp.swift`'s `.onOpenURL`. +/// +/// Returns `true` if the URL was a `lxma-test://` action that this +/// handler consumed (the caller should NOT also feed the URL into the +/// production deeplink path); `false` for any URL we don't recognize so +/// the production handler still runs. +@MainActor +public enum TestURLHandler { + + /// Bind to live AppServices (called once, from RootView's task block + /// when the test surface is enabled and AppServices is initialized). + /// Wires the [TestController]'s closures to the real `AppServices` + /// + router + interfaces + path table. + /// + /// - Parameter incomingMessageHandler: production `LXMRouterDelegate` + /// the relay forwards to. `attachDelegate` replaces the router's + /// delegate, so `nil` here drops conversation-ensure + the + /// chats-list UI refresh for every inbound message. + public static func bind( + appServices: AppServices, + incomingMessageHandler: LXMRouterDelegate? + ) { + guard let router = appServices.router else { + TestLog.emit("bind_err reason=router_nil") + return + } + let interfaceRepo = InterfaceRepository() + let destHash = appServices.localIdentityHash + TestController.shared.bind( + appServices: appServices, + router: router, + interfaceRepo: interfaceRepo, + destHash: destHash + ) + + // Populate the bridge closures so TestController can drive + // path lookups, sends, announces without importing AppServices. + TestPathBridge.hasPath = { [weak appServices] destHash in + guard let svc = appServices, let pathTable = svc.pathTable else { return false } + // PathTable is an actor; cross the actor boundary explicitly. + return await pathTable.hasPath(for: destHash) + } + TestPathBridge.send = { [weak appServices] destHash, text, method in + guard let svc = appServices, let identity = svc.identity, let router = svc.router else { + throw TestError.notReady + } + var message = LXMessage( + destinationHash: destHash, + sourceIdentity: identity, + content: text.data(using: .utf8) ?? Data(), + title: Data(), + fields: nil, + desiredMethod: method + ) + try await router.handleOutbound(&message) + return message.hash + } + TestPathBridge.announce = { [weak appServices] in + guard let svc = appServices else { + throw TestError.notReady + } + try await svc.sendAnnounce(displayName: "Columba") + } + TestPathBridge.selectPropNode = { [weak appServices] hash in + guard let svc = appServices, let mgr = svc.propagationManager else { + // Falls back to the router-level setter inside + // handleSetPropNode if this bridge isn't populated. + return + } + await mgr.selectNode(hash: hash) + } + + #if ENABLE_NETWORK_EXTENSION + // Tunnel-control bridge: lets the smoke harness bring the + // Background-Transport tunnel up from a URL action so the + // `suspended_notification` scenario can guarantee the extension + // is alive across the suspend window. + // + // Deliberately does NOT persist `tunnelEnabledKey` — the + // Settings toggle path persists it so a cold relaunch auto- + // restarts the tunnel, but tests are ephemeral: persisting + // would poison every subsequent test run with auto-tunnel-on, + // and the in-flight transition would race the harness's + // bringup (path discovery) and break baseline scenarios. + // Tests that need the tunnel call this every run; the persisted + // flag stays off across runs. + TestTunnelBridge.enableTunnel = { [weak appServices] in + guard let svc = appServices, let tunnel = svc.tunnelManager else { + throw TestError.notReady + } + if tunnel.isRunning { + return Self.tunnelStatusString(tunnel.status) + } + try await tunnel.start() + let deadline = Date().addingTimeInterval(30) + while Date() < deadline, tunnel.status != .connected { + try? await Task.sleep(nanoseconds: 200_000_000) + } + return Self.tunnelStatusString(tunnel.status) + } + TestTunnelBridge.getTunnelStatus = { [weak appServices] in + guard let svc = appServices, let tunnel = svc.tunnelManager else { + return "no_tunnel" + } + return Self.tunnelStatusString(tunnel.status) + } + #endif + + // Relay delegate: lets the harness observe inbound messages + + // delivery-state changes, forwarding to `incomingMessageHandler` + // (see `bind` docs for why the forward is mandatory). + Task { @MainActor in + await TestController.shared.attachDelegate( + to: router, + originalDelegate: incomingMessageHandler + ) + } + } + + /// Dispatch a single `lxma-test://?` URL. Returns + /// `true` if consumed. + @discardableResult + public static func handle(url: URL) -> Bool { + guard url.scheme == "lxma-test" else { return false } + + // Defense-in-depth: this file is `#if DEBUG`, but the assertion + // also fires if someone mis-builds a release with DEBUG on. + assertionFailure_releaseGuard() + + TestLog.emit("rx_url action=\(url.host ?? "") path=\(url.path)") + + // The convention is `lxma-test://?`. URLComponents + // surfaces as the host (because it's the authority + // component of the URL), which mirrors `am broadcast`'s + // action-string contract on Android. + let action = (url.host ?? "").lowercased() + let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) + let query: [String: String] = Dictionary( + uniqueKeysWithValues: (comps?.queryItems ?? []) + .compactMap { item -> (String, String)? in + guard let v = item.value else { return nil } + return (item.name, v) + } + ) + + let c = TestController.shared + + switch action { + case "get_dest": + c.handleGetDest() + case "has_path": + c.handleHasPath(toHex: query["to"] ?? "") + case "send_direct": + c.handleSend(method: .direct, toHex: query["to"] ?? "", text: query["text"] ?? "") + case "send_opp": + c.handleSend(method: .opportunistic, toHex: query["to"] ?? "", text: query["text"] ?? "") + case "send_prop": + c.handleSend(method: .propagated, toHex: query["to"] ?? "", text: query["text"] ?? "") + case "get_msg_state": + c.handleGetMsgState(idHex: query["id"] ?? "") + case "get_rx": + c.handleGetRx() + case "rx_clear": + c.handleRxClear() + case "announce": + c.handleAnnounce() + case "list_interfaces": + c.handleListInterfaces() + case "disable_all_interfaces": + c.handleDisableAllInterfaces() + case "disable_interface": + c.handleSetInterfaceEnabled(name: query["name"] ?? "", enabled: false) + case "enable_interface": + c.handleSetInterfaceEnabled(name: query["name"] ?? "", enabled: true) + case "add_tcp_client": + let port = Int(query["port"] ?? "") ?? -1 + c.handleAddTcpClient( + name: query["name"] ?? "", + host: query["host"] ?? "", + port: port + ) + case "remove_interface": + c.handleRemoveInterface(name: query["name"] ?? "") + case "set_prop_node": + c.handleSetPropNode(hex: query["hex"] ?? "") + case "sync_prop": + c.handleSyncProp() + case "dump_log": + // Dump iOS unified log entries for our subsystems into + // test_log.txt. `?since=` (default 120s). + // `?cat=` overrides the default category + // filter (Propagation,Sync,LXMRouter,Stamper,Identity, + // PropagationNodeManager). Pass `cat=*` to disable category + // filtering entirely. + let since = Double(query["since"] ?? "") ?? 120.0 + let cat = query["cat"] + c.handleDumpLog(sinceSeconds: since, categoryFilter: cat) + case "dump_db": + // Dump conversation list + per-conversation message + // metadata into test_log.txt. Diagnoses UI-grouping bugs + // (e.g. "PROP messages appear in a separate conversation" + // — DB inspection reveals whether destination_hash is + // genuinely diverging or the UI is mis-rendering). + c.handleDumpDb() + case "get_notifications": + // Query UNUserNotificationCenter.deliveredNotifications and + // emit one `notif` line per delivered notification, plus a + // `notif_begin` / `notif_end` pair carrying a `query_ts` + // timestamp. Used by the suspended_notification smoke + // scenario to test whether a notification was actually + // delivered while the app was suspended (compare each + // notification's `delivery_ts` to the foregrounding + // moment). + c.handleGetNotifications() + case "get_notif_status": + // Emit current iOS notification authorization status + + // Columba's `notifications_enabled` pref. The + // suspended_notification scenario checks this up-front so + // it can short-circuit with a clear `permission_missing` + // error rather than ambiguous "0/0 notifications" output. + c.handleGetNotifStatus() + case "request_notif_permission": + // Trigger UNUserNotificationCenter.requestAuthorization + // (iOS shows the system prompt on first run) AND set + // `notifications_enabled = true` in UserDefaults. The + // orchestrator can't tap "Allow" on the system prompt + // automatically, but a single manual tap on first run + // persists the grant for subsequent smoke runs. + c.handleRequestNotifPermission() + case "enable_tunnel": + // Persist `tunnelEnabledKey` and kick `TunnelManager.start()` + // so the NE extension stays alive across host-app suspension. + // Required for the `suspended_notification` scenario: without + // the tunnel up, the inbound TCP socket dies on suspend and + // Phase B's destination filter never sees a frame. + c.handleEnableTunnel() + case "get_tunnel_status": + // Emit the current tunnel status. Used by the suspend + // scenarios to assert the tunnel is `connected` before + // backgrounding the host app. + c.handleGetTunnelStatus() + case "skip_onboarding": + // Bootstrap an anonymous identity + a TCP interface + + // flip `has_completed_onboarding` so a fresh-install + // device can be brought to the smoke-testable state + // without manual tap-through. Mirrors + // `OnboardingViewModel.skipOnboarding` byte-for-byte + // but doesn't require `OnboardingView` to be on screen + // (works from a URL while the OnboardingView is showing). + // The host app's `@State showOnboarding` is decided at + // `init` time, so the caller must force-terminate + + // relaunch the app after this returns ok before the + // state takes effect. + // + // Self-contained — does NOT route through `TestController`, + // since `TestController.bind` requires AppServices to be + // initialized and that hasn't happened yet on a fresh + // install. Uses `IdentityManager` and `InterfaceRepository` + // directly (both safe to instantiate standalone). + handleSkipOnboarding( + host: query["host"] ?? "10.0.0.145", + port: Int(query["port"] ?? "4242") ?? 4242, + name: query["name"] ?? "test_mac" + ) + default: + TestLog.emit("rx_url_unknown action=\(action)") + } + return true + } + + // MARK: - Helpers + + enum TestError: Error { + case notReady + } + + /// Self-contained programmatic equivalent of + /// `OnboardingViewModel.skipOnboarding`. Creates an anonymous + /// identity, switches to it, adds a TCP interface, and flips + /// `has_completed_onboarding` + `settings_initialized`. Idempotent: + /// if onboarding is already done, just emits the active identity + /// hash and returns. Always callable — does NOT route through + /// `TestController` (which isn't bound until AppServices + /// initializes, which doesn't happen during the onboarding view). + fileprivate static func handleSkipOnboarding(host: String, port: Int, name: String) { + Task { + do { + let identityManager = IdentityManager() + // Idempotency check: if an active identity already + // exists, don't create a second one. Just confirm + // the onboarding flag and TCP interface are in + // shape and return. + if let active = await identityManager.getActiveIdentity() { + Self.ensureOnboardingFlagsSet() + Self.ensureTCPInterface(host: host, port: port, name: name) + TestLog.emit( + "skip_onboarding ok identity=\(active.identityHash) already_onboarded=true" + ) + return + } + let local = try await identityManager.createIdentity(displayName: "Anonymous Peer") + _ = try await identityManager.switchToIdentity(local.identityHash) + Self.ensureTCPInterface(host: host, port: port, name: name) + Self.ensureOnboardingFlagsSet() + TestLog.emit( + "skip_onboarding ok identity=\(local.identityHash) already_onboarded=false" + ) + } catch { + TestLog.emit( + "skip_onboarding err=\(error.localizedDescription.replacingOccurrences(of: " ", with: "\u{2423}"))" + ) + } + } + } + + /// Set `has_completed_onboarding` + `settings_initialized` + + /// `notifications_enabled`. The first two gate the host app's + /// `showOnboarding` decision at next launch; the third lines up + /// with `NotificationService.postMessageNotification`'s gating + /// check so the harness doesn't need a separate + /// `request_notif_permission` call before the first send. + fileprivate static func ensureOnboardingFlagsSet() { + let std = UserDefaults.standard + std.set(true, forKey: "has_completed_onboarding") + std.set(true, forKey: "settings_initialized") + std.set(true, forKey: "notifications_enabled") + std.set(true, forKey: "notify_received_message") + } + + /// Replace any existing interface with the same `name` and add a + /// fresh TCP-client config pointing at `host:port`. Mirrors + /// `TestController.handleAddTcpClient` byte-for-byte so the + /// resulting `InterfaceEntity` is identical to what the harness + /// would build later. + fileprivate static func ensureTCPInterface(host: String, port: Int, name: String) { + let interfaceRepo = InterfaceRepository() + if let existing = interfaceRepo.interfaces.first(where: { $0.name == name }) { + interfaceRepo.deleteInterface(id: existing.id) + } + let cfg = TCPClientConfig(targetHost: host, targetPort: UInt16(port)) + let entity = InterfaceEntity( + name: name, + type: .tcpClient, + enabled: true, + mode: .full, + config: .tcpClient(cfg) + ) + interfaceRepo.addInterface(entity) + } + + #if ENABLE_NETWORK_EXTENSION + /// Stringify `NEVPNStatus` for the harness log line. Mirrors the + /// Settings UI's choice of names so logs read like user intent. + fileprivate static func tunnelStatusString(_ status: NEVPNStatus) -> String { + switch status { + case .invalid: return "invalid" + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + case .reasserting: return "reasserting" + case .disconnecting: return "disconnecting" + @unknown default: return "unknown" + } + } + #endif + + /// Same release-guard rationale as TestController's: the file is + /// already `#if DEBUG`, this is the inner layer. + /// Defense-in-depth runtime guard: if some build-config or compile- + /// conditions misconfiguration ever lets this code run in a non-DEBUG + /// build, crash hard at the first invocation rather than silently + /// expose the test surface. In normal DEBUG builds this is a no-op. + /// + /// (Earlier this called `assertionFailure(...)` unconditionally, which + /// is exactly the wrong direction — `assertionFailure` ALWAYS crashes + /// in DEBUG builds, so every test invocation crashed the app on the + /// guard before reaching any actual test logic. Mirrors the Android + /// side's `check(BuildConfig.DEBUG)` semantics: throw only when DEBUG + /// is FALSE.) + private static func assertionFailure_releaseGuard() { + #if !DEBUG + fatalError( + "TestURLHandler must not run in release builds — " + + "this is a debug-only test surface." + ) + #endif + } +} + +#endif // DEBUG diff --git a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift index fe790563..42fa79f6 100644 --- a/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/OnboardingViewModel.swift @@ -23,14 +23,37 @@ final class OnboardingViewModel { var selectedTcpServer: TcpCommunityServer? = nil var notificationsGranted: Bool = false var isSaving: Bool = false + /// Pre-checked default for the Background Transport onboarding step. + /// User can opt out before finishing — when they do, the tunnel is + /// not auto-started on first launch and the toggle in Settings → + /// Background Transport defaults off until flipped manually. + var backgroundTunnelEnabled: Bool = true + /// True when the flow is being re-run from Settings → Advanced + /// (existing user who wants to walk through onboarding again, + /// e.g. to see a newly-added step). `completeOnboarding()` and + /// `prepareIdentity()` both skip identity / interface / + /// display-name creation in this mode so re-running the flow + /// doesn't duplicate data or replace the active identity — it + /// only commits the values that the new onboarding steps drive. + let isRestart: Bool + + init(isRestart: Bool = false) { + self.isRestart = isRestart + } /// Identity created during onboarding (set by prepareIdentity). var createdIdentity: LocalIdentity? /// QR code string for the created identity. var qrCodeString: String = "" - /// Total number of onboarding pages. + /// Total number of onboarding pages. Includes the Background + /// Transport step only when `ENABLE_NETWORK_EXTENSION` is + /// compiled in — otherwise the toggle would drive nothing. + #if ENABLE_NETWORK_EXTENSION + static let pageCount = 6 + #else static let pageCount = 5 + #endif // MARK: - Computed @@ -81,7 +104,25 @@ final class OnboardingViewModel { // MARK: - Identity Preparation /// Create the identity eagerly so the QR code is available on the complete page. + /// + /// Bails out when re-running onboarding for an existing user + /// (`isRestart == true`) — creating a fresh identity here was + /// silently switching them onto a brand-new empty one and + /// hiding their existing chats. For restart we use the active + /// identity to populate the QR string. func prepareIdentity(identityManager: IdentityManager) async { + if isRestart { + // Build the QR from the currently-active identity, no + // new keys created. + if qrCodeString.isEmpty, + let active = await identityManager.getActiveIdentity() { + if let identity = try? await identityManager.loadIdentityKeys(for: active.identityHash) { + let pubKeyHex = identity.publicKeys.map { String(format: "%02x", $0) }.joined() + qrCodeString = "lxma://\(active.destinationHash):\(pubKeyHex)" + } + } + return + } guard createdIdentity == nil else { return } do { let local = try await identityManager.createIdentity(displayName: effectiveDisplayName) @@ -108,36 +149,51 @@ final class OnboardingViewModel { isSaving = true defer { isSaving = false } - // 1. Use existing identity or create one - let local: LocalIdentity - if let existing = createdIdentity { - local = existing - } else { - local = try await identityManager.createIdentity(displayName: effectiveDisplayName) - } - let _ = try await identityManager.switchToIdentity(local.identityHash) + if !isRestart { + // 1. Use existing identity or create one + let local: LocalIdentity + if let existing = createdIdentity { + local = existing + } else { + local = try await identityManager.createIdentity(displayName: effectiveDisplayName) + } + let _ = try await identityManager.switchToIdentity(local.identityHash) - // 2. Save display name to settings - await settingsRepository.setDisplayName(effectiveDisplayName) + // 2. Save display name to settings + await settingsRepository.setDisplayName(effectiveDisplayName) - // 3. Create selected interfaces - let interfaceRepo = InterfaceRepository() - createInterfaces(in: interfaceRepo) + // 3. Create selected interfaces + let interfaceRepo = InterfaceRepository() + createInterfaces(in: interfaceRepo) + } // 4. Save notification preference if notificationsGranted { UserDefaults.standard.set(true, forKey: "notifications_enabled") } + // 4b. Persist Background Transport preference. AppServices.initialize() + // reads this from the App Group container on first launch and + // auto-starts the tunnel (which triggers the VPN-profile prompt + // the first time). The Settings toggle keeps the same key in sync. + // Gated on `ENABLE_NETWORK_EXTENSION` so non-extension builds + // (simulator, builds without the entitlement) don't write a + // stale `true` that nothing reads. + #if ENABLE_NETWORK_EXTENSION + UserDefaults(suiteName: appGroupIdentifier)? + .set(backgroundTunnelEnabled, forKey: SharedDefaultsConstants.tunnelEnabledKey) + #endif + // 5. Mark onboarding and settings as initialized UserDefaults.standard.set(true, forKey: "has_completed_onboarding") UserDefaults.standard.set(true, forKey: "settings_initialized") // 6. If user opted into RNode, flag the shell to open the configuration // wizard once MainTabView is on screen. Only meaningful on iOS — the - // RNode wizard's fullScreenCover is iOS-only. + // RNode wizard's fullScreenCover is iOS-only. Skipped on restart + // since the user already configured their interfaces. #if os(iOS) - if selectedInterfaces.contains(.rnode) { + if !isRestart && selectedInterfaces.contains(.rnode) { UserDefaults.standard.set(true, forKey: Self.pendingRNodeSetupKey) } #endif diff --git a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift index 9bce8529..27cd57fe 100644 --- a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift @@ -76,6 +76,28 @@ public struct IdentityInfo: Equatable { @available(iOS 17.0, macOS 14.0, *) @Observable public final class SettingsViewModel { + // MARK: - Static (app-launch defaults) + + /// Register the user-facing default values for keys this view + /// model owns. Called both from `ColumbaApp.init()` so the + /// AppServices on-reconnect announce + notification gating fire + /// correctly before the user ever opens Settings, and from + /// `loadLocalSettings()` so the view itself is also self-sufficient. + /// `register(defaults:)` only sets fallbacks for unset keys, so + /// repeated calls are harmless. + public static func registerLocalDefaults(into defaults: UserDefaults = .standard) { + defaults.register(defaults: [ + "notifications_enabled": true, + "show_message_previews": true, + "play_sounds": true, + "vibrate": true, + "auto_announce_enabled": true, + "auto_announce_on_interval": true, + "auto_announce_on_tcp_reconnect": true, + "auto_announce_on_peer_spawned": true + ]) + } + // MARK: - Types /// Available map sources. @@ -368,18 +390,12 @@ public final class SettingsViewModel { private func loadLocalSettings() { let defaults = UserDefaults.standard - // Register sane defaults so bool(forKey:) returns true for notifications - // even if the key was never explicitly written (e.g. pre-existing installs). - defaults.register(defaults: [ - "notifications_enabled": true, - "show_message_previews": true, - "play_sounds": true, - "vibrate": true, - "auto_announce_enabled": true, - "auto_announce_on_interval": true, - "auto_announce_on_tcp_reconnect": true, - "auto_announce_on_peer_spawned": true - ]) + // Defaults are registered at app launch (see `ColumbaApp.init()`) + // so AppServices's on-reconnect announce and the notification + // gating fire correctly even when the user never opens this + // view. Calling `register(defaults:)` again here is harmless — + // it only sets fallbacks for keys without explicit values. + Self.registerLocalDefaults(into: defaults) blockUnknownSenders = defaults.bool(forKey: "block_unknown_senders") isNotificationsEnabled = defaults.bool(forKey: "notifications_enabled") diff --git a/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift b/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift index f15c331f..06409e29 100644 --- a/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift +++ b/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift @@ -205,7 +205,25 @@ struct MessageBubble: View { @ViewBuilder private var deliveryStatusIcon: some View { - switch message.deliveryStatus { + // PROPAGATED messages cap at "sent" semantically — propagation + // nodes ack the upload, but never report the recipient's + // receipt. The python reference's `__mark_propagated` + // (LXMF/LXMessage.py:568-578) sets state=SENT, never DELIVERED. + // So a PROPAGATED message in `.delivered` is either (a) a + // stale DB row from before the LXMF-swift fix, or (b) a bug + // we should still render conservatively. Display a single + // checkmark either way — claiming "delivered" with a double + // checkmark on a propagated message is a false promise that + // misleads the user about what the recipient actually got. + let isPropagated = message.deliveryMethod == "propagated" + let effectiveStatus: DeliveryStatus = { + if isPropagated && (message.deliveryStatus == .delivered || message.deliveryStatus == .read) { + return .sent + } + return message.deliveryStatus + }() + + switch effectiveStatus { case .sending: Image(systemName: "clock") .font(.caption2) diff --git a/Sources/ColumbaApp/Views/Messaging/MessageDetailView.swift b/Sources/ColumbaApp/Views/Messaging/MessageDetailView.swift index 1e007550..8e514b5a 100644 --- a/Sources/ColumbaApp/Views/Messaging/MessageDetailView.swift +++ b/Sources/ColumbaApp/Views/Messaging/MessageDetailView.swift @@ -241,9 +241,27 @@ struct MessageDetailView: View { // MARK: - Card Components private var statusCard: some View { + // For PROPAGATED messages, "sent" is the terminal state — the + // sender knows the propagation node accepted the upload, but + // the propagation node does NOT report back when the recipient + // syncs the message down. The python reference caps PROPAGATED + // at `state = SENT` in `LXMessage.__mark_propagated` + // (LXMF/LXMessage.py:568-578). Showing "awaiting delivery + // confirmation" for a propagated message is a false promise — + // there will never be such confirmation. + let isPropagated = message.deliveryMethod == "propagated" + let (icon, color, title, subtitle): (String, Color, String, String) = { switch message.deliveryStatus { case .delivered: + // Should not occur for PROPAGATED in correctly-built + // pipelines (see LXMF-swift LXMRouter.handlePropagationAccepted), + // but guard the UI text anyway in case stale rows + // predate the fix or a different sender mismarks it. + if isPropagated { + return ("checkmark.circle.fill", .green, "Sent to relay", + "Uploaded to propagation node. Recipient will receive on next sync.") + } return ("checkmark.circle.fill", .green, "Delivered", "Message was successfully delivered to recipient") case .failed: @@ -253,6 +271,10 @@ struct MessageDetailView: View { return ("hourglass", .orange, "Sending", "Message is being sent") case .sent: + if isPropagated { + return ("paperplane.fill", .blue, "Sent to relay", + "Uploaded to propagation node. Recipient will receive on next sync — propagation nodes don't report back when the recipient pulls the message.") + } return ("paperplane.fill", .blue, "Sent", "Message sent, awaiting delivery confirmation") case .read: diff --git a/Sources/ColumbaApp/Views/Onboarding/BackgroundTransportPage.swift b/Sources/ColumbaApp/Views/Onboarding/BackgroundTransportPage.swift new file mode 100644 index 00000000..6bdf2eff --- /dev/null +++ b/Sources/ColumbaApp/Views/Onboarding/BackgroundTransportPage.swift @@ -0,0 +1,140 @@ +// +// BackgroundTransportPage.swift +// ColumbaApp +// +// Onboarding page 3: Background Transport opt-in. Pre-checked ON +// by default; lets the user opt out before completing onboarding. +// Drives `tunnel_enabled` in the App Group UserDefaults, which +// AppServices.initialize() reads to auto-start the Network Extension. +// + +import SwiftUI + +@available(iOS 17.0, macOS 14.0, *) +struct BackgroundTransportPage: View { + @Binding var enabled: Bool + let onBack: () -> Void + let onContinue: () -> Void + + var body: some View { + VStack(spacing: 0) { + Spacer() + + Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(Theme.accentColor) + .padding(.bottom, 24) + + Text("Stay Connected in the Background") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.bottom, 8) + + Text("Keep messages flowing while your phone is locked.") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 14) { + featureRow("Receive messages over the internet (TCP) when locked") + featureRow("Voice calls ring even when the app is closed") + featureRow("Reconnects automatically across networks") + } + .padding(.horizontal, 40) + .padding(.bottom, 16) + + Text("Auto Discovery and Nearby only work while the app is open — iOS doesn't allow extensions to send LAN packets in the background.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .padding(.bottom, 16) + + // Toggle card + HStack(spacing: 14) { + Image(systemName: enabled ? "checkmark.shield.fill" : "shield") + .font(.system(size: 24)) + .foregroundStyle(enabled ? Theme.success : Theme.textSecondary) + + VStack(alignment: .leading, spacing: 2) { + Text("Background Transport") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Theme.textPrimary) + Text("Recommended on") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + + Spacer() + + Toggle("", isOn: $enabled) + .labelsHidden() + .tint(Theme.accentColor) + } + .padding(16) + .background(Theme.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(enabled ? Theme.success.opacity(0.5) : Theme.divider, lineWidth: 1) + ) + .padding(.horizontal, 24) + .padding(.bottom, 12) + + Text("iOS will ask you to install a VPN profile to allow this. You can change it anytime in Settings.") + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Spacer() + + 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)) + } + + Button(action: onContinue) { + HStack(spacing: 6) { + Text("Continue") + Image(systemName: "chevron.right") + } + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Theme.accentGradient) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + } + } + + private func featureRow(_ text: String) -> some View { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle") + .font(.system(size: 18)) + .foregroundStyle(Theme.accentColor) + .frame(width: 24) + + Text(text) + .font(.body) + .foregroundStyle(Theme.textPrimary) + } + } +} diff --git a/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift index c5079641..dd55638c 100644 --- a/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift +++ b/Sources/ColumbaApp/Views/Onboarding/OnboardingView.swift @@ -12,12 +12,38 @@ import SwiftUI struct OnboardingView: View { let identityManager: IdentityManager let settingsRepository: SettingsRepository + /// True when the flow is being shown to an existing user from + /// Settings → Advanced — `OnboardingViewModel.completeOnboarding` + /// skips identity / interface / display-name creation in that + /// mode so we don't duplicate the user's data. + var isRestart: Bool = false let onComplete: () -> Void - @State private var viewModel = OnboardingViewModel() + @State private var viewModel: OnboardingViewModel @State private var showRestoreSheet = false @State private var migrationVM: MigrationViewModel? + init( + identityManager: IdentityManager, + settingsRepository: SettingsRepository, + isRestart: Bool = false, + onComplete: @escaping () -> Void + ) { + self.identityManager = identityManager + self.settingsRepository = settingsRepository + self.isRestart = isRestart + self.onComplete = onComplete + // Initialize the view model with the correct restart flag at + // construction time so `prepareIdentity` / + // `completeOnboarding` see it before any onboarding-page + // `onAppear` fires. The previous `Color.clear.onAppear` hack + // raced the page lifecycle and could let + // `CompletePage.onAppear → prepareIdentity` create a fresh + // identity, swap it in as active, and orphan the user's + // chats. + self._viewModel = State(initialValue: OnboardingViewModel(isRestart: isRestart)) + } + var body: some View { ZStack { Theme.backgroundPrimary.ignoresSafeArea() @@ -76,37 +102,23 @@ struct OnboardingView: View { onBack: { viewModel.previousPage() }, onContinue: { viewModel.nextPage() } ) + #if ENABLE_NETWORK_EXTENSION case 3: - PermissionsPage( - notificationsGranted: viewModel.notificationsGranted, - onRequestNotifications: { - Task { await viewModel.requestNotificationPermission() } - }, + BackgroundTransportPage( + enabled: $viewModel.backgroundTunnelEnabled, onBack: { viewModel.previousPage() }, onContinue: { viewModel.nextPage() } ) case 4: - CompletePage( - displayName: viewModel.effectiveDisplayName, - interfaceNames: viewModel.selectedInterfaceNames, - notificationsGranted: viewModel.notificationsGranted, - isSaving: viewModel.isSaving, - selectedRNode: viewModel.selectedInterfaces.contains(.rnode), - identityManager: identityManager, - qrCodeString: viewModel.qrCodeString, - onPrepare: { - await viewModel.prepareIdentity(identityManager: identityManager) - }, - onFinish: { - Task { - try? await viewModel.completeOnboarding( - identityManager: identityManager, - settingsRepository: settingsRepository - ) - onComplete() - } - } - ) + permissionsPageView() + case 5: + completePageView() + #else + case 3: + permissionsPageView() + case 4: + completePageView() + #endif default: EmptyView() } @@ -143,4 +155,41 @@ struct OnboardingView: View { } } } + + @ViewBuilder + private func permissionsPageView() -> some View { + PermissionsPage( + notificationsGranted: viewModel.notificationsGranted, + onRequestNotifications: { + Task { await viewModel.requestNotificationPermission() } + }, + onBack: { viewModel.previousPage() }, + onContinue: { viewModel.nextPage() } + ) + } + + @ViewBuilder + private func completePageView() -> some View { + CompletePage( + displayName: viewModel.effectiveDisplayName, + interfaceNames: viewModel.selectedInterfaceNames, + notificationsGranted: viewModel.notificationsGranted, + isSaving: viewModel.isSaving, + selectedRNode: viewModel.selectedInterfaces.contains(.rnode), + identityManager: identityManager, + qrCodeString: viewModel.qrCodeString, + onPrepare: { + await viewModel.prepareIdentity(identityManager: identityManager) + }, + onFinish: { + Task { + try? await viewModel.completeOnboarding( + identityManager: identityManager, + settingsRepository: settingsRepository + ) + onComplete() + } + } + ) + } } diff --git a/Sources/ColumbaApp/Views/Settings/IdentityManagerView.swift b/Sources/ColumbaApp/Views/Settings/IdentityManagerView.swift index 65a16e88..504f099b 100644 --- a/Sources/ColumbaApp/Views/Settings/IdentityManagerView.swift +++ b/Sources/ColumbaApp/Views/Settings/IdentityManagerView.swift @@ -313,20 +313,12 @@ struct IdentityManagerView: View { // Update display name in settings repo for announce compatibility await settingsRepository.setDisplayName(localId.displayName) - // Get server address - let interfaceRepo = InterfaceRepository() - let serverAddress: String - if let tcpEntity = interfaceRepo.getEnabledInterfaces().first(where: { $0.type == .tcpClient }), - case .tcpClient(let config) = tcpEntity.config { - serverAddress = "\(config.targetHost):\(config.targetPort)" - } else { - serverAddress = "" - } - + // Switch identity. Interface reconnection is handled by the + // parent `onIdentitySwitch` hook below, which re-runs the + // app's Step 7 loop against `InterfaceRepository`. try await appServices.switchIdentity( to: identity, - identityHash: localId.identityHash, - tcpServerAddress: serverAddress + identityHash: localId.identityHash ) await loadIdentities() diff --git a/Sources/ColumbaApp/Views/Settings/SettingsView.swift b/Sources/ColumbaApp/Views/Settings/SettingsView.swift index eb80a7c9..1464debe 100644 --- a/Sources/ColumbaApp/Views/Settings/SettingsView.swift +++ b/Sources/ColumbaApp/Views/Settings/SettingsView.swift @@ -44,6 +44,33 @@ struct SettingsView: View { /// Persisted across body re-evaluations so showRNodeWizard=true is not lost /// when SettingsView re-renders due to connection status polling changes. @State private var interfaceViewModel: InterfaceManagementViewModel? + #if DEBUG + /// Drives the fullScreenCover that re-shows the onboarding flow + /// from the Advanced → Re-run Onboarding debug card. + @State private var showRestartOnboarding = false + #endif + #if ENABLE_NETWORK_EXTENSION + /// Last error message from the Background Transport toggle. Cleared + /// on the next successful toggle. Surfaced inline below the toggle + /// so install / start failures (entitlement mismatch, unregistered + /// App ID, user denying the VPN-profile prompt) are visible + /// instead of silently bouncing the toggle off. + @State private var tunnelErrorMessage: String? + /// User's pending intent during a tunnel start/disable transition. + /// `tunnel.isRunning` only flips `true` once iOS reaches `.connected`, + /// but the VPN passes through `.connecting` first — and the + /// `@Observable` polling re-renders during that window would snap + /// the toggle back to OFF. While set, this overrides the binding + /// `get` so the toggle stays where the user put it. Cleared once + /// the actual status matches the intent (or after a timeout). + @State private var tunnelPending: Bool? + /// In-flight tunnel start/disable Task. Cancelled before a new + /// one is spawned so a rapid ON→OFF tap can't race the previous + /// `start()`'s `install()` flow — without cancellation the older + /// Task would still call `startVPNTunnel()` after the user's + /// last intent was OFF. + @State private var tunnelTask: Task? + #endif // MARK: - Body @@ -96,6 +123,13 @@ struct SettingsView: View { // Transport Mode (advanced) transportModeCard(vm) + + #if DEBUG + restartOnboardingCard() + #if ENABLE_NETWORK_EXTENSION + reloadExtensionCard() + #endif + #endif } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -355,6 +389,97 @@ struct SettingsView: View { } } + #if DEBUG && ENABLE_NETWORK_EXTENSION + // MARK: - Reload Extension (DEBUG only) + + /// Force the Network Extension to reload its binary by sending + /// the debug-reload appMessage. The extension calls + /// `cancelTunnelWithError`, iOS spawns a fresh extension + /// process on the next start, and the new binary on disk is + /// loaded — without the user manually deleting and re-adding + /// the VPN profile in iOS Settings. + private func reloadExtensionCard() -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Theme.accentColor) + + Text("Reload Extension") + .font(.headline) + .foregroundStyle(Theme.textPrimary) + + Spacer() + + Button("Reload") { + Task { + if let tunnel = appServices.tunnelManager { + await tunnel.debugReloadExtension() + } + } + } + .buttonStyle(.bordered) + .tint(Theme.accentColor) + } + + Text("Force the Network Extension to reload its binary after a build. Sends `cancelTunnelWithError` so iOS spawns a fresh extension process on the next start. Debug builds only.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .glassCard() + } + #endif + + #if DEBUG + // MARK: - Re-run Onboarding (DEBUG only) + + /// Debug entry point for an existing user to walk through the + /// onboarding flow again — useful when verifying a newly-added + /// onboarding step (e.g. Background Transport) without losing + /// chats / identities. The OnboardingView is presented with + /// `isRestart = true` so `OnboardingViewModel.completeOnboarding()` + /// skips identity / interface / display-name creation and only + /// commits the values that the new step drives. + private func restartOnboardingCard() -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "arrow.triangle.2.circlepath.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Theme.accentColor) + + Text("Re-run Onboarding") + .font(.headline) + .foregroundStyle(Theme.textPrimary) + + Spacer() + + Button("Start") { + showRestartOnboarding = true + } + .buttonStyle(.bordered) + .tint(Theme.accentColor) + } + + Text("Walks through the onboarding pages again. Existing identity, interfaces, and display name are preserved — only new flags (e.g. Background Transport) are committed. Debug builds only.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .glassCard() + .fullScreenCover(isPresented: $showRestartOnboarding) { + OnboardingView( + identityManager: identityManager, + settingsRepository: settingsRepository, + isRestart: true, + onComplete: { showRestartOnboarding = false } + ) + } + } + #endif + #if ENABLE_NETWORK_EXTENSION // MARK: - Background Transport Card @@ -374,13 +499,84 @@ struct SettingsView: View { Spacer() Toggle("", isOn: Binding( - get: { tunnel.isRunning }, + get: { tunnelPending ?? tunnel.isRunning }, set: { newValue in - Task { - if newValue { - try? await tunnel.start() - } else { - tunnel.stop() + tunnelPending = newValue + // Cancel any in-flight start/disable so a + // rapid re-tap doesn't race the previous + // operation — otherwise an older `start()` + // can finish `install()` and call + // `startVPNTunnel()` after the user's + // last intent was already OFF. + tunnelTask?.cancel() + tunnelTask = Task { @MainActor in + do { + if newValue { + try await tunnel.start() + } else { + try await tunnel.disable() + } + try Task.checkCancellation() + tunnelErrorMessage = nil + // Wait briefly for the VPN status to + // settle into the requested state + // before letting the toggle reflect + // `tunnel.isRunning` again — `.connecting` + // and `.disconnecting` are transient and + // would otherwise flicker the toggle. + let deadline = Date().addingTimeInterval(30) + while tunnel.isRunning != newValue && Date() < deadline { + if Task.isCancelled { break } + try? await Task.sleep(nanoseconds: 200_000_000) + } + if !Task.isCancelled && newValue && !tunnel.isRunning { + // We asked for ON but the tunnel + // never reached `.connected` — + // failure happened asynchronously + // after `startVPNTunnel()` returned + // (airplane mode, routing failure, + // extension crash). Surface the + // reason and *don't* persist the + // user's intent to the App Group, + // otherwise auto-restart would loop + // the same failure on every relaunch. + let reason = await tunnel.lastFailureReason() + ?? "Background Transport could not connect" + DiagLog.log("[TUNNEL] start did not reach .connected: \(reason)") + tunnelErrorMessage = reason + } else if !Task.isCancelled { + // The actual outcome matches the + // user's intent; safe to persist. + UserDefaults(suiteName: appGroupIdentifier)? + .set(newValue, forKey: SharedDefaultsConstants.tunnelEnabledKey) + } + } catch is CancellationError { + // Superseded by a newer toggle — + // leave state alone; the newer + // Task will own the next state. + return + } catch { + let action = newValue ? "start" : "disable" + let msg = "Background Transport \(action) failed: \(error.localizedDescription)" + DiagLog.log(msg) + tunnelErrorMessage = error.localizedDescription + // If `disable()` threw mid-flight + // (e.g. `saveToPreferences()` failed + // after `stopVPNTunnel()`), the user + // still asked for OFF — persist that + // intent so a relaunch doesn't + // auto-restart the tunnel against + // their wishes. We don't write the + // intent on `start()` failures + // because committing to a failing + // start would loop the same failure. + if !newValue { + UserDefaults(suiteName: appGroupIdentifier)? + .set(false, forKey: SharedDefaultsConstants.tunnelEnabledKey) + } + } + if !Task.isCancelled { + tunnelPending = nil } } } @@ -389,18 +585,30 @@ struct SettingsView: View { .tint(Theme.accentColor) } - Text("Keep TCP and LAN connections alive when the app is backgrounded. Enables receiving messages and notifications without opening the app.") + Text("Keeps the TCP relay connection alive while the app is backgrounded so messages arrive when your phone is locked. Auto Discovery and Nearby need the app to be open — iOS doesn't let extensions send LAN packets in the background.") .font(.caption) .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) HStack(spacing: 6) { + let displayedRunning = tunnelPending ?? tunnel.isRunning + let isTransitional = tunnelPending != nil && tunnelPending != tunnel.isRunning Circle() - .fill(tunnel.isRunning ? Theme.success : Theme.textSecondary) + .fill(displayedRunning ? Theme.success : Theme.textSecondary) .frame(width: 8, height: 8) - Text(tunnel.isRunning ? "Running" : "Stopped") + Text(isTransitional + ? (displayedRunning ? "Starting…" : "Stopping…") + : (displayedRunning ? "Running" : "Stopped")) + .font(.caption) + .foregroundStyle(displayedRunning ? Theme.success : Theme.textSecondary) + } + + if let tunnelErrorMessage { + Text(tunnelErrorMessage) .font(.caption) - .foregroundStyle(tunnel.isRunning ? Theme.success : Theme.textSecondary) + .foregroundStyle(Theme.error) + .fixedSize(horizontal: false, vertical: true) } } .padding(16) diff --git a/Sources/ColumbaNetworkExtension/ExtensionAutoBridge.swift b/Sources/ColumbaNetworkExtension/ExtensionAutoBridge.swift new file mode 100644 index 00000000..184eec7b --- /dev/null +++ b/Sources/ColumbaNetworkExtension/ExtensionAutoBridge.swift @@ -0,0 +1,562 @@ +// +// ExtensionAutoBridge.swift +// ColumbaNetworkExtension +// +// Hybrid AutoInterface bridge for the Network Extension. Mixes the +// two iOS APIs that were each verified empirically inside an +// `NEPacketTunnelProvider` sandbox: +// +// - POSIX UDP sockets for the multicast HELLO discovery channel. +// `NWConnectionGroup` with `NWMulticastGroup` reports `ready` +// but never delivers inbound packets to the receive handler in +// this sandbox; an explicit `setsockopt(IPV6_JOIN_GROUP)` on a +// raw socket does receive them. +// - `NWListener` for the unicast data port. POSIX `bind` to a +// link-local IPv6 + port succeeds but iOS routes incoming +// packets to the system networking stack rather than our +// socket; `NWListener` on the same port does receive them. +// - `NWConnection` per peer for outbound unicast data. The +// Network framework handles per-peer routing on the Wi-Fi +// interface correctly. +// +// Wire-compatible with reticulum-swift's `AutoInterface`: +// - multicast group: `ff12:0:…` from +// `AutoInterfaceConstants.multicastAddress(for:)` +// - HELLO beacons: 32-byte SHA-256 from +// `AutoInterfaceConstants.discoveryToken(groupId:address:)` +// - data: plain UDP datagrams on `defaultDataPort` (42671) +// + +import Foundation +import Network +import Darwin +@preconcurrency import ReticulumSwift + +final class ExtensionAutoBridge: @unchecked Sendable { + + // MARK: - Dependencies + + private let frameQueue: SharedFrameQueue + private let postNotif: () -> Void + + // MARK: - State + + private(set) var groupId: String? + private var multicastAddress: String = "" + private var discoveryPort: UInt16 = AutoInterfaceConstants.defaultDiscoveryPort + private var dataPort: UInt16 = AutoInterfaceConstants.defaultDataPort + + /// POSIX UDP sockets keyed by Wi-Fi interface name. Each socket + /// is joined to the multicast group on its interface; receive + /// loop reads HELLOs via `recvfrom`. Sends fan out via + /// `sendto` to the multicast endpoint. + private var multicastSockets: [String: Int32] = [:] + + /// `ifname → ifIndex` for `IPV6_JOIN_GROUP` and scope id when + /// converting endpoints into `sockaddr_in6`. + private var multicastInterfaces: [String: UInt32] = [:] + + /// Per-interface receive task spinning on `poll() / recvfrom`. + private var multicastReceiveTasks: [String: Task] = [:] + + /// Outbound `NWConnection` to the multicast group endpoint. + /// POSIX `sendto` from the multicast socket fails with + /// `ENETUNREACH` in the extension sandbox even though POSIX + /// `IPV6_JOIN_GROUP` + `recvfrom` works fine on the same + /// socket. `NWConnection` to the multicast destination goes out + /// cleanly — Apple's framework owns its own routing decisions. + private var multicastSender: NWConnection? + + /// `NWListener` on the data port. `NWConnection` per peer. + private var dataListener: NWListener? + private var peerConnections: [String: NWConnection] = [:] + private var peerLastHeard: [String: Date] = [:] + + /// Our own link-local IPv6 addresses — used to filter our own + /// multicast echoes and to compute the discovery tokens we + /// announce on each interface. + private var ownAddresses: Set = [] + + private var announceTask: Task? + private var maintenanceTask: Task? + + private let stateQueue = DispatchQueue(label: "network.columba.tunnel.auto.state") + + // MARK: - Init + + init(frameQueue: SharedFrameQueue, postNotif: @escaping () -> Void) { + self.frameQueue = frameQueue + self.postNotif = postNotif + } + + // MARK: - Public API + + func start(groupId: String) { + stop() + self.groupId = groupId + self.multicastAddress = AutoInterfaceConstants.multicastAddress(for: groupId) + self.ownAddresses = Self.discoverLinkLocalAddresses() + let interfaces = Self.discoverWifiInterfaces() + for ifInfo in interfaces { + multicastInterfaces[ifInfo.name] = ifInfo.index + } + + ExtensionDiagLog.log("[EXT/Auto] starting groupId=\(groupId) mcast=\(multicastAddress) own=\(ownAddresses) ifaces=\(interfaces.map(\.name))") + + for ifInfo in interfaces { + do { + let fd = try setupMulticastSocket(interfaceIndex: ifInfo.index) + multicastSockets[ifInfo.name] = fd + startMulticastReceiveLoop(ifname: ifInfo.name, fd: fd) + ExtensionDiagLog.log("[EXT/Auto] multicast socket bound on \(ifInfo.name) idx=\(ifInfo.index)") + } catch { + ExtensionDiagLog.log("[EXT/Auto] multicast socket setup failed on \(ifInfo.name): \(error)") + } + } + + startMulticastSender() + startDataListener() + startAnnounceLoop() + startMaintenanceLoop() + } + + /// Open an `NWConnection` aimed at the multicast group so we can + /// `send()` HELLOs through Apple's framework — empirically the + /// only outbound path that doesn't fail with `ENETUNREACH` from + /// inside an `NEPacketTunnelProvider` sandbox. + private func startMulticastSender() { + guard let port = NWEndpoint.Port(rawValue: discoveryPort), + let host = IPv6Address(multicastAddress) else { return } + let conn = NWConnection(host: .ipv6(host), port: port, using: .udp) + conn.stateUpdateHandler = { state in + ExtensionDiagLog.log("[EXT/Auto] mcast sender state: \(state)") + } + conn.start(queue: .global(qos: .utility)) + self.multicastSender = conn + } + + func stop() { + announceTask?.cancel() + announceTask = nil + maintenanceTask?.cancel() + maintenanceTask = nil + + for (_, task) in multicastReceiveTasks { task.cancel() } + multicastReceiveTasks.removeAll() + + multicastSender?.cancel() + multicastSender = nil + + for (_, fd) in multicastSockets { Darwin.close(fd) } + multicastSockets.removeAll() + multicastInterfaces.removeAll() + + dataListener?.cancel() + dataListener = nil + + stateQueue.sync { + for (_, conn) in peerConnections { conn.cancel() } + peerConnections.removeAll() + peerLastHeard.removeAll() + ownAddresses.removeAll() + } + groupId = nil + } + + func send(_ data: Data) { + let conns: [NWConnection] = stateQueue.sync { + Array(peerConnections.values) + } + guard !conns.isEmpty else { + ExtensionDiagLog.log("[EXT/Auto] TX dropped \(data.count)B — no peers") + return + } + for conn in conns { + conn.send(content: data, completion: .contentProcessed { error in + if let error { + ExtensionDiagLog.log("[EXT/Auto] TX \(data.count)B failed: \(error)") + } + }) + } + ExtensionDiagLog.log("[EXT/Auto] TX \(data.count)B fanned out to \(conns.count)") + } + + // MARK: - Multicast (POSIX) + + private func setupMulticastSocket(interfaceIndex: UInt32) throws -> Int32 { + let fd = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP) + guard fd >= 0 else { throw POSIXError(.EIO) } + + // Reuse so multiple processes / multiple sockets don't + // collide on the multicast port. + var reuse: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &reuse, socklen_t(MemoryLayout.size)) + + // Multicast send egress interface. + var ifIdx: UInt32 = interfaceIndex + setsockopt(fd, IPPROTO_IPV6, IPV6_MULTICAST_IF, &ifIdx, socklen_t(MemoryLayout.size)) + + // Loopback so our own HELLOs come back to us — lets us + // filter our own echoes by `ownAddresses` and confirms the + // socket is alive. + var loop: Int32 = 1 + setsockopt(fd, IPPROTO_IPV6, IPV6_MULTICAST_LOOP, &loop, socklen_t(MemoryLayout.size)) + + // Join the multicast group on this interface. + var mreq = ipv6_mreq() + var grpAddr = in6_addr() + _ = multicastAddress.withCString { cstr in + inet_pton(AF_INET6, cstr, &grpAddr) + } + mreq.ipv6mr_multiaddr = grpAddr + mreq.ipv6mr_interface = interfaceIndex + let joinResult = setsockopt(fd, IPPROTO_IPV6, IPV6_JOIN_GROUP, &mreq, socklen_t(MemoryLayout.size)) + if joinResult != 0 { + let err = errno + Darwin.close(fd) + ExtensionDiagLog.log("[EXT/Auto] IPV6_JOIN_GROUP failed errno=\(err)") + throw POSIXError(POSIXErrorCode(rawValue: err) ?? .EIO) + } + + // Bind to the discovery port on the wildcard address. + var sin6 = sockaddr_in6() + sin6.sin6_family = sa_family_t(AF_INET6) + sin6.sin6_port = discoveryPort.bigEndian + sin6.sin6_addr = in6addr_any + let bindResult = withUnsafePointer(to: &sin6) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + if bindResult != 0 { + let err = errno + Darwin.close(fd) + ExtensionDiagLog.log("[EXT/Auto] mcast bind failed errno=\(err)") + throw POSIXError(POSIXErrorCode(rawValue: err) ?? .EIO) + } + + // Non-blocking so our recv loop can poll cooperatively. + let flags = fcntl(fd, F_GETFL, 0) + _ = fcntl(fd, F_SETFL, flags | O_NONBLOCK) + + return fd + } + + private func startMulticastReceiveLoop(ifname: String, fd: Int32) { + let task = Task.detached { [weak self] in + var buf = [UInt8](repeating: 0, count: 1024) + while !Task.isCancelled { + var pollFd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let pollResult = poll(&pollFd, 1, 500) + if Task.isCancelled { return } + guard pollResult > 0 else { continue } + + var src = sockaddr_in6() + var srcLen = socklen_t(MemoryLayout.size) + let n = buf.withUnsafeMutableBufferPointer { bufPtr -> Int in + withUnsafeMutablePointer(to: &src) { srcPtr in + srcPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saPtr in + recvfrom(fd, bufPtr.baseAddress, bufPtr.count, 0, saPtr, &srcLen) + } + } + } + guard n > 0 else { continue } + + let data = Data(buf[0.. timeout ? addr : nil + } + let result = staleAddrs.map { addr -> (String, NWConnection?) in + let conn = peerConnections.removeValue(forKey: addr) + peerLastHeard.removeValue(forKey: addr) + return (addr, conn) + } + return result + } + for (addr, conn) in stale { + conn?.cancel() + ExtensionDiagLog.log("[EXT/Auto] peer expired: \(addr)") + } + } + + // MARK: - Helpers + + /// All Wi-Fi-shaped (`en*`) interfaces that have a link-local + /// IPv6 address. Used to pick which interface(s) we open + /// multicast sockets on. + static func discoverWifiInterfaces() -> [(name: String, index: UInt32)] { + var seen = Set() + var results: [(String, UInt32)] = [] + var ifap: UnsafeMutablePointer? + guard getifaddrs(&ifap) == 0 else { return results } + defer { freeifaddrs(ifap) } + var ptr = ifap + while let p = ptr { + defer { ptr = p.pointee.ifa_next } + let name = String(cString: p.pointee.ifa_name) + if !name.hasPrefix("en") { continue } + if seen.contains(name) { continue } + + // Only adopt interfaces that actually have a link-local + // IPv6 we can announce as. + guard let saPtr = p.pointee.ifa_addr, + saPtr.pointee.sa_family == sa_family_t(AF_INET6) else { continue } + let sin6 = saPtr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } + let bytes = withUnsafePointer(to: sin6.sin6_addr) { ptr in + UnsafeRawPointer(ptr).load(as: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8).self) + } + guard bytes.0 == 0xfe, (bytes.1 & 0xc0) == 0x80 else { continue } + + seen.insert(name) + results.append((name, if_nametoindex(name))) + } + return results + } + + /// All link-local IPv6 addresses on this device (`fe80::/10` on + /// every interface — not just `en*`). Used for filtering own + /// multicast echoes; we need to include `awdl0`, `utun*`, etc. + /// because IPv6 multicast loopback delivers our own packets back + /// from those interfaces' addresses, and missing any of them + /// would create spurious "peers" that are really ourselves. + static func discoverLinkLocalAddresses() -> Set { + var addresses = Set() + var ifap: UnsafeMutablePointer? + guard getifaddrs(&ifap) == 0 else { return addresses } + defer { freeifaddrs(ifap) } + var ptr = ifap + while let p = ptr { + defer { ptr = p.pointee.ifa_next } + guard let saPtr = p.pointee.ifa_addr, + saPtr.pointee.sa_family == sa_family_t(AF_INET6) else { continue } + let sin6 = saPtr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } + let bytes = withUnsafePointer(to: sin6.sin6_addr) { ptr in + UnsafeRawPointer(ptr).load(as: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8).self) + } + guard bytes.0 == 0xfe, (bytes.1 & 0xc0) == 0x80 else { continue } + + var buf = [Int8](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + var addrCopy = sin6.sin6_addr + inet_ntop(AF_INET6, &addrCopy, &buf, socklen_t(INET6_ADDRSTRLEN)) + addresses.insert(String(cString: buf)) + } + return addresses + } +} diff --git a/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift b/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift index f12a68c5..0d21f3d4 100644 --- a/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift +++ b/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift @@ -13,6 +13,7 @@ import Foundation import Network import NetworkExtension +import UserNotifications class PacketTunnelProvider: NEPacketTunnelProvider { @@ -24,45 +25,134 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// changes; triggers a reload so unrelated interfaces stay /// connected while a single relay is added/removed/edited. private static let configChangedNotification = SharedDefaultsConstants.configChangedNotificationName + /// Notification observed when the app updates the set of locally- + /// registered LXMF/LXST destination hashes; triggers a re-read of + /// `localDestinationsKey` so inbound-frame filtering picks up the + /// new set without restarting the tunnel. + private static let localDestinationsChangedNotification = SharedDefaultsConstants.localDestinationsChangedNotificationName + /// Darwin notification observed when the host app updates the + /// dual-interface tunnel TCP endpoint list. Mirrors the + /// `configChangedNotification` pattern. + private static let tunnelTCPEndpointsChangedNotification = SharedDefaultsConstants.tunnelTCPEndpointsChangedNotificationName private static let interfacesKey = SharedDefaultsConstants.interfacesKey + private static let localDestinationsKey = SharedDefaultsConstants.localDestinationsKey + private static let tunnelTCPEndpointsKey = SharedDefaultsConstants.tunnelTCPEndpointsKey // MARK: - Properties - private var tcpConnection: NWConnection? - private var autoListener: NWConnectionGroup? private lazy var frameQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier) - - /// Currently-applied TCP endpoint (used to diff config changes - /// from the app). nil when no TCP interface is configured. + /// Drives the extension's AutoInterface — peer discovery + /// (`ff12:0:…` multicast derived from the group id) plus + /// per-peer unicast data on the data port. Replaces the + /// previous single-`NWConnectionGroup` path that hard-coded + /// `ff02::1` and never delivered data to peers. + private lazy var autoBridge = ExtensionAutoBridge( + frameQueue: frameQueue, + postNotif: { [weak self] in self?.postDarwinNotification() } + ) + + /// Per-entity TCP `NWConnection`s. Multiple TCP relays can be + /// tunneled simultaneously — each `InterfaceEntity` from the app + /// gets its own connection and its own HDLC receive buffer here. /// Mutated only on `configQueue` to avoid races with Darwin /// notification callbacks arriving on a Mach-port thread. - private var currentTCP: (host: String, port: UInt16)? + private var tcpConnections: [String: NWConnection] = [:] + + /// Currently-applied TCP endpoints, keyed by entity id. Used to + /// diff config changes so an unrelated entry doesn't get its + /// connection torn down when the user adds or edits a different + /// one. + private var currentTCPs: [String: (host: String, port: UInt16)] = [:] + + /// Per-connection HDLC receive buffer. Each TCP relay has its own + /// stream so they cannot share a single buffer without corrupting + /// frame boundaries. + private var tcpReceiveBuffers: [String: Data] = [:] /// Currently-applied AutoInterface group id. nil when no Auto /// interface is configured. Mutated only on `configQueue`. private var currentAutoGroupId: String? + /// Locally-registered LXMF/LXST destination hashes, decoded from + /// the App Group `localDestinationsKey`. Inbound frames whose + /// destination_hash header matches one of these get an extension- + /// scheduled `UNUserNotificationCenter` notification so the user + /// sees that a message arrived even while the host app is + /// suspended. Mutated only on `configQueue` to avoid racing the + /// inbound TCP handler that reads it. + private var localDestinationHashes: Set = [] + /// Serial queue serializing all config-state mutations and the /// associated NWConnection lifecycle calls so a Darwin /// notification fired by the app (`configChanged`) can't race /// `startTunnel` / `stopTunnel` / NWConnection state handlers. private let configQueue = DispatchQueue(label: "network.columba.tunnel.config") - /// HDLC receive buffer for TCP stream framing - private var tcpReceiveBuffer = Data() + /// One-shot diagnostic UDP listener on port 9999. Used by + /// `tools/auto-test/run_test.sh` to determine whether an + /// iOS Network Extension can receive inbound UDP unicast at + /// all — independent of any AutoInterface protocol logic. + /// We hold the reference here so it stays alive across + /// `startTunnel` / `applyConfigs` calls. + private var diagListener: NWListener? /// HDLC constants private static let FLAG: UInt8 = 0x7E private static let ESC: UInt8 = 0x7D private static let ESC_MASK: UInt8 = 0x20 + /// AppMessage tag commands sent from the app to the extension + /// for debugging-only purposes. + private static let DEBUG_RELOAD_COMMAND: UInt8 = 0xFE + // MARK: - Tunnel Lifecycle override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { NSLog("[EXT] startTunnel called") + // Write a heartbeat file we can pull from the App Group + // container even if `ExtensionDiagLog`'s file path resolution + // is silently failing — confirms the extension actually ran + // and that file writes from the extension reach the shared + // container. + if let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) { + let heartbeat = containerURL.appendingPathComponent("ext_heartbeat.txt") + let line = "startTunnel @ \(ISO8601DateFormatter().string(from: Date()))\n" + try? line.data(using: .utf8)?.write(to: heartbeat) + NSLog("[EXT] heartbeat path: %@", heartbeat.path) + } else { + NSLog("[EXT] containerURL returned nil at startTunnel — App Group not accessible from extension") + } + ExtensionDiagLog.log("[EXT] startTunnel called") + + // Diagnostic listener — answers the question "can a + // NEPacketTunnelProvider extension receive inbound UDP + // unicast at all" independent of AutoInterface protocol + // wiring. Test harness sends a UDP datagram to port 9999 + // from a Mac on the same Wi-Fi and checks whether + // `[EXT/Diag] received` lands in the diag log. + // + // Diagnostic outbound test — answers "can the extension + // send UDP unicast to the LAN at all". Sends to a hard- + // coded test peer (the Mac's link-local address used by + // `tools/auto-test/`); the test harness listens on + // port 9998 and reports whether the packet arrived. + // + // Both probes are gated behind `#if DEBUG` so production + // builds neither bind extra listening ports nor leak the + // developer's hard-coded link-local IPv6 to every user's + // device on every tunnel start. + #if DEBUG + startDiagListener() + sendDiagOutboundProbe() + #endif // Apply current interface configs. applyConfigs() + // Load the locally-registered destination set so we can + // filter inbound frames before the first packet arrives. + reloadLocalDestinations() // Subscribe to live config changes so the user adding / // removing / editing an interface in the app updates the @@ -83,6 +173,40 @@ class PacketTunnelProvider: NEPacketTunnelProvider { .deliverImmediately ) + // Subscribe to local-destination changes so an identity + // switch (or first-launch destination registration) updates + // the filter without a tunnel restart. Mirrors the config- + // change observer above; teardown happens in `stopTunnel`. + CFNotificationCenterAddObserver( + center, + observer, + { _, observer, _, _, _ in + guard let observer else { return } + let provider = Unmanaged.fromOpaque(observer).takeUnretainedValue() + provider.reloadLocalDestinations() + }, + Self.localDestinationsChangedNotification as CFString, + nil, + .deliverImmediately + ) + + // Subscribe to dual-interface tunnel TCP endpoint changes — + // the host app writes to `tunnelTCPEndpointsKey` when it + // registers / deregisters its `TunnelTCPInterface`. Same + // diff-based reconciliation as `configChangedNotification`. + CFNotificationCenterAddObserver( + center, + observer, + { _, observer, _, _, _ in + guard let observer else { return } + let provider = Unmanaged.fromOpaque(observer).takeUnretainedValue() + provider.applyConfigs() + }, + Self.tunnelTCPEndpointsChangedNotification as CFString, + nil, + .deliverImmediately + ) + // Set up dummy tunnel settings (required by NEPacketTunnelProvider) let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") settings.ipv4Settings = NEIPv4Settings(addresses: ["169.254.1.1"], subnetMasks: ["255.255.255.255"]) @@ -112,58 +236,79 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } - /// Tear down the current TCP connection and clear the HDLC - /// receive buffer so a reconnect doesn't prepend a partial frame - /// from the previous session to the new connection's first - /// bytes (which would corrupt the next decoded packet). Always - /// called from `configQueue`. - private func teardownTCPConnectionLocked() { - tcpConnection?.cancel() - tcpConnection = nil - tcpReceiveBuffer = Data() + /// Tear down a single TCP connection by entity id and clear its + /// HDLC receive buffer so a reconnect doesn't prepend a partial + /// frame from the previous session to the new connection's first + /// bytes. Always called from `configQueue`. + private func teardownTCPConnectionLocked(entityId: String) { + tcpConnections[entityId]?.cancel() + tcpConnections.removeValue(forKey: entityId) + tcpReceiveBuffers.removeValue(forKey: entityId) + } + + /// Tear down every TCP connection (used on `stopTunnel`). + /// Always called from `configQueue`. + private func teardownAllTCPConnectionsLocked() { + for (_, conn) in tcpConnections { + conn.cancel() + } + tcpConnections.removeAll() + tcpReceiveBuffers.removeAll() } /// Body of `applyConfigs` — runs on `configQueue`. Mutates - /// `currentTCP` / `currentAutoGroupId` / `tcpConnection` / - /// `autoListener` only from this serial context. + /// `currentTCPs` / `currentAutoGroupId` / `tcpConnections` only + /// from this serial context. private func applyConfigsLocked() { let defaults = UserDefaults(suiteName: appGroupIdentifier) ?? .standard let configs = loadInterfaceConfigs(from: defaults) - // TCP: bring up if newly configured; tear down if removed; - // restart if endpoint changed. - if let tcp = configs.tcp { - if let existing = currentTCP, existing.host == tcp.host && existing.port == tcp.port { - // No change. - } else { - NSLog("[EXT] TCP config (re)applying: \(tcp.host):\(tcp.port)") - teardownTCPConnectionLocked() - startTCPConnection(host: tcp.host, port: tcp.port) - currentTCP = (tcp.host, tcp.port) + // TCP: per-entity diff. Bring up newly-configured entries, + // tear down removed ones, restart only entries whose endpoint + // changed. Untouched entries keep their existing connection. + for (entityId, endpoint) in configs.tcps { + if let existing = currentTCPs[entityId], + existing.host == endpoint.host && existing.port == endpoint.port { + // No change for this entity. + continue } - } else if currentTCP != nil { - NSLog("[EXT] TCP config removed; tearing down connection") - teardownTCPConnectionLocked() - currentTCP = nil + NSLog("[EXT] TCP config (re)applying [\(entityId)]: \(endpoint.host):\(endpoint.port)") + ExtensionDiagLog.log("[EXT/TCP] (re)applying [\(entityId)]: \(endpoint.host):\(endpoint.port)") + teardownTCPConnectionLocked(entityId: entityId) + startTCPConnection(entityId: entityId, host: endpoint.host, port: endpoint.port) + currentTCPs[entityId] = endpoint } - // Auto: same diff. - if let groupId = configs.autoGroupId { - if currentAutoGroupId == groupId { - // No change. - } else { - NSLog("[EXT] Auto config (re)applying: groupId=\(groupId)") - autoListener?.cancel() - autoListener = nil - startAutoListener(groupId: groupId) - currentAutoGroupId = groupId - } - } else if currentAutoGroupId != nil { - NSLog("[EXT] Auto config removed; tearing down listener") - autoListener?.cancel() - autoListener = nil - currentAutoGroupId = nil + // Tear down entities the app removed. Snapshot the stale ids + // before iterating: `currentTCPs.keys` is a live view over the + // backing dictionary, and `teardownTCPConnectionLocked` + + // `removeValue(forKey:)` below both mutate that dictionary + // (and `tcpConnections` / `tcpReceiveBuffers`) inside the loop. + // Mutating the dictionary while its `Keys` iterator holds an + // index into the hash table is undefined behaviour per the + // Swift docs and can silently skip remaining entries or crash. + let desiredIds = Set(configs.tcps.keys) + let staleIds = currentTCPs.keys.filter { !desiredIds.contains($0) } + for staleId in staleIds { + NSLog("[EXT] TCP config removed [\(staleId)]; tearing down connection") + ExtensionDiagLog.log("[EXT/TCP] removed [\(staleId)]; tearing down") + teardownTCPConnectionLocked(entityId: staleId) + currentTCPs.removeValue(forKey: staleId) } + + // Auto: not tunneled. NEPacketTunnelProvider extensions + // can receive UDP unicast and subscribe to multicast (we + // verified with Mac-side test traffic + tcpdump), but + // cannot send any UDP to the LAN — `NWConnection` reports + // success while the packet silently never reaches the wire, + // POSIX `sendto` returns `ENETUNREACH`, and even responses + // on `NWListener`-accepted connections fail with + // `ECANCELED`. Without outbound UDP we can't HELLO out for + // discovery, can't reverse-peer to known peers, and can't + // reply to peers that send us data — so AutoInterface in + // the extension is fundamentally non-functional. The app's + // local AutoInterface owns Auto for foreground use. + _ = configs.autoGroupId } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { @@ -175,14 +320,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // keeps the existing contract that the completion handler // fires only after teardown has finished. configQueue.sync { - teardownTCPConnectionLocked() - autoListener?.cancel() - autoListener = nil - currentTCP = nil + teardownAllTCPConnectionsLocked() + autoBridge.stop() + currentTCPs.removeAll() currentAutoGroupId = nil } - // Remove the config-changed observer registered in startTunnel. + // Remove all Darwin observers registered in startTunnel. let center = CFNotificationCenterGetDarwinNotifyCenter() let observer = Unmanaged.passUnretained(self).toOpaque() CFNotificationCenterRemoveObserver( @@ -191,19 +335,67 @@ class PacketTunnelProvider: NEPacketTunnelProvider { CFNotificationName(Self.configChangedNotification as CFString), nil ) + CFNotificationCenterRemoveObserver( + center, + observer, + CFNotificationName(Self.localDestinationsChangedNotification as CFString), + nil + ) + CFNotificationCenterRemoveObserver( + center, + observer, + CFNotificationName(Self.tunnelTCPEndpointsChangedNotification as CFString), + nil + ) completionHandler() } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { - // Format: [1-byte interface tag][N-byte HDLC-framed data] + // Format: [1B tag][1B idLen][N idBytes][M HDLC-framed data] + guard messageData.count >= 1 else { + completionHandler?(nil) + return + } + + // Debug commands (tags reserved 0xF0-0xFF). Lets the test + // harness force-reload the extension so it picks up the + // freshly-installed binary without the user toggling the + // VPN profile in iOS Settings. + if messageData[0] == Self.DEBUG_RELOAD_COMMAND { + ExtensionDiagLog.log("[EXT] debug reload requested via handleAppMessage") + completionHandler?(Data([0x01])) + // Killing the tunnel session forces iOS to re-spawn the + // extension process on the next start, picking up the + // new binary on disk. + cancelTunnelWithError(NSError( + domain: "ColumbaDebug", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Debug reload"] + )) + return + } + guard messageData.count >= 2 else { completionHandler?(nil) return } let interfaceTag = messageData[0] - let frameData = messageData.dropFirst() + let idLen = Int(messageData[1]) + guard messageData.count >= 2 + idLen else { + completionHandler?(nil) + return + } + let entityId: String + if idLen > 0 { + let idStart = messageData.index(messageData.startIndex, offsetBy: 2) + let idEnd = messageData.index(idStart, offsetBy: idLen) + entityId = String(data: messageData[idStart..= 19 else { return } + let flags = frame[0] + let headerType = (flags & 0b01000000) >> 6 + let packetType = flags & 0b00000011 + + let destHash: Data + let contextOffset: Int + if headerType == 0 { + // HEADER_1 + destHash = frame.subdata(in: 2..<18) + contextOffset = 18 + } else { + // HEADER_2 — final-destination hash is at offset 18..34. + guard frame.count >= 35 else { return } + destHash = frame.subdata(in: 18..<34) + contextOffset = 34 + } - private func startAutoListener(groupId: String) { - // AutoInterface uses link-local multicast on a well-known group/port - // The discovery and data ports match ReticulumSwift AutoInterface defaults - let discoveryPort: UInt16 = 29716 - let multicastGroup: NWMulticastGroup - do { - multicastGroup = try NWMulticastGroup(for: [ - .hostPort(host: .ipv6(IPv6Address("ff02::1")!), port: NWEndpoint.Port(rawValue: discoveryPort)!) - ]) - } catch { - NSLog("[EXT] Failed to create multicast group: %@", "\(error)") - return + // Cheap envelope-only filter: only fire for packets addressed + // to one of our local LXMF/LXST destinations. Announces are + // addressed to the announcer's own destination, so they never + // match our set — no need to filter them explicitly. + guard localDestinationHashes.contains(destHash) else { return } + + guard frame.count > contextOffset else { return } + let context = frame[contextOffset] + + // packet_type filter (defense-in-depth): + // - DATA(0x00) + context==NONE(0x00) — OPPORTUNISTIC LXMF + // message arrives as a single DATA packet to the + // recipient's delivery hash (see LXMF/LXMessage.py + // __as_packet for OPPORTUNISTIC). + // - LINKREQUEST(0x02) — DIRECT delivery starts with a link + // request to the recipient's delivery hash (see + // LXMF/LXMRouter.py:2660). After the link is established + // the conversation uses the link's own hash, so this is + // the only packet from the DIRECT flow that's addressed + // to us. + // PROOF(0x03) and ANNOUNCE(0x01) are skipped: PROOFs only + // arrive in response to something we sent (no new-message + // signal), and ANNOUNCEs wouldn't match our hash anyway. + let shouldNotify: Bool + switch packetType { + case 0x00: + shouldNotify = (context == 0x00) + case 0x02: + shouldNotify = true + default: + shouldNotify = false } + guard shouldNotify else { return } - let params = NWParameters.udp - params.allowLocalEndpointReuse = true - params.requiredInterfaceType = .other + let destHex = Self.hexString(destHash) + ExtensionDiagLog.log( + "[EXT/NOTIF] match dest=\(destHex.prefix(8)) header=\(headerType) " + + "ptype=\(packetType) ctx=\(context)" + ) + // Tell the notification poster whether this was a LINKREQUEST so + // it can coalesce retries into a single banner. LXMF retries + // DIRECT delivery up to MAX_DELIVERY_ATTEMPTS=5 times with + // DELIVERY_RETRY_WAIT=10s gaps (LXMF/LXMRouter.py:2654), each + // retry sending a fresh LINKREQUEST — without coalescing the + // user sees up to 5 banners for one undelivered message. + ExtensionNotifications.postMessageArrived( + destHashHex: destHex, + isLinkRequest: packetType == 0x02 + ) + } - let group = NWConnectionGroup(with: multicastGroup, using: params) - self.autoListener = group + // MARK: - Diagnostic Listener helpers - group.stateUpdateHandler = { state in - NSLog("[EXT] Auto multicast state: \(state)") - } + private static func hexString(_ data: Data) -> String { + return data.map { String(format: "%02x", $0) }.joined() + } - group.setReceiveHandler(maximumMessageSize: 2048, rejectOversizedMessages: false) { [weak self] message, content, isComplete in - guard let content, !content.isEmpty else { return } + // MARK: - Diagnostic Listener - // Auto frames are complete UDP datagrams (no HDLC framing needed) - self?.frameQueue.append(frame: content, interfaceTag: FrameInterfaceTag.auto.rawValue) - self?.postDarwinNotification() + /// Open a plain `NWListener` on UDP port 9999 with no parameter + /// constraints whatsoever. Logs every state transition + every + /// inbound packet. Lets us answer empirically whether an iOS + /// `NEPacketTunnelProvider` extension can receive inbound UDP + /// unicast at all, instead of speculating about it. + private func startDiagListener() { + guard let port = NWEndpoint.Port(rawValue: 9999) else { return } + let listener: NWListener + do { + listener = try NWListener(using: .udp, on: port) + } catch { + ExtensionDiagLog.log("[EXT/Diag] NWListener init failed: \(error)") + return } + self.diagListener = listener + listener.stateUpdateHandler = { state in + ExtensionDiagLog.log("[EXT/Diag] listener state: \(state)") + } + listener.newConnectionHandler = { conn in + ExtensionDiagLog.log("[EXT/Diag] received from \(conn.endpoint)") + conn.start(queue: .global(qos: .utility)) + conn.receiveMessage { content, _, _, _ in + if let content { + ExtensionDiagLog.log("[EXT/Diag] payload \(content.count)B: \(Self.hexString(Data(content.prefix(32))))") + } + conn.cancel() + } + } + listener.start(queue: .global(qos: .utility)) + ExtensionDiagLog.log("[EXT/Diag] listening on udp/9999") + } - group.start(queue: .main) + // MARK: - Diagnostic Outbound + + /// Send a UDP datagram to a hard-coded Mac test address to + /// verify whether the extension's outbound socket actually + /// reaches the LAN. The Mac's address is the one we use from + /// `tools/auto-test/` (`fe80::c2d:e309:eb09:6343`); listen on + /// the Mac with `nc -lu -6 9998` while running this build. + private func sendDiagOutboundProbe() { + guard let host = IPv6Address("fe80::c2d:e309:eb09:6343"), + let port = NWEndpoint.Port(rawValue: 9998) else { return } + let conn = NWConnection(host: .ipv6(host), port: port, using: .udp) + conn.stateUpdateHandler = { state in + ExtensionDiagLog.log("[EXT/Diag] outbound conn state: \(state)") + if state == .ready { + let payload = "diag-outbound-probe-\(Date().timeIntervalSince1970)".data(using: .utf8)! + conn.send(content: payload, completion: .contentProcessed { error in + if let error { + ExtensionDiagLog.log("[EXT/Diag] outbound probe failed: \(error)") + } else { + ExtensionDiagLog.log("[EXT/Diag] outbound probe sent (\(payload.count)B)") + } + conn.cancel() + }) + } + } + conn.start(queue: .global(qos: .utility)) } // MARK: - HDLC Frame Extraction @@ -453,24 +814,98 @@ class PacketTunnelProvider: NEPacketTunnelProvider { ) } + // MARK: - Local Destinations (for inbound notification filter) + + /// Re-read the locally-registered destination hashes the host + /// app publishes to the App Group and rebuild `localDestinationHashes`. + /// Serialized onto `configQueue` because the inbound TCP handler + /// reads the set from there too and we don't want a Darwin + /// callback arriving on a Mach-port thread mid-frame to race us. + private func reloadLocalDestinations() { + configQueue.async { [weak self] in + guard let self else { return } + let defaults = UserDefaults(suiteName: appGroupIdentifier) ?? .standard + let hexes = defaults.stringArray(forKey: Self.localDestinationsKey) ?? [] + let decoded: Set = Set(hexes.compactMap { Self.dataFromHex($0) }) + self.localDestinationHashes = decoded + ExtensionDiagLog.log("[EXT/NOTIF] localDestinations reloaded count=\(decoded.count)") + } + } + + /// Decode a hex-encoded byte string to `Data`. Returns nil on any + /// non-hex character or odd-length input — both impossible from + /// the host app's hex encoder, but defensive in case App Group + /// prefs ever get hand-edited or written by a stale build. + private static func dataFromHex(_ hex: String) -> Data? { + guard hex.count % 2 == 0 else { return nil } + var out = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. InterfaceConfigs { var result = InterfaceConfigs() + // Preferred: dual-interface tunnel endpoints. + if let data = defaults.data(forKey: Self.tunnelTCPEndpointsKey), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + !array.isEmpty { + for entry in array { + guard let entityId = entry["id"] as? String, + let host = entry["host"] as? String, + let portInt = entry["port"] as? Int, + let port = UInt16(exactly: portInt) else { + continue + } + result.tcps[entityId] = (host: host, port: port) + NSLog("[EXT] Found tunnel TCP endpoint [\(entityId)]: \(host):\(port)") + } + } + + // Always parse `interfacesKey` — needed for AutoInterface + // config, and for the legacy TCP-fallback path when + // `tunnelTCPEndpointsKey` was absent / empty above. guard let data = defaults.data(forKey: Self.interfacesKey) else { - NSLog("[EXT] No interface configs found") + if result.tcps.isEmpty { + NSLog("[EXT] No interface configs found") + } return result } - - // Parse the JSON array — we only need type + config fields guard let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { NSLog("[EXT] Failed to parse interface configs") return result @@ -478,6 +913,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { for entity in array { guard let enabled = entity["enabled"] as? Bool, enabled, + let entityId = entity["id"] as? String, let configWrapper = entity["config"] as? [String: Any], let type = configWrapper["type"] as? String, let config = configWrapper["config"] as? [String: Any] else { @@ -486,10 +922,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { switch type { case "tcpClient": + // Legacy fallback only — skip if the dual-interface + // tunnel key already populated `result.tcps`. + guard result.tcps.isEmpty else { continue } if let host = config["targetHost"] as? String, - let port = config["targetPort"] as? Int { - result.tcp = (host: host, port: UInt16(port)) - NSLog("[EXT] Found TCP config: \(host):\(port)") + let portInt = config["targetPort"] as? Int, + let port = UInt16(exactly: portInt) { + result.tcps[entityId] = (host: host, port: port) + NSLog("[EXT] Found TCP config (legacy) [\(entityId)]: \(host):\(port)") } case "autoInterface": let groupId = config["groupId"] as? String ?? "reticulum" @@ -503,3 +943,63 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return result } } + +/// Local notification poster usable from inside `NEPacketTunnelProvider`. +/// Notifications inherit the host app's bundle identity (extensions are +/// part of the container app's notification authorization domain — per +/// Apple DTS engineer Quinn, eskimo). Authorization is requested by +/// the host app on first launch and the grant transfers here, so the +/// extension never needs `requestAuthorization` of its own. +/// +/// Body is intentionally generic ("New message") because the extension +/// has no crypto key — the ciphertext is opaque to it. When the user +/// taps the notification iOS launches the host app, which then drains +/// `SharedFrameQueue` and (optionally) replaces this generic notification +/// with a per-conversation one carrying the decrypted sender + preview. +enum ExtensionNotifications { + /// Schedule a one-shot notification announcing that a packet + /// addressed to one of our locally-registered destinations + /// arrived while the host app was (likely) suspended. + /// + /// - Parameter destHashHex: 16-byte truncated destination hash, hex-encoded. + /// - Parameter isLinkRequest: True when the source packet was a + /// `LINKREQUEST` (DIRECT-flow link establishment). LXMF retries + /// DIRECT delivery up to `MAX_DELIVERY_ATTEMPTS=5` times with + /// `DELIVERY_RETRY_WAIT=10s` gaps (LXMF/LXMRouter.py:2654), each + /// retry sending a fresh LINKREQUEST packet. To prevent a single + /// undelivered message from generating multiple lock-screen + /// banners, LINKREQUEST notifications use a static + /// `ext-linkreq-` identifier so iOS replaces the prior + /// pending banner instead of stacking. `DATA`-path + /// (OPPORTUNISTIC) notifications keep a timestamp suffix because + /// each carries an independently-delivered message. + static func postMessageArrived(destHashHex: String, isLinkRequest: Bool = false) { + let content = UNMutableNotificationContent() + content.title = "Columba" + content.body = "New message" + content.sound = .default + content.userInfo = [ + "source": "extension", + "destHashHex": destHashHex, + ] + let identifier: String + if isLinkRequest { + identifier = "ext-linkreq-\(destHashHex)" + } else { + let timestamp = Int(Date().timeIntervalSince1970 * 1000) + identifier = "ext-\(destHashHex)-\(timestamp)" + } + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) { error in + if let error { + ExtensionDiagLog.log("[EXT/NOTIF] UN add err: \(error.localizedDescription)") + } else { + ExtensionDiagLog.log("[EXT/NOTIF] UN add ok dest=\(destHashHex.prefix(8))") + } + } + } +} diff --git a/Sources/Shared/SharedFrameQueue.swift b/Sources/Shared/SharedFrameQueue.swift index 9b209373..f7270930 100644 --- a/Sources/Shared/SharedFrameQueue.swift +++ b/Sources/Shared/SharedFrameQueue.swift @@ -5,7 +5,13 @@ // Lock-free append-only queue backed by a shared file in the App Group container. // The Network Extension appends frames; the main app reads and clears them. // -// Frame format: [4-byte length (big-endian)][1-byte interface tag][N-byte frame data] +// Frame format: [4-byte total length (big-endian)][1-byte interface tag] +// [1-byte entityId length][N-byte entityId UTF-8][M-byte frame data] +// +// Total length covers everything after itself (tag + idLen + id + data), so a +// frame's data length is `totalLength - 1 - idLen`. EntityId may be empty +// (idLen = 0) for non-TCP traffic where there's only a single source. +// // Interface tags: 0x01 = TCP, 0x02 = Auto // @@ -36,17 +42,163 @@ public enum SharedDefaultsConstants { /// app's `InterfaceRepository` and the extension's /// `loadInterfaceConfigs` read from this key. public static let interfacesKey = "com.columba.interfaces" + + /// Shared UserDefaults key for the user's "Background Transport" + /// preference. Persisted across launches so `AppServices` can + /// auto-restart the tunnel without waiting for the user to + /// re-toggle every relaunch. Written by the Settings toggle and + /// by the onboarding step; read by `AppServices.initialize()`. + public static let tunnelEnabledKey = "com.columba.tunnelEnabled" + + /// Shared UserDefaults key carrying the set of locally-registered + /// LXMF/LXST destination hashes as `[String]` of hex-encoded + /// 16-byte truncated hashes. Written by the host app every time + /// the registered-destination set changes; read by the + /// `PacketTunnelProvider` extension on tunnel start and on the + /// matching Darwin reload notification below. The extension uses + /// the set to decide whether an inbound packet's destination + /// matches one of our local endpoints and therefore warrants + /// scheduling a user-visible notification while the host app + /// is suspended. + public static let localDestinationsKey = "com.columba.localDestinations" + + /// Darwin notification posted by the host app whenever it writes a + /// fresh value to `localDestinationsKey`. The extension observes + /// this so it re-reads the set without restarting the tunnel — + /// mirrors the existing `configChangedNotificationName` pattern + /// used for interface-config edits. + public static let localDestinationsChangedNotificationName = "network.columba.localDestinationsChanged" + + /// Shared UserDefaults key carrying the TCP endpoints the + /// `PacketTunnelProvider` should connect to for the + /// **dual-interface tunnel architecture**. JSON-encoded + /// `[{id: String, host: String, port: Int}]`. Replaces the + /// extension's previous reliance on the foreground app's TCP + /// entries in `interfacesKey`: + /// + /// In the dual-interface model, the host app's foreground + /// `TCPInterface` owns its own `NWConnection` in-process and is + /// never tunneled. The extension's `NWConnection` is a *separate* + /// TCP path to rnsd, registered with the transport as a + /// `TunnelTCPInterface`. Both interfaces send announces, but the + /// tunnel's announce arrives at rnsd most recently (extra + /// extension hop adds a few ms of latency), so rnsd's path table + /// last-write-wins ends up pointing at the tunnel socket. When + /// the host app suspends, the foreground socket dies but the + /// tunnel socket stays alive, and rnsd keeps routing to it. + /// + /// When this key is absent or empty, the extension falls back to + /// the legacy behaviour of opening connections for every enabled + /// TCP entry in `interfacesKey` (preserves the pre-dual-interface + /// multi-TCP tunnel work). + public static let tunnelTCPEndpointsKey = "com.columba.tunnelTCPEndpoints" + + /// Darwin notification posted by the host app whenever it writes a + /// fresh value to `tunnelTCPEndpointsKey`. The extension re-reads + /// the list and reapplies its TCP connections. + public static let tunnelTCPEndpointsChangedNotificationName = "network.columba.tunnelTCPEndpointsChanged" } +/// Append-only log file in the App Group container. Both the app and +/// the Network Extension can write to it; the extension uses it +/// because `os.log` from extensions is hard to surface (no +/// `NSLog`-equivalent that lands in the app's `DiagLog`). The app +/// can copy it into its Documents/diag.log on next foreground for +/// `xcrun devicectl device copy from` retrieval. +/// +/// The log is bounded at `maxBytes` with a tail-keep rotation: when +/// a write would push the file past the cap, the oldest ~half of +/// the file is dropped. The extension is always-on by design and +/// `ExtensionAutoBridge` logs every received packet / HELLO beacon, +/// so an unbounded file would eventually exhaust the App Group +/// container and silently break `SharedFrameQueue.append` (same +/// container, different file). +public enum ExtensionDiagLog { + /// Soft cap on the on-disk log size. When exceeded, the oldest + /// ~half of the file is dropped on the next write. 1 MiB chosen + /// to comfortably exceed a single test session's output while + /// being small relative to the App Group quota. + public static let maxBytes: UInt64 = 1 * 1024 * 1024 + + /// Compute on every call so a transient `containerURL` failure + /// at static-init time doesn't permanently disable logging. + /// Writes under `Library/Caches/` which is already auto-created + /// in the App Group container — writing to the container root + /// silently fails on at least some iOS versions when invoked + /// from a Network Extension process. + private static func resolveLogURL() -> URL? { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) else { return nil } + let cachesDir = containerURL.appendingPathComponent("Library/Caches", isDirectory: true) + try? FileManager.default.createDirectory(at: cachesDir, withIntermediateDirectories: true) + return cachesDir.appendingPathComponent("ext_diag.log") + } + + /// Drop the oldest ~half of the log when it exceeds `maxBytes`. + /// Best-effort; if any step fails we silently leave the file + /// alone — losing a rotation is preferable to losing a log line. + private static func rotateIfNeeded(at url: URL) { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? UInt64, + size > maxBytes else { return } + guard let data = try? Data(contentsOf: url) else { return } + // Keep the newest half; align to the next newline so we + // don't truncate mid-line. + let cutoff = data.count / 2 + var start = cutoff + while start < data.count, data[start] != 0x0A /* \n */ { start += 1 } + if start < data.count { start += 1 } // skip the newline itself + let tail = data[start..> 24) & 0xFF) - header[1] = UInt8((length >> 16) & 0xFF) - header[2] = UInt8((length >> 8) & 0xFF) - header[3] = UInt8(length & 0xFF) + // 4-byte big-endian total length (everything after these 4 bytes) + header[0] = UInt8((dataLen >> 24) & 0xFF) + header[1] = UInt8((dataLen >> 16) & 0xFF) + header[2] = UInt8((dataLen >> 8) & 0xFF) + header[3] = UInt8(dataLen & 0xFF) // 1-byte interface tag header[4] = interfaceTag + // 1-byte entityId length + header[5] = idLen withFileLock { let fh: FileHandle @@ -127,6 +291,9 @@ public final class SharedFrameQueue: @unchecked Sendable { } fh.seekToEndOfFile() fh.write(header) + if !idBytes.isEmpty { + fh.write(Data(idBytes)) + } fh.write(frame) fh.closeFile() } @@ -151,23 +318,37 @@ public final class SharedFrameQueue: @unchecked Sendable { // Parse frames var offset = 0 while offset + Self.headerSize <= data.count { - let length = Int( + let totalLen = Int( (UInt32(data[offset]) << 24) | (UInt32(data[offset + 1]) << 16) | (UInt32(data[offset + 2]) << 8) | UInt32(data[offset + 3]) ) let tag = data[offset + 4] + let idLen = Int(data[offset + 5]) offset += Self.headerSize - guard offset + length <= data.count else { - // Truncated frame — stop parsing + // totalLen covers the idLen byte (already consumed) + id bytes + frame data. + // Frame data length is therefore totalLen - 1 - idLen. + guard idLen <= totalLen - 1, + offset + idLen + (totalLen - 1 - idLen) <= data.count else { + // Truncated or malformed frame — stop parsing break } - let frameData = data[offset..<(offset + length)] - frames.append(QueuedFrame(interfaceTag: tag, data: Data(frameData))) - offset += length + let entityId: String + if idLen > 0 { + let idBytes = data[offset..<(offset + idLen)] + entityId = String(data: Data(idBytes), encoding: .utf8) ?? "" + } else { + entityId = "" + } + offset += idLen + + let frameLen = totalLen - 1 - idLen + let frameData = data[offset..<(offset + frameLen)] + frames.append(QueuedFrame(interfaceTag: tag, entityId: entityId, data: Data(frameData))) + offset += frameLen } // Truncate the file diff --git a/Tests/ColumbaAppTests/AutoAnnouncePolicyTests.swift b/Tests/ColumbaAppTests/AutoAnnouncePolicyTests.swift index 63e8b7f1..94ac35f7 100644 --- a/Tests/ColumbaAppTests/AutoAnnouncePolicyTests.swift +++ b/Tests/ColumbaAppTests/AutoAnnouncePolicyTests.swift @@ -117,21 +117,25 @@ final class AutoAnnouncePolicyTests: XCTestCase { // MARK: - Empty defaults - /// On a fresh install the keys aren't in the suite at all. UserDefaults.bool(forKey:) - /// returns false for absent keys — so policy must report all-off when nothing has - /// been registered or set. (Production code uses `register(defaults:)` in - /// SettingsViewModel.loadLocalSettings to default these to true; that registration - /// is on UserDefaults.standard, not on a per-suite scratch defaults, so this test - /// validates the *raw* read behavior.) - func testEmptyDefaultsReportsAllOff() { + /// Production registers the four auto_announce_* defaults to + /// `true` at app launch (`ColumbaApp.init` → `SettingsViewModel + /// .registerLocalDefaults`). The registration domain is + /// process-wide — `UserDefaults(suiteName:)` instances inherit + /// it. The XCTest host loads the @main App, so by the time this + /// test runs the per-suite scratch defaults read all four + /// toggles as on. This regression test pins that wire so a + /// future refactor that drops the app-init registration call + /// fails loudly instead of silently regressing every fresh + /// install to no-auto-announce. + func testEmptyPerSuiteInheritsProcessWideRegistrationAsAllOn() { let p = AutoAnnouncePolicy.current(defaults: defaults) - XCTAssertFalse(p.masterEnabled) - XCTAssertFalse(p.onInterval) - XCTAssertFalse(p.onTcpReconnect) - XCTAssertFalse(p.onPeerSpawned) - XCTAssertFalse(p.shouldFireOnInterval) - XCTAssertFalse(p.shouldFireOnTcpReconnect) - XCTAssertFalse(p.shouldFireOnPeerSpawned) + XCTAssertTrue(p.masterEnabled, "process-wide registration domain leaks to per-suite UserDefaults; production needs this for on-reconnect announce") + XCTAssertTrue(p.onInterval) + XCTAssertTrue(p.onTcpReconnect) + XCTAssertTrue(p.onPeerSpawned) + XCTAssertTrue(p.shouldFireOnInterval) + XCTAssertTrue(p.shouldFireOnTcpReconnect) + XCTAssertTrue(p.shouldFireOnPeerSpawned) } // MARK: - Snapshot semantics diff --git a/tools/auto-test/run_test.sh b/tools/auto-test/run_test.sh new file mode 100755 index 00000000..33daf317 --- /dev/null +++ b/tools/auto-test/run_test.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Drive an end-to-end check of the Columba extension's AutoInterface. +# +# Prereqs (run once): +# - Mac on the same Wi-Fi as the iPhone +# - iPhone paired via `xcrun devicectl` (Wi-Fi pairing OK) +# - VPN profile installed and Background Transport ON in Columba +# - $DEVICE_UDID set or passed via `--device` (no default — UDID is +# personally-identifying and not tracked in source) +# - $DEVELOPMENT_TEAM set to your Apple Developer Team ID (no default) +# +# What this does (each iteration): +# 1. Build + install the latest Columba.app +# 2. Relaunch the app (`devicectl process launch --terminate-existing`) +# — this will trigger auto-restart of the tunnel via the saved +# pref. Note: iOS keeps the running extension instance across +# app reinstalls, so the new extension binary may not load +# until the user manually deletes/re-adds the VPN profile or +# we add a programmatic force-reload (see TODO in this script). +# 3. Wait for the tunnel to settle. +# 4. Send AutoInterface-shaped test traffic from this Mac: +# - 3 multicast HELLO beacons to ff12:0:… port 29716 +# - 1 unicast announce-shaped UDP packet to iPhone:42671 +# 5. Wait briefly for the extension to log + queue any received +# packets. +# 6. Pull `Library/Caches/ext_diag.log` from the App Group container. +# 7. Pull `Documents/diag.log` from the app container. +# 8. Verify the expected log entries are present. +# +# Exit code is 0 if all expected entries are present, non-zero +# otherwise — usable in CI / a watch loop. +# +# TODO (not blocking): bake a "/debug/reload-extension" handleAppMessage +# command into the extension that calls `cancelTunnelWithError`, so +# this script can force the new binary to load without the user +# tapping anything in iOS Settings. + +set -euo pipefail + +# DEVICE_UDID and DEVELOPMENT_TEAM must be supplied by the contributor. +# We deliberately don't ship defaults — both are personally-identifying +# values (a specific physical device UDID; an Apple Developer Team ID) +# that should not be tracked in source control. Set them via env or +# pass `--device ` for the UDID. +DEVICE_UDID="${DEVICE_UDID:-}" +DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-}" +PROJECT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +APP_BUNDLE_ID="network.columba.Columba" +APP_GROUP_ID="group.network.columba.Columba" +# Resolve `DERIVED` from xcodebuild's BUILD_DIR rather than baking in +# a DerivedData hash — Xcode regenerates that hash on rename / clone / +# fresh checkout, so the literal path was breaking even on Tyler's +# machine after a re-clone, and never worked for any other contributor. +# Override DERIVED via env to skip this query (e.g. when --skip-build +# is used). +if [[ -z "${DERIVED:-}" ]]; then + DERIVED_BUILD_DIR=$(cd "$PROJECT_DIR" && xcodebuild \ + -project Columba.xcodeproj -scheme Columba \ + -configuration Debug -sdk iphoneos \ + -showBuildSettings 2>/dev/null \ + | awk '/^[[:space:]]*BUILD_DIR = /{print $3; exit}') + if [[ -z "$DERIVED_BUILD_DIR" ]]; then + echo "ERROR: could not resolve BUILD_DIR from xcodebuild — set \$DERIVED manually." >&2 + exit 1 + fi + DERIVED="$DERIVED_BUILD_DIR/Debug-iphoneos/ColumbaApp.app" +fi + +usage() { + cat < or export DEVICE_UDID." + exit 1 +fi + +if [[ "$SKIP_BUILD" == "0" && -z "$DEVELOPMENT_TEAM" ]]; then + echo "ERROR: \$DEVELOPMENT_TEAM not set. Export DEVELOPMENT_TEAM with your Apple Developer Team ID." + echo " (skip with --skip-build if you already have a built .app at \$DERIVED)" + exit 1 +fi + +cd "$PROJECT_DIR" + +if [[ "$SKIP_BUILD" == "0" ]]; then + echo "[1/8] Building..." + xcodebuild build -project Columba.xcodeproj -scheme Columba \ + -configuration Debug -sdk iphoneos \ + -destination "id=$DEVICE_UDID" \ + CODE_SIGN_STYLE=Automatic "DEVELOPMENT_TEAM=$DEVELOPMENT_TEAM" \ + -allowProvisioningUpdates 2>&1 | tail -3 + + echo "[2/8] Installing..." + xcrun devicectl device install app --device "$DEVICE_UDID" \ + "$DERIVED" 2>&1 | tail -2 + + echo "[3/8] Relaunching app..." + xcrun devicectl device process launch --device "$DEVICE_UDID" \ + --terminate-existing "$APP_BUNDLE_ID" 2>&1 | tail -1 +fi + +echo "[4/8] Waiting 8s for tunnel to settle..." +sleep 8 + +if [[ "$SKIP_TRAFFIC" == "0" ]]; then + echo "[5/8] Sending test traffic..." + python3 "$(dirname "$0")/send_test_traffic.py" \ + --iface en0 --target-ip "$TARGET_IP" + + echo "[6/8] Waiting 4s for extension to log..." + sleep 4 +fi + +echo "[7/8] Pulling logs..." +mkdir -p /tmp/columba-test +xcrun devicectl device copy from --device "$DEVICE_UDID" \ + --domain-type appGroupDataContainer --domain-identifier "$APP_GROUP_ID" \ + --source Library/Caches/ext_diag.log \ + --destination /tmp/columba-test/ext_diag.log 2>&1 | tail -1 +xcrun devicectl device copy from --device "$DEVICE_UDID" \ + --domain-type appDataContainer --domain-identifier "$APP_BUNDLE_ID" \ + --source Documents/diag.log \ + --destination /tmp/columba-test/diag.log 2>&1 | tail -1 + +echo "[8/8] Verifying..." +fail=0 + +# Tunnel must be up. +if ! grep -q "enabled tunnel mode" /tmp/columba-test/diag.log; then + echo " FAIL: tunnel never reached enabled state" + fail=1 +else + echo " OK: tunnel reached enabled state" +fi + +# When auto is in tunnel mode (future), expect HELLO and listener +# accept; for now, just confirm extension wrote anything. +if [[ -s /tmp/columba-test/ext_diag.log ]]; then + echo " OK: extension diag log written ($(wc -l < /tmp/columba-test/ext_diag.log) lines)" +else + echo " WARN: extension diag log empty" +fi + +# When auto-in-extension is back, uncomment: +#if grep -q "data listener accepted" /tmp/columba-test/ext_diag.log; then +# echo " OK: extension's NWListener accepted inbound from $TARGET_IP" +#else +# echo " FAIL: extension's NWListener never saw inbound — sandbox probably blocking" +# fail=1 +#fi + +if [[ "$fail" == "1" ]]; then + echo + echo "FAIL" + exit 1 +fi +echo +echo "OK" diff --git a/tools/auto-test/send_test_traffic.py b/tools/auto-test/send_test_traffic.py new file mode 100755 index 00000000..50f01bbf --- /dev/null +++ b/tools/auto-test/send_test_traffic.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Send AutoInterface-shaped test traffic to a Columba iPhone over the +LAN — multicast HELLO discovery beacons (so iOS spawns a peer for +us) plus a unicast announce-shaped UDP packet on the data port. + +Used by `run_test.sh` to drive an automated end-to-end check of the +Columba extension's AutoInterface implementation. Run from a Mac +that's on the same Wi-Fi as the iPhone. + +Usage: + python3 send_test_traffic.py --iface en0 \\ + --target-ip fe80::14cb:9def:5400:73b9 \\ + --group-id reticulum \\ + [--data-port 42671] [--discovery-port 29716] + +Mirrors reticulum-swift's `AutoInterfaceConstants`: +- multicast group: ff12:0:<6 segments derived from SHA256(groupId)> +- discovery token: SHA256(groupId + sourceAddressString) +- discovery port: 29716, data port: 42671 +""" + +import argparse +import hashlib +import os +import socket +import struct +import sys +import time + + +def derive_multicast_address(group_id: str) -> str: + """Mirror AutoInterfaceConstants.multicastAddress(for:).""" + h = hashlib.sha256(group_id.encode("utf-8")).digest() + # 6 segments from bytes 2..13, little-endian pair + segments = [] + for i in range(6): + lo = h[2 + i * 2 + 1] + hi = h[2 + i * 2] << 8 + segments.append(format(lo + hi, "x")) + return "ff12:0:" + ":".join(segments) + + +def derive_discovery_token(group_id: str, address: str) -> bytes: + """Mirror AutoInterfaceConstants.discoveryToken(groupId:address:).""" + return hashlib.sha256(group_id.encode("utf-8") + address.encode("utf-8")).digest() + + +def get_iface_link_local(iface: str) -> str: + """Pick the first IPv6 link-local address on the given interface.""" + import ipaddress + import subprocess + + out = subprocess.check_output(["ifconfig", iface]).decode() + for line in out.splitlines(): + line = line.strip() + if line.startswith("inet6 fe80"): + addr = line.split()[1].split("%")[0] + return addr + raise RuntimeError(f"no link-local address found on {iface}") + + +def send_multicast_hello(group_addr: str, port: int, token: bytes, + iface_idx: int) -> None: + """Send a HELLO beacon to the multicast group on the chosen interface.""" + sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + # Bind multicast send to the chosen interface so iOS sees us + # arriving on en0 (the Wi-Fi side they're on). + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, + struct.pack("I", iface_idx)) + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 1) + addr_info = socket.getaddrinfo(group_addr, port, socket.AF_INET6, + socket.SOCK_DGRAM)[0] + sockaddr = addr_info[4] + sock.sendto(token, sockaddr) + sock.close() + + +def send_unicast_data(target_ip: str, port: int, payload: bytes, + iface: str) -> None: + """Send a UDP datagram to the iPhone's data port.""" + sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + # Build sockaddr_in6 with the scope id for the link-local target. + addr_info = socket.getaddrinfo(f"{target_ip}%{iface}", port, + socket.AF_INET6, socket.SOCK_DGRAM)[0] + sockaddr = addr_info[4] + sock.sendto(payload, sockaddr) + sock.close() + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--iface", default="en0", + help="local Wi-Fi interface (default en0)") + p.add_argument("--target-ip", required=True, + help="iPhone's link-local IPv6 (without scope)") + p.add_argument("--group-id", default="reticulum", + help="AutoInterface group id (default 'reticulum')") + p.add_argument("--discovery-port", type=int, default=29716) + p.add_argument("--data-port", type=int, default=42671) + p.add_argument("--n-hellos", type=int, default=3, + help="number of HELLO beacons to send") + p.add_argument("--data-payload-bytes", type=int, default=128, + help="size of the unicast test packet") + args = p.parse_args() + + multicast_addr = derive_multicast_address(args.group_id) + own_addr = get_iface_link_local(args.iface) + token = derive_discovery_token(args.group_id, own_addr) + iface_idx = socket.if_nametoindex(args.iface) + + print(f"local link-local : {own_addr}") + print(f"multicast group : {multicast_addr}") + print(f"discovery token : {token.hex()}") + print(f"target : {args.target_ip}%{args.iface}") + print() + + for i in range(args.n_hellos): + send_multicast_hello(multicast_addr, args.discovery_port, token, + iface_idx) + print(f" HELLO #{i + 1} sent") + time.sleep(0.5) + + # Distinctive payload — first 4 bytes are an ASCII tag the verifier + # can grep for in the iOS log if we ever decide to dump received + # bytes there. + payload = b"COLB" + os.urandom(args.data_payload_bytes - 4) + send_unicast_data(args.target_ip, args.data_port, payload, args.iface) + print(f" unicast test packet ({len(payload)}B) sent to " + f"{args.target_ip}%{args.iface}:{args.data_port}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())