diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index 667cc12..d6d1bde 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ T001 /* AudioRingBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT01 /* AudioRingBufferTests.swift */; }; T002 /* AudioManagerConfigChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT02 /* AudioManagerConfigChangeTests.swift */; }; T004 /* CallManagerCallKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT04 /* CallManagerCallKitTests.swift */; }; + T005 /* MapStyleURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT05 /* MapStyleURLTests.swift */; }; 001 /* ColumbaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F001 /* ColumbaApp.swift */; }; 002 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = F002 /* Theme.swift */; }; 003 /* ChatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F003 /* ChatsViewModel.swift */; }; @@ -49,8 +50,12 @@ 037 /* ProfileIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F037 /* ProfileIcon.swift */; }; 038 /* IconPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F038 /* IconPickerView.swift */; }; 039 /* materialdesignicons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F039 /* materialdesignicons.ttf */; }; + FNT1 /* JetBrainsMono-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FNT1F /* JetBrainsMono-Regular.ttf */; }; + FNT2 /* JetBrainsMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FNT2F /* JetBrainsMono-Bold.ttf */; }; 040 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F040 /* NotificationService.swift */; }; 041 /* AutoAnnounceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F041 /* AutoAnnounceManager.swift */; }; + 041P /* AutoAnnouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F041P /* AutoAnnouncePolicy.swift */; }; + 041R /* PeerChildInterfaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F041R /* PeerChildInterfaceRegistry.swift */; }; 042 /* LocalIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F042 /* LocalIdentity.swift */; }; 043 /* IdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F043 /* IdentityManager.swift */; }; 044 /* IdentityManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F044 /* IdentityManagerView.swift */; }; @@ -118,14 +123,21 @@ 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 = F086 /* BackgroundTransportPage.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 */; }; T003 /* MicronParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT03 /* MicronParserTests.swift */; }; + 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 */ @@ -164,6 +176,9 @@ FT02 /* AudioManagerConfigChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioManagerConfigChangeTests.swift; sourceTree = ""; }; FT04 /* CallManagerCallKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManagerCallKitTests.swift; sourceTree = ""; }; FT03 /* MicronParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronParserTests.swift; sourceTree = ""; }; + FTAA /* AutoAnnouncePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoAnnouncePolicyTests.swift; sourceTree = ""; }; + FTPC /* PeerChildInterfaceRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChildInterfaceRegistryTests.swift; sourceTree = ""; }; + FT05 /* MapStyleURLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapStyleURLTests.swift; sourceTree = ""; }; TPROD /* ColumbaAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ColumbaAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EPROD /* ColumbaNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ColumbaNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F001 /* ColumbaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumbaApp.swift; sourceTree = ""; }; @@ -205,8 +220,12 @@ F037 /* ProfileIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileIcon.swift; sourceTree = ""; }; F038 /* IconPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconPickerView.swift; sourceTree = ""; }; F039 /* materialdesignicons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = materialdesignicons.ttf; sourceTree = ""; }; + FNT1F /* JetBrainsMono-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "JetBrainsMono-Regular.ttf"; sourceTree = ""; }; + FNT2F /* JetBrainsMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "JetBrainsMono-Bold.ttf"; sourceTree = ""; }; F040 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; F041 /* AutoAnnounceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoAnnounceManager.swift; sourceTree = ""; }; + F041P /* AutoAnnouncePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoAnnouncePolicy.swift; sourceTree = ""; }; + F041R /* PeerChildInterfaceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChildInterfaceRegistry.swift; sourceTree = ""; }; F042 /* LocalIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalIdentity.swift; sourceTree = ""; }; F043 /* IdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManager.swift; sourceTree = ""; }; F044 /* IdentityManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManagerView.swift; sourceTree = ""; }; @@ -273,7 +292,10 @@ 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 = ""; }; - F086 /* BackgroundTransportPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTransportPage.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 = ""; }; F07B /* Config/Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/Signing.xcconfig; sourceTree = SOURCE_ROOT; }; F07C /* Config/LocalSigning.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/LocalSigning.xcconfig.example; sourceTree = SOURCE_ROOT; }; FE01 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; @@ -281,6 +303,8 @@ 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 */ @@ -421,7 +445,7 @@ F04F /* ConnectivityPage.swift */, F050 /* PermissionsPage.swift */, F051 /* CompletePage.swift */, - F086 /* BackgroundTransportPage.swift */, + F088 /* BackgroundTransportPage.swift */, F079 /* OnboardingRestoreSheet.swift */, ); path = Onboarding; @@ -445,6 +469,8 @@ F022 /* Assets.xcassets */, F023 /* Info.plist */, F039 /* materialdesignicons.ttf */, + FNT1F /* JetBrainsMono-Regular.ttf */, + FNT2F /* JetBrainsMono-Bold.ttf */, F075 /* ColumbaApp.entitlements */, ); path = Resources; @@ -480,6 +506,7 @@ F066 /* AppearanceCard.swift */, F067 /* CustomThemeEditorView.swift */, F071 /* BLEConnectionsView.swift */, + F087 /* TCPClientWizard.swift */, GRNW /* RNodeWizard */, ); path = Settings; @@ -505,6 +532,8 @@ F033 /* PropagationNodeManager.swift */, F040 /* NotificationService.swift */, F041 /* AutoAnnounceManager.swift */, + F041P /* AutoAnnouncePolicy.swift */, + F041R /* PeerChildInterfaceRegistry.swift */, F042 /* LocalIdentity.swift */, F043 /* IdentityManager.swift */, F04B /* LocationSharingManager.swift */, @@ -564,6 +593,7 @@ F05A /* RNodeWizardViewModel.swift */, F05F /* MigrationViewModel.swift */, F080 /* NomadNetBrowserViewModel.swift */, + F086 /* TCPClientWizardViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -585,6 +615,10 @@ FT02 /* AudioManagerConfigChangeTests.swift */, FT03 /* MicronParserTests.swift */, FT04 /* CallManagerCallKitTests.swift */, + FTAA /* AutoAnnouncePolicyTests.swift */, + FTPC /* PeerChildInterfaceRegistryTests.swift */, + FT05 /* MapStyleURLTests.swift */, + FT06 /* TCPClientWizardViewModelTests.swift */, ); path = Tests/ColumbaAppTests; sourceTree = ""; @@ -612,10 +646,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 */ @@ -735,6 +779,8 @@ files = ( 022 /* Assets.xcassets in Resources */, 039 /* materialdesignicons.ttf in Resources */, + FNT1 /* JetBrainsMono-Regular.ttf in Resources */, + FNT2 /* JetBrainsMono-Bold.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -762,6 +808,10 @@ T002 /* AudioManagerConfigChangeTests.swift in Sources */, T003 /* MicronParserTests.swift in Sources */, T004 /* CallManagerCallKitTests.swift in Sources */, + TAA0 /* AutoAnnouncePolicyTests.swift in Sources */, + TPCR /* PeerChildInterfaceRegistryTests.swift in Sources */, + T005 /* MapStyleURLTests.swift in Sources */, + T006 /* TCPClientWizardViewModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -819,6 +869,8 @@ 038 /* IconPickerView.swift in Sources */, 040 /* NotificationService.swift in Sources */, 041 /* AutoAnnounceManager.swift in Sources */, + 041P /* AutoAnnouncePolicy.swift in Sources */, + 041R /* PeerChildInterfaceRegistry.swift in Sources */, 042 /* LocalIdentity.swift in Sources */, 043 /* IdentityManager.swift in Sources */, 044 /* IdentityManagerView.swift in Sources */, @@ -883,6 +935,10 @@ 082B /* MicronRenderContainer.swift in Sources */, 083B /* MonospaceLineView.swift in Sources */, 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; }; @@ -1229,7 +1285,7 @@ repositoryURL = "https://github.com/torlando-tech/LXMF-swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.3.4; + minimumVersion = 0.4.0; }; }; PKGREF2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = { @@ -1253,7 +1309,7 @@ repositoryURL = "https://github.com/torlando-tech/reticulum-swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.2.3; + minimumVersion = 0.3.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2303492..eefca79 100644 --- a/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/torlando-tech/LXMF-swift.git", "state" : { - "revision" : "b31a14a8fe9ab9626ce9b333d5978098910b54ea", - "version" : "0.3.4" + "revision" : "21f877614181800116013771dcab163b08c113fc", + "version" : "0.4.0" } }, { @@ -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" : "b8ae6491d5ca62db30b097382cdf8553edda9b92", - "version" : "0.2.3" + "revision" : "034d9c7570c7428ebe5daab1ee1b8d17fc1e9c87", + "version" : "0.3.0" } }, { diff --git a/Package.swift b/Package.swift index 263a550..ef7ac2c 100644 --- a/Package.swift +++ b/Package.swift @@ -20,9 +20,9 @@ let package = Package( // `.swiftpm/configuration/mirrors.json` mapping the URL to a local // directory — see README "Local development against unreleased // library changes" for the exact recipe. - .package(url: "https://github.com/torlando-tech/LXMF-swift.git", from: "0.3.0"), + .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.2.0"), + .package(url: "https://github.com/torlando-tech/reticulum-swift.git", from: "0.3.0"), .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 d5285da..39b2224 100644 --- a/Sources/ColumbaApp/App/ColumbaApp.swift +++ b/Sources/ColumbaApp/App/ColumbaApp.swift @@ -64,6 +64,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 } @@ -519,6 +530,13 @@ struct RootView: View { self.isInitialized = true + #if DEBUG + // Wire the test-harness surface to the live AppServices. + // No-op in release: the entire TestURLHandler / TestController + // graph is `#if DEBUG`-gated. + TestURLHandler.bind(appServices: appServices) + #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/MicronParser.swift b/Sources/ColumbaApp/Models/MicronParser.swift index 119e7cd..9f2a1eb 100644 --- a/Sources/ColumbaApp/Models/MicronParser.swift +++ b/Sources/ColumbaApp/Models/MicronParser.swift @@ -12,6 +12,11 @@ public struct MicronParser { var literalLines: [String] = [] var currentIndent = 0 var currentAlignment: MicronAlignment = .left + // Formatting state persists across lines (matches python NomadNet's + // MicronParser, where `!/`*/`_/`Fxxx/`Bxxx are document-scoped until + // toggled off or reset). Without this the chat-room page's + // `F0ff`B52f preamble drops its colors before the ASCII art. + var currentStyle: MicronTextStyle = .plain // Parse headers from top of document while lineIndex < lines.count { @@ -68,7 +73,8 @@ public struct MicronParser { if content.isEmpty { continue } - let (spans, alignment, fields) = parseInline(content, currentStyle: .plain, currentAlignment: currentAlignment) + let (spans, alignment, fields, updatedStyle) = parseInline(content, currentStyle: currentStyle, currentAlignment: currentAlignment) + currentStyle = updatedStyle if let alignment = alignment { currentAlignment = alignment } elements.append(.heading(level: headingLevel, spans: spans, alignment: currentAlignment)) for field in fields { elements.append(.formField(field)) } @@ -83,12 +89,16 @@ public struct MicronParser { continue } - // Reset indent + // Reset indent — also resets formatting state to plain, matching + // python NomadNet's `<` semantics where the line restarts parsing + // from a default state. if firstChar == "<" { currentIndent = 0 + currentStyle = .plain let rest = String(line.dropFirst()) if !rest.isEmpty { - let (spans, alignment, fields) = parseInline(rest, currentStyle: .plain, currentAlignment: currentAlignment) + let (spans, alignment, fields, updatedStyle) = parseInline(rest, currentStyle: currentStyle, currentAlignment: currentAlignment) + currentStyle = updatedStyle if let alignment = alignment { currentAlignment = alignment } elements.append(.paragraph(spans: spans, alignment: currentAlignment, indentLevel: currentIndent)) for field in fields { elements.append(.formField(field)) } @@ -112,7 +122,8 @@ public struct MicronParser { } // Regular paragraph — parse inline formatting - let (spans, alignment, fields) = parseInline(line, currentStyle: .plain, currentAlignment: currentAlignment) + let (spans, alignment, fields, updatedStyle) = parseInline(line, currentStyle: currentStyle, currentAlignment: currentAlignment) + currentStyle = updatedStyle if let alignment = alignment { currentAlignment = alignment } elements.append(.paragraph(spans: spans, alignment: currentAlignment, indentLevel: currentIndent)) for field in fields { elements.append(.formField(field)) } @@ -142,12 +153,14 @@ public struct MicronParser { // MARK: - Inline Parsing /// Parse inline formatting within a line of text. - /// Returns parsed spans, any alignment change detected, and any form fields found. + /// Returns parsed spans, any alignment change detected, any form fields found, + /// and the formatting style at the end of the line so callers can carry it + /// forward (matches python NomadNet's document-scoped formatting state). private static func parseInline( _ text: String, currentStyle: MicronTextStyle, currentAlignment: MicronAlignment - ) -> ([MicronSpan], MicronAlignment?, [MicronFormField]) { + ) -> ([MicronSpan], MicronAlignment?, [MicronFormField], MicronTextStyle) { var spans: [MicronSpan] = [] var style = currentStyle var alignment: MicronAlignment? = nil @@ -321,7 +334,7 @@ public struct MicronParser { } flushBuffer() - return (spans, alignment, formFields) + return (spans, alignment, formFields, style) } // MARK: - Form Field Parsing diff --git a/Sources/ColumbaApp/Models/TcpCommunityServer.swift b/Sources/ColumbaApp/Models/TcpCommunityServer.swift index 3db53b9..5c4abd2 100644 --- a/Sources/ColumbaApp/Models/TcpCommunityServer.swift +++ b/Sources/ColumbaApp/Models/TcpCommunityServer.swift @@ -25,24 +25,41 @@ struct TcpCommunityServer: Identifiable { extension TcpCommunityServer { /// Curated list of public Reticulum transport nodes. /// - /// Sourced from Android Columba's `TcpCommunityServers.kt`. - /// Bootstrap servers are preferred for first-time connections. + /// Sourced from Android Columba's `TcpCommunityServer.kt`. Keep this list + /// in sync with `app/src/main/java/network/columba/app/data/model/TcpCommunityServer.kt`. + /// Up-to-date community directories: directory.rns.recipes, rmap.world. static let servers: [TcpCommunityServer] = [ - // Bootstrap servers + // Bootstrap-class servers (well-established, reliable nodes). + // Reticulum-Swift does not yet support the bootstrap interface mode, + // so the iOS UI surfaces these alongside other community servers. TcpCommunityServer(name: "Beleth RNS Hub", host: "rns.beleth.net", port: 4242, isBootstrap: true), - TcpCommunityServer(name: "Quad4 RNS", host: "rns.quad4.io", port: 4242, isBootstrap: true), - TcpCommunityServer(name: "FireZen Hub", host: "reticulum.firezen.xyz", port: 4242, isBootstrap: true), + TcpCommunityServer(name: "Quad4 TCP Node 1", host: "rns.quad4.io", port: 4242, isBootstrap: true), + TcpCommunityServer(name: "FireZen", host: "firezen.com", port: 4242, isBootstrap: true), // Community servers - TcpCommunityServer(name: "RNS Amsterdam", host: "amsterdam.connect.reticulum.network", port: 4965, isBootstrap: false), - TcpCommunityServer(name: "RNS BetweenTheBorders", host: "betweentheborders.com", port: 4242, isBootstrap: false), - TcpCommunityServer(name: "RNS Frankfurt", host: "frankfurt.connect.reticulum.network", port: 5377, isBootstrap: false), - TcpCommunityServer(name: "i2p Reticulum", host: "uxg5a4t3pnif7zoo43fkdrhgamlbfcovgsrzjakqab3pxjfqwdcq.b32.i2p", port: 5001, isBootstrap: false), - TcpCommunityServer(name: "Reticulum Ireland", host: "reticulum.liamcottle.net", port: 4242, isBootstrap: false), - TcpCommunityServer(name: "TheHub", host: "thehub.duckdns.org", port: 4242, isBootstrap: false), - TcpCommunityServer(name: "Kosciuszko", host: "kosciuszko.au.int.rns.directory", port: 9696, isBootstrap: false), - TcpCommunityServer(name: "Reticulum Ireland v2", host: "reticulum.liamcottle.net", port: 4343, isBootstrap: false), - TcpCommunityServer(name: "RNS Roaming", host: "roaming.int.rns.directory", port: 9697, isBootstrap: false), + 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 bb84980..6203cbd 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. @@ -28,6 +46,8 @@ UIAppFonts materialdesignicons.ttf + JetBrainsMono-Regular.ttf + JetBrainsMono-Bold.ttf UIApplicationSceneManifest diff --git a/Sources/ColumbaApp/Resources/JetBrainsMono-Bold.ttf b/Sources/ColumbaApp/Resources/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/Sources/ColumbaApp/Resources/JetBrainsMono-Bold.ttf differ diff --git a/Sources/ColumbaApp/Resources/JetBrainsMono-Regular.ttf b/Sources/ColumbaApp/Resources/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/Sources/ColumbaApp/Resources/JetBrainsMono-Regular.ttf differ diff --git a/Sources/ColumbaApp/Services/AppServices.swift b/Sources/ColumbaApp/Services/AppServices.swift index fb36cb8..cf88efc 100644 --- a/Sources/ColumbaApp/Services/AppServices.swift +++ b/Sources/ColumbaApp/Services/AppServices.swift @@ -90,6 +90,15 @@ enum DiagLog { @Observable @MainActor public final class AppServices { + + /// Host:port pair identifying a TCP interface's destination. Used to + /// detect whether a `connectTCPInterface` call would change the + /// interface's configuration or just re-apply the same one. + public struct TCPEndpoint: Equatable, Hashable, Sendable { + public let host: String + public let port: UInt16 + } + // MARK: - Components /// Local Reticulum identity for signing and encryption. @@ -107,6 +116,15 @@ public final class AppServices { /// TCP interfaces keyed by entity ID. Multiple concurrent connections are supported. public private(set) var tcpInterfaces: [String: TCPInterface] = [:] + /// Last-applied host:port per TCP entity. Used by `connectTCPInterface` + /// to short-circuit when the caller is re-applying an already-running + /// config (e.g. `InterfaceManagementViewModel.applyChanges` loops over + /// every enabled TCP entity on every toggle, so an unchanged interface + /// would otherwise be torn down and recreated alongside the genuinely- + /// changed one — triggering the relay to redeliver its full announce + /// table per reconnect). + public private(set) var tcpEndpoints: [String: TCPEndpoint] = [:] + /// Convenience accessor for the first TCP interface (backward compat). public var tcpInterface: TCPInterface? { tcpInterfaces.values.first } @@ -157,6 +175,24 @@ public final class AppServices { /// `EADDRINUSE`. private var pendingTunnelDisableTask: Task? + /// Tracks whether `applyTunnelModeToInterfaces(active: true)` has + /// run. Required because `endTunnelMode()` on reticulum-swift's + /// TCPInterface is NOT idempotent — it unconditionally tears down + /// the working NWConnection and re-runs `setupTransport()` (see + /// TCPInterface.swift:257-269 in reticulum-swift 0.3.0). If we + /// fire the `active: false` path on the initial `.invalid` / + /// `.disconnected` state notification — which iOS emits on every + /// cold start before the VPN profile is loaded, even when the + /// user hasn't enabled Background Transport — we'd kill every + /// TCPInterface's connection seconds after Step 7 brings them + /// up, leaving sends stuck at `state=OUTBOUND` indefinitely + /// (reproduced as the all-4-scenarios FAIL on the smoke harness, + /// 2026-05-11). + /// + /// Only flip back to `active: false` if we previously flipped to + /// `active: true`, matching the "undo what we did" contract. + private var isTunnelModeActive: Bool = false + /// Extension frame reader for processing queued frames from the extension. private var extensionFrameReader: ExtensionFrameReader? #endif @@ -215,6 +251,30 @@ public final class AppServices { /// Interface state observer task (cancelled on deinit). private var stateObserverTask: Task? + /// Registry of interface ids that were spawned as peer-children of an + /// AutoInterface / BLEInterface / MPCInterface parent, recorded from + /// `onInterfacePeerSpawned`. Used to attribute the subsequent + /// `onInterfaceConnected` event for the same id to the peer-spawned + /// trigger rather than the tcp-reconnect trigger — see + /// `AutoAnnouncePolicy.shouldFireOnInterfaceConnected(isPeerChild:)`. + /// + /// Synchronous lock-protected (rather than actor-isolated) so the + /// peer-spawned closure can commit a record before any `await` + /// suspension. If both record and lookup hopped to the main actor, + /// Swift's task scheduler would not guarantee record-before-lookup + /// ordering: both events fire from independent reticulum-swift Tasks, + /// and a connected-event Task could win the actor enqueue race even + /// though peer-spawn fired first in wall-clock time. The lock makes + /// the record a synchronous, atomic side-effect of the peer-spawned + /// callback's first line, before any await. + /// + /// Grows monotonically — entries are not removed on peer departure. + /// Peer-children are typically dozens at most on a Columba mesh, so + /// memory is a non-concern. If that ever becomes meaningful, add + /// removal in a `setOnInterfacePeerRemoved` callback when reticulum-swift + /// exposes one. + private let peerChildRegistry = PeerChildInterfaceRegistry() + // MARK: - Identity Persistence Constants /// Keychain service identifier for storing identity. @@ -449,8 +509,23 @@ 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 + // block below for why this ordering matters. + tcpEndpoints["tcp-server"] = TCPEndpoint(host: host, port: port) } catch { + // Initialization is "non-fatal" with respect to TCP — the + // rest of init proceeds without it, and the user can + // retry via reconnectTCPOnly. But that retry routes + // through connectTCPInterface, whose new idempotency + // guard would silently no-op if a stale tcpEndpoints + // entry survived this catch. Roll back any partial + // dictionary writes so a same-address retry isn't + // stuck. + tcpInterfaces.removeValue(forKey: "tcp-server") + tcpEndpoints.removeValue(forKey: "tcp-server") logger.warning("TCP interface failed (non-fatal): \(error.localizedDescription, privacy: .public)") } } @@ -599,11 +674,34 @@ public final class AppServices { // this the transport never sees Sideband's auto announces // matched against the registered AutoInterface, and announce // routing silently drops them. - reader.onTCPFrameReceived = { [weak self] data in + reader.onTCPFrameReceived = { [weak self] entityId, data in guard let self else { return } Task { - let tcpId = await self.tcpInterface?.id ?? "ext-tcp" - guard let transport = self.transport else { return } + // Prefer the per-frame entity ID supplied by the + // extension (so each TCP connection's inbound routes + // back to the correct `TCPInterface`). Fall back to + // the first TCP interface for legacy single-TCP frames + // and finally to a synthetic id so the transport never + // drops the frame. `tcpInterfaces` is `@MainActor`- + // isolated so we read both the lookup and the fallback + // id in one hop to avoid two round-trips. + let (tcpId, transport): (String, ReticulumTransport?) = await MainActor.run { + // The dict keys are the `InterfaceEntity.id` + // values used to register each `TCPInterface`, + // which is exactly what the transport routes + // against — so we can pick the fallback id from + // the keys without touching the actor-isolated + // `TCPInterface.id`. + let firstId = self.tcpInterfaces.keys.first + if !entityId.isEmpty, self.tcpInterfaces[entityId] != nil { + return (entityId, self.transport) + } else if let first = firstId { + return (first, self.transport) + } else { + return ("ext-tcp", self.transport) + } + } + guard let transport else { return } await transport.handleReceivedData(data: data, from: tcpId) } } @@ -759,16 +857,31 @@ public final class AppServices { 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) + for (entityId, iface) in tcpInterfaces { + await iface.beginTunnelMode { [weak tunnel, entityId] frame in + await tunnel?.sendFrame(frame, interfaceTag: FrameInterfaceTag.tcp.rawValue, entityId: entityId) } } + 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() } + isTunnelModeActive = false DiagLog.log("[TUNNEL] disabled tunnel mode; TCP interfaces resuming local connections") } } @@ -898,13 +1011,26 @@ public final class AppServices { return needsAnnounce } - // Auto-announce on connect (outside the MainActor.run to avoid blocking UI) + // Auto-announce on connect (outside the MainActor.run to avoid blocking UI). + // This polled path is functionally similar to the event-driven + // `onInterfaceConnected` hook in `configureTransportCallbacks` — + // it fires once when any interface aggregates to connected. We + // gate both the announce *and* the resetTimer() call behind the + // same toggles: if the announce wasn't sent, restarting the + // periodic loop would push the next interval-announce a full + // interval into the future every reconnect, starving the + // periodic schedule on a flap-y network. if shouldAnnounce { try? await Task.sleep(for: .seconds(1)) _ = await MainActor.run { Task { - await self.autoAnnounce() - self.autoAnnounceManager?.resetTimer() + let policy = AutoAnnouncePolicy.current() + if policy.shouldFireOnTcpReconnect { + await self.autoAnnounce() + self.autoAnnounceManager?.resetTimer() + } else { + DiagLog.log("[AUTO_ANNOUNCE] state-observer connect trigger gated off (master=\(policy.masterEnabled), tcp_reconnect=\(policy.onTcpReconnect))") + } } } } @@ -1324,12 +1450,22 @@ public final class AppServices { /// Connect a TCP interface by entity ID, replacing any existing one with the same ID. /// /// Multiple concurrent TCP interfaces are supported — each entity ID is independent. + /// Idempotent: if an interface is already running for `entityId` with the same + /// `host:port`, returns without disturbing it. public func connectTCPInterface(entityId: String, host: String, port: UInt16) async throws { - // Stop any existing interface with this entity ID + let endpoint = TCPEndpoint(host: host, port: port) + + // Already running with the same endpoint — leave it alone. + if tcpInterfaces[entityId] != nil, tcpEndpoints[entityId] == endpoint { + return + } + + // Stop any existing interface with this entity ID (config changed) if let existing = tcpInterfaces[entityId] { await existing.disconnect() await transport?.removeInterface(id: entityId) tcpInterfaces.removeValue(forKey: entityId) + tcpEndpoints.removeValue(forKey: entityId) } // Ensure base stack exists @@ -1351,7 +1487,23 @@ public final class AppServices { ) let newInterface = try TCPInterface(config: config) tcpInterfaces[entityId] = newInterface - try await transport.addInterface(newInterface) + do { + try await transport.addInterface(newInterface) + } catch { + // addInterface failed — roll back the dictionary write so a + // retry with the same endpoint isn't silently no-op'd by the + // idempotency guard at the top of this function. Without + // this cleanup, a transient addInterface failure would leave + // a stuck entry that permanently blocks self-healing + // reconnects for this entityId until the user edits its + // host or port. + tcpInterfaces.removeValue(forKey: entityId) + throw error + } + // Only record the applied endpoint after the interface has been + // successfully attached to the transport — see the catch block + // above for the reasoning. + tcpEndpoints[entityId] = endpoint if let dest = deliveryDestination { await transport.registerDestination(dest) @@ -1378,8 +1530,8 @@ public final class AppServices { // foreground, dies when the app is suspended. #if ENABLE_NETWORK_EXTENSION if let tunnel = tunnelManager, tunnel.isRunning { - await newInterface.beginTunnelMode { [weak tunnel] frame in - await tunnel?.sendFrame(frame, interfaceTag: FrameInterfaceTag.tcp.rawValue) + await newInterface.beginTunnelMode { [weak tunnel, entityId] frame in + await tunnel?.sendFrame(frame, interfaceTag: FrameInterfaceTag.tcp.rawValue, entityId: entityId) } DiagLog.log("[TUNNEL] late-added TCP interface \(entityId) put into tunnel mode") } @@ -1394,6 +1546,7 @@ public final class AppServices { await interface.disconnect() await transport?.removeInterface(id: entityId) tcpInterfaces.removeValue(forKey: entityId) + tcpEndpoints.removeValue(forKey: entityId) } /// Stop all TCP interfaces. @@ -1403,6 +1556,7 @@ public final class AppServices { await transport?.removeInterface(id: entityId) } tcpInterfaces.removeAll() + tcpEndpoints.removeAll() isConnected = false } @@ -1486,9 +1640,23 @@ 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) - try await newTransport.addInterface(newInterface) + do { + try await newTransport.addInterface(newInterface) + } catch { + // addInterface failed — roll back the dictionary write so a + // retry via reconnectTCPOnly with the same address isn't + // silently no-op'd by connectTCPInterface's idempotency + // guard. See connectTCPInterface's catch block for the full + // rationale. + tcpInterfaces.removeValue(forKey: "tcp-server") + throw error + } + // Only record the applied endpoint after the interface has been + // successfully attached to the transport. + tcpEndpoints["tcp-server"] = TCPEndpoint(host: host, port: port) // Set transport on router and re-register delivery destination if let router = router { @@ -1623,9 +1791,71 @@ public final class AppServices { #endif /// Wire transport callbacks that need app-layer context. + /// + /// Auto-announce triggers are split across two reticulum-swift hooks + /// and gated independently behind user-facing settings: + /// + /// - `onInterfaceConnected` fires whenever any interface transitions to + /// `.connected` (TCP / RNode reconnects, plus the connected transition + /// of peer-children). Gated by `auto_announce_on_tcp_reconnect`. + /// - `onInterfacePeerSpawned` fires when AutoInterface / BLE / MPC + /// accepts a new peer. Gated by `auto_announce_on_peer_spawned`. + /// + /// Both are also gated behind the master `auto_announce_enabled`. If + /// the user has disabled auto-announce entirely, neither path fires. private func configureTransportCallbacks(_ transport: ReticulumTransport) async { - await transport.setOnInterfaceAdded { [weak self] _ in + await transport.setOnInterfaceConnected { [weak self] id in guard let self else { return } + // Attribute peer-child connected transitions to the peer-spawn + // trigger, not tcp-reconnect: a peer joining causes both an + // `onInterfacePeerSpawned` and (a moment later) an + // `onInterfaceConnected` for the peer's child transport, but + // they describe the same user-visible event. + // + // The lookup is synchronous (lock-protected, not actor-hop), + // and the corresponding record on the peer-spawn side is also + // synchronous and runs before any await — see + // `peerChildRegistry`'s docstring for why this ordering is + // load-bearing for the attribution. + let isPeerChild = self.isPeerChildInterface(id) + let policy = AutoAnnouncePolicy.current() + guard policy.masterEnabled else { + DiagLog.log("[AUTO_ANNOUNCE] onInterfaceConnected(\(id)) — master toggle off, skipping") + return + } + guard policy.shouldFireOnInterfaceConnected(isPeerChild: isPeerChild) else { + let gate = isPeerChild ? "on-peer-spawned (peer-child reconnect)" : "on-tcp-reconnect" + DiagLog.log("[AUTO_ANNOUNCE] onInterfaceConnected(\(id)) — \(gate) off, skipping") + return + } + let gate = isPeerChild ? "on-peer-spawned (peer-child reconnect)" : "on-tcp-reconnect" + DiagLog.log("[AUTO_ANNOUNCE] onInterfaceConnected(\(id)) — firing via \(gate)") + await self.autoAnnounce() + } + await transport.setOnInterfacePeerSpawned { [weak self] id in + guard let self else { return } + // Record this id so that the subsequent `onInterfaceConnected` + // for the same id is gated by the peer-spawned trigger rather + // than tcp-reconnect. + // + // SYNCHRONOUS — runs before any await suspension in this + // closure. This guarantees that even if the peer's child + // transport reaches `.connected` immediately and fires its own + // Task before this one completes its policy/announce work, the + // connected closure's `isPeerChildInterface(id)` lookup will + // already see the recorded id. Without that synchronous + // guarantee, the two MainActor hops would race. + self.recordPeerChildInterface(id) + let policy = AutoAnnouncePolicy.current() + guard policy.masterEnabled else { + DiagLog.log("[AUTO_ANNOUNCE] onInterfacePeerSpawned(\(id)) — master toggle off, skipping") + return + } + guard policy.shouldFireOnPeerSpawned else { + DiagLog.log("[AUTO_ANNOUNCE] onInterfacePeerSpawned(\(id)) — on-peer-spawned off, skipping") + return + } + DiagLog.log("[AUTO_ANNOUNCE] onInterfacePeerSpawned(\(id)) — firing") await self.autoAnnounce() } // Wire diagnostic logging from transport to DiagLog @@ -1643,9 +1873,32 @@ public final class AppServices { /// so peers can discover us for both messaging and voice calls. /// /// Debounced to at most once per 5 seconds — AutoInterface peers fire - /// onInterfaceAdded from both the peer callback and the state-change - /// delegate, so this prevents redundant announces. + /// the connected-trigger from both the peer callback and the + /// state-change delegate, so this prevents redundant announces. + /// + /// Mark an interface id as a peer-child of an AutoInterface / BLE / + /// MPC parent so its later `onInterfaceConnected` event is attributed + /// to the peer-spawned trigger. Safe to call from any thread; the + /// underlying registry uses a lock, not actor isolation. + nonisolated private func recordPeerChildInterface(_ id: String) { + peerChildRegistry.record(id) + } + + /// True if this interface id was previously recorded as a peer-child + /// via `recordPeerChildInterface`. Safe to call from any thread. + nonisolated private func isPeerChildInterface(_ id: String) -> Bool { + peerChildRegistry.contains(id) + } + + /// Defensive master-gate: even though every individual call site checks + /// the master `auto_announce_enabled` toggle, this method also bails if + /// the master is off, so a future caller that forgets to gate doesn't + /// silently emit announces against the user's preference. private func autoAnnounce() async { + guard AutoAnnouncePolicy.current().masterEnabled else { + DiagLog.log("[AUTO_ANNOUNCE] master toggle off — skipping at autoAnnounce() entry") + return + } let now = Date() guard now.timeIntervalSince(lastAutoAnnounce) > 5.0 else { DiagLog.log("[AUTO_ANNOUNCE] debounced (last announce \(String(format: "%.1f", now.timeIntervalSince(lastAutoAnnounce)))s ago)") diff --git a/Sources/ColumbaApp/Services/AutoAnnounceManager.swift b/Sources/ColumbaApp/Services/AutoAnnounceManager.swift index 98595cd..9c9e386 100644 --- a/Sources/ColumbaApp/Services/AutoAnnounceManager.swift +++ b/Sources/ColumbaApp/Services/AutoAnnounceManager.swift @@ -50,10 +50,19 @@ public final class AutoAnnounceManager { stop() let defaults = UserDefaults.standard - guard defaults.bool(forKey: "auto_announce_enabled") else { + let policy = AutoAnnouncePolicy.current(defaults: defaults) + guard policy.masterEnabled else { logger.info("Auto-announce disabled, not starting") return } + // Granular gate: respect the per-trigger toggle. The interval loop + // is one of three triggers (interval / TCP reconnect / peer spawned). + // If the user turned the interval trigger off, don't spin up the + // periodic loop even though the master is on. + guard policy.shouldFireOnInterval else { + logger.info("Auto-announce on-interval trigger disabled, not starting periodic loop") + return + } let intervalHours = defaults.integer(forKey: "announce_interval_hours") let effectiveInterval = intervalHours > 0 ? intervalHours : 3 @@ -111,10 +120,15 @@ public final class AutoAnnounceManager { // Re-check settings in case they changed during sleep let defaults = UserDefaults.standard - guard defaults.bool(forKey: "auto_announce_enabled") else { + let policy = AutoAnnouncePolicy.current(defaults: defaults) + guard policy.masterEnabled else { logger.info("Auto-announce disabled during sleep, stopping") return } + guard policy.shouldFireOnInterval else { + logger.info("Auto-announce on-interval trigger disabled during sleep, stopping") + return + } // Perform the announce guard let services = appServices else { return } diff --git a/Sources/ColumbaApp/Services/AutoAnnouncePolicy.swift b/Sources/ColumbaApp/Services/AutoAnnouncePolicy.swift new file mode 100644 index 0000000..0823a40 --- /dev/null +++ b/Sources/ColumbaApp/Services/AutoAnnouncePolicy.swift @@ -0,0 +1,95 @@ +// +// AutoAnnouncePolicy.swift +// ColumbaApp +// +// Pure value type that captures the user's auto-announce settings at a +// point in time and decides whether each of the three trigger kinds +// should fire. Extracted from inline `UserDefaults.standard.bool(...)` +// reads so the gating logic is unit-testable without bringing up the +// full AppServices stack. +// + +import Foundation + +/// Decides whether each auto-announce trigger should fire, based on the +/// current state of the user's settings. +/// +/// The four UserDefaults keys this snapshots: +/// +/// - `auto_announce_enabled` — master kill switch. False suppresses +/// every trigger regardless of the granular flags below. +/// - `auto_announce_on_interval` — periodic timer trigger. +/// - `auto_announce_on_tcp_reconnect` — fires on TCP/RNode interface +/// transitions to `.connected` (and on the polled state-observer's +/// isConnected→true edge). +/// - `auto_announce_on_peer_spawned` — fires when AutoInterface / BLE +/// / MPC accepts a new peer. +/// +/// Defaults are registered as `true` for all four keys (see +/// `SettingsViewModel.loadLocalSettings`) so a fresh install behaves the +/// way pre-granular-trigger Columba did when the master was on. +public struct AutoAnnouncePolicy: Equatable, Sendable { + public let masterEnabled: Bool + public let onInterval: Bool + public let onTcpReconnect: Bool + public let onPeerSpawned: Bool + + public init( + masterEnabled: Bool, + onInterval: Bool, + onTcpReconnect: Bool, + onPeerSpawned: Bool + ) { + self.masterEnabled = masterEnabled + self.onInterval = onInterval + self.onTcpReconnect = onTcpReconnect + self.onPeerSpawned = onPeerSpawned + } + + /// Snapshot the current state of `defaults`. + public static func current(defaults: UserDefaults = .standard) -> AutoAnnouncePolicy { + AutoAnnouncePolicy( + masterEnabled: defaults.bool(forKey: "auto_announce_enabled"), + onInterval: defaults.bool(forKey: "auto_announce_on_interval"), + onTcpReconnect: defaults.bool(forKey: "auto_announce_on_tcp_reconnect"), + onPeerSpawned: defaults.bool(forKey: "auto_announce_on_peer_spawned") + ) + } + + /// True iff the periodic interval-based announce trigger should fire. + public var shouldFireOnInterval: Bool { + masterEnabled && onInterval + } + + /// True iff the on-(re)connect announce trigger should fire. + public var shouldFireOnTcpReconnect: Bool { + masterEnabled && onTcpReconnect + } + + /// True iff the on-peer-spawn announce trigger should fire. + public var shouldFireOnPeerSpawned: Bool { + masterEnabled && onPeerSpawned + } + + /// Decide whether an `onInterfaceConnected` event should fire an + /// announce, taking into account whether the interface is a *peer-child* + /// of an AutoInterface / BLE / MPC parent. + /// + /// Reticulum-swift fires `onInterfacePeerSpawned` when a peer joins, + /// then a moment later fires `onInterfaceConnected` for the peer's own + /// child transport. Both events describe the same peer-add, so the + /// connected transition for a peer-child must be attributed to the + /// peer-spawned trigger — *not* tcp-reconnect — otherwise turning the + /// peer-spawned toggle off but leaving tcp-reconnect on would still + /// produce an announce every time a peer joined, which contradicts the + /// user's mental model. + /// + /// - Parameter isPeerChild: whether this interface id is a child of a + /// peer-spawning parent (`AutoInterface` / `BLEInterface` / + /// `MPCInterface`). The caller maintains this attribution by + /// tracking the ids passed to `onInterfacePeerSpawned`. + public func shouldFireOnInterfaceConnected(isPeerChild: Bool) -> Bool { + guard masterEnabled else { return false } + return isPeerChild ? onPeerSpawned : onTcpReconnect + } +} diff --git a/Sources/ColumbaApp/Services/ExtensionFrameReader.swift b/Sources/ColumbaApp/Services/ExtensionFrameReader.swift index ec6110c..0f61804 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/PeerChildInterfaceRegistry.swift b/Sources/ColumbaApp/Services/PeerChildInterfaceRegistry.swift new file mode 100644 index 0000000..587c75d --- /dev/null +++ b/Sources/ColumbaApp/Services/PeerChildInterfaceRegistry.swift @@ -0,0 +1,55 @@ +// +// PeerChildInterfaceRegistry.swift +// ColumbaApp +// +// Synchronous, lock-protected set of interface ids known to be +// peer-children of an AutoInterface / BLEInterface / MPCInterface +// parent. Used by AppServices to attribute the `onInterfaceConnected` +// event for peer-children to the peer-spawned auto-announce trigger, +// not the tcp-reconnect trigger. +// +// Why a lock and not an actor: the peer-spawned and connected callbacks +// fire from independent reticulum-swift Tasks. If the registry were +// actor-isolated, both record-and-lookup would require an `await` hop, +// and Swift's task scheduler does not guarantee record-before-lookup +// ordering between unrelated Tasks. By making the operations +// synchronous, the peer-spawned closure can commit its record on its +// first line — before any `await` — and the connected closure's lookup +// sees the committed value regardless of how the schedulers interleave +// the rest of the closure bodies. +// + +import Foundation +import os.lock + +/// Thread-safe registry of peer-child interface ids. +/// +/// Backed by `os_unfair_lock` (the most lightweight option for a tiny +/// critical section). Access is non-isolated so the registry can be +/// touched from any thread / actor / Task without an additional hop. +public final class PeerChildInterfaceRegistry: @unchecked Sendable { + private let lock = OSAllocatedUnfairLock>(initialState: []) + + public init() {} + + /// Mark `id` as a peer-child interface. + public func record(_ id: String) { + lock.withLock { ids in + ids.insert(id) + } + } + + /// Whether `id` was previously recorded as a peer-child. + public func contains(_ id: String) -> Bool { + lock.withLock { ids in + ids.contains(id) + } + } + + /// Test-only: clear all recorded ids. + internal func reset() { + lock.withLock { ids in + ids.removeAll() + } + } +} diff --git a/Sources/ColumbaApp/Services/PropagationNodeManager.swift b/Sources/ColumbaApp/Services/PropagationNodeManager.swift index 3107a02..3f38f36 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 886fa6c..7e3f25e 100644 --- a/Sources/ColumbaApp/Services/TunnelManager.swift +++ b/Sources/ColumbaApp/Services/TunnelManager.swift @@ -249,17 +249,32 @@ public final class TunnelManager: @unchecked Sendable { /// 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/Test/TestController.swift b/Sources/ColumbaApp/Test/TestController.swift new file mode 100644 index 0000000..2a520a5 --- /dev/null +++ b/Sources/ColumbaApp/Test/TestController.swift @@ -0,0 +1,979 @@ +// +// 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 +#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))" + ) + } + } + } + + // 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)? +} + +#endif // DEBUG diff --git a/Sources/ColumbaApp/Test/TestURLHandler.swift b/Sources/ColumbaApp/Test/TestURLHandler.swift new file mode 100644 index 0000000..c1ddcce --- /dev/null +++ b/Sources/ColumbaApp/Test/TestURLHandler.swift @@ -0,0 +1,232 @@ +// +// 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 + +/// 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. + public static func bind(appServices: AppServices) { + 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) + } + + // Attach the relay delegate so received messages + delivery + // state changes get observed for the harness. Forwards to the + // existing IncomingMessageHandler. + Task { @MainActor in + // The router's currently-set delegate is reachable as + // `await router.delegate` if exposed, but LXMRouter's API + // doesn't expose it. We approximate by passing nil and + // accepting that during a test run the harness observer is + // the only delegate. The production IncomingMessageHandler + // remains wired through AppServices initialization, but the + // harness deliberately runs against a debug build that + // doesn't need its UI hooks. + await TestController.shared.attachDelegate( + to: router, + originalDelegate: nil + ) + } + } + + /// 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() + default: + TestLog.emit("rx_url_unknown action=\(action)") + } + return true + } + + // MARK: - Helpers + + enum TestError: Error { + case notReady + } + + /// 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/InterfaceManagementViewModel.swift b/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift index 1c9723b..75e414c 100644 --- a/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift @@ -21,7 +21,7 @@ private let logger = Logger(subsystem: "network.columba.Columba", category: "Int /// with InterfaceRepository for persistence. @available(iOS 17.0, macOS 14.0, *) @Observable -public final class InterfaceManagementViewModel { +public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { // MARK: - Dependencies @@ -68,6 +68,9 @@ public final class InterfaceManagementViewModel { /// Whether the RNode wizard is shown (uses fullScreenCover to survive BLE pairing dialog) public var showRNodeWizard: Bool = false + /// Whether the TCP client wizard is shown (community server picker → review/configure) + public var showTCPWizard: Bool = false + /// Interface being edited (nil for new interface) public var editingInterface: InterfaceEntity? @@ -215,6 +218,8 @@ public final class InterfaceManagementViewModel { if type == .rnode { showRNodeWizard = true + } else if type == .tcpClient { + showTCPWizard = true } else { showConfigSheet = true } @@ -226,6 +231,8 @@ public final class InterfaceManagementViewModel { populateConfigForm(from: interface) if interface.type == .rnode { showRNodeWizard = true + } else if interface.type == .tcpClient { + showTCPWizard = true } else { showConfigSheet = true } @@ -235,6 +242,7 @@ public final class InterfaceManagementViewModel { public func dismissConfigSheet() { showConfigSheet = false showRNodeWizard = false + showTCPWizard = false editingInterface = nil resetConfigForm() } @@ -280,6 +288,49 @@ public final class InterfaceManagementViewModel { } } + /// Save a TCP client interface from the wizard flow. + /// + /// Bypasses the form-field validation path (the wizard does its own validation + /// in `canProceed`) and writes directly through the repository, then triggers + /// the standard apply-changes pipeline. + public func saveTCPInterface( + editing: InterfaceEntity?, + name: String, + enabled: Bool, + mode: InterfaceMode, + config: TCPClientConfig + ) { + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let interfaceConfig: InterfaceTypeConfig = .tcpClient(config) + + if let existing = editing { + var updated = existing + updated.name = trimmedName + updated.enabled = enabled + updated.mode = mode + updated.config = interfaceConfig + repository.updateInterface(updated) + showSuccess("Interface updated") + } else { + let newInterface = InterfaceEntity( + name: trimmedName, + type: .tcpClient, + enabled: enabled, + mode: mode, + config: interfaceConfig + ) + repository.addInterface(newInterface) + showSuccess("Interface added") + } + + hasPendingChanges = true + dismissConfigSheet() + + Task { @MainActor in + await applyChanges() + } + } + // MARK: - Apply Changes /// Apply pending interface changes to the running network. @@ -298,9 +349,20 @@ public final class InterfaceManagementViewModel { let enabledTCPs = enabledInterfaces.filter { $0.type == .tcpClient } let enabledTCPIds = Set(enabledTCPs.map { $0.id }) - // Connect/reconnect each enabled TCP interface + // Connect/reconnect each enabled TCP interface, skipping ones that + // are already running with the same host:port. Without the skip, + // toggling or editing any single interface caused this loop to + // tear down every other healthy TCP connection alongside the one + // the user actually changed — and reconnecting prompted the relay + // to redeliver its full announce table per interface, swamping + // the app for ~90s per change. for tcpIf in enabledTCPs { if case .tcpClient(let config) = tcpIf.config { + let desired = AppServices.TCPEndpoint(host: config.targetHost, port: config.targetPort) + if appServices.tcpInterfaces[tcpIf.id] != nil, + appServices.tcpEndpoints[tcpIf.id] == desired { + continue + } logger.info("Applying TCP[\(tcpIf.id)]: \(config.targetHost):\(config.targetPort)") interfaceStatus[tcpIf.id] = .connecting do { diff --git a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift index a31b4bc..9bce852 100644 --- a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift @@ -215,6 +215,15 @@ public final class SettingsViewModel { public var manualAnnounceSuccess: Bool = false public var manualAnnounceError: String? + // Granular announce triggers (all gated under isAutoAnnounceEnabled). + // Default true — equivalent to "all triggers active" pre-introduction. + /// Fire an announce on the periodic interval timer. + public var autoAnnounceOnInterval: Bool = true + /// Fire an announce when a TCP / RNode / static interface (re)connects. + public var autoAnnounceOnTcpReconnect: Bool = true + /// Fire an announce when an AutoInterface / BLE / MPC peer is spawned. + public var autoAnnounceOnPeerSpawned: Bool = true + // MARK: - Location Sharing Settings /// Live reflection of whether location is being shared with any peer. @@ -366,7 +375,10 @@ public final class SettingsViewModel { "show_message_previews": true, "play_sounds": true, "vibrate": true, - "auto_announce_enabled": true + "auto_announce_enabled": true, + "auto_announce_on_interval": true, + "auto_announce_on_tcp_reconnect": true, + "auto_announce_on_peer_spawned": true ]) blockUnknownSenders = defaults.bool(forKey: "block_unknown_senders") @@ -383,6 +395,9 @@ public final class SettingsViewModel { notifyBleConnected = defaults.bool(forKey: "notify_ble_connected") notifyBleDisconnected = defaults.bool(forKey: "notify_ble_disconnected") isAutoAnnounceEnabled = defaults.bool(forKey: "auto_announce_enabled") + autoAnnounceOnInterval = defaults.bool(forKey: "auto_announce_on_interval") + autoAnnounceOnTcpReconnect = defaults.bool(forKey: "auto_announce_on_tcp_reconnect") + autoAnnounceOnPeerSpawned = defaults.bool(forKey: "auto_announce_on_peer_spawned") let storedInterval = defaults.integer(forKey: "announce_interval_hours") announceIntervalHours = storedInterval > 0 ? storedInterval : 3 let lastTs = defaults.double(forKey: "last_announce_time") @@ -408,6 +423,9 @@ public final class SettingsViewModel { playSounds = true vibrate = true isAutoAnnounceEnabled = true + autoAnnounceOnInterval = true + autoAnnounceOnTcpReconnect = true + autoAnnounceOnPeerSpawned = true announceIntervalHours = 3 defaultSharingDuration = SharingDuration.oneHour.rawValue defaults.set(true, forKey: "settings_initialized") @@ -433,6 +451,9 @@ public final class SettingsViewModel { defaults.set(notifyBleConnected, forKey: "notify_ble_connected") defaults.set(notifyBleDisconnected, forKey: "notify_ble_disconnected") defaults.set(isAutoAnnounceEnabled, forKey: "auto_announce_enabled") + defaults.set(autoAnnounceOnInterval, forKey: "auto_announce_on_interval") + defaults.set(autoAnnounceOnTcpReconnect, forKey: "auto_announce_on_tcp_reconnect") + defaults.set(autoAnnounceOnPeerSpawned, forKey: "auto_announce_on_peer_spawned") defaults.set(announceIntervalHours, forKey: "announce_interval_hours") SharedDefaults.suite.set(isTransportEnabled, forKey: "transport_enabled") defaults.set(isLocationSharingEnabled, forKey: "location_sharing_enabled") diff --git a/Sources/ColumbaApp/ViewModels/TCPClientWizardViewModel.swift b/Sources/ColumbaApp/ViewModels/TCPClientWizardViewModel.swift new file mode 100644 index 0000000..e9c1b0a --- /dev/null +++ b/Sources/ColumbaApp/ViewModels/TCPClientWizardViewModel.swift @@ -0,0 +1,195 @@ +// +// TCPClientWizardViewModel.swift +// ColumbaApp +// +// State management for the 2-step TCP client interface configuration wizard. +// Mirrors the Android Columba TcpClientWizardViewModel. +// + +import Foundation +import SwiftUI +import ReticulumSwift + +// MARK: - Wizard Step + +/// Steps in the TCP client configuration wizard. +@available(iOS 17.0, macOS 14.0, *) +enum TCPClientWizardStep: Int, CaseIterable, Identifiable { + case serverSelection = 0 + case reviewConfigure = 1 + + var id: Int { rawValue } + + var title: String { + switch self { + case .serverSelection: return "Select Server" + case .reviewConfigure: return "Review & Configure" + } + } +} + +// MARK: - Parent Save Sink + +/// Minimal protocol the wizard uses to forward a built TCP config to the +/// parent `InterfaceManagementViewModel`. Lets tests stub the parent without +/// pulling in repository / AppServices wiring. +@available(iOS 17.0, macOS 14.0, *) +protocol TCPClientWizardSaveSink: AnyObject { + func saveTCPInterface( + editing: InterfaceEntity?, + name: String, + enabled: Bool, + mode: InterfaceMode, + config: TCPClientConfig + ) +} + +// MARK: - ViewModel + +/// ViewModel for the TCP client configuration wizard. +/// +/// Manages step navigation, server selection vs custom mode, edit-mode +/// pre-population, and forwards the built `TCPClientConfig` through a +/// `TCPClientWizardSaveSink` so the existing add/update path on +/// `InterfaceManagementViewModel` stays the single source of persistence. +@available(iOS 17.0, macOS 14.0, *) +@Observable +@MainActor +final class TCPClientWizardViewModel { + + // MARK: - Navigation + + var currentStep: TCPClientWizardStep = .serverSelection + + // MARK: - Step 1: Server Selection + + var selectedServer: TcpCommunityServer? + var isCustomMode: Bool = false + + // MARK: - Step 2: Review & Configure + + var interfaceName: String = "" + var targetHost: String = "" + var targetPort: String = "4242" + var networkName: String = "" + var passphrase: String = "" + var showPassphrase: Bool = false + var mode: InterfaceMode = .full + var enabled: Bool = true + var showAdvanced: Bool = false + + // MARK: - Edit Context + + /// The interface being edited (nil for create flow). + private(set) var editingInterface: InterfaceEntity? + + /// Whether this wizard run is editing an existing interface. + var isEditing: Bool { editingInterface != nil } + + // MARK: - Step 1 Actions + + /// Pre-fill name/host/port from a community server and clear custom mode. + func selectServer(_ server: TcpCommunityServer) { + selectedServer = server + isCustomMode = false + interfaceName = server.name + targetHost = server.host + targetPort = String(server.port) + } + + /// Switch to custom-server mode: clear the selection and blank + /// the name/host/port fields so the user types fresh values in step 2. + func enableCustomMode() { + selectedServer = nil + isCustomMode = true + interfaceName = "" + targetHost = "" + targetPort = "" + } + + // MARK: - Edit Pre-population + + /// Populate fields from an existing TCP interface. + /// + /// If `(host, port)` matches a known `TcpCommunityServer`, that server + /// is selected and the wizard opens at step 1. Otherwise the wizard opens + /// at step 1 in custom mode so the user can confirm or change the entry. + func loadExisting(_ entity: InterfaceEntity) { + guard case .tcpClient(let config) = entity.config else { return } + editingInterface = entity + interfaceName = entity.name + targetHost = config.targetHost + targetPort = String(config.targetPort) + networkName = config.networkName ?? "" + passphrase = config.passphrase ?? "" + mode = entity.mode + enabled = entity.enabled + + let match = TcpCommunityServer.servers.first { server in + server.host == config.targetHost && server.port == config.targetPort + } + if let match = match { + selectedServer = match + isCustomMode = false + } else { + selectedServer = nil + isCustomMode = true + } + currentStep = .serverSelection + } + + // MARK: - Validation + + /// Whether the wizard can advance / save from the given step. + func canProceed(from step: TCPClientWizardStep) -> Bool { + switch step { + case .serverSelection: + return selectedServer != nil || isCustomMode + case .reviewConfigure: + let host = targetHost.trimmingCharacters(in: .whitespaces) + guard !host.isEmpty else { return false } + guard let port = UInt16(targetPort.trimmingCharacters(in: .whitespaces)), + port > 0 else { + return false + } + let trimmedName = interfaceName.trimmingCharacters(in: .whitespaces) + return !trimmedName.isEmpty + } + } + + // MARK: - Step Navigation + + func goToReview() { + currentStep = .reviewConfigure + } + + func goToServerSelection() { + currentStep = .serverSelection + } + + // MARK: - Save + + /// Build the `TCPClientConfig` and forward it to the parent through the + /// save sink. Persistence + apply-changes stay on the parent. + func save(into sink: TCPClientWizardSaveSink) { + let trimmedHost = targetHost.trimmingCharacters(in: .whitespaces) + let port = UInt16(targetPort.trimmingCharacters(in: .whitespaces)) ?? 4242 + let trimmedNetwork = networkName.trimmingCharacters(in: .whitespaces) + let trimmedPassphrase = passphrase.trimmingCharacters(in: .whitespaces) + + let config = TCPClientConfig( + targetHost: trimmedHost, + targetPort: port, + networkName: trimmedNetwork.isEmpty ? nil : trimmedNetwork, + passphrase: trimmedPassphrase.isEmpty ? nil : trimmedPassphrase + ) + + sink.saveTCPInterface( + editing: editingInterface, + name: interfaceName.trimmingCharacters(in: .whitespaces), + enabled: enabled, + mode: mode, + config: config + ) + } +} diff --git a/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift b/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift index 53e9879..13f25a0 100644 --- a/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift +++ b/Sources/ColumbaApp/Views/Contacts/NodeDetailsView.swift @@ -171,12 +171,12 @@ struct NodeDetailsView: View { .foregroundStyle(Theme.accentColor) VStack(alignment: .leading, spacing: 4) { - Text("Waiting for an announce") + Text("Path needs refresh") .font(.subheadline) .fontWeight(.semibold) .foregroundStyle(Theme.textPrimary) - Text("This contact hasn't announced themselves to the network recently. Ask them to send an announce from their app, or wait for one to arrive automatically.") + Text("We haven't routed to this contact recently. Tap an action to issue a path request — any node on the network with a recent announce will respond.") .font(.caption) .foregroundStyle(Theme.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -218,8 +218,7 @@ struct NodeDetailsView: View { } private func actionButton(icon: String, title: String, action: @escaping () -> Void) -> some View { - let isOnline = displayedContact.isOnline - return Button(action: action) { + Button(action: action) { HStack(spacing: 8) { Image(systemName: icon) Text(title) @@ -233,8 +232,6 @@ struct NodeDetailsView: View { .fill(Theme.accentGradient) } } - .disabled(!isOnline) - .opacity(isOnline ? 1.0 : 0.5) } // MARK: - Details Section @@ -370,11 +367,6 @@ struct NodeDetailsView: View { } } } - // Match the primary action button: an offline node can't be - // designated as a relay, so the button should look and act - // disabled while the badge says "Expired". - .disabled(!displayedContact.isOnline) - .opacity(displayedContact.isOnline ? 1.0 : 0.5) } // MARK: - Propagation Details diff --git a/Sources/ColumbaApp/Views/Map/MapLibreMapView.swift b/Sources/ColumbaApp/Views/Map/MapLibreMapView.swift index 575e22e..8d4292c 100644 --- a/Sources/ColumbaApp/Views/Map/MapLibreMapView.swift +++ b/Sources/ColumbaApp/Views/Map/MapLibreMapView.swift @@ -11,6 +11,18 @@ import SwiftUI import MapLibre import LXMFSwift +/// Returns the OpenFreeMap style URL for the active color scheme. +/// MLNOfflineStorage caches the style JSON + tiles during region download, +/// so loading this URL offline serves everything from the local cache — +/// but cached regions are pinned to one style at download time, so the +/// dark style assets are not served offline if a region was downloaded +/// while light was active. TODO(#59 follow-up): cache both style packs. +func mapStyleURL(forDarkMode dark: Bool) -> URL { + URL(string: dark + ? "https://tiles.openfreemap.org/styles/dark" + : "https://tiles.openfreemap.org/styles/liberty")! +} + @available(iOS 17.0, *) struct MapLibreMapView: UIViewRepresentable { @Binding var centerOnUser: Bool @@ -18,11 +30,7 @@ struct MapLibreMapView: UIViewRepresentable { var showsUserLocation: Bool var peerLocations: [PeerLocation] var httpEnabled: Bool - - /// Style URL from OpenFreeMap — used for both online and offline modes. - /// MLNOfflineStorage caches the style JSON + tiles during region download, - /// so loading this URL offline serves everything from the local cache. - private static let styleURL = URL(string: "https://tiles.openfreemap.org/styles/liberty")! + var isDark: Bool func makeUIView(context: Context) -> MLNMapView { // Set up network delegate to block HTTP when toggle is off. @@ -31,7 +39,9 @@ struct MapLibreMapView: UIViewRepresentable { context.coordinator.httpEnabled = httpEnabled MLNNetworkConfiguration.sharedManager.delegate = context.coordinator - let mapView = MLNMapView(frame: .zero, styleURL: Self.styleURL) + let initialStyleURL = mapStyleURL(forDarkMode: isDark) + context.coordinator.lastStyleURL = initialStyleURL + let mapView = MLNMapView(frame: .zero, styleURL: initialStyleURL) mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] mapView.showsUserLocation = showsUserLocation mapView.delegate = context.coordinator @@ -53,6 +63,15 @@ struct MapLibreMapView: UIViewRepresentable { // Update network blocking state when HTTP toggle changes context.coordinator.httpEnabled = httpEnabled + // Swap style URL when color scheme changes; lastStyleURL avoids + // a no-op assignment (which would still trigger a reload) on every + // peer-location tick. + let desiredStyleURL = mapStyleURL(forDarkMode: isDark) + if context.coordinator.lastStyleURL != desiredStyleURL { + context.coordinator.lastStyleURL = desiredStyleURL + mapView.styleURL = desiredStyleURL + } + if centerOnUser { DispatchQueue.main.async { centerOnUser = false @@ -131,6 +150,11 @@ struct MapLibreMapView: UIViewRepresentable { /// Whether HTTP tile fetching is allowed. var httpEnabled = true + /// Last style URL applied to the underlying MLNMapView; used to skip + /// no-op assignments on the frequent SwiftUI updates that don't change + /// the color scheme. + var lastStyleURL: URL? + /// Tracks peer annotations by hash for efficient updates. var peerAnnotations: [Data: PeerPointAnnotation] = [:] diff --git a/Sources/ColumbaApp/Views/Map/MapView.swift b/Sources/ColumbaApp/Views/Map/MapView.swift index 2329072..963d450 100644 --- a/Sources/ColumbaApp/Views/Map/MapView.swift +++ b/Sources/ColumbaApp/Views/Map/MapView.swift @@ -39,7 +39,8 @@ struct MapView: View { metersPerPixel: $metersPerPixel, showsUserLocation: locationAuthorized, peerLocations: locationSharingManager.map { Array($0.peerLocations.values) } ?? [], - httpEnabled: mapHttpEnabled + httpEnabled: mapHttpEnabled, + isDark: ThemeManager.shared.isDarkMode ) .ignoresSafeArea() diff --git a/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift b/Sources/ColumbaApp/Views/Messaging/MessageBubble.swift index f15c331..06409e2 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 1e00755..8e514b5 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/NomadNet/MicronDocumentView.swift b/Sources/ColumbaApp/Views/NomadNet/MicronDocumentView.swift index ed4d425..dc4b038 100644 --- a/Sources/ColumbaApp/Views/NomadNet/MicronDocumentView.swift +++ b/Sources/ColumbaApp/Views/NomadNet/MicronDocumentView.swift @@ -11,6 +11,14 @@ struct MicronDocumentView: View { var loadingPartials: Set = [] var onLinkTapped: ((MicronLink) -> Void)? var style: MicronRenderStyle = .monospaceScroll + /// Viewport width for the SCROLL mode. Each row gets at least this width so + /// `\`c`/`\`r` alignment centers/right-aligns content relative to the screen, + /// not the document's max line width. Mirrors Android's + /// `Modifier.widthIn(min = viewportLineWidth)` (NomadNetBrowserScreen.kt:474). + /// Without this, a single wide row (e.g. the chat-room's 550-char trailing- + /// whitespace line) sets the VStack width and centered shorter rows end up + /// scrolled offscreen-right. + var viewportWidth: CGFloat = 0 var body: some View { VStack(alignment: .leading, spacing: isScrollMode ? 0 : 2) { @@ -49,6 +57,7 @@ struct MicronDocumentView: View { bold: true, onLinkTapped: onLinkTapped ) + .frame(minWidth: viewportWidth, alignment: alignment.swiftUI) } else { renderSpans(spans, onLinkTapped: onLinkTapped) .font(headingFont(level: level)) @@ -69,6 +78,7 @@ struct MicronDocumentView: View { onLinkTapped: onLinkTapped ) .padding(.leading, CGFloat(indentLevel) * style.approxCharWidth) + .frame(minWidth: viewportWidth, alignment: alignment.swiftUI) } else { renderSpans(spans, onLinkTapped: onLinkTapped) .font(bodyFont) @@ -89,6 +99,7 @@ struct MicronDocumentView: View { bold: false, onLinkTapped: nil ) + .frame(minWidth: viewportWidth, alignment: .leading) } else if let ch = character { Text(String(repeating: ch, count: 40)) .font(.system(size: style.fontSize, design: .monospaced)) diff --git a/Sources/ColumbaApp/Views/NomadNet/MicronRenderContainer.swift b/Sources/ColumbaApp/Views/NomadNet/MicronRenderContainer.swift index 46349a9..2104e7f 100644 --- a/Sources/ColumbaApp/Views/NomadNet/MicronRenderContainer.swift +++ b/Sources/ColumbaApp/Views/NomadNet/MicronRenderContainer.swift @@ -80,18 +80,28 @@ struct MonospaceScrollContainer: View { var body: some View { #if os(iOS) - ZoomableScrollView { - MicronDocumentView( - document: document, - formFields: $formFields, - checkboxFields: $checkboxFields, - radioFields: $radioFields, - partialDocuments: partialDocuments, - loadingPartials: loadingPartials, - onLinkTapped: onLinkTapped, - style: .monospaceScroll - ) - .fixedSize() + // Capture the actual screen viewport width before the inner + // ZoomableScrollView's UIHostingController gets sized to its (much + // larger) intrinsic content width. We pass this down so each row is + // at least viewport-wide, which keeps `\`c`-centered content visually + // centered on screen rather than centered relative to the document's + // max line width — matching Android's + // `Modifier.widthIn(min = viewportLineWidth)` pattern. + GeometryReader { geo in + ZoomableScrollView { + MicronDocumentView( + document: document, + formFields: $formFields, + checkboxFields: $checkboxFields, + radioFields: $radioFields, + partialDocuments: partialDocuments, + loadingPartials: loadingPartials, + onLinkTapped: onLinkTapped, + style: .monospaceScroll, + viewportWidth: geo.size.width + ) + .fixedSize() + } } #else ScrollView([.horizontal, .vertical]) { diff --git a/Sources/ColumbaApp/Views/NomadNet/MonospaceLineView.swift b/Sources/ColumbaApp/Views/NomadNet/MonospaceLineView.swift index 4d04957..fae8e8c 100644 --- a/Sources/ColumbaApp/Views/NomadNet/MonospaceLineView.swift +++ b/Sources/ColumbaApp/Views/NomadNet/MonospaceLineView.swift @@ -18,6 +18,10 @@ struct MonospaceLineView: View { let cellHeight: CGFloat let alignment: MicronAlignment let bold: Bool + /// Force the UIKit label to be at least this wide so center/right alignment + /// resolves against the visible viewport, not just the text's intrinsic width. + /// Pass 0 to opt out (label sizes to its own intrinsic only). + var minWidth: CGFloat = 0 var onLinkTapped: ((MicronLink) -> Void)? var body: some View { @@ -26,6 +30,7 @@ struct MonospaceLineView: View { attributedString: buildAttributedString(), cellHeight: cellHeight, alignment: alignment, + minWidth: minWidth, onTap: handleTap ) .frame(height: cellHeight) @@ -51,13 +56,24 @@ struct MonospaceLineView: View { paragraph.maximumLineHeight = cellHeight paragraph.lineSpacing = 0 paragraph.lineHeightMultiple = 0 - switch alignment { - case .left: paragraph.alignment = .left - case .center: paragraph.alignment = .center - case .right: paragraph.alignment = .right - } - + // Always render content left-aligned within the UILabel. SwiftUI + // `.frame(alignment:)` at the call site handles visual centering / + // right-alignment for narrow rows. This avoids Core Text's + // trailing-whitespace stripping under .center / .right alignment. + paragraph.alignment = .left + + // Prefer bundled JetBrains Mono — its Unicode block-drawing glyphs are + // truly cell-uniform with ASCII spaces, which the iOS system monospaced + // font (SF Mono) is not. SF Mono renders ▗▄▖█ at slightly different + // pixel widths than space, so a row of mixed box-chars + spaces ends + // up at a different intrinsic width than the next row, breaking + // column alignment in NomadNet ASCII art (e.g. fr33n0w/thechatroom). + // Falls back to the system font if the bundled font fails to load. let baseFont: UIFont = { + let name = bold ? "JetBrainsMono-Bold" : "JetBrainsMono-Regular" + if let custom = UIFont(name: name, size: fontSize) { + return custom + } if bold { return UIFont.monospacedSystemFont(ofSize: fontSize, weight: .bold) } @@ -104,7 +120,8 @@ struct MonospaceLineView: View { private func font(for style: MicronTextStyle, base: UIFont) -> UIFont { var font = base if style.bold { - font = UIFont.monospacedSystemFont(ofSize: base.pointSize, weight: .bold) + font = UIFont(name: "JetBrainsMono-Bold", size: base.pointSize) + ?? UIFont.monospacedSystemFont(ofSize: base.pointSize, weight: .bold) } if style.italic, let desc = font.fontDescriptor.withSymbolicTraits(.traitItalic) { @@ -141,8 +158,21 @@ private struct UIMonospaceLine: UIViewRepresentable { let attributedString: NSAttributedString let cellHeight: CGFloat let alignment: MicronAlignment + let minWidth: CGFloat var onTap: ((Int) -> Void)? + /// Return only the label's intrinsic content width. SwiftUI `.frame` + /// at the call site handles minimum-width / alignment for narrow rows, + /// which avoids Core Text's trailing-whitespace stripping under + /// `textAlignment = .center` (a row ending in a regular space would + /// otherwise center as if it were one cell narrower than its sibling + /// rows that have no trailing space, breaking column alignment in + /// ASCII art — see fr33n0w/thechatroom letter "T"). + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UILabel, context: Context) -> CGSize? { + let intrinsicWidth = uiView.intrinsicContentSize.width + return CGSize(width: intrinsicWidth, height: cellHeight) + } + func makeUIView(context: Context) -> UILabel { let label = UILabel() label.numberOfLines = 1 @@ -163,11 +193,9 @@ private struct UIMonospaceLine: UIViewRepresentable { func updateUIView(_ uiView: UILabel, context: Context) { uiView.attributedText = attributedString context.coordinator.onTap = onTap - switch alignment { - case .left: uiView.textAlignment = .left - case .center: uiView.textAlignment = .center - case .right: uiView.textAlignment = .right - } + // Always left — SwiftUI .frame(alignment:) handles visual centering + // outside the label so trailing whitespace isn't stripped. + uiView.textAlignment = .left } func makeCoordinator() -> Coordinator { Coordinator() } diff --git a/Sources/ColumbaApp/Views/Settings/InterfaceManagementScreen.swift b/Sources/ColumbaApp/Views/Settings/InterfaceManagementScreen.swift index d87382b..8146879 100644 --- a/Sources/ColumbaApp/Views/Settings/InterfaceManagementScreen.swift +++ b/Sources/ColumbaApp/Views/Settings/InterfaceManagementScreen.swift @@ -122,6 +122,11 @@ struct InterfaceManagementScreen: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $viewModel.showTCPWizard, onDismiss: { viewModel.dismissConfigSheet() }) { + TCPClientWizard(viewModel: viewModel) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } .alert("Delete Interface?", isPresented: $viewModel.showDeleteConfirmation) { Button("Cancel", role: .cancel) { viewModel.interfaceToDelete = nil diff --git a/Sources/ColumbaApp/Views/Settings/SettingsView.swift b/Sources/ColumbaApp/Views/Settings/SettingsView.swift index 2de92ef..1464deb 100644 --- a/Sources/ColumbaApp/Views/Settings/SettingsView.swift +++ b/Sources/ColumbaApp/Views/Settings/SettingsView.swift @@ -876,31 +876,81 @@ struct SettingsView: View { .foregroundStyle(Theme.textSecondary) if vm.isAutoAnnounceEnabled { - // Interval selector + // Granular trigger toggles. All gated behind the master + // above; turning all three off effectively suppresses + // every automatic announce (manual still works). VStack(alignment: .leading, spacing: 8) { - Text("Announce Interval: \(vm.announceIntervalHours) hour\(vm.announceIntervalHours == 1 ? "" : "s")") - .font(.subheadline.weight(.medium)) - .foregroundStyle(Theme.accentColor) + Text("Triggers") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Theme.textPrimary) - // Preset chips - HStack(spacing: 8) { - ForEach([1, 3, 6, 12], id: \.self) { hours in - Button { - vm.announceIntervalHours = hours + autoAnnounceTriggerRow( + title: "On interval", + subtitle: "Periodic timer (configurable below)", + isOn: Binding( + get: { vm.autoAnnounceOnInterval }, + set: { newValue in + vm.autoAnnounceOnInterval = newValue vm.saveSettings() vm.syncAutoAnnounce() - } label: { - Text("\(hours)h") - .font(.caption.weight(.medium)) - .foregroundStyle(vm.announceIntervalHours == hours ? .white : Theme.textSecondary) - .padding(.horizontal, 14) - .padding(.vertical, 6) - .background( - vm.announceIntervalHours == hours - ? Theme.accentColor - : Theme.backgroundTertiary - ) - .clipShape(Capsule()) + } + ) + ) + + autoAnnounceTriggerRow( + title: "On interface (re)connect", + subtitle: "When TCP / RNode interfaces reach connected", + isOn: Binding( + get: { vm.autoAnnounceOnTcpReconnect }, + set: { newValue in + vm.autoAnnounceOnTcpReconnect = newValue + vm.saveSettings() + } + ) + ) + + autoAnnounceTriggerRow( + title: "On peer spawned", + subtitle: "When AutoInterface / BLE / Multipeer accepts a new peer", + isOn: Binding( + get: { vm.autoAnnounceOnPeerSpawned }, + set: { newValue in + vm.autoAnnounceOnPeerSpawned = newValue + vm.saveSettings() + } + ) + ) + } + .padding(.vertical, 4) + + // Interval selector — only meaningful when the on-interval + // trigger is on. + if vm.autoAnnounceOnInterval { + VStack(alignment: .leading, spacing: 8) { + Text("Announce Interval: \(vm.announceIntervalHours) hour\(vm.announceIntervalHours == 1 ? "" : "s")") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.accentColor) + + // Preset chips + HStack(spacing: 8) { + ForEach([1, 3, 6, 12], id: \.self) { hours in + Button { + vm.announceIntervalHours = hours + vm.saveSettings() + vm.syncAutoAnnounce() + } label: { + Text("\(hours)h") + .font(.caption.weight(.medium)) + .foregroundStyle(vm.announceIntervalHours == hours ? .white : Theme.textSecondary) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + vm.announceIntervalHours == hours + ? Theme.accentColor + : Theme.backgroundTertiary + ) + .clipShape(Capsule()) + } } } } @@ -996,6 +1046,30 @@ struct SettingsView: View { } } + /// Single-row toggle for one auto-announce trigger. + @ViewBuilder + private func autoAnnounceTriggerRow( + title: String, + subtitle: String, + isOn: Binding + ) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .foregroundStyle(Theme.textPrimary) + Text(subtitle) + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + Spacer() + Toggle("", isOn: isOn) + .labelsHidden() + .tint(Theme.accentColor) + } + .padding(.vertical, 2) + } + // MARK: - Location Sharing Card private func locationSharingCard(_ vm: SettingsViewModel) -> some View { diff --git a/Sources/ColumbaApp/Views/Settings/TCPClientWizard.swift b/Sources/ColumbaApp/Views/Settings/TCPClientWizard.swift new file mode 100644 index 0000000..2b8767b --- /dev/null +++ b/Sources/ColumbaApp/Views/Settings/TCPClientWizard.swift @@ -0,0 +1,456 @@ +// +// TCPClientWizard.swift +// ColumbaApp +// +// 2-step wizard for adding / editing a TCP client interface: +// Server Selection (community list or custom) → Review & Configure. +// Mirrors the Android Columba TcpClientWizardScreen. +// + +import SwiftUI + +// MARK: - Wizard Container + +/// 2-step TCP client interface wizard. +@available(iOS 17.0, macOS 14.0, *) +struct TCPClientWizard: View { + + @Bindable var viewModel: InterfaceManagementViewModel + @State private var wizard = TCPClientWizardViewModel() + + var body: some View { + NavigationStack { + ZStack { + Theme.backgroundPrimary.ignoresSafeArea() + + VStack(spacing: 0) { + // Step content + Group { + switch wizard.currentStep { + case .serverSelection: + TCPServerSelectionStep(wizard: wizard) + case .reviewConfigure: + TCPReviewConfigureStep(wizard: wizard) + } + } + + bottomBar + } + } + .navigationTitle(wizard.isEditing ? "Edit TCP Interface" : "Add TCP Interface") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + viewModel.dismissConfigSheet() + } + .foregroundStyle(Theme.textPrimary) + } + } + .toolbarBackground(Theme.backgroundPrimary, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + #endif + } + .onAppear { + // Pre-populate when editing an existing interface. + if let editing = viewModel.editingInterface, + editing.type == .tcpClient, + !wizard.isEditing { + wizard.loadExisting(editing) + } + } + .animation(.easeInOut(duration: 0.2), value: wizard.currentStep) + } + + // MARK: - Bottom Bar + + private var bottomBar: some View { + HStack(spacing: 16) { + if wizard.currentStep == .reviewConfigure { + Button { + wizard.goToServerSelection() + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.left") + Text("Back") + } + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(Theme.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + } + + stepIndicator + + Spacer() + + primaryActionButton + } + .padding(16) + .background(Theme.backgroundPrimary) + } + + private var stepIndicator: some View { + Text("\(wizard.currentStep.rawValue + 1) of \(TCPClientWizardStep.allCases.count)") + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.textSecondary) + } + + private var canProceed: Bool { + wizard.canProceed(from: wizard.currentStep) + } + + private var primaryActionButton: some View { + Button { + switch wizard.currentStep { + case .serverSelection: + wizard.goToReview() + case .reviewConfigure: + wizard.save(into: viewModel) + } + } label: { + HStack(spacing: 6) { + Text(wizard.currentStep == .reviewConfigure ? (wizard.isEditing ? "Update" : "Save") : "Next") + if wizard.currentStep == .serverSelection { + Image(systemName: "chevron.right") + } + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.vertical, 12) + .padding(.horizontal, 20) + .background(canProceed ? Theme.accentColor : Theme.textDisabled) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + .disabled(!canProceed) + } +} + +// MARK: - Step 1: Server Selection + +@available(iOS 17.0, macOS 14.0, *) +struct TCPServerSelectionStep: View { + + @Bindable var wizard: TCPClientWizardViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Choose a public Reticulum transport node, or set up a custom server.") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 16) + + // Community servers. Reticulum-Swift does not yet support + // bootstrap interfaces, so all servers share a single section. + if !TcpCommunityServer.servers.isEmpty { + sectionHeader("Community Servers") + VStack(spacing: 8) { + ForEach(TcpCommunityServer.servers) { server in + serverRow(server) + } + } + .padding(.horizontal, 16) + } + + sectionHeader("Custom") + customRow + .padding(.horizontal, 16) + + Spacer(minLength: 24) + } + .padding(.top, 12) + } + } + + private func sectionHeader(_ text: String) -> some View { + Text(text.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 16) + } + + private func serverRow(_ server: TcpCommunityServer) -> some View { + Button { + wizard.selectServer(server) + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(server.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Theme.textPrimary) + Text(server.address) + .font(.caption.monospaced()) + .foregroundStyle(Theme.textSecondary) + } + + Spacer() + + if wizard.selectedServer?.id == server.id && !wizard.isCustomMode { + Image(systemName: "checkmark.circle.fill") + .font(.title3) + .foregroundStyle(Theme.accentColor) + } + } + .padding(14) + .background(rowBackground(selected: wizard.selectedServer?.id == server.id && !wizard.isCustomMode)) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + .buttonStyle(.plain) + } + + private var customRow: some View { + Button { + wizard.enableCustomMode() + } label: { + HStack(spacing: 12) { + Image(systemName: "slider.horizontal.3") + .font(.title3) + .foregroundStyle(Theme.accentColor) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 4) { + Text("Custom Server") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Theme.textPrimary) + Text("Enter your own host and port") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + + Spacer() + + if wizard.isCustomMode { + Image(systemName: "checkmark.circle.fill") + .font(.title3) + .foregroundStyle(Theme.accentColor) + } + } + .padding(14) + .background(rowBackground(selected: wizard.isCustomMode)) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + .buttonStyle(.plain) + } + + private func rowBackground(selected: Bool) -> some View { + ZStack { + Theme.backgroundSecondary + if selected { + Theme.accentColor.opacity(0.12) + } + } + } +} + +// MARK: - Step 2: Review & Configure + +@available(iOS 17.0, macOS 14.0, *) +struct TCPReviewConfigureStep: View { + + @Bindable var wizard: TCPClientWizardViewModel + + var body: some View { + ScrollView { + VStack(spacing: 16) { + serverSummaryCard + interfaceFields + enabledToggle + advancedSection + } + .padding(16) + } + } + + private var serverSummaryCard: some View { + HStack(spacing: 12) { + Image(systemName: wizard.isCustomMode ? "slider.horizontal.3" : "globe") + .font(.title2) + .foregroundStyle(Theme.accentColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(wizard.isCustomMode ? "Custom Server" : (wizard.selectedServer?.name ?? "—")) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Theme.textPrimary) + if let server = wizard.selectedServer, !wizard.isCustomMode { + Text(server.address) + .font(.caption.monospaced()) + .foregroundStyle(Theme.textSecondary) + } else { + Text("Enter host and port below") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + } + + Spacer() + } + .padding(16) + .background(Theme.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + + private var interfaceFields: some View { + VStack(spacing: 16) { + field( + title: "Interface Name", + placeholder: "e.g., Beleth RNS Hub", + text: $wizard.interfaceName + ) + + field( + title: "Target Host", + placeholder: "IP address or hostname", + text: $wizard.targetHost + ) + + field( + title: "Target Port", + placeholder: "4242", + text: $wizard.targetPort, + isNumeric: true + ) + } + } + + private var enabledToggle: some View { + HStack { + Text("Enabled") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + + Spacer() + + Toggle("", isOn: $wizard.enabled) + .labelsHidden() + .tint(Theme.accentColor) + } + .padding(16) + .background(Theme.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + + private var advancedSection: some View { + VStack(spacing: 12) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + wizard.showAdvanced.toggle() + } + } label: { + HStack { + Text("Advanced Options") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + Spacer() + Image(systemName: wizard.showAdvanced ? "chevron.up" : "chevron.down") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + } + .padding(16) + .background(Theme.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + + if wizard.showAdvanced { + VStack(spacing: 16) { + field( + title: "Network Name (optional)", + placeholder: "Virtual network name", + text: $wizard.networkName + ) + + passphraseField + + modePicker + } + .padding(16) + .background(Theme.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + + private var passphraseField: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Passphrase (optional)") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + + HStack { + if wizard.showPassphrase { + TextField("Authentication passphrase", text: $wizard.passphrase) + .textFieldStyle(.plain) + } else { + SecureField("Authentication passphrase", text: $wizard.passphrase) + .textFieldStyle(.plain) + } + + Button { + wizard.showPassphrase.toggle() + } label: { + Image(systemName: wizard.showPassphrase ? "eye.slash" : "eye") + .foregroundStyle(Theme.textSecondary) + } + } + .padding(12) + .background(Theme.backgroundPrimary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusSmall)) + #if os(iOS) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + #endif + + Text("Optional: Sets an authentication passphrase on the interface.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + } + + private var modePicker: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Interface Mode") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + + Picker("Mode", selection: $wizard.mode) { + ForEach(InterfaceMode.allCases, id: \.self) { mode in + Text("\(mode.displayName) - \(mode.description)") + .tag(mode) + } + } + .pickerStyle(.menu) + .tint(Theme.accentColor) + } + } + + private func field( + title: String, + placeholder: String, + text: Binding, + isNumeric: Bool = false + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.textPrimary) + + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .padding(12) + .background(Theme.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusSmall)) + .foregroundStyle(Theme.textPrimary) + #if os(iOS) + .keyboardType(isNumeric ? .numberPad : .default) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + #endif + } + } +} diff --git a/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift b/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift index f4118a1..842fdba 100644 --- a/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift +++ b/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift @@ -28,7 +28,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Properties - private var tcpConnection: NWConnection? private lazy var frameQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier) /// Drives the extension's AutoInterface — peer discovery /// (`ff12:0:…` multicast derived from the group id) plus @@ -40,11 +39,23 @@ class PacketTunnelProvider: NEPacketTunnelProvider { postNotif: { [weak self] in self?.postDarwinNotification() } ) - /// Currently-applied TCP endpoint (used to diff config changes - /// from the app). nil when no TCP interface is configured. + /// 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`. @@ -56,9 +67,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// `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 @@ -163,39 +171,64 @@ 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 + } + + // 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 @@ -222,9 +255,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // keeps the existing contract that the completion handler // fires only after teardown has finished. configQueue.sync { - teardownTCPConnectionLocked() + teardownAllTCPConnectionsLocked() autoBridge.stop() - currentTCP = nil + currentTCPs.removeAll() currentAutoGroupId = nil } @@ -242,7 +275,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } 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 @@ -272,7 +305,20 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } 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..> 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 @@ -188,6 +210,9 @@ public final class SharedFrameQueue: @unchecked Sendable { } fh.seekToEndOfFile() fh.write(header) + if !idBytes.isEmpty { + fh.write(Data(idBytes)) + } fh.write(frame) fh.closeFile() } @@ -212,23 +237,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 new file mode 100644 index 0000000..63e8b7f --- /dev/null +++ b/Tests/ColumbaAppTests/AutoAnnouncePolicyTests.swift @@ -0,0 +1,246 @@ +// +// AutoAnnouncePolicyTests.swift +// ColumbaAppTests +// +// Unit tests for the AutoAnnouncePolicy struct that encodes the user's +// auto-announce trigger gating rules. Covers master-on/off behavior, +// per-trigger toggle independence, and the snapshot reader. +// + +import XCTest +@testable import ColumbaApp + +final class AutoAnnouncePolicyTests: XCTestCase { + /// Per-test scratch UserDefaults so we don't leak into the real + /// `UserDefaults.standard` and persist across runs. + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "test.AutoAnnouncePolicy.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + // MARK: - Construction + + func testDirectInitializerStoresAllFlags() { + let p = AutoAnnouncePolicy( + masterEnabled: true, + onInterval: false, + onTcpReconnect: true, + onPeerSpawned: false + ) + XCTAssertTrue(p.masterEnabled) + XCTAssertFalse(p.onInterval) + XCTAssertTrue(p.onTcpReconnect) + XCTAssertFalse(p.onPeerSpawned) + } + + // MARK: - Master gate + + func testMasterOffSuppressesAllTriggersEvenWhenAllGranularsOn() { + defaults.set(false, forKey: "auto_announce_enabled") + defaults.set(true, forKey: "auto_announce_on_interval") + defaults.set(true, forKey: "auto_announce_on_tcp_reconnect") + defaults.set(true, forKey: "auto_announce_on_peer_spawned") + + let p = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertFalse(p.shouldFireOnInterval, "master off must suppress interval") + XCTAssertFalse(p.shouldFireOnTcpReconnect, "master off must suppress tcp-reconnect") + XCTAssertFalse(p.shouldFireOnPeerSpawned, "master off must suppress peer-spawned") + } + + // MARK: - Granular toggles + + func testEachGranularToggleGatesIndependently() { + // master on, only interval enabled + defaults.set(true, forKey: "auto_announce_enabled") + defaults.set(true, forKey: "auto_announce_on_interval") + defaults.set(false, forKey: "auto_announce_on_tcp_reconnect") + defaults.set(false, forKey: "auto_announce_on_peer_spawned") + + let only_interval = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertTrue(only_interval.shouldFireOnInterval) + XCTAssertFalse(only_interval.shouldFireOnTcpReconnect) + XCTAssertFalse(only_interval.shouldFireOnPeerSpawned) + + // master on, only tcp-reconnect enabled + defaults.set(false, forKey: "auto_announce_on_interval") + defaults.set(true, forKey: "auto_announce_on_tcp_reconnect") + defaults.set(false, forKey: "auto_announce_on_peer_spawned") + let only_reconnect = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertFalse(only_reconnect.shouldFireOnInterval) + XCTAssertTrue(only_reconnect.shouldFireOnTcpReconnect) + XCTAssertFalse(only_reconnect.shouldFireOnPeerSpawned) + + // master on, only peer-spawned enabled + defaults.set(false, forKey: "auto_announce_on_interval") + defaults.set(false, forKey: "auto_announce_on_tcp_reconnect") + defaults.set(true, forKey: "auto_announce_on_peer_spawned") + let only_peer = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertFalse(only_peer.shouldFireOnInterval) + XCTAssertFalse(only_peer.shouldFireOnTcpReconnect) + XCTAssertTrue(only_peer.shouldFireOnPeerSpawned) + } + + func testAllGranularsOnFiresAll() { + defaults.set(true, forKey: "auto_announce_enabled") + defaults.set(true, forKey: "auto_announce_on_interval") + defaults.set(true, forKey: "auto_announce_on_tcp_reconnect") + defaults.set(true, forKey: "auto_announce_on_peer_spawned") + + let p = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertTrue(p.shouldFireOnInterval) + XCTAssertTrue(p.shouldFireOnTcpReconnect) + XCTAssertTrue(p.shouldFireOnPeerSpawned) + } + + func testAllGranularsOffFiresNone() { + defaults.set(true, forKey: "auto_announce_enabled") + defaults.set(false, forKey: "auto_announce_on_interval") + defaults.set(false, forKey: "auto_announce_on_tcp_reconnect") + defaults.set(false, forKey: "auto_announce_on_peer_spawned") + + let p = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertFalse(p.shouldFireOnInterval) + XCTAssertFalse(p.shouldFireOnTcpReconnect) + XCTAssertFalse(p.shouldFireOnPeerSpawned) + } + + // 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() { + 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) + } + + // MARK: - Snapshot semantics + + /// The struct is a snapshot — changing UserDefaults after `current()` + /// returned must not retroactively change the policy. Catches any + /// accidental future refactor that holds a defaults reference. + func testSnapshotIsImmutableAfterCapture() { + defaults.set(true, forKey: "auto_announce_enabled") + defaults.set(true, forKey: "auto_announce_on_interval") + let snapshot = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertTrue(snapshot.shouldFireOnInterval) + + // Flip the master AFTER snapshotting + defaults.set(false, forKey: "auto_announce_enabled") + XCTAssertTrue(snapshot.shouldFireOnInterval, "captured snapshot must not reflect later writes") + + // A fresh snapshot does see the new value + let fresh = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertFalse(fresh.shouldFireOnInterval) + } + + // MARK: - Default-true registration contract + // + // SettingsViewModel.loadLocalSettings calls defaults.register(defaults: [...]) + // for the four auto_announce_* keys with value `true`, so a fresh install + // (where the keys were never explicitly written) reads as all-on. This test + // validates that contract on a per-suite scratch defaults — protects against + // a future refactor that drops the registration or flips a default to false. + + func testRegisterDefaultsTrueProducesAllFireForFreshInstall() { + defaults.register(defaults: [ + "auto_announce_enabled": true, + "auto_announce_on_interval": true, + "auto_announce_on_tcp_reconnect": true, + "auto_announce_on_peer_spawned": true, + ]) + + let p = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertTrue(p.masterEnabled) + XCTAssertTrue(p.onInterval) + XCTAssertTrue(p.onTcpReconnect) + XCTAssertTrue(p.onPeerSpawned) + XCTAssertTrue(p.shouldFireOnInterval) + XCTAssertTrue(p.shouldFireOnTcpReconnect) + XCTAssertTrue(p.shouldFireOnPeerSpawned) + } + + // MARK: - Peer-child attribution + // + // `onInterfaceConnected` fires for peer-children of AutoInterface / BLE / + // MPC parents in addition to standalone TCP / RNode interfaces. When the + // user disables the peer-spawned toggle but leaves tcp-reconnect on, a + // peer joining must NOT produce an announce — even though the peer's + // child transport's `.connected` transition triggers `onInterfaceConnected`. + // The policy attributes peer-child connected events to the peer-spawned + // gate, not tcp-reconnect. + + func testPeerChildConnectedGatedByPeerSpawnedNotTcpReconnect() { + // peer-spawned OFF, tcp-reconnect ON — peer-child connected must NOT fire + let p1 = AutoAnnouncePolicy(masterEnabled: true, onInterval: false, + onTcpReconnect: true, onPeerSpawned: false) + XCTAssertFalse(p1.shouldFireOnInterfaceConnected(isPeerChild: true), + "peer-child connected gated by peer-spawned (off)") + XCTAssertTrue(p1.shouldFireOnInterfaceConnected(isPeerChild: false), + "non-peer-child connected gated by tcp-reconnect (on)") + + // peer-spawned ON, tcp-reconnect OFF — peer-child connected must fire + let p2 = AutoAnnouncePolicy(masterEnabled: true, onInterval: false, + onTcpReconnect: false, onPeerSpawned: true) + XCTAssertTrue(p2.shouldFireOnInterfaceConnected(isPeerChild: true), + "peer-child connected gated by peer-spawned (on)") + XCTAssertFalse(p2.shouldFireOnInterfaceConnected(isPeerChild: false), + "non-peer-child connected gated by tcp-reconnect (off)") + } + + func testPeerChildAttributionRespectsMasterGate() { + // master off → never fires regardless of peer-child or granulars + let p = AutoAnnouncePolicy(masterEnabled: false, onInterval: true, + onTcpReconnect: true, onPeerSpawned: true) + XCTAssertFalse(p.shouldFireOnInterfaceConnected(isPeerChild: true)) + XCTAssertFalse(p.shouldFireOnInterfaceConnected(isPeerChild: false)) + } + + func testPeerChildAttributionAllOn() { + let p = AutoAnnouncePolicy(masterEnabled: true, onInterval: true, + onTcpReconnect: true, onPeerSpawned: true) + XCTAssertTrue(p.shouldFireOnInterfaceConnected(isPeerChild: true)) + XCTAssertTrue(p.shouldFireOnInterfaceConnected(isPeerChild: false)) + } + + func testPeerChildAttributionAllGranularsOff() { + let p = AutoAnnouncePolicy(masterEnabled: true, onInterval: false, + onTcpReconnect: false, onPeerSpawned: false) + XCTAssertFalse(p.shouldFireOnInterfaceConnected(isPeerChild: true)) + XCTAssertFalse(p.shouldFireOnInterfaceConnected(isPeerChild: false)) + } + + /// Explicit user writes always override the registered default. + func testExplicitFalseOverridesRegisteredDefaultTrue() { + defaults.register(defaults: [ + "auto_announce_enabled": true, + "auto_announce_on_interval": true, + ]) + defaults.set(false, forKey: "auto_announce_on_interval") + + let p = AutoAnnouncePolicy.current(defaults: defaults) + XCTAssertTrue(p.masterEnabled, "registered default-true survives") + XCTAssertFalse(p.onInterval, "explicit false overrides registered default") + XCTAssertFalse(p.shouldFireOnInterval) + } +} diff --git a/Tests/ColumbaAppTests/MapStyleURLTests.swift b/Tests/ColumbaAppTests/MapStyleURLTests.swift new file mode 100644 index 0000000..852815c --- /dev/null +++ b/Tests/ColumbaAppTests/MapStyleURLTests.swift @@ -0,0 +1,22 @@ +#if os(iOS) +import XCTest +@testable import ColumbaApp + +@available(iOS 17.0, *) +final class MapStyleURLTests: XCTestCase { + + func testStyleURL_lightMode() { + XCTAssertEqual( + mapStyleURL(forDarkMode: false).absoluteString, + "https://tiles.openfreemap.org/styles/liberty" + ) + } + + func testStyleURL_darkMode() { + XCTAssertEqual( + mapStyleURL(forDarkMode: true).absoluteString, + "https://tiles.openfreemap.org/styles/dark" + ) + } +} +#endif diff --git a/Tests/ColumbaAppTests/MicronParserTests.swift b/Tests/ColumbaAppTests/MicronParserTests.swift index 2179bf2..b0179d2 100644 --- a/Tests/ColumbaAppTests/MicronParserTests.swift +++ b/Tests/ColumbaAppTests/MicronParserTests.swift @@ -172,6 +172,173 @@ final class MicronParserTests: XCTestCase { } else { XCTFail("Expected text") } } + // MARK: - Cross-line Formatting State (issue #31) + + func testStylePersistsAcrossLines() { + let doc = MicronParser.parse("`!bold-on-line-1\nplain-on-line-2") + XCTAssertEqual(doc.elements.count, 2) + guard case .paragraph(let line1Spans, _, _) = doc.elements[0] else { + XCTFail("Expected paragraph at 0"); return + } + XCTAssertEqual(line1Spans.count, 1) + guard case .text(let t1, let s1) = line1Spans[0] else { + XCTFail("Expected text on line 1"); return + } + XCTAssertEqual(t1, "bold-on-line-1") + XCTAssertTrue(s1.bold) + + guard case .paragraph(let line2Spans, _, _) = doc.elements[1] else { + XCTFail("Expected paragraph at 1"); return + } + XCTAssertEqual(line2Spans.count, 1) + guard case .text(let t2, let s2) = line2Spans[0] else { + XCTFail("Expected text on line 2"); return + } + XCTAssertEqual(t2, "plain-on-line-2") + XCTAssertTrue(s2.bold) // bold from line 1 carries because never toggled off + } + + func testColorPreambleAppliesToFollowingLine() { + let doc = MicronParser.parse("`F0ff`B52f\nART") + XCTAssertEqual(doc.elements.count, 2) + guard case .paragraph(let preambleSpans, _, _) = doc.elements[0] else { + XCTFail("Expected paragraph at 0"); return + } + XCTAssertEqual(preambleSpans.count, 0) // color codes consumed; no text + + guard case .paragraph(let artSpans, _, _) = doc.elements[1] else { + XCTFail("Expected paragraph at 1"); return + } + XCTAssertEqual(artSpans.count, 1) + guard case .text(let text, let style) = artSpans[0] else { + XCTFail("Expected text on ART line"); return + } + XCTAssertEqual(text, "ART") + XCTAssertEqual(style.foregroundColor, "0ff") + XCTAssertEqual(style.backgroundColor, "52f") + } + + func testResetSequenceClearsStyleAcrossLines() { + let doc = MicronParser.parse("`Ff00colored\n`f\nplain") + XCTAssertEqual(doc.elements.count, 3) + + guard case .paragraph(let line1Spans, _, _) = doc.elements[0] else { + XCTFail("Expected paragraph at 0"); return + } + XCTAssertEqual(line1Spans.count, 1) + if case .text(let t, let s) = line1Spans[0] { + XCTAssertEqual(t, "colored") + XCTAssertEqual(s.foregroundColor, "f00") + } else { XCTFail("Expected text on line 1") } + + guard case .paragraph(let line2Spans, _, _) = doc.elements[1] else { + XCTFail("Expected paragraph at 1"); return + } + XCTAssertEqual(line2Spans.count, 0) // bare `f consumes; no text spans + + guard case .paragraph(let line3Spans, _, _) = doc.elements[2] else { + XCTFail("Expected paragraph at 2"); return + } + XCTAssertEqual(line3Spans.count, 1) + if case .text(let t, let s) = line3Spans[0] { + XCTAssertEqual(t, "plain") + XCTAssertNil(s.foregroundColor) // reset on line 2 must persist to line 3 + } else { XCTFail("Expected text on line 3") } + } + + func testDoubleBacktickResetPersists() { + // `!`*styled`` carries no styles into the next line. + let doc = MicronParser.parse("`!`*styled``\nplain") + XCTAssertEqual(doc.elements.count, 2) + + guard case .paragraph(let line2Spans, _, _) = doc.elements[1] else { + XCTFail("Expected paragraph at 1"); return + } + XCTAssertEqual(line2Spans.count, 1) + guard case .text(let t, let s) = line2Spans[0] else { + XCTFail("Expected text on line 2"); return + } + XCTAssertEqual(t, "plain") + XCTAssertFalse(s.bold) + XCTAssertFalse(s.italic) + XCTAssertFalse(s.underline) + XCTAssertNil(s.foregroundColor) + XCTAssertNil(s.backgroundColor) + } + + /// Regression sentinel for the chat-room page (issue #31). A trimmed but + /// structurally representative chunk: `Faff prefix, then `F0ff`B52f + /// preamble before the ASCII art, then `f`b reset. + func testTheChatRoomFixture() { + let markup = """ + `Faff Welcome To: + + `F0ff`B52f + ART + `f`b + """ + let doc = MicronParser.parse(markup) + XCTAssertEqual(doc.elements.count, 5) + + // Line 0: `Faff Welcome To: → fg=aff + guard case .paragraph(let welcomeSpans, _, _) = doc.elements[0] else { + XCTFail("Expected paragraph at 0"); return + } + XCTAssertEqual(welcomeSpans.count, 1) + if case .text(let t, let s) = welcomeSpans[0] { + XCTAssertEqual(t, " Welcome To:") + XCTAssertEqual(s.foregroundColor, "aff") + XCTAssertNil(s.backgroundColor) + } else { XCTFail("Expected text") } + + // Line 1: blank line → empty paragraph + guard case .paragraph(let blankSpans, _, _) = doc.elements[1] else { + XCTFail("Expected paragraph at 1"); return + } + XCTAssertEqual(blankSpans.count, 1) + if case .text(let t, _) = blankSpans[0] { + XCTAssertEqual(t, "") + } else { XCTFail("Expected empty text") } + + // Line 2: `F0ff`B52f preamble → no text spans + guard case .paragraph(let preambleSpans, _, _) = doc.elements[2] else { + XCTFail("Expected paragraph at 2"); return + } + XCTAssertEqual(preambleSpans.count, 0) + + // Line 3: ART must carry fg=0ff, bg=52f from the preamble + guard case .paragraph(let artSpans, _, _) = doc.elements[3] else { + XCTFail("Expected paragraph at 3"); return + } + XCTAssertEqual(artSpans.count, 1) + if case .text(let t, let s) = artSpans[0] { + XCTAssertEqual(t, "ART") + XCTAssertEqual(s.foregroundColor, "0ff") + XCTAssertEqual(s.backgroundColor, "52f") + } else { XCTFail("Expected text") } + + // Line 4: `f`b reset → no text spans + guard case .paragraph(let resetSpans, _, _) = doc.elements[4] else { + XCTFail("Expected paragraph at 4"); return + } + XCTAssertEqual(resetSpans.count, 0) + } + + func testIndentResetClearsStyle() { + // `< at line-start resets formatting state in addition to indent. + let doc = MicronParser.parse("`!bold-line\n.yml`. Use existing flows as templates. +2. Make it deterministic: `clearState: true` + `clearKeychain: true` on + launch, handle the onboarding skip path, no network-state assumptions. +3. End with `takeScreenshot: ` (the agent expects the PNG to land + at `./.png`). +4. Don't add voice-call flows yet — they need a debug-only `lxma://debug/...` + URL handler that doesn't exist (Stage 1 limitation). + +## Running locally + +```sh +export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home +export PATH="$JAVA_HOME/bin:$HOME/.maestro/bin:$PATH" +maestro --device test flows/contacts-list.yml +``` + +The `` is from `xcrun simctl list devices booted`. + +## Stage roadmap + +- **Stage 1** (now): capture + write the table to PLAN.md only. +- **Stage 2**: pixel diff column. +- **Stage 3**: regression gating (PR fails if golden flow drifts > N%). +- **Stage 4**: graduate to PR comments + GitHub-attachment uploads. + +Plan: `~/.claude/plans/ui-screenshotter.md` (vault `Agent Plans/`). diff --git a/flows/chats-list.yml b/flows/chats-list.yml new file mode 100644 index 0000000..86cd954 --- /dev/null +++ b/flows/chats-list.yml @@ -0,0 +1,34 @@ +appId: network.columba.Columba +name: chats-list +tags: + - smoke + - screenshot +--- +# Capture the Chats tab — the default landing tab. Stable enough that the +# onboarding-skip path lands here naturally without further taps. +- launchApp: + clearState: true + clearKeychain: true +- waitForAnimationToEnd: + timeout: 5000 +- runFlow: + when: + visible: "Skip" + commands: + - tapOn: "Skip" + - waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: + visible: "Get Started" + commands: + - tapOn: "Get Started" + - waitForAnimationToEnd: + timeout: 3000 +# Default tab is Chats — no tap needed if onboarding lands there. +- tapOn: + text: "Chats" + optional: true +- waitForAnimationToEnd: + timeout: 3000 +- takeScreenshot: "chats-list" diff --git a/flows/contacts-list.yml b/flows/contacts-list.yml new file mode 100644 index 0000000..7cb7839 --- /dev/null +++ b/flows/contacts-list.yml @@ -0,0 +1,38 @@ +appId: network.columba.Columba +name: contacts-list +tags: + - smoke + - screenshot +--- +# Visit the Contacts tab and capture the list state. This is the most stable +# UI surface that shows in every PR (no network needed beyond app boot — the +# contacts list renders even with no cached announces). +# +# The screenshot lands at /contacts-list.png; the orchestrator moves it +# to ~/.claude-runner/screenshots///contacts-list.png. +- launchApp: + clearState: true + clearKeychain: true +- waitForAnimationToEnd: + timeout: 5000 +# Onboarding may show on a fresh install; skip if "Skip" / "Get Started" is visible. +- runFlow: + when: + visible: "Skip" + commands: + - tapOn: "Skip" + - waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: + visible: "Get Started" + commands: + - tapOn: "Get Started" + - waitForAnimationToEnd: + timeout: 3000 +- tapOn: + text: "Contacts" + optional: true +- waitForAnimationToEnd: + timeout: 3000 +- takeScreenshot: "contacts-list" diff --git a/flows/map.yml b/flows/map.yml new file mode 100644 index 0000000..4a05aa3 --- /dev/null +++ b/flows/map.yml @@ -0,0 +1,35 @@ +appId: network.columba.Columba +name: map +tags: + - smoke + - screenshot +--- +# Capture the Map tab. PR #59/#65 changes this view's style URL based on +# system appearance. We screenshot it in the Sim's default light appearance — +# Stage 2 will add a dark-mode capture column. +- launchApp: + clearState: true + clearKeychain: true +- waitForAnimationToEnd: + timeout: 5000 +- runFlow: + when: + visible: "Skip" + commands: + - tapOn: "Skip" + - waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: + visible: "Get Started" + commands: + - tapOn: "Get Started" + - waitForAnimationToEnd: + timeout: 3000 +- tapOn: + text: "Map" + optional: true +# Maps need a beat to load tile JSON + first-render +- waitForAnimationToEnd: + timeout: 8000 +- takeScreenshot: "map" diff --git a/flows/settings.yml b/flows/settings.yml new file mode 100644 index 0000000..19416f1 --- /dev/null +++ b/flows/settings.yml @@ -0,0 +1,33 @@ +appId: network.columba.Columba +name: settings +tags: + - smoke + - screenshot +--- +# Open the Settings tab and capture the top of the panel — the most static UI +# surface in the app, ideal for catching unintended typography/theme drift. +- launchApp: + clearState: true + clearKeychain: true +- waitForAnimationToEnd: + timeout: 5000 +- runFlow: + when: + visible: "Skip" + commands: + - tapOn: "Skip" + - waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: + visible: "Get Started" + commands: + - tapOn: "Get Started" + - waitForAnimationToEnd: + timeout: 3000 +- tapOn: + text: "Settings" + optional: true +- waitForAnimationToEnd: + timeout: 3000 +- takeScreenshot: "settings"