diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce7e4f5f..5bbf99cb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,6 +2,10 @@ Target / module dependency graph for the iOS app. Mirrors the role of `docs/architecture.md` in the sibling Android repo (`columba/`). +## Subsystem deep-dives + +- [Model B — Background LXMF Delivery](docs/MODEL_B_BACKGROUND_DELIVERY.md) — how the Network Extension delivers LXMF messages + notifications while the app is backgrounded/suspended/locked (no APNS): the NE-canonical node, the control IPC + App-Group frame bridge, the load-bearing invariants, and the on-device-verified inbound/outbound/announce flows. + Regenerate this file from the current `Package.swift` + `Columba.xcodeproj/project.pbxproj`: ```sh diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index d1577b4e..bdfffc7a 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 007 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F007 /* MainTabView.swift */; }; 008 /* ChatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F008 /* ChatsView.swift */; }; 009 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F009 /* ConversationRow.swift */; }; + 00C7D4D86301E2E6D027DE0A /* PropagationSeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55FE6D8CFA13376BCD23AE86 /* PropagationSeam.swift */; }; 010 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F010 /* ContactsView.swift */; }; 011 /* ContactCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F011 /* ContactCard.swift */; }; 012 /* NetworkAnnouncesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = F012 /* NetworkAnnouncesTab.swift */; }; @@ -106,14 +107,24 @@ 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 */; }; + 0AGD /* AppGroupBLEDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAGD /* AppGroupBLEDriver.swift */; }; + 0BDS /* BLEDriverSeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDS /* BLEDriverSeam.swift */; }; + 0BST /* AppGroupBLESeamTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBST /* AppGroupBLESeamTransport.swift */; }; + 0BSV /* AppGroupBLEServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBSV /* AppGroupBLEServer.swift */; }; + 0MBS /* ModelBBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FMBS /* ModelBBLEService.swift */; }; 13F299C3C99F5D86B797FA11 /* SwiftBLEBridge in Frameworks */ = {isa = PBXBuildFile; productRef = 5BE4B79EB452909EE61ACE6C /* SwiftBLEBridge */; }; + 1FC4483D48CABE8BF49850EF /* SyncStatusBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B5525A88EBCEAF05E9DE3F /* SyncStatusBottomSheet.swift */; }; 238336F8024494A8B57F9419 /* CeaseTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF48C97880B30682DC35613C /* CeaseTelemetry.swift */; }; + 266929D6972E8D373E7A926D /* ReticulumSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 021FA3D73B6F8B711A97D40F /* ReticulumSwift */; }; 2B3A3E3FB59D1E22B424E109 /* AudioRingBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE307E24C72DBA41D76C9A6C /* AudioRingBufferTests.swift */; }; 2F8EC8212FC732AB00235991 /* ReticulumSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2F8EC8202FC732AB00235991 /* ReticulumSwift */; }; 2F8EC8232FC732AF00235991 /* LXMFSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2F8EC8222FC732AF00235991 /* LXMFSwift */; }; 35DF1F7406C71743BBE8C39B /* ReticulumSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DA31FE974552414C399D4949 /* ReticulumSwift */; }; 3A790961A270CBDE9A30BC04 /* VoiceCallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691F2029BDC2C1024AA9B01 /* VoiceCallScreen.swift */; }; + 4120DE0B1AC043758779DD40 /* ModelBRNodeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A676BFC846C7D1174C3A20CD /* ModelBRNodeService.swift */; }; + 45C9E9AFAC34D61BFB3797AA /* RNodeSeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A7FF6A056815712B1D13D9 /* RNodeSeam.swift */; }; 4758210ABE17DE6E3BE0B3F6 /* AutoAnnouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F87296BB580791A95C977B6 /* AutoAnnouncePolicy.swift */; }; + 48B07E3EF989716BF75BFEE5 /* ColumbaNetworkExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = EPROD /* ColumbaNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, CodeSignOnCopy, ); }; }; 4CC7FE5D6B0D6557B8868210 /* PythonBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710EBCE55DCC9E71A9AB0E86 /* PythonBridge.swift */; }; 5254FC2433ED759989FB1094 /* LXSTSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A49337EFC55C10979AEB702B /* LXSTSwift */; }; 557D530BBEDAEBE9A6A0BE41 /* PyConversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D8CBCF7E546028A043E1C7 /* PyConversation.swift */; }; @@ -125,19 +136,33 @@ 67079BBC2E8309A43DF576E5 /* app in Resources */ = {isa = PBXBuildFile; fileRef = D001472BC7DFD3CD7BF27F0C /* app */; }; 69B724BD147A18C5EAFD080C /* PythonConfigWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBA4F8C4C9008A06DFF5AC8A /* PythonConfigWriter.swift */; }; 6B53DC3B0EC0F0F6AF49E066 /* BackendPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB2569A3C1BFBFDD77F7E639 /* BackendPreference.swift */; }; + 6CFC16593387554678F3928F /* RNodeSeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A7FF6A056815712B1D13D9 /* RNodeSeam.swift */; }; 71FAB7848D244A6D2FC1628A /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A8D344945F752C18EF2D9E /* AudioManager.swift */; }; 779118E89F4D38BF960DB3D0 /* PyAnnounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C562D79BB3B6559A1B1EFDA /* PyAnnounce.swift */; }; 7BE550B9F4D32F6346EBD2F2 /* CodecProfileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA6D4C7151A87D862FF9B8A /* CodecProfileInfo.swift */; }; 8768F2E6CD7941D82997A1BB /* CallControlButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71922EC204982A357F814F23 /* CallControlButton.swift */; }; 886AB689C7699471510BAF9A /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86E428836698BA8A8973A92F /* CallManager.swift */; }; 8A321B0938566F0D62D64562 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3BEE6E339CC852C9BA8C5D75 /* Python.xcframework */; }; + 8A784DFE5A52489803B27984 /* lucide.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FF6CE45231CD48906B9C226D /* lucide.ttf */; }; 922658C82CEEA53695143F9B /* MapLibre in Frameworks */ = {isa = PBXBuildFile; productRef = DBD8F3A253D413F087742BC0 /* MapLibre */; }; 92DDF4AE0AB493CC2AA0BA20 /* LXSTSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 6000D5CED2C3C89F74999BC1 /* LXSTSwift */; }; 9566DCEF56FEFC97BAAA47BA /* MessageFormattedTimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC4EF2D71A3D88C71D5BEDAD /* MessageFormattedTimeTests.swift */; }; 98547ADE9B17DD692240E7F7 /* LXMFSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 85B9530D8CAE0E16D5371319 /* LXMFSwift */; }; 9D9069A3F6302111A4727454 /* SwiftBLEBridge in Frameworks */ = {isa = PBXBuildFile; productRef = DD88CFE74E7E22427BC4D163 /* SwiftBLEBridge */; }; + 9E99C06B5658EA687323CF82 /* AppGroupRNodeServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36D94E7D28E35241C46426 /* AppGroupRNodeServer.swift */; }; A0C0D9AE12F728A484F0F108 /* AudioManagerConfigChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE282FA3937E59BC026E072 /* AudioManagerConfigChangeTests.swift */; }; + A6EA4093A9929FCEAB44C4B6 /* AppGroupRNodeSeamTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 550749F04079B0D39E4DAD05 /* AppGroupRNodeSeamTransport.swift */; }; + A746EE4A4494C45D97908924 /* AppGroupRNodeSeamWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34E6D26205158570EA4228EF /* AppGroupRNodeSeamWire.swift */; }; + A9DB03705BE9BA74A494310C /* AppGroupRNodeServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36D94E7D28E35241C46426 /* AppGroupRNodeServer.swift */; }; + AAD9231170B11C89EF80F9B8 /* PropagationSeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55FE6D8CFA13376BCD23AE86 /* PropagationSeam.swift */; }; AC4014BF281AD8BE6FF9E852 /* PeerChildInterfaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8A2F8952F6F036C94824552 /* PeerChildInterfaceRegistry.swift */; }; + AGB1B /* AppGroupBridgeInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGBF /* AppGroupBridgeInterface.swift */; }; + AGB2B /* AppGroupBridgeInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGBF /* AppGroupBridgeInterface.swift */; }; + AGP1B /* AppGroupPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGPF /* AppGroupPaths.swift */; }; + AGP2B /* AppGroupPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGPF /* AppGroupPaths.swift */; }; + BB2453727F7CFDAF2E0B196F /* AppGroupRNodeSeamTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 550749F04079B0D39E4DAD05 /* AppGroupRNodeSeamTransport.swift */; }; + BDC9FF09929596ECF0A1037F /* LXMFSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B528C71CCBFAB3CB30E0BFB2 /* LXMFSwift */; }; + BGTB /* BackgroundTransportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BGTF /* BackgroundTransportView.swift */; }; BKF001 /* BackendFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BKF002 /* BackendFactory.swift */; }; C2487934101CA21C68F1F458 /* PythonRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C41F9E953D0445F3C34AE7 /* PythonRuntime.swift */; }; C91321B8E0BA9F487D5E1AC8 /* PyMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CBD293157E715F490613984 /* PyMessage.swift */; }; @@ -148,17 +173,38 @@ E002 /* SharedFrameQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = F076 /* SharedFrameQueue.swift */; }; E49B977D311DA1C9ACE14CAB /* CallManagerCallKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A79B9ECDB4166F8A36A670 /* CallManagerCallKitTests.swift */; }; EA609BF7A35298B1651160C3 /* PttButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68384C48BFF8F5294340EDB /* PttButton.swift */; }; + EAGD /* AppGroupBLEDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAGD /* AppGroupBLEDriver.swift */; }; + EBDS /* BLEDriverSeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDS /* BLEDriverSeam.swift */; }; + EBST /* AppGroupBLESeamTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBST /* AppGroupBLESeamTransport.swift */; }; + EBSV /* AppGroupBLEServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBSV /* AppGroupBLEServer.swift */; }; EDF2F6B945FAA23366617FD6 /* TCPClientWizardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD87870C760723D7E77C87E9 /* TCPClientWizardViewModel.swift */; }; + EDL1B /* ExtensionDiagLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDLF /* ExtensionDiagLog.swift */; }; + EDL2B /* ExtensionDiagLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDLF /* ExtensionDiagLog.swift */; }; F4CA7E2F745F05E21D45E11A /* RNSAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 0FD6A68A52A54D21FDB70324 /* RNSAPI */; }; + F4E9991226B4D464017DA247 /* Lucide.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA586EB5F8EB62D2579CEAAB /* Lucide.swift */; }; F80B09722B3A7CCA7C99DE0C /* DiscoveredDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9630845DA60F57A34819CC4B /* DiscoveredDevice.swift */; }; FB2F3248AE405614B36BC798 /* RNSAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E049A7D4D6D58690C0B674CE /* RNSAPI */; }; + FDE0DB7957C9D877D3E67367 /* AppGroupRNodeSeamWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34E6D26205158570EA4228EF /* AppGroupRNodeSeamWire.swift */; }; + NERN2 /* NEReticulumNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = NERN1 /* NEReticulumNode.swift */; }; + OBQ1B /* OutboxQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBQF /* OutboxQueue.swift */; }; + OBQ2B /* OutboxQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBQF /* OutboxQueue.swift */; }; PNT001 /* PythonNetworkTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = PNT002 /* PythonNetworkTransport.swift */; }; - PRC001 /* PythonRNodeCallbackBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = PRC002 /* PythonRNodeCallbackBridge.swift */; }; + PRB001 /* ProxyRnsBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = PRB002 /* ProxyRnsBackend.swift */; }; + PXI1B /* ProxyIPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = PXIF /* ProxyIPC.swift */; }; + PXI2B /* ProxyIPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = PXIF /* ProxyIPC.swift */; }; SRB001 /* SwiftRNSBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = SRB002 /* SwiftRNSBackend.swift */; }; T003 /* MicronParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT03 /* MicronParserTests.swift */; }; + TBDT /* BLESeamDriverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDT /* BLESeamDriverTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + C6825ACD06BF2495019C47AB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = PROJ /* Project object */; + proxyType = 1; + remoteGlobalIDString = ETARG; + remoteInfo = ColumbaNetworkExtension; + }; TTPROXY /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = PROJ /* Project object */; @@ -169,6 +215,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 039DD8D9BCB53A1A02C04D9C /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 48B07E3EF989716BF75BFEE5 /* ColumbaNetworkExtension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9796AB46906D510ACD8F0AB1 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -188,10 +245,16 @@ 1F7375908681A4DF99F125C7 /* CallKitManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CallKitManager.swift; sourceTree = ""; }; 30A79B9ECDB4166F8A36A670 /* CallManagerCallKitTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CallManagerCallKitTests.swift; sourceTree = ""; }; 34E1ECAE91B0A2C56D0FC8AA /* PyLocalIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PyLocalIdentity.swift; path = Python/Models/PyLocalIdentity.swift; sourceTree = ""; }; + 34E6D26205158570EA4228EF /* AppGroupRNodeSeamWire.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppGroupRNodeSeamWire.swift; sourceTree = ""; }; 3BEE6E339CC852C9BA8C5D75 /* Python.xcframework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.xcframework; name = Python.xcframework; path = Frameworks/Python.xcframework; sourceTree = ""; }; 3C562D79BB3B6559A1B1EFDA /* PyAnnounce.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PyAnnounce.swift; path = Python/Models/PyAnnounce.swift; sourceTree = ""; }; 3D4C54CECCAF0B117FB6C197 /* IncomingCallScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IncomingCallScreen.swift; sourceTree = ""; }; + 43A7FF6A056815712B1D13D9 /* RNodeSeam.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RNodeSeam.swift; sourceTree = ""; }; + 4E36D94E7D28E35241C46426 /* AppGroupRNodeServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppGroupRNodeServer.swift; sourceTree = ""; }; 51D8CBCF7E546028A043E1C7 /* PyConversation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PyConversation.swift; path = Python/Models/PyConversation.swift; sourceTree = ""; }; + 550749F04079B0D39E4DAD05 /* AppGroupRNodeSeamTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppGroupRNodeSeamTransport.swift; sourceTree = ""; }; + 55FE6D8CFA13376BCD23AE86 /* PropagationSeam.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PropagationSeam.swift; sourceTree = ""; }; + 67B5525A88EBCEAF05E9DE3F /* SyncStatusBottomSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncStatusBottomSheet.swift; sourceTree = ""; }; 6FE282FA3937E59BC026E072 /* AudioManagerConfigChangeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioManagerConfigChangeTests.swift; sourceTree = ""; }; 710EBCE55DCC9E71A9AB0E86 /* PythonBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonBridge.swift; sourceTree = ""; }; 71922EC204982A357F814F23 /* CallControlButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CallControlButton.swift; sourceTree = ""; }; @@ -203,20 +266,26 @@ 96EBCA636D502CF4367E32C7 /* TCPClientWizard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TCPClientWizard.swift; sourceTree = ""; }; 9F87296BB580791A95C977B6 /* AutoAnnouncePolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AutoAnnouncePolicy.swift; sourceTree = ""; }; A0C41F9E953D0445F3C34AE7 /* PythonRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonRuntime.swift; sourceTree = ""; }; + A676BFC846C7D1174C3A20CD /* ModelBRNodeService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ModelBRNodeService.swift; sourceTree = ""; }; A8A2F8952F6F036C94824552 /* PeerChildInterfaceRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PeerChildInterfaceRegistry.swift; sourceTree = ""; }; ACA6D4C7151A87D862FF9B8A /* CodecProfileInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CodecProfileInfo.swift; sourceTree = ""; }; AD87870C760723D7E77C87E9 /* TCPClientWizardViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModel.swift; sourceTree = ""; }; + AGBF /* AppGroupBridgeInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupBridgeInterface.swift; sourceTree = ""; }; + AGPF /* AppGroupPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupPaths.swift; sourceTree = ""; }; B1AB71D8977FE792BA9B176D /* JetBrainsMono-Bold.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = "JetBrainsMono-Bold.ttf"; sourceTree = ""; }; B68384C48BFF8F5294340EDB /* PttButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PttButton.swift; sourceTree = ""; }; BC4EF2D71A3D88C71D5BEDAD /* MessageFormattedTimeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageFormattedTimeTests.swift; sourceTree = ""; }; BF48C97880B30682DC35613C /* CeaseTelemetry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CeaseTelemetry.swift; sourceTree = ""; }; + BGTF /* BackgroundTransportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTransportView.swift; sourceTree = ""; }; BKF002 /* BackendFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendFactory.swift; sourceTree = ""; }; CCF4DCA18506B96230721ACC /* PythonBLECallbackBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonBLECallbackBridge.swift; sourceTree = ""; }; D001472BC7DFD3CD7BF27F0C /* app */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = app; sourceTree = ""; }; D691F2029BDC2C1024AA9B01 /* VoiceCallScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoiceCallScreen.swift; sourceTree = ""; }; + DA586EB5F8EB62D2579CEAAB /* Lucide.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Lucide.swift; sourceTree = ""; }; DE307E24C72DBA41D76C9A6C /* AudioRingBufferTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioRingBufferTests.swift; sourceTree = ""; }; E4AB432DD1264CAE1F35B22A /* JetBrainsMono-Regular.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = "JetBrainsMono-Regular.ttf"; sourceTree = ""; }; EBA4F8C4C9008A06DFF5AC8A /* PythonConfigWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonConfigWriter.swift; sourceTree = ""; }; + EDLF /* ExtensionDiagLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionDiagLog.swift; sourceTree = ""; }; 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 = ""; }; F002 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; @@ -318,14 +387,24 @@ 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 = ""; }; + FAGD /* AppGroupBLEDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupBLEDriver.swift; sourceTree = ""; }; FB2569A3C1BFBFDD77F7E639 /* BackendPreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackendPreference.swift; sourceTree = ""; }; + FBDS /* BLEDriverSeam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEDriverSeam.swift; sourceTree = ""; }; + FBDT /* BLESeamDriverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BLESeamDriverTests.swift; sourceTree = ""; }; + FBST /* AppGroupBLESeamTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupBLESeamTransport.swift; sourceTree = ""; }; + FBSV /* AppGroupBLEServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupBLEServer.swift; sourceTree = ""; }; FE01 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; FE02 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FE03 /* ColumbaNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ColumbaNetworkExtension.entitlements; sourceTree = ""; }; + FF6CE45231CD48906B9C226D /* lucide.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = lucide.ttf; sourceTree = ""; }; + FMBS /* ModelBBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelBBLEService.swift; sourceTree = ""; }; FT03 /* MicronParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronParserTests.swift; sourceTree = ""; }; + NERN1 /* NEReticulumNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NEReticulumNode.swift; sourceTree = ""; }; + OBQF /* OutboxQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxQueue.swift; sourceTree = ""; }; PNT002 /* PythonNetworkTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonNetworkTransport.swift; sourceTree = ""; }; - PRC002 /* PythonRNodeCallbackBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonRNodeCallbackBridge.swift; sourceTree = ""; }; + PRB002 /* ProxyRnsBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProxyRnsBackend.swift; path = Sources/RNSBackendProxy/ProxyRnsBackend.swift; sourceTree = SOURCE_ROOT; }; PROD /* ColumbaApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ColumbaApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + PXIF /* ProxyIPC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyIPC.swift; sourceTree = ""; }; SRB002 /* SwiftRNSBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwiftRNSBackend.swift; path = Sources/RNSBackendSwift/SwiftRNSBackend.swift; sourceTree = SOURCE_ROOT; }; TPROD /* ColumbaAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ColumbaAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -335,6 +414,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 266929D6972E8D373E7A926D /* ReticulumSwift in Frameworks */, + BDC9FF09929596ECF0A1037F /* LXMFSwift in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -429,6 +510,7 @@ children = ( 8CAFC5BF3DDB882B1864F1C0 /* PythonRNSBackend.swift */, SRB002 /* SwiftRNSBackend.swift */, + PRB002 /* ProxyRnsBackend.swift */, ); name = RNSBackendPy; path = Sources/RNSBackendPy; @@ -441,7 +523,6 @@ A0C41F9E953D0445F3C34AE7 /* PythonRuntime.swift */, 11D4DB375C0C7BB62E8A8B23 /* ColumbaPython-Bridging-Header.h */, CCF4DCA18506B96230721ACC /* PythonBLECallbackBridge.swift */, - PRC002 /* PythonRNodeCallbackBridge.swift */, ); name = PythonBridge; path = Sources/PythonBridge; @@ -494,6 +575,8 @@ F020 /* IdenticonGenerator.swift */, F036 /* MaterialDesignIcons.swift */, F037 /* ProfileIcon.swift */, + DA586EB5F8EB62D2579CEAAB /* Lucide.swift */, + 67B5525A88EBCEAF05E9DE3F /* SyncStatusBottomSheet.swift */, ); path = Components; sourceTree = ""; @@ -515,6 +598,7 @@ isa = PBXGroup; children = ( FE01 /* PacketTunnelProvider.swift */, + NERN1 /* NEReticulumNode.swift */, FE02 /* Info.plist */, FE03 /* ColumbaNetworkExtension.entitlements */, ); @@ -592,6 +676,7 @@ F075 /* ColumbaApp.entitlements */, E4AB432DD1264CAE1F35B22A /* JetBrainsMono-Regular.ttf */, B1AB71D8977FE792BA9B176D /* JetBrainsMono-Bold.ttf */, + FF6CE45231CD48906B9C226D /* lucide.ttf */, ); path = Resources; sourceTree = ""; @@ -618,6 +703,7 @@ F018 /* ExpandableSettingsCard.swift */, F031 /* InterfaceManagementScreen.swift */, F035 /* NetworkStatusView.swift */, + BGTF /* BackgroundTransportView.swift */, F038 /* IconPickerView.swift */, F044 /* IdentityManagerView.swift */, F046 /* BLEDevicePickerSheet.swift */, @@ -636,6 +722,20 @@ isa = PBXGroup; children = ( F076 /* SharedFrameQueue.swift */, + FBST /* AppGroupBLESeamTransport.swift */, + FBSV /* AppGroupBLEServer.swift */, + FBDS /* BLEDriverSeam.swift */, + FAGD /* AppGroupBLEDriver.swift */, + AGBF /* AppGroupBridgeInterface.swift */, + EDLF /* ExtensionDiagLog.swift */, + AGPF /* AppGroupPaths.swift */, + PXIF /* ProxyIPC.swift */, + OBQF /* OutboxQueue.swift */, + 43A7FF6A056815712B1D13D9 /* RNodeSeam.swift */, + 34E6D26205158570EA4228EF /* AppGroupRNodeSeamWire.swift */, + 550749F04079B0D39E4DAD05 /* AppGroupRNodeSeamTransport.swift */, + 4E36D94E7D28E35241C46426 /* AppGroupRNodeServer.swift */, + 55FE6D8CFA13376BCD23AE86 /* PropagationSeam.swift */, ); path = Sources/Shared; sourceTree = ""; @@ -663,6 +763,7 @@ F074 /* SharedDefaults.swift */, F077 /* TunnelManager.swift */, F078 /* ExtensionFrameReader.swift */, + FMBS /* ModelBBLEService.swift */, F07F /* NomadNetBrowserService.swift */, EBA4F8C4C9008A06DFF5AC8A /* PythonConfigWriter.swift */, 00A8D344945F752C18EF2D9E /* AudioManager.swift */, @@ -673,6 +774,7 @@ BF48C97880B30682DC35613C /* CeaseTelemetry.swift */, 9F87296BB580791A95C977B6 /* AutoAnnouncePolicy.swift */, A8A2F8952F6F036C94824552 /* PeerChildInterfaceRegistry.swift */, + A676BFC846C7D1174C3A20CD /* ModelBRNodeService.swift */, ); path = Services; sourceTree = ""; @@ -682,6 +784,7 @@ children = ( FT03 /* MicronParserTests.swift */, DE307E24C72DBA41D76C9A6C /* AudioRingBufferTests.swift */, + FBDT /* BLESeamDriverTests.swift */, 6FE282FA3937E59BC026E072 /* AudioManagerConfigChangeTests.swift */, 30A79B9ECDB4166F8A36A670 /* CallManagerCallKitTests.swift */, BC4EF2D71A3D88C71D5BEDAD /* MessageFormattedTimeTests.swift */, @@ -793,6 +896,10 @@ dependencies = ( ); name = ColumbaNetworkExtension; + packageProductDependencies = ( + 021FA3D73B6F8B711A97D40F /* ReticulumSwift */, + B528C71CCBFAB3CB30E0BFB2 /* LXMFSwift */, + ); productName = ColumbaNetworkExtension; productReference = EPROD /* ColumbaNetworkExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -806,10 +913,12 @@ RESBP /* Resources */, 9796AB46906D510ACD8F0AB1 /* Embed Frameworks */, CF4F87412D5E39178E82799E /* Install Python stdlib & process dylibs */, + 039DD8D9BCB53A1A02C04D9C /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + B4BE17F06FA1F20BA8E46747 /* PBXTargetDependency */, ); name = ColumbaApp; packageProductDependencies = ( @@ -908,6 +1017,7 @@ 67079BBC2E8309A43DF576E5 /* app in Resources */, DE9220E1D4ABF9A4C2CBA761 /* JetBrainsMono-Regular.ttf in Resources */, 5F1FF2954475331208BAAD47 /* JetBrainsMono-Bold.ttf in Resources */, + 8A784DFE5A52489803B27984 /* lucide.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -944,6 +1054,21 @@ files = ( E001 /* PacketTunnelProvider.swift in Sources */, E002 /* SharedFrameQueue.swift in Sources */, + EBST /* AppGroupBLESeamTransport.swift in Sources */, + EBSV /* AppGroupBLEServer.swift in Sources */, + EBDS /* BLEDriverSeam.swift in Sources */, + EAGD /* AppGroupBLEDriver.swift in Sources */, + EDL2B /* ExtensionDiagLog.swift in Sources */, + AGP2B /* AppGroupPaths.swift in Sources */, + AGB2B /* AppGroupBridgeInterface.swift in Sources */, + NERN2 /* NEReticulumNode.swift in Sources */, + PXI2B /* ProxyIPC.swift in Sources */, + OBQ2B /* OutboxQueue.swift in Sources */, + 45C9E9AFAC34D61BFB3797AA /* RNodeSeam.swift in Sources */, + FDE0DB7957C9D877D3E67367 /* AppGroupRNodeSeamWire.swift in Sources */, + BB2453727F7CFDAF2E0B196F /* AppGroupRNodeSeamTransport.swift in Sources */, + 9E99C06B5658EA687323CF82 /* AppGroupRNodeServer.swift in Sources */, + 00C7D4D86301E2E6D027DE0A /* PropagationSeam.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1033,8 +1158,19 @@ 071B /* BLEConnectionsView.swift in Sources */, 074B /* SharedDefaults.swift in Sources */, 076B /* SharedFrameQueue.swift in Sources */, + 0BST /* AppGroupBLESeamTransport.swift in Sources */, + 0BSV /* AppGroupBLEServer.swift in Sources */, + 0BDS /* BLEDriverSeam.swift in Sources */, + 0AGD /* AppGroupBLEDriver.swift in Sources */, + AGB1B /* AppGroupBridgeInterface.swift in Sources */, + EDL1B /* ExtensionDiagLog.swift in Sources */, + AGP1B /* AppGroupPaths.swift in Sources */, + PXI1B /* ProxyIPC.swift in Sources */, + OBQ1B /* OutboxQueue.swift in Sources */, 077B /* TunnelManager.swift in Sources */, + BGTB /* BackgroundTransportView.swift in Sources */, 078B /* ExtensionFrameReader.swift in Sources */, + 0MBS /* ModelBBLEService.swift in Sources */, 079B /* OnboardingRestoreSheet.swift in Sources */, 07AB /* PlatformCompat.swift in Sources */, 07CB /* MicronDocument.swift in Sources */, @@ -1054,6 +1190,7 @@ C2487934101CA21C68F1F458 /* PythonRuntime.swift in Sources */, 668C0A33D82AB3D07CC52E83 /* PythonRNSBackend.swift in Sources */, SRB001 /* SwiftRNSBackend.swift in Sources */, + PRB001 /* ProxyRnsBackend.swift in Sources */, 69B724BD147A18C5EAFD080C /* PythonConfigWriter.swift in Sources */, 71FAB7848D244A6D2FC1628A /* AudioManager.swift in Sources */, D33B15C781E3C98A5CBD06F3 /* CallKitManager.swift in Sources */, @@ -1066,7 +1203,6 @@ 3A790961A270CBDE9A30BC04 /* VoiceCallScreen.swift in Sources */, 7BE550B9F4D32F6346EBD2F2 /* CodecProfileInfo.swift in Sources */, 0229B2C848210EE825D13B8E /* PythonBLECallbackBridge.swift in Sources */, - PRC001 /* PythonRNodeCallbackBridge.swift in Sources */, 6B53DC3B0EC0F0F6AF49E066 /* BackendPreference.swift in Sources */, 238336F8024494A8B57F9419 /* CeaseTelemetry.swift in Sources */, F80B09722B3A7CCA7C99DE0C /* DiscoveredDevice.swift in Sources */, @@ -1074,6 +1210,14 @@ AC4014BF281AD8BE6FF9E852 /* PeerChildInterfaceRegistry.swift in Sources */, EDF2F6B945FAA23366617FD6 /* TCPClientWizardViewModel.swift in Sources */, 55F3BF7D600C32E07B7B8C26 /* TCPClientWizard.swift in Sources */, + 6CFC16593387554678F3928F /* RNodeSeam.swift in Sources */, + A746EE4A4494C45D97908924 /* AppGroupRNodeSeamWire.swift in Sources */, + A6EA4093A9929FCEAB44C4B6 /* AppGroupRNodeSeamTransport.swift in Sources */, + A9DB03705BE9BA74A494310C /* AppGroupRNodeServer.swift in Sources */, + 4120DE0B1AC043758779DD40 /* ModelBRNodeService.swift in Sources */, + F4E9991226B4D464017DA247 /* Lucide.swift in Sources */, + AAD9231170B11C89EF80F9B8 /* PropagationSeam.swift in Sources */, + 1FC4483D48CABE8BF49850EF /* SyncStatusBottomSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1083,6 +1227,7 @@ files = ( T003 /* MicronParserTests.swift in Sources */, 2B3A3E3FB59D1E22B424E109 /* AudioRingBufferTests.swift in Sources */, + TBDT /* BLESeamDriverTests.swift in Sources */, A0C0D9AE12F728A484F0F108 /* AudioManagerConfigChangeTests.swift in Sources */, E49B977D311DA1C9ACE14CAB /* CallManagerCallKitTests.swift in Sources */, 9566DCEF56FEFC97BAAA47BA /* MessageFormattedTimeTests.swift in Sources */, @@ -1092,6 +1237,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + B4BE17F06FA1F20BA8E46747 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ColumbaNetworkExtension; + target = ETARG /* ColumbaNetworkExtension */; + targetProxy = C6825ACD06BF2495019C47AB /* PBXContainerItemProxy */; + }; TTDEP /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = TTARG /* ColumbaAppTests */; @@ -1201,7 +1352,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT ENABLE_NETWORK_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Sources/PythonBridge/ColumbaPython-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -1247,7 +1398,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT ENABLE_NETWORK_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Sources/PythonBridge/ColumbaPython-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -1773,16 +1924,16 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/torlando-tech/reticulum-swift.git"; requirement = { - kind = exactVersion; - version = 0.2.3; + branch = "main"; + kind = branch; }; }; 2F8EC81F2FC7326600235991 /* XCRemoteSwiftPackageReference "LXMF-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/torlando-tech/LXMF-swift.git"; requirement = { - kind = exactVersion; - version = 0.3.4; + branch = "feat/lxmfdb-appgroup-sharing"; + kind = branch; }; }; PKGREF2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = { @@ -1804,6 +1955,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 021FA3D73B6F8B711A97D40F /* ReticulumSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 2F8EC81E2FC7324D00235991 /* XCRemoteSwiftPackageReference "reticulum-swift" */; + productName = ReticulumSwift; + }; 0FD6A68A52A54D21FDB70324 /* RNSAPI */ = { isa = XCSwiftPackageProductDependency; productName = RNSAPI; @@ -1837,6 +1993,11 @@ package = PKGREF3 /* XCRemoteSwiftPackageReference "LXST-swift" */; productName = LXSTSwift; }; + B528C71CCBFAB3CB30E0BFB2 /* LXMFSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 2F8EC81F2FC7326600235991 /* XCRemoteSwiftPackageReference "LXMF-swift" */; + productName = LXMFSwift; + }; DA31FE974552414C399D4949 /* ReticulumSwift */ = { isa = XCSwiftPackageProductDependency; package = 2F8EC81E2FC7324D00235991 /* XCRemoteSwiftPackageReference "reticulum-swift" */; diff --git a/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f549b7e..4dec6b7c 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" + "branch" : "feat/lxmfdb-appgroup-sharing", + "revision" : "584c30cc82622e0b6920e654efd9b182110490ad" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/torlando-tech/reticulum-swift.git", "state" : { - "revision" : "b8ae6491d5ca62db30b097382cdf8553edda9b92", - "version" : "0.2.3" + "branch" : "main", + "revision" : "b366f29365232c9ee62462619f3411171bd7341b" } }, { diff --git a/Sources/ColumbaApp/App/ColumbaApp.swift b/Sources/ColumbaApp/App/ColumbaApp.swift index 9ecb651d..98d18541 100644 --- a/Sources/ColumbaApp/App/ColumbaApp.swift +++ b/Sources/ColumbaApp/App/ColumbaApp.swift @@ -10,7 +10,11 @@ import SwiftUI import RNSAPI import UserNotifications import BackgroundTasks +import SwiftBLEBridge import os +#if canImport(CoreBluetooth) +import CoreBluetooth +#endif private let logger = Logger(subsystem: "network.columba.Columba", category: "ColumbaApp") @@ -45,6 +49,45 @@ struct ColumbaApp: App { logger.error("Python runtime failed: \(err.localizedDescription, privacy: .public)") } + #if os(iOS) && canImport(CoreBluetooth) + // Track C8 — background BLE wake / CoreBluetooth state restoration. + // When iOS RELAUNCHES the app for a preserved BLE event, it sets + // UIApplication.LaunchOptionsKey.bluetoothCentrals / .bluetoothPeripherals + // and expects the app to RE-CREATE its CBCentralManager / + // CBPeripheralManager with the SAME restore identifiers EARLY in launch, + // so it can replay the preserved state via `willRestoreState`. This is a + // pure-SwiftUI app (`@main struct ColumbaApp: App`) with NO + // UIApplicationDelegate, so there is no + // `application(_:didFinishLaunchingWithOptions:)` from which to read + // `launchOptions` and branch on those keys. `App.init()` is the earliest + // app-owned hook and runs before the run loop settles, so we + // re-materialise the managers here UNCONDITIONALLY (every launch). That + // is cheap and satisfies CoreBluetooth's "re-create promptly with the + // same identifier" contract on the relaunch-for-BLE case; on a normal + // launch it just pre-creates the managers (the regular + // AppServices.startBLEInterface() path reuses them via start()). + // + // GAP / FOLLOW-ON: this re-arms the wake and re-adopts CoreBluetooth + // state, but inbound BLE bytes only become a *delivered + notified* + // message through the Python delivery path, which requires the active + // backend to be the Python backend AND its BLE bring-up + // (startBLEInterface → re-install of SwiftBLEBridge's callbackInvoker) to + // run on this relaunch. Native-Swift BLE delivery is a deliberate + // follow-on; until it lands, background-wake delivery is + // Python-backend-only. See the DELIVERY CAVEAT in SwiftBLEBridge.start(). + // + // Model B follow-on (now landing): reticulum-swift's `CoreBluetoothBLEDriver` + // owns CoreBluetooth via `ModelBBLEService` (started from `AppServices` once + // the identity is ready). It must be the ONLY CB stack — `SwiftBLEBridge` + // creating its own managers would fight over the same GATT service — so we + // restore `SwiftBLEBridge` only on the Python-backend (non-Model-B) path. + // (Model B background-restore via CoreBluetoothBLEDriver's own restore + // identifier is a further follow-on: it needs the identity at launch.) + if !BackendPreference.modelB { + SwiftBLEBridge.shared.restoreAtLaunch() + } + #endif + #if os(iOS) BGTaskScheduler.shared.register( forTaskWithIdentifier: "network.columba.Columba.sync", @@ -56,6 +99,11 @@ struct ColumbaApp: App { // Install notification delegate early so didReceive (notification tap) works UNUserNotificationCenter.current().delegate = NotificationService.delegate + + // Register app-side notification/announce defaults at launch (not lazily on + // first Settings open) so the foreground notification path isn't suppressed + // on a fresh install that never visits Settings. (ports #57 dc1024b) + SettingsViewModel.registerLocalDefaults() } // MARK: - App Body @@ -622,6 +670,17 @@ struct RootView: View { private func initializeServices() async { DiagLog.log("[STARTUP] initializeServices() ENTERED") + // Surface the Network Extension's App-Group diagnostic log into Documents + // so it's retrievable via `devicectl ... copy from` alongside diag.log. + // The NE (sandboxed) writes ext-diag.log to the shared container; the host + // copies the previous background session's log out here on each launch. + DiagLog.copyExtensionDiagToDocuments() + #if DEBUG + // Keep that copy LIVE (not just this launch's snapshot) so on-device NE + // diagnostics can be tailed in real time. DEBUG-only. + DiagLog.startExtDiagLiveCopy() + #endif + // Retry the entire init up to 5 times with increasing delay — // the Keychain, file system, or CryptoKit may not be ready // immediately after device unlock. @@ -718,13 +777,18 @@ struct RootView: View { ) DiagLog.log("[STARTUP] Step 5: AppServices initialized OK") - // 6. Wire up database, message repo, handler - guard let db = appServices.database else { + // 6. Wire up database, message repo, handler. + // `db` is the RNSAPI Compat store IncomingMessageHandler uses for + // sender-name lookups. `repo` is the GRDB-backed canonical store + // (Track A0) the UI reads — built and held by AppServices during + // initialize(), so reuse that single instance rather than opening a + // second handle to the same `lxmf-swift.db` (and keeping the + // LXMFSwift import walled off in MessageRepository.swift). + guard let db = appServices.database, + let repo = appServices.messageRepository else { throw AppServicesError.routerNotInitialized } self.database = db - - let repo = MessageRepository(database: db) self.messageRepository = repo #if os(iOS) @@ -751,7 +815,14 @@ struct RootView: View { DiagLog.log("[STARTUP] Starting interface: \(iface.type) name=\(iface.name)") switch iface.type { case .tcpClient: - if case .tcpClient(let config) = iface.config { + if BackendPreference.modelB { + // Model B: the NE owns the single TCP relay interface. The app + // must NOT open a competing/duplicate one — doing so spawns a + // second socket to the relay and surfaces as a stray + // "enabled but disconnected" interface in the UI. The app owns + // only Auto/BLE/RNode in Model B; their frames bridge to the NE. + DiagLog.log("[STARTUP] Model B: skipping app-side TCP interface (NE owns TCP)") + } else if case .tcpClient(let config) = iface.config { let entityId = iface.id Task { DiagLog.log("[STARTUP] TCP interface \(config.targetHost):\(config.targetPort) — registering") @@ -817,8 +888,14 @@ struct RootView: View { } } - // 8. Request notification permission and install foreground delegate - await NotificationService.shared.requestPermission() + // 8. Request notification permission WITHOUT blocking init. A blocking + // `await` here holds the rest of RootView setup (and `isInitialized`) + // hostage behind the OS auth sheet until the user taps Allow/Don't Allow + // — and on a fresh-install device the smoke harness (no UI driver) can't + // tap it at all, so init never completes. The foreground UN delegate is + // already installed eagerly in `init()` (see the delegate assignment in + // ColumbaApp.init), so deferring the prompt is safe. (ports #57 fc9b0b8) + Task { _ = await NotificationService.shared.requestPermission() } self.isInitialized = true diff --git a/Sources/ColumbaApp/Resources/ColumbaApp.entitlements b/Sources/ColumbaApp/Resources/ColumbaApp.entitlements index 4b414136..c33658cb 100644 --- a/Sources/ColumbaApp/Resources/ColumbaApp.entitlements +++ b/Sources/ColumbaApp/Resources/ColumbaApp.entitlements @@ -6,6 +6,13 @@ group.network.columba.Columba + + keychain-access-groups + + $(AppIdentifierPrefix)network.columba.Columba.shared + com.apple.developer.networking.networkextension packet-tunnel-provider diff --git a/Sources/ColumbaApp/Resources/Info.plist b/Sources/ColumbaApp/Resources/Info.plist index 6d8b7663..25a14ad1 100644 --- a/Sources/ColumbaApp/Resources/Info.plist +++ b/Sources/ColumbaApp/Resources/Info.plist @@ -36,6 +36,7 @@ materialdesignicons.ttf JetBrainsMono-Regular.ttf JetBrainsMono-Bold.ttf + lucide.ttf UIApplicationSceneManifest diff --git a/Sources/ColumbaApp/Resources/lucide-font-LICENSE.txt b/Sources/ColumbaApp/Resources/lucide-font-LICENSE.txt new file mode 100644 index 00000000..46e69621 --- /dev/null +++ b/Sources/ColumbaApp/Resources/lucide-font-LICENSE.txt @@ -0,0 +1,39 @@ +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2023 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2025. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The MIT License (MIT) (for portions derived from Feather) + +Copyright (c) 2013-2023 Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Sources/ColumbaApp/Resources/lucide.ttf b/Sources/ColumbaApp/Resources/lucide.ttf new file mode 100644 index 00000000..6c323a55 Binary files /dev/null and b/Sources/ColumbaApp/Resources/lucide.ttf differ diff --git a/Sources/ColumbaApp/Services/AppServices.swift b/Sources/ColumbaApp/Services/AppServices.swift index ca1da67d..2c591919 100644 --- a/Sources/ColumbaApp/Services/AppServices.swift +++ b/Sources/ColumbaApp/Services/AppServices.swift @@ -49,6 +49,40 @@ enum DiagLog { } } } + + /// Copy the Network Extension's App-Group diagnostic log + /// (`ExtensionDiagLog`'s `ext-diag.log`) into the app's Documents directory + /// as `ext-diag.log` so it's retrievable alongside `diag.log` via + /// `devicectl ... copy from --domain-type appDataContainer`. The NE is + /// sandboxed and can only write to the shared App-Group container; the host + /// surfaces it on launch. No-op when the App-Group container or source file + /// is unavailable. NO-PII: the source carries envelope/metadata only — see + /// `ExtensionDiagLog`'s contract. + static func copyExtensionDiagToDocuments() { + guard let source = ExtensionDiagLog.fileURL, + FileManager.default.fileExists(atPath: source.path) else { + return + } + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let dest = docs.appendingPathComponent("ext-diag.log") + try? FileManager.default.removeItem(at: dest) + try? FileManager.default.copyItem(at: source, to: dest) + } + + #if DEBUG + /// Keep `Documents/ext-diag.log` LIVE (refresh ~every 2s) instead of a single + /// launch-time snapshot, so on-device NE diagnostics — including the smoke + /// harness — can tail the NE's log in real time. The NE (sandboxed) writes to + /// the App-Group container; the app is the only process that can bridge it into + /// Documents (the appGroupDataContainer isn't reliably reachable via devicectl). + /// DEBUG-only, self-rescheduling; a cheap small-file copy. + static func startExtDiagLiveCopy() { + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2) { + copyExtensionDiagToDocuments() + startExtDiagLiveCopy() + } + } + #endif } /// Central LXMF service layer for the SwiftUI application. @@ -135,9 +169,26 @@ public final class AppServices { /// LXMF delivery destination for receiving messages. public private(set) var deliveryDestination: Destination? - /// LXMF database for message persistence. + /// RNSAPI Compat LXMF database — still used by `IncomingMessageHandler` + /// for sender-name lookups and by `CallManager`. NOT the canonical message + /// store any more (that's the GRDB store behind `messageRepository`); kept + /// because those collaborators take an `RNSAPI.LXMFDatabase`. public private(set) var database: LXMFDatabase? + /// Filesystem path of the GRDB-backed canonical LXMF store + /// (`/lxmf-swift.db`) the Swift / NE backend writes. Set during + /// `initialize(...)`. External call sites (ColumbaApp / MapView) read this + /// and pass it to `MessageRepository(grdbPath:)` so they don't have to + /// import LXMFSwift or re-derive the path. + public private(set) var grdbDatabasePath: String? + + /// The repository over the GRDB canonical store, built once during + /// `initialize(...)`. Held so the Python inbound-persist path + /// (`persistInboundFromPython`) and delivery-state updates route their + /// writes through the SAME store the UI reads, instead of constructing a + /// throwaway repo or touching a separate store. + public private(set) var messageRepository: MessageRepository? + /// Propagation node manager for relay discovery and sync. public private(set) var propagationManager: PropagationNodeManager? @@ -243,6 +294,19 @@ public final class AppServices { private var extensionFrameReader: ExtensionFrameReader? #endif + /// Darwin notification name used by on-device test instrumentation to + /// trigger a manual announce. Posted from the host via + /// `xcrun devicectl device notification post network.columba.test.announce`, + /// since Maestro/idb can't drive the physical device. Not gated behind the + /// Network Extension flag — the handler only calls `sendAllAnnounces`, which + /// is meaningful regardless of the background-transport posture. + private static let testAnnounceNotification = "network.columba.test.announce" + + /// Whether the test-announce Darwin observer has been registered. Guards + /// against double-registration across the two `initialize` overloads / + /// re-init cycles (the observer is process-global, keyed by `self`). + private var testAnnounceObserverRegistered = false + // MARK: - Interface Lookup /// Get a human-readable name for an interface ID. @@ -329,6 +393,61 @@ public final class AppServices { /// Keychain account identifier for storing identity. private static let keychainAccount = "reticulum-identity" + /// Suffix of the shared keychain access group (app + Network Extension). The full + /// group is `.` — see ColumbaApp.entitlements. + private static let keychainGroupSuffix = "network.columba.Columba.shared" + + /// The shared keychain access group, resolved at runtime so the team-id prefix is + /// NOT hardcoded in source (no deployment-identifying PII). Returns nil on unsigned / + /// simulator builds where the keychain-access-groups entitlement isn't enforced; in + /// that case identity ops fall back to the app's default (unshared) keychain group. + private static func sharedKeychainAccessGroup() -> String? { + guard let prefix = keychainAccessGroupPrefix() else { return nil } + return "\(prefix).\(keychainGroupSuffix)" + } + + /// Resolve the app-identifier (team-id) prefix by reading the access group the system + /// assigns to a fresh generic-password item (the standard "bundle seed id" probe). + private static func keychainAccessGroupPrefix() -> String? { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "columba.bundleSeedProbe", + kSecAttrService as String: "columba.bundleSeedProbe", + ] + // Ensure the probe item exists. Add WITH a value (a value-less generic- + // password Add can fail) and tolerate an existing item; the system assigns + // the app's default keychain access group ("."). + // NB: the previous code read the group from SecItemAdd's RESULT, which + // omits kSecAttrAccessGroup — so the probe always returned nil and the + // shared group was never resolved (A3 silently fell back to the default + // group, unreachable by the NE). Read it back via CopyMatching instead. + var addDict = base + addDict[kSecValueData as String] = Data() + let addStatus = SecItemAdd(addDict as CFDictionary, nil) + guard addStatus == errSecSuccess || addStatus == errSecDuplicateItem else { + DiagLog.log("[IDENTITY] bundleSeedProbe add failed: \(addStatus)") + return nil + } + var query = base + query[kSecReturnAttributes as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let copyStatus = SecItemCopyMatching(query as CFDictionary, &result) + // The probe item exists ONLY to read the system-assigned access group; delete it + // now (regardless of the read result) so it doesn't accumulate in the user's + // keychain for the lifetime of the install. Re-resolution re-adds it cheaply. + SecItemDelete(base as CFDictionary) + guard copyStatus == errSecSuccess, + let attrs = result as? [String: Any], + let group = attrs[kSecAttrAccessGroup as String] as? String, + let prefix = group.components(separatedBy: ".").first, + !prefix.isEmpty else { + DiagLog.log("[IDENTITY] bundleSeedProbe read failed: \(copyStatus)") + return nil + } + return prefix + } + /// File path for identity persistence (fallback when Keychain unavailable). private static var identityFilePath: URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! @@ -355,6 +474,127 @@ public final class AppServices { return columbaDir.appendingPathComponent(filename).path } + /// File path for the GRDB-backed canonical LXMF store (`lxmf-swift.db`). + /// + /// Under Model B this store lives in the SHARED App-Group container so the app + /// and the Network Extension converge on ONE store, computed via the shared + /// `AppGroupPaths` helper (the single source of truth both sides delegate to — + /// see `AppGroupPaths.swift`). The layout is + /// `/Columba/python-/lxmf-swift.db`, and + /// `identityHashHex` is `identity.hexHash` (the raw identity hash — NOT the + /// lxmf.delivery destination hash). + /// + /// Falls back to the legacy process-local Application Support path + /// (`/Columba/python-/lxmf-swift.db`) ONLY when the App-Group + /// container is unavailable (unsigned / simulator builds with no App-Group + /// entitlement); on such builds the NE isn't running anyway, so there is no + /// store to converge with. One-time migration of an existing process-local + /// store into the App-Group container is handled by + /// `migrateLXMFDatabaseToAppGroupIfNeeded(identityHashHex:)`, which callers run + /// before opening the store. + /// + /// - Parameter identityHashHex: Hex of the raw identity hash (`identity.hexHash`). + /// - Returns: Full path to `lxmf-swift.db` for that identity. + static func grdbDatabaseFilePath(for identityHashHex: String) -> String { + if let url = AppGroupPaths.lxmfDatabaseURL(identityHashHex: identityHashHex) { + return url.path + } + return legacyProcessLocalGRDBDatabaseFilePath(for: identityHashHex) + } + + /// Legacy process-local path for `lxmf-swift.db` + /// (`/Columba/python-/lxmf-swift.db`). + /// This is the location the store lived at BEFORE the A2 move to the App-Group + /// container; retained as (a) the fallback when the App-Group container is + /// unavailable and (b) the migration source in + /// `migrateLXMFDatabaseToAppGroupIfNeeded(identityHashHex:)`. + private static func legacyProcessLocalGRDBDatabaseFilePath(for identityHashHex: String) -> String { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let pyDir = appSupport.appendingPathComponent("Columba/python-\(identityHashHex)", isDirectory: true) + try? FileManager.default.createDirectory(at: pyDir, withIntermediateDirectories: true) + return pyDir.appendingPathComponent("lxmf-swift.db").path + } + + /// One-time migration of the canonical LXMF GRDB store from the legacy + /// process-local Application Support path to the SHARED App-Group container, so + /// an existing install's message history carries over when the store relocates + /// for Model B (A2). Idempotent and guarded by a `SharedDefaults` flag. + /// + /// Behavior: if the flag is unset AND the OLD process-local `lxmf-swift.db` + /// exists AND the NEW App-Group `lxmf-swift.db` does NOT exist, copy all three + /// SQLite WAL-mode files (`lxmf-swift.db`, `-wal`, `-shm`) into the App-Group + /// container, then set the flag. The old files are LEFT in place as a fallback + /// (we only flip the flag). Must be called BEFORE the store is opened + /// (`MessageRepository(grdbPath:)`), so the copied files are in place when GRDB + /// first attaches. No-op (just flips the flag, if not already set) when there's + /// nothing to migrate or when the App-Group container is unavailable. + /// + /// - Parameter identityHashHex: Hex of the raw identity hash (`identity.hexHash`). + static func migrateLXMFDatabaseToAppGroupIfNeeded(identityHashHex: String) { + // Idempotent: once migrated (or determined a no-op), never run again. + guard !SharedDefaults.suite.bool(forKey: lxmfDatabaseMigratedToAppGroupKey) else { + return + } + + // New (App-Group) destination. nil ⇒ container unavailable (unsigned / + // simulator): nothing to migrate to; leave the flag unset so a later + // signed run can still migrate. + guard let newURL = AppGroupPaths.lxmfDatabaseURL(identityHashHex: identityHashHex) else { + return + } + + let fm = FileManager.default + let oldPath = legacyProcessLocalGRDBDatabaseFilePath(for: identityHashHex) + let oldURL = URL(fileURLWithPath: oldPath) + + // If the old store doesn't exist, there's nothing to copy (fresh install, + // or already running on the App-Group store). Mark migrated so we don't + // re-check on every launch. + guard fm.fileExists(atPath: oldURL.path) else { + SharedDefaults.suite.set(true, forKey: lxmfDatabaseMigratedToAppGroupKey) + return + } + + // If the new store already exists, do NOT clobber it — the App-Group store + // is authoritative. Just flip the flag. + guard !fm.fileExists(atPath: newURL.path) else { + SharedDefaults.suite.set(true, forKey: lxmfDatabaseMigratedToAppGroupKey) + return + } + + // Copy the main DB plus the WAL sidecar files. Copying -wal/-shm matters + // for a WAL-mode SQLite DB: recent committed pages may live only in the + // WAL until a checkpoint folds them into the main file, so omitting them + // could silently drop the newest messages. + for suffix in ["", "-wal", "-shm"] { + let src = URL(fileURLWithPath: oldURL.path + suffix) + let dst = URL(fileURLWithPath: newURL.path + suffix) + guard fm.fileExists(atPath: src.path) else { continue } + do { + // Destination dir already created by AppGroupPaths.lxmfDatabaseURL. + if fm.fileExists(atPath: dst.path) { + try fm.removeItem(at: dst) + } + try fm.copyItem(at: src, to: dst) + } catch { + // Best-effort: log and continue. We deliberately do NOT set the + // flag on a copy failure so a subsequent launch can retry. The old + // files are untouched, so the worst case is the app opens an empty + // App-Group store this run and retries the copy next launch. + sLogger.warning("[A2-MIGRATE] copy of lxmf-swift.db\(suffix, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + return + } + } + + SharedDefaults.suite.set(true, forKey: lxmfDatabaseMigratedToAppGroupKey) + sLogger.info("[A2-MIGRATE] migrated lxmf-swift.db to the App-Group container") + } + + /// `SharedDefaults` flag key recording that the one-time A2 migration of + /// `lxmf-swift.db` into the App-Group container has run (see + /// `migrateLXMFDatabaseToAppGroupIfNeeded(identityHashHex:)`). + private static let lxmfDatabaseMigratedToAppGroupKey = "lxmf_db_migrated_to_appgroup" + /// File path for ratchet key storage for a specific identity. /// /// - Parameter identityHash: Hex hash of the identity @@ -384,43 +624,93 @@ public final class AppServices { /// 2. File-based storage (fallback for unsigned builds) /// 3. Creates new identity and saves it /// + /// Model B: make the active identity reachable by the in-NE node, regardless of + /// which init path established it. Resolve the shared keychain group (the app + /// runs unlocked, so its probe works), share it via the App Group (the NE can't + /// reliably probe while locked), and persist the identity into that shared group + /// so the NE can load it (`...AfterFirstUnlockThisDeviceOnly`, NE-readable while + /// locked after first unlock). No-op on unsigned/simulator builds (group == nil). + private static func shareIdentityForModelB(_ identity: Identity) { + guard let group = sharedKeychainAccessGroup() else { + DiagLog.log("[IDENTITY] Model B share: shared keychain group unresolved") + return + } + SharedDefaults.suite.set(group, forKey: "resolvedSharedKeychainGroup") + do { + try identity.saveToKeychain(service: keychainService, account: keychainAccount, accessGroup: group) + DiagLog.log("[IDENTITY] Model B share: group resolved + identity persisted to shared keychain") + } catch { + DiagLog.log("[IDENTITY] Model B share: keychain save failed: \(error.localizedDescription)") + } + } + /// - Returns: The loaded or newly created identity private static func loadOrCreateIdentity() -> Identity { - // Try Keychain first (most secure) + // Shared group so the Network Extension reads the SAME identity (Model B). + // nil on unsigned/simulator builds → falls back to the app's default group. + let group = sharedKeychainAccessGroup() + DiagLog.log("[IDENTITY] shared keychain group resolved=\(group != nil)") + // Hand the resolved group to the NE via the App Group: the in-NE keychain + // probe is unreliable while the device is locked (exactly when background + // delivery must run) and before the app has ever launched, so the NE reads + // this app-resolved value instead of probing. + if let group { + SharedDefaults.suite.set(group, forKey: "resolvedSharedKeychainGroup") + } + + // 1. Keychain, shared group (the group the NE also reads). do { if let stored = try Identity.loadFromKeychain( - service: keychainService, - account: keychainAccount + service: keychainService, account: keychainAccount, accessGroup: group ) { - sLogger.info("[IDENTITY] Loaded from Keychain") + sLogger.info("[IDENTITY] Loaded from Keychain (shared group)") return stored } } catch { sLogger.warning("[IDENTITY] Keychain load error: \(error.localizedDescription)") } - // Try file-based storage (fallback) + // 1b. One-time migration: an identity stored before the shared-group change lives + // in the app's DEFAULT keychain group, unreachable by the NE. Move it into the + // shared group, then delete the legacy copy. Only meaningful on signed builds + // (group != nil). + if group != nil { + if let legacy = try? Identity.loadFromKeychain( + service: keychainService, account: keychainAccount, accessGroup: nil + ) { + try? legacy.saveToKeychain( + service: keychainService, account: keychainAccount, accessGroup: group + ) + _ = Identity.deleteFromKeychain( + service: keychainService, account: keychainAccount, accessGroup: nil + ) + sLogger.info("[IDENTITY] Migrated identity into the shared keychain group") + return legacy + } + } + + // 2. File-based storage (fallback for unsigned builds where keychain is unavailable). if let stored = loadIdentityFromFile() { sLogger.info("[IDENTITY] Loaded from file") return stored } - // Create new identity + // 3. Create a new identity and save it to the shared keychain group. let created = Identity() sLogger.info("[IDENTITY] Created new identity") - - // Save to Keychain (try first, more secure) do { try created.saveToKeychain( - service: keychainService, - account: keychainAccount + service: keychainService, account: keychainAccount, accessGroup: group ) + // Keychain is the source of truth on signed builds; remove any stale plaintext + // fallback file so an unencrypted private key never lingers at rest. + removeIdentityFile() return created } catch { sLogger.warning("[IDENTITY] Keychain save failed: \(error.localizedDescription)") } - // Fall back to file storage + // Fall back to file storage (dev/unsigned only). _ = saveIdentityToFile(created) return created } @@ -440,11 +730,19 @@ public final class AppServices { } } - /// Save identity to file. + /// Save identity to file (dev/unsigned fallback only). private static func saveIdentityToFile(_ identity: Identity) -> Bool { do { let data = try identity.exportPrivateKeys() try data.write(to: identityFilePath, options: .atomic) + #if os(iOS) + // Even the fallback must not leave the private key at default protection; + // require at least first-unlock so it isn't readable on a locked cold device. + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: identityFilePath.path + ) + #endif return true } catch { sLogger.warning("[IDENTITY] File save error: \(error.localizedDescription)") @@ -452,6 +750,13 @@ public final class AppServices { } } + /// Remove the plaintext identity fallback file (called once the keychain — the + /// source of truth on signed builds — holds the identity, so an unencrypted private + /// key doesn't linger at rest). + private static func removeIdentityFile() { + try? FileManager.default.removeItem(at: identityFilePath) + } + // MARK: - Initialization /// Create uninitialized AppServices. @@ -500,11 +805,23 @@ public final class AppServices { await configureTransportCallbacks(newTransport) await newTransport.registerPathRequestHandler() - // 4. Create persistent LXMF database + // 4. Create persistent LXMF database (RNSAPI Compat store — sender-name + // lookups for IncomingMessageHandler / CallManager). let dbPath = Self.databaseFilePath let newDatabase = try LXMFDatabase(path: dbPath) self.database = newDatabase + // 4b. Open the GRDB canonical store the Swift/NE backend writes, so the + // UI reads the same messages. Keyed by the raw identity hash (the + // same `identity.hexHash` startPythonBackend derives configDir from). + // The store now lives in the shared App-Group container so the app and + // the NE converge on ONE store (Model B / A2); migrate any pre-existing + // process-local store over BEFORE opening it. + Self.migrateLXMFDatabaseToAppGroupIfNeeded(identityHashHex: newIdentity.hexHash) + let grdbPath = Self.grdbDatabaseFilePath(for: newIdentity.hexHash) + self.grdbDatabasePath = grdbPath + self.messageRepository = try MessageRepository(grdbPath: grdbPath) + // 5. Create LXMRouter with identity and database path let newRouter = try await LXMRouter(identity: newIdentity, databasePath: dbPath) self.router = newRouter @@ -606,16 +923,22 @@ public final class AppServices { displayName: "" ) + // On-device test instrumentation: listen for the test-announce Darwin + // notification now that the backend is up (see helper docs). Idempotent. + registerTestAnnounceObserver() + logger.info("Initialization complete") } // MARK: - Python backend + #if DEBUG /// Register a block-based NotificationCenter observer and retain its token /// in `pythonNotificationObservers` so `shutdown()` can remove it. Use this /// for every observer added by `startPythonBackend()` — keeping the tokens /// is what lets a restart cycle tear the old observers down instead of - /// stacking duplicates. + /// stacking duplicates. DEBUG-only: its sole callers are the `lxma://test-*` + /// observers (`shutdown()` still tears down the array unconditionally). private func addPythonObserver( _ name: String, _ block: @escaping @Sendable (Notification) -> Void @@ -626,6 +949,7 @@ public final class AppServices { ) ) } + #endif /// Boot the embedded Python RNS stack and hook `LXMRouter.sendHook` so /// outbound LXMF sends go through Python. Spawns a Task that drains @@ -653,9 +977,48 @@ public final class AppServices { self.pythonStartIdentity = identity self.pythonStartDisplayName = displayName + // Model B (Track C3): when `BackendPreference.modelB` is on, + // `BackendFactory.make` returns the thin-client `ProxyRnsBackend`, which + // needs a live IPC transport to the NE's `NEReticulumNode`. Inject + // `TunnelManager.proxySend` (wraps `sendProviderMessage`). Resolved LAZILY + // (read `self.tunnelManager` at send-time, not make-time) so it works even + // though one of the two init paths creates the tunnel after this call. The + // closure is `@Sendable`; it hops to the @MainActor `AppServices` to read + // the tunnel, then calls the non-isolated async `proxySend`. When Model B + // is off (the default) `make` ignores `proxySend` and returns the + // Swift/Python backend, so this wiring is inert until the flag is flipped. + #if ENABLE_NETWORK_EXTENSION + let proxySend: @Sendable (Data) async -> Data? = { [weak self] data in + // Read the @MainActor-isolated `tunnelManager` via `MainActor.run` + // (the established pattern in this file for crossing into MainActor + // state from a Sendable async context — see `applyPythonInterfaceStatus` + // callers). `TunnelManager` is Sendable and `proxySend` is non-isolated, + // so the IPC call itself needs no hop. + guard let tunnel = await MainActor.run(body: { self?.tunnelManager }) else { + return nil + } + return await tunnel.proxySend(data) + } + let backend = BackendFactory.make(proxySend: proxySend) + #else let backend = BackendFactory.make() + #endif self.backend = backend + #if ENABLE_NETWORK_EXTENSION + // Model B: bring up the app-side BLE host — reticulum-swift's + // `CoreBluetoothBLEDriver` (CoreBluetooth can't run in the NE) + the + // `AppGroupBLEServer` that bridges it to the NE's `BLEInterface` over the + // App-Group seam. The NE drives scan/advertise/connect through the seam. + // Idempotent; uses the SAME 16-byte identity the NE's BLEInterface uses. + // (ModelBBLEService lives in its own file because it `import ReticulumSwift` + // for the REAL driver — here `CoreBluetoothBLEDriver` would be the RNSAPI + // Compat stub.) `SwiftBLEBridge` is gated off under Model B at launch. + if BackendPreference.modelB { + ModelBBLEService.shared.start(identityHash: identity.hash) + } + #endif + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let pyDir = appSupport.appendingPathComponent("Columba/python-\(identityHashHex)", isDirectory: true) try? FileManager.default.createDirectory(at: pyDir, withIntermediateDirectories: true) @@ -669,11 +1032,6 @@ public final class AppServices { // current config) so a later restart with BLE-enabled config finds // them without an extra deployment step. deployIOSBLEPythonFilesIfPossible(configDir: pyDir) - // Likewise deploy the RNode (LoRa) custom interface so RNS can load a - // `type = IOSRNodeInterface` section if the config has one. Always - // copied — cheap, and a later RNode-enabled restart then needs no extra - // deploy step (mirrors the BLE case). - deployIOSRNodePythonFilesIfPossible(configDir: pyDir) // Generate the RNS config from user-saved interface entities. The // file lands at `/config` where Python's @@ -713,21 +1071,6 @@ public final class AppServices { return } - // Install the Swift→Python RNode callback bridge. Unlike BLE (which has - // an explicit startBLEInterface()), an RNode interface is instantiated - // by RNS's config loader, whose _RNodeBLEBridge registers callbacks via - // rns_bridge as soon as it loads — so the invoker must already be in - // place. Cheap to install unconditionally: it only stores a ref; the - // CBCentralManager isn't created until Python calls columba_rnode_start. - #if canImport(CoreBluetooth) - if let py = backend as? PythonRNSBackend { - SwiftRNodeBridge.shared.setCallbackInvoker( - PythonRNodeCallbackBridge(pythonBridge: py.pythonBridge) - ) - DiagLog.log("[RNODE] PythonRNodeCallbackBridge installed") - } - #endif - // Outbound LXMF now goes directly through `backend.lxmf.sendLxmfMessage` // (MessagingViewModel + RnsLxmf) with TYPED fields, so the old Compat // router sendHook — which forwarded content only and dropped every field — @@ -802,6 +1145,14 @@ public final class AppServices { DiagLog.log("[RNS-POLL] task exiting (cancelled)") } + #if DEBUG + // Test-only deep-link observers (the `lxma://test-*` surface used by the + // interop / smoke harnesses). The matching `onOpenURL` trigger in + // ColumbaApp is itself `#if DEBUG`, so nothing posts these notifications in + // release — gate the registrations too so they don't compile into release + // builds (no inert listeners, smaller binary, no latent footgun if some + // other code ever posts a `ColumbaTest*` name). + // Listen for test-send deep links (lxma://test-send?to=HEX&content=… // [&method=…][&image_hex=…&image_format=…][&file_hex=…&file_name=…]). // Drives the full typed-LXMF send path the interop harness uses to @@ -1207,6 +1558,22 @@ public final class AppServices { guard let self else { return } let node = (note.userInfo?["node"] as? String) ?? "" Task { @MainActor in + // Model B: the LXMF router lives in the NE — `backend.propagationSync` + // is a no-op proxy stub. Route through the propagation manager so the + // PN crosses the App-Group seam and the NE runs the sync (this mirrors + // the production Sync Now path). + if BackendPreference.modelB { + guard let propManager = self.propagationManager else { + DiagLog.log("[TEST-PROP-SYNC] modelB: no propagation manager") + return + } + if !node.isEmpty, let hash = Data(hexString: node) { + await propManager.selectNode(hash: hash) + } + await propManager.syncNow() + DiagLog.log("[TEST-PROP-SYNC] modelB sync-now posted to NE, state=\(propManager.syncState.state)") + return + } guard let backend = self.backend else { DiagLog.log("[TEST-PROP-SYNC] no backend") return @@ -1258,6 +1625,7 @@ public final class AppServices { } } } + #endif // DEBUG — test-only deep-link observers } /// Look up the matching Python interface for each user `InterfaceEntity` @@ -1365,12 +1733,10 @@ public final class AppServices { ble.online = status.online } case .rnode: - // The real RNode runs as the Python IOSRNodeInterface; the Swift - // RNodeInterface stub never reaches .connected on its own, so the - // Network Interfaces row sat at "disconnected" even while the - // backend reported the interface online. Mirror Python's state - // onto the stub the UI polls — same as Auto/BLE above. (Was - // missing here, hence the gap.) + // RNode now runs through the Model B seam on the Swift backend + // (UI state applied via applyRNodeLinkState). The Python backend + // no longer has an RNode interface, so this status mirror is inert + // there — kept for switch exhaustiveness + parity with Auto/BLE. if let rnode = self.rnodeInterface, rnode.state != newState { DiagLog.log("[RNS] iface \(status.sectionName) -> \(newState) (RNode, rx=\(status.rxBytes) tx=\(status.txBytes))") rnode.state = newState @@ -1542,6 +1908,18 @@ public final class AppServices { await hotAddInterface(entity, backend: backend) } + #if ENABLE_NETWORK_EXTENSION + // 4. Newly hot-added interfaces are created in normal (local-socket) mode. + // The tunnel-mode coordinator (`applyTunnelModeToInterfaces`) only fires on + // VPN *status* changes, not interface changes — so if background transport + // is already up, an interface added afterward (e.g. switching Auto -> a TCP + // relay after enabling background transport) would never enter tunnel mode, + // and with the packet tunnel active its own socket is black-holed + // (connected, rx=0 tx=0). Re-assert tunnel mode so anything added while the + // tunnel is up is bridged through the extension. + await reapplyTunnelModeIfActive() + #endif + // 5. Keep the status-poll's matching set in sync with what's live. pythonInterfaceEntities = freshById } @@ -1708,11 +2086,16 @@ public final class AppServices { /// IncomingMessageHandler. Returns nil if blocked or persistence failed. @discardableResult private func persistInboundFromPython(sourceHash: Data, content: String, title: String, fields: [UInt8: Any]?, timestamp: Date) async -> LXMessage? { - guard let database = self.database else { - DiagLog.log("[RNS] persistInbound: no database") + // Route Python-path inbound persistence through the GRDB canonical + // store (the same one the UI reads and the Swift/NE path writes), via + // the shared MessageRepository's RNSAPI-typed methods — NOT the + // RNSAPI Compat `database`. (The Swift backend already persists its own + // inbound to GRDB through its LXMRouter, so this AppServices write is + // only for the Python backend path.) + guard let repo = self.messageRepository else { + DiagLog.log("[RNS] persistInbound: no messageRepository") return nil } - let repo = MessageRepository(database: database) let sourceHashHex = sourceHash.map { String(format: "%02x", $0) }.joined() // Privacy: block_unknown_senders drops messages from anyone the @@ -1722,7 +2105,7 @@ public final class AppServices { if UserDefaults.standard.bool(forKey: "block_unknown_senders") { let isKnownContact: Bool do { - let conversation = try await database.getConversation(hash: sourceHash) + let conversation = try await repo.fetchConversation(sourceHash) isKnownContact = (conversation?.isFavorite ?? 0) != 0 } catch { // Fail open: surface the message if the DB check itself @@ -1830,6 +2213,23 @@ public final class AppServices { "timestamp": t, ] ) + + // Stamp the announced display name onto an EXISTING conversation + // that still lacks one. Under Model B the NE persists an inbound + // message — creating the conversation row with a nil display name — + // BEFORE this announce is heard, and the app's inbound-side name + // backfill (IncomingMessageHandler) never runs in that path, so the + // conversation title would otherwise stay stuck on the "Peer " + // fallback even though the announce tells us the real name. This is + // UPDATE-only (never creates a conversation for a bare announce) and + // only fills an empty/nil name (never clobbers one we already have). + if !displayName.isEmpty, let repo = self.messageRepository, + let convo = try? await repo.fetchConversation(data) { + if (convo.displayName ?? "").isEmpty { + try? await repo.updateDisplayName(data, displayName: displayName) + DiagLog.log("[RNS] stamped display name onto convo \(data.map { String(format: "%02x", $0) }.joined().prefix(8))") + } + } case .inbound(let sourceHash, let content, let title, let fieldsPacked, let t): DiagLog.log("[RNS] inbound source=\(sourceHash) content=\"\(content)\" fields=\(fieldsPacked.count)B") guard let data = Data(hexString: sourceHash) else { return } @@ -1860,8 +2260,11 @@ public final class AppServices { DiagLog.log("[RNS] delivery \(messageHash.prefix(16)) state=\(state)") guard let hashData = Data(hexString: messageHash) else { return } let newState: LXMessageState = (state == "delivered") ? .delivered : .failed - if let database = self.database { - try? database.updateMessageState(id: hashData, state: newState) + // Update the GRDB canonical store (where outbound messages are + // persisted and the UI reads from), via the shared repository's + // RNSAPI-typed method — not the Compat `database`. + if let repo = self.messageRepository { + try? await repo.updateMessageState(id: hashData, state: newState) } // Notify the open chat so it can flip the bubble's indicator // (double-check for delivered / failed) without a full reload. @@ -1935,6 +2338,10 @@ public final class AppServices { self.identity = identity self.localIdentityHashHex = localIdentityHash.map { String(format: "%02x", $0) }.joined() + // Model B: make this identity reachable by the in-NE node. This overload + // receives the identity pre-loaded (multi-identity path) and never calls + // loadOrCreateIdentity, so do the NE-sharing here. + Self.shareIdentityForModelB(identity) // 2. Create path table for routing with persistence let pathDbPath = Self.pathTableFilePath @@ -1947,11 +2354,22 @@ public final class AppServices { await configureTransportCallbacks(newTransport) await newTransport.registerPathRequestHandler() - // 4. Create persistent LXMF database (per-identity) + // 4. Create persistent LXMF database (per-identity; RNSAPI Compat store + // used for IncomingMessageHandler / CallManager sender lookups). let dbPath = Self.databaseFilePath(for: identityHash) let newDatabase = try LXMFDatabase(path: dbPath) self.database = newDatabase + // 4b. Open the GRDB canonical store the Swift/NE backend writes (keyed + // by the same identity hash startPythonBackend uses for configDir), + // so the UI reads the same messages. Store lives in the shared + // App-Group container (Model B / A2); migrate any pre-existing + // process-local store over BEFORE opening it. + Self.migrateLXMFDatabaseToAppGroupIfNeeded(identityHashHex: identityHash) + let grdbPath = Self.grdbDatabaseFilePath(for: identityHash) + self.grdbDatabasePath = grdbPath + self.messageRepository = try MessageRepository(grdbPath: grdbPath) + // 5. Create LXMRouter with identity and database path let newRouter = try await LXMRouter(identity: identity, databasePath: dbPath) self.router = newRouter @@ -2033,8 +2451,12 @@ public final class AppServices { self.autoAnnounceManager = announceManager announceManager.start() - // Dump all registered destinations and link callbacks for diagnostics - let regDests = await newTransport.registeredDestinationHashes() + // Dump all registered destinations and link callbacks for diagnostics. + // Registered destinations now come from the active backend's neutral + // `RnsCore` seam (the backend-agnostic source of truth both backends + // share — the same set the NE's destination filter matches), not the + // dead Compat-layer transport stub which always returned []. + let regDests = await self.backend?.core.registeredDestinationHashes() ?? [] let regCallbacks = await newTransport.registeredLinkCallbackHashes() DiagLog.log("[INIT2] Registered destinations: \(regDests)") DiagLog.log("[INIT2] Registered link callbacks: \(regCallbacks)") @@ -2102,6 +2524,10 @@ public final class AppServices { displayName: "" ) + // On-device test instrumentation: listen for the test-announce Darwin + // notification now that the backend is up (see helper docs). Idempotent. + registerTestAnnounceObserver() + DiagLog.log("[INIT2] Initialization complete (identity: \(identityHash))") } @@ -2117,29 +2543,45 @@ public final class AppServices { @MainActor private func applyTunnelModeToInterfaces(active: Bool) async { guard let tunnel = tunnelManager else { return } - + tunnelModeActive = active + + // Tunnel mode is TCP-only. The AutoInterface is deliberately NOT bridged: + // forwarding its frames to `tunnel.sendFrame(tag: .auto)` black-holes them + // — PacketTunnelProvider drops every non-ProxyRequest frame, and the NE + // node has no UDP/Auto path to send them on anyway. Leaving Auto in its + // local mode keeps its own foreground LAN socket working; tunneling it can + // only break background Auto outbound. (ports #57 d3719c2 fix #3) if active { for (_, iface) in tcpInterfaces { await iface.beginTunnelMode { [weak tunnel] frame in + DiagLog.log("[BRIDGE-OUT] iface->sendFrame tag=tcp len=\(frame.count)") await tunnel?.sendFrame(frame, interfaceTag: FrameInterfaceTag.tcp.rawValue) } } - if let auto = autoInterface { - await auto.beginTunnelMode { [weak tunnel] frame in - await tunnel?.sendFrame(frame, interfaceTag: FrameInterfaceTag.auto.rawValue) - } - } - DiagLog.log("[TUNNEL] enabled tunnel mode on \(self.tcpInterfaces.count) TCP + \(self.autoInterface != nil ? 1 : 0) Auto interface(s)") + DiagLog.log("[TUNNEL] enabled tunnel mode on \(self.tcpInterfaces.count) TCP interface(s); Auto stays local") } else { for (_, iface) in tcpInterfaces { await iface.endTunnelMode() } - if let auto = autoInterface { - await auto.endTunnelMode() - } DiagLog.log("[TUNNEL] disabled tunnel mode; interfaces resuming local connections") } } + + /// Whether tunnel mode is currently active (background-transport tunnel + /// connected + interfaces bridged through the extension). Tracked so + /// `applyInterfaceChanges` can bring interfaces hot-added *after* the tunnel + /// came up into tunnel mode — `onStatusChange` only fires on VPN state changes, + /// not on interface changes. + @MainActor private var tunnelModeActive = false + + /// Re-assert tunnel mode on the current interface set if the tunnel is up. + /// Called after a hot-reload so a freshly added interface doesn't get stranded + /// in local-socket mode (black-holed by the active packet tunnel). + @MainActor + private func reapplyTunnelModeIfActive() async { + guard tunnelModeActive else { return } + await applyTunnelModeToInterfaces(active: true) + } #endif /// Switch to a different identity, tearing down and re-initializing the full stack. @@ -2356,6 +2798,12 @@ public final class AppServices { try await transport.addAutoInterface(newAutoInterface) logger.info("AutoInterface started with group: \(groupId)") + + #if ENABLE_NETWORK_EXTENSION + // Same launch-race fix as connectTCPInterface: if the tunnel is already up, + // bring this freshly-registered interface into tunnel mode. + await reapplyTunnelModeIfActive() + #endif } /// Stop the AutoInterface. @@ -2479,48 +2927,6 @@ public final class AppServices { } } - /// Copy `IOSRNodeInterface.py` from `/app/rnode/` to - /// `/interfaces/` so RNS's external-interface loader can `exec()` - /// it for a `type = IOSRNodeInterface` config section. Idempotent — - /// overwrites each call so build-time updates ship without manual cleanup. - /// Called eagerly during `startPythonBackend` (before `backend.start()`), - /// regardless of whether the current config has an RNode interface, so a - /// later RNode-enabled restart finds the file. Mirror of the BLE deploy. - private func deployIOSRNodePythonFilesIfPossible(configDir: URL) { - let fm = FileManager.default - guard let bundleAppDir = Bundle.main.url(forResource: "app", withExtension: nil) else { - DiagLog.log("[RNODE] app/ bundle resource missing — skipping deploy") - return - } - let srcDir = bundleAppDir.appendingPathComponent("rnode", isDirectory: true) - guard fm.fileExists(atPath: srcDir.path) else { - DiagLog.log("[RNODE] app/rnode/ missing in bundle at \(srcDir.path) — skipping deploy") - return - } - - let interfacesDir = configDir.appendingPathComponent("interfaces", isDirectory: true) - do { - try fm.createDirectory(at: interfacesDir, withIntermediateDirectories: true) - } catch { - DiagLog.log("[RNODE] failed to create interfaces dir: \(error)") - return - } - - for name in ["IOSRNodeInterface.py"] { - let src = srcDir.appendingPathComponent(name) - let dst = interfacesDir.appendingPathComponent(name) - if fm.fileExists(atPath: dst.path) { - try? fm.removeItem(at: dst) - } - do { - try fm.copyItem(at: src, to: dst) - DiagLog.log("[RNODE] Deployed \(name) to \(dst.path)") - } catch { - DiagLog.log("[RNODE] Failed to copy \(name): \(error)") - } - } - } - /// Stop the BLE interface. public func stopBLEInterface() async { guard let ble = bleInterface else { return } @@ -2595,50 +3001,63 @@ public final class AppServices { /// - config: RNode radio configuration (device name, frequency, etc.) /// - name: Display name for the interface public func startRNodeInterface(config rnodeConfig: RNodeConfig, name: String) async throws { - // Stop existing RNode interface if running - await stopRNodeInterface() - - // Ensure base stack exists - if transport == nil { - try await initializeBaseStack() - } - - guard let transport = transport else { - throw AppServicesError.transportNotConnected - } - - let transportConfig = InterfaceConfig( - id: "rnode0", - name: name, - type: .rnode, - enabled: true, - mode: .full, - host: rnodeConfig.deviceName, // BLE device name in "host" field - port: 0 + // Model B: the RNode protocol stack (RNodeInterface + KISS framing) runs in the + // Network Extension — the app hosts ONLY the CoreBluetooth NUS radio. Start the + // app-side seam server FIRST (so it's listening when the NE responds), then + // persist the radio config for the NE, which (re)builds its RNodeInterface on + // the change notification and drives connect/send/disconnect over the seam. + // UI-facing Compat interface object; its `.state` is driven by the app-side + // radio's BLE link state via the onLinkStateChange callback below (the NE owns + // the authoritative RNodeInterface, but the BLE link state is a good proxy and + // the app has it directly). + let uiInterface = RNodeInterface(config: rnodeConfig, name: name) + uiInterface.state = .connecting + self.rnodeInterface = uiInterface + + ModelBRNodeService.shared.start(onLinkStateChange: { [weak self] linkState in + self?.applyRNodeLinkState(linkState) + }) + + let seamConfig = RNodeSeamConfig( + deviceName: rnodeConfig.deviceName, + frequency: rnodeConfig.frequency, + bandwidth: rnodeConfig.bandwidth, + txPower: rnodeConfig.txPower, + spreadingFactor: rnodeConfig.spreadingFactor, + codingRate: rnodeConfig.codingRate, + stAlock: rnodeConfig.stAlock, + ltAlock: rnodeConfig.ltAlock ) + seamConfig.saveToAppGroup() // posts rnodeConfigChanged → NE (re)builds its RNodeInterface - let newRNodeInterface = try RNodeInterface(config: transportConfig) - - // Configure radio BEFORE connecting (critical ordering) - let radioConfig = rnodeConfig.toRadioConfig() - try await newRNodeInterface.configureRadio(radioConfig) - - self.rnodeInterface = newRNodeInterface - - // Register with transport — this calls connect() which starts BLE scan - try await transport.addInterface(newRNodeInterface) - logger.info("RNodeInterface started: \(name)") + logger.info("RNodeInterface (Model B) started: \(name)") } - /// Stop the RNode interface. + /// Stop the RNode interface (Model B). public func stopRNodeInterface() async { - guard let rnode = rnodeInterface else { return } - await rnode.disconnect() - if let transport = transport { - await transport.removeInterface(id: rnode.id) - } + // Clear the NE's RNode config (→ it tears down its RNodeInterface) and stop the + // app-side radio server. + RNodeSeamConfig.clearFromAppGroup() + ModelBRNodeService.shared.stop() rnodeInterface = nil - logger.info("RNodeInterface stopped") + logger.info("RNodeInterface (Model B) stopped") + } + + /// Reflect the app-side RNode radio's BLE link state onto the UI-facing Compat + /// interface object + refresh the UI. The NE owns the authoritative `RNodeInterface`; + /// the BLE link state is a good-enough proxy for the Settings "connected" indicator. + private func applyRNodeLinkState(_ linkState: RNodeLinkState) { + let mapped: InterfaceState + switch linkState { + case .disconnected: mapped = .disconnected + case .connecting: mapped = .connecting + case .connected: mapped = .connected + case .failed: mapped = .connectionFailed(underlying: "RNode radio link failed") + } + DispatchQueue.main.async { [weak self] in + self?.rnodeInterface?.state = mapped + NotificationObserver.postNetworkStateChanged() + } } /// Resolve a peer's LXST **telephony** destination hash from their LXMF @@ -2698,13 +3117,23 @@ public final class AppServices { await configureTransportCallbacks(newTransport) } - // 4. Database + // 4. Database (RNSAPI Compat store) if database == nil { let dbPath = Self.databaseFilePath let newDatabase = try LXMFDatabase(path: dbPath) self.database = newDatabase } + // 4b. GRDB canonical store (matches the Swift/NE backend path). Store lives + // in the shared App-Group container (Model B / A2); migrate any + // pre-existing process-local store over BEFORE opening it. + if messageRepository == nil { + Self.migrateLXMFDatabaseToAppGroupIfNeeded(identityHashHex: existingIdentity.hexHash) + let grdbPath = Self.grdbDatabaseFilePath(for: existingIdentity.hexHash) + self.grdbDatabasePath = grdbPath + self.messageRepository = try MessageRepository(grdbPath: grdbPath) + } + // 5. Router if router == nil { let dbPath = Self.databaseFilePath @@ -2783,6 +3212,13 @@ public final class AppServices { /// `BleConnectionDetails` → `BLEConnectionInfo` here so the dedicated /// connections screen renders real peers. public func getBLEConnectionInfos() async -> [BLEConnectionInfo] { + // Model B: the BLE radio + reticulum-swift `BLEInterface` run across the NE + // seam, NOT `SwiftBLEBridge` (the Model A Python-path CoreBluetooth + // singleton). Query the NE's native peers over the proxy IPC. The Model A + // `SwiftBLEBridge` path below only applies when Model B is off. + if BackendPreference.modelB { + return await backend?.bleConnections() ?? [] + } guard bleInterface != nil else { return [] } let details = SwiftBLEBridge.shared.getConnectionDetails() // Group by identity. When a peer is connected via BOTH central @@ -2905,6 +3341,9 @@ public final class AppServices { NotificationCenter.default.removeObserver(token) } pythonNotificationObservers.removeAll() + // Remove the test-announce Darwin observer so a re-init re-registers + // cleanly instead of stacking callbacks (no-op if never registered). + unregisterTestAnnounceObserver() // Drop stale Compat Link records. Python assigns link IDs sequentially // from 0 on each fresh backend, so without this a post-restart inbound // link (id 0, 1, …) would collide with a dead entry and dispatchInbound @@ -3027,6 +3466,16 @@ public final class AppServices { } startStateObserver() + + #if ENABLE_NETWORK_EXTENSION + // Launch-race fix: the persistent background-transport tunnel can already be + // `.connected` when the app cold-starts, so `onStatusChange` fires (and tunnel + // mode is applied) BEFORE this interface is registered — leaving it in + // local-socket mode, black-holed by the active packet tunnel (connected, + // rx=0 tx=0, no announces). Re-assert tunnel mode now that this interface + // exists so it's bridged through the extension. + await reapplyTunnelModeIfActive() + #endif } /// Stop a specific TCP interface by entity ID. @@ -3198,6 +3647,17 @@ public final class AppServices { DiagLog.log("[ANNOUNCE] sent via Python (name=\"\(displayName)\")") } + /// Model B UI helper: the NE owns the TCP relay, so the app has no local + /// `TCPInterface` to report — the interface card would otherwise show a + /// permanent "disconnected". Query the NE (via the proxy `statusSnapshot`) + /// for the relay's connected state so the card reflects reality. Returns + /// `false` off Model B or when the NE isn't reachable. + public func neTcpRelayOnline() async -> Bool { + guard BackendPreference.modelB, let backend = backend else { return false } + let snap = await backend.statusSnapshot() + return snap?.interfaces.first { $0.sectionName == "ne-tcp-relay" }?.online ?? false + } + /// Send both the LXMF delivery announce and the LXST telephony announce. /// /// This is the single entry point for all announce triggers (app start, @@ -3233,6 +3693,63 @@ public final class AppServices { if let firstError { throw firstError } } + // MARK: - Test instrumentation (Darwin-notification trigger) + + /// Register a Darwin-notification observer for `network.columba.test.announce` + /// so an on-device test harness can drive a manual announce on a physical + /// device that Maestro/idb can't automate. The host posts the notification + /// via `xcrun devicectl device notification post network.columba.test.announce`; + /// on receipt this fires `sendAllAnnounces(displayName:)` (the same entry point + /// the auto-announce path uses), passing the empty string the backend resolves + /// to the configured display name. + /// + /// Idempotent: registers at most once (see `testAnnounceObserverRegistered`). + /// Call only AFTER the backend is started, so the announce has a live stack to + /// route through. The C callback can't capture `self`, so we pass the opaque + /// pointer and resolve it back, then hop to the `@MainActor` to call the async + /// announce inside a `Task` (the callback runs on a Mach-port thread). + private func registerTestAnnounceObserver() { + guard !testAnnounceObserverRegistered else { return } + testAnnounceObserverRegistered = true + + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + center, + observer, + { _, observer, _, _, _ in + guard let observer else { return } + let self_ = Unmanaged + .fromOpaque(observer) + .takeUnretainedValue() + DiagLog.log("[TEST-TRIGGER] test-announce Darwin notification received -> sendAllAnnounces") + Task { @MainActor in + // Empty string -> backend resolves the configured display name, + // matching the auto-announce path. + try? await self_.sendAllAnnounces(displayName: "") + } + }, + Self.testAnnounceNotification as CFString, + nil, + .deliverImmediately + ) + } + + /// Remove the test-announce Darwin observer. Called from `shutdown()` so a + /// re-init cycle re-registers cleanly rather than stacking callbacks. + private func unregisterTestAnnounceObserver() { + guard testAnnounceObserverRegistered else { return } + testAnnounceObserverRegistered = false + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterRemoveObserver( + center, + observer, + CFNotificationName(Self.testAnnounceNotification as CFString), + nil + ) + } + /// Wire transport callbacks that need app-layer context. /// /// Auto-announce triggers are split across two reticulum-swift hooks diff --git a/Sources/ColumbaApp/Services/BackendFactory.swift b/Sources/ColumbaApp/Services/BackendFactory.swift index 9b9bcf15..9d4a7738 100644 --- a/Sources/ColumbaApp/Services/BackendFactory.swift +++ b/Sources/ColumbaApp/Services/BackendFactory.swift @@ -14,7 +14,33 @@ import RNSAPI /// stack-init; changing it takes effect on the next app launch. @available(iOS 17.0, macOS 14.0, *) enum BackendFactory { - static func make() -> any RnsBackend { + /// Construct the active backend for this launch. + /// + /// - Parameter proxySend: the Model B IPC transport — an async + /// encode-send-receive closure (wrapping `NETunnelProviderSession + /// .sendProviderMessage`; supplied by `TunnelManager.proxySend`). Only used + /// when `BackendPreference.modelB` is on; pass `nil` (the default) when + /// Model B is off or no tunnel session is available. When Model B is on but + /// this is `nil`, the proxy is still constructed with a closure that always + /// returns `nil` (every op then degrades to an IPC-failure / not-ready), + /// keeping construction total — but in practice the caller wires the real + /// closure once A5c flips `modelB`. + static func make(proxySend: (@Sendable (Data) async -> Data?)? = nil) -> any RnsBackend { + // ── Track A5b / Model B (default OFF) ──────────────────────────────────── + // When enabled, the NE owns the single `lxmf.delivery` destination + node + // (A5a's `NEReticulumNode`); the app must therefore NOT start a + // destination-owning backend (`SwiftRNSBackend`/`PythonRNSBackend`) — doing + // so would double-register the destination and double-deliver. The + // always-the-node invariant is enforced HERE: we return EITHER the proxy OR + // a destination-owning backend, never both. `ProxyRnsBackend` owns no + // destination; it only marshals node ops to the NE. `modelB` defaults + // `false`, so this branch is inert until A5c intentionally flips it. + if BackendPreference.modelB { + DiagLog.log("[BACKEND] active=proxy (Model B — NE owns the node)") + let send: @Sendable (Data) async -> Data? = proxySend ?? { _ in nil } + return ProxyRnsBackend(send: send) + } + // One unambiguous line stating which RNS engine is live this launch. // Every other backend log is prefixed `[RNS]` (engine-neutral, since // AppServices drives either backend through `any RnsBackend`), so grep diff --git a/Sources/ColumbaApp/Services/BackendPreference.swift b/Sources/ColumbaApp/Services/BackendPreference.swift index c7d34446..83090a38 100644 --- a/Sources/ColumbaApp/Services/BackendPreference.swift +++ b/Sources/ColumbaApp/Services/BackendPreference.swift @@ -26,6 +26,32 @@ import Foundation enum BackendPreference { private static let key = "useSwiftBackend" + /// Whether the app runs as the thin **Model B** proxy — the Network + /// Extension owns the `lxmf.delivery` node and the app marshals node-owning + /// ops to it over IPC (`ProxyRnsBackend`) — rather than a destination-owning + /// local backend. + /// + /// This is **not** a user setting. It's tied to the build: the NE is only + /// compiled in on the Swift build (`ENABLE_NETWORK_EXTENSION`, the same + /// `Debug-Swift` / `Release-Swift` configs that define `COLUMBA_BACKEND_SWIFT`), + /// and on that build Model B is the *sole* architecture — there is no toggle + /// and no opt-out. On the standard build the NE isn't present, so the app runs + /// a foreground node (embedded-Python or local Swift). + /// + /// INVARIANT: when `true`, the NE is the SINGLE owner of `lxmf.delivery` + /// (see `BackendFactory.make()` / `ProxyRnsBackend`). The NE mirrors this via + /// `NEReticulumNode.modelBNodeEnabled`, which is likewise hardcoded `true` + /// (the extension only exists to be the node). Hardcoding both sides also + /// removes the cross-process flag race that used to leave the NE in sniff + /// mode while the app came up as the proxy. + static var modelB: Bool { + #if COLUMBA_BACKEND_SWIFT + return true + #else + return false + #endif + } + /// Default when the user has never chosen explicitly. The `Columba-Swift` /// scheme (`COLUMBA_BACKEND_SWIFT`) starts on the Swift backend; the stock /// scheme starts on embedded Python. diff --git a/Sources/ColumbaApp/Services/ExtensionFrameReader.swift b/Sources/ColumbaApp/Services/ExtensionFrameReader.swift index 1f67826e..83b612ef 100644 --- a/Sources/ColumbaApp/Services/ExtensionFrameReader.swift +++ b/Sources/ColumbaApp/Services/ExtensionFrameReader.swift @@ -84,6 +84,10 @@ public final class ExtensionFrameReader: @unchecked Sendable { guard !frames.isEmpty else { return } logger.info("Processing \(frames.count) queued frame(s) from extension") + // Bridge diagnostic: frames pulled from the shared queue and about to be + // injected into transport. Only reached when frames>0 (guard above). + // NO-PII: frame count only. DiagLog is visible from this module (ColumbaApp). + DiagLog.log("[BRIDGE-IN] \(frames.count) frame(s) from queue -> transport") for frame in frames { switch frame.interfaceTag { diff --git a/Sources/ColumbaApp/Services/IncomingMessageHandler.swift b/Sources/ColumbaApp/Services/IncomingMessageHandler.swift index 9f65eb6f..709f96d4 100644 --- a/Sources/ColumbaApp/Services/IncomingMessageHandler.swift +++ b/Sources/ColumbaApp/Services/IncomingMessageHandler.swift @@ -145,12 +145,15 @@ public final class IncomingMessageHandler: LXMRouterDelegate { } } - // Check block unknown senders setting (needs async DB access) - if UserDefaults.standard.bool(forKey: "block_unknown_senders"), - let db = self.database { + // Check block unknown senders setting (needs async DB access). + // Reads the unified GRDB store via messageRepository (Track A0): the + // Swift/NE backend writes only that store, so the old Compat + // `database` would miss Swift/NE-delivered favorites. Same semantics: + // "known" == an existing conversation with isFavorite != 0. + if UserDefaults.standard.bool(forKey: "block_unknown_senders") { let isKnownContact: Bool do { - let conversation = try await db.getConversation(hash: sourceHash) + let conversation = try await self.messageRepository.fetchConversation(sourceHash) isKnownContact = conversation != nil && conversation!.isFavorite != 0 } catch { // Fail open: allow message through if DB check fails @@ -255,15 +258,17 @@ public final class IncomingMessageHandler: LXMRouterDelegate { // User is viewing this conversation — skip notification } else { // Check if sender is a saved/favorite contact - let senderIsFavorite: Bool - if let db = self.database { - senderIsFavorite = ((try? await db.getConversation(hash: sourceHash))?.isFavorite ?? 0) != 0 - } else { - senderIsFavorite = false - } + // Resolve the sender once from the unified GRDB store (Track A0): + // the UI writes favorites + display-names via messageRepository and + // the Swift/NE backend persists inbound there, so the old Compat + // `database` is stale for both. Passing the resolved name lets + // NotificationService use it directly (senderName takes precedence + // over its own — now bypassed — Compat lookup). + let senderConversation = try? await self.messageRepository.fetchConversation(sourceHash) + let senderIsFavorite = (senderConversation?.isFavorite ?? 0) != 0 await NotificationService.shared.postMessageNotification( message, - senderName: nil, + senderName: senderConversation?.displayName, database: self.database, isFavorite: senderIsFavorite ) diff --git a/Sources/ColumbaApp/Services/MessageRepository.swift b/Sources/ColumbaApp/Services/MessageRepository.swift index a60a7a20..bb1d98cb 100644 --- a/Sources/ColumbaApp/Services/MessageRepository.swift +++ b/Sources/ColumbaApp/Services/MessageRepository.swift @@ -2,59 +2,75 @@ // MessageRepository.swift // ColumbaApp // -// Async wrapper for LXMFDatabase operations, providing thread-safe -// message and conversation access for SwiftUI ViewModels. +// Async wrapper for the LXMF message store, providing thread-safe message +// and conversation access for SwiftUI ViewModels. +// +// ── ARCHITECTURE (Track A0 — unify the canonical message store) ────────────── +// This is the SOLE file in ColumbaApp that imports `LXMFSwift`. It opens the +// GRDB-backed `LXMFSwift.LXMFDatabase` that the Swift / Network-Extension +// backend writes (at `/lxmf-swift.db`), so Swift/NE-delivered +// messages show up in the existing SwiftUI layer WITHOUT changing the UI or +// ViewModels — every public method below still takes/returns the RNSAPI +// *Compat* types (RNSAPI.ConversationRecord / RNSAPI.MessageRecord / +// RNSAPI.LXMessage / RNSAPI.IconAppearance / RNSAPI.LXMessageState / +// RNSAPI.LXDeliveryMethod). The GRDB store's LXMFSwift types are adapted to +// the RNSAPI types via the pure `static` mapping funcs at the bottom. +// +// `import LXMFSwift` MUST NOT be added to AppServices.swift / ColumbaApp.swift +// / MapView.swift or any other RNSAPI-dense file: LXMFSwift transitively +// re-exports ReticulumSwift, whose type names (Identity, Destination, Link, +// Packet, LXMRouter, …) collide with the RNSAPI Compat type names those files +// use, producing an un-fixable ambiguity cascade. Keep the LXMFSwift import +// walled off here. +// +// A5 NOTE: this opens the GRDB store read-WRITE for now (the app's Python +// inbound-persist path still writes through here). Once the NE owns all writes +// (Track A5), switch to `LXMFSwift.LXMFDatabase(path:, readonly: true)`. // import Foundation import RNSAPI +import LXMFSwift /// Actor for thread-safe message database operations. /// -/// Wraps LXMFDatabase to provide async methods for ViewModel consumption. -/// All operations are serialized through the actor to ensure thread safety. +/// Wraps the GRDB-backed `LXMFSwift.LXMFDatabase` and exposes RNSAPI Compat +/// types so the existing ViewModels compile unchanged. All operations are +/// serialized through the underlying GRDB actor. public actor MessageRepository { // MARK: - Properties - private let database: LXMFDatabase + /// The GRDB-backed canonical store written by the Swift / NE backend. + private let database: LXMFSwift.LXMFDatabase // MARK: - Initialization - /// Create repository with database instance. + /// Open the repository over the GRDB store at `grdbPath`. + /// + /// This is the SAME `/lxmf-swift.db` the Swift backend's + /// `LXMRouter` uses, so messages persisted by the Swift/NE path are visible + /// here. Opened read-WRITE for now (A5 will switch to read-only once the NE + /// owns writes). /// - /// - Parameter database: LXMFDatabase instance to wrap - public init(database: LXMFDatabase) { - self.database = database + /// - Parameter grdbPath: Filesystem path to `lxmf-swift.db`. + /// - Throws: rethrows `LXMFSwift.LXMFDatabase` initialization errors. + public init(grdbPath: String) throws { + self.database = try LXMFSwift.LXMFDatabase(path: grdbPath, readonly: false) } // MARK: - Conversation Operations /// Fetch all conversations, sorted by most recent message. - /// - /// - Parameters: - /// - limit: Maximum number of conversations to return (default 100) - /// - offset: Number of conversations to skip (default 0) - /// - Returns: Array of ConversationRecord ordered by last message timestamp - /// - Throws: DatabaseError if query fails - public func fetchConversations(limit: Int = 100, offset: Int = 0) async throws -> [ConversationRecord] { - try await database.getConversations(limit: limit, offset: offset) + public func fetchConversations(limit: Int = 100, offset: Int = 0) async throws -> [RNSAPI.ConversationRecord] { + try await database.getConversations(limit: limit, offset: offset).map(Self.mapConversation) } /// Fetch a single conversation by destination hash. - /// - /// - Parameter conversationHash: Destination hash (16 bytes) of the conversation - /// - Returns: ConversationRecord if found, nil otherwise - /// - Throws: DatabaseError if query fails - public func fetchConversation(_ conversationHash: Data) async throws -> ConversationRecord? { - try await database.getConversation(hash: conversationHash) + public func fetchConversation(_ conversationHash: Data) async throws -> RNSAPI.ConversationRecord? { + try await database.getConversation(hash: conversationHash).map(Self.mapConversation) } /// Mark conversation as read (reset unread count). - /// - /// Updates the conversation record to set unreadCount = 0 and isUnread = 0. - /// - /// - Parameter conversationHash: Destination hash (16 bytes) of the conversation - /// - Throws: DatabaseError if update fails public func markConversationRead(_ conversationHash: Data) async throws { try await database.markConversationRead(hash: conversationHash) } @@ -64,65 +80,32 @@ public actor MessageRepository { try await database.setUnreadCount(hash: conversationHash, count: count) } - /// Delete conversation and all its messages. - /// - /// Cascades deletion to all messages in the conversation due to foreign key constraint. - /// - /// - Parameter conversationHash: Destination hash (16 bytes) of the conversation - /// - Throws: DatabaseError if deletion fails + /// Delete conversation and all its messages (cascades via FK). public func deleteConversation(_ conversationHash: Data) async throws { try await database.deleteConversation(hash: conversationHash) } /// Delete a single message by its ID hash. - /// - /// - Parameter messageId: Message hash (32 bytes) - /// - Throws: DatabaseError if deletion fails public func deleteMessage(_ messageId: Data) async throws { try await database.deleteMessage(id: messageId) } /// Ensure a conversation exists for a destination. - /// - /// Creates a new conversation record if one doesn't exist. - /// If conversation already exists, updates the display name if provided and not already set. - /// This is used when starting a chat from announces to ensure the conversation - /// appears in the conversation list. - /// - /// - Parameters: - /// - conversationHash: Destination hash (16 bytes) of the conversation - /// - displayName: Display name for the conversation (optional) - /// - Throws: DatabaseError if operation fails public func ensureConversation(_ conversationHash: Data, displayName: String?) async throws { try await database.ensureConversation(hash: conversationHash, displayName: displayName) } /// Set favorite status for a conversation. - /// - /// - Parameters: - /// - conversationHash: Destination hash (16 bytes) - /// - isFavorite: Whether to mark as favorite - /// - Throws: DatabaseError public func setFavorite(_ conversationHash: Data, isFavorite: Bool) async throws { try await database.setFavorite(hash: conversationHash, isFavorite: isFavorite) } /// Set pinned status for a conversation. - /// - /// - Parameters: - /// - conversationHash: Destination hash (16 bytes) - /// - isPinned: Whether to pin the conversation - /// - Throws: DatabaseError public func setPinned(_ conversationHash: Data, isPinned: Bool) async throws { try await database.setPinned(hash: conversationHash, isPinned: isPinned) } /// Update display name for a conversation. - /// - /// - Parameters: - /// - conversationHash: Destination hash (16 bytes) - /// - displayName: New display name (nil to clear) - /// - Throws: DatabaseError public func updateDisplayName(_ conversationHash: Data, displayName: String?) async throws { try await database.updateDisplayName(hash: conversationHash, displayName: displayName) } @@ -130,22 +113,13 @@ public actor MessageRepository { // MARK: - Icon Appearance /// Update peer icon appearance for a conversation. - /// - /// - Parameters: - /// - hash: Destination hash (16 bytes) - /// - icon: IconAppearance to save - /// - Throws: DatabaseError - public func updatePeerIcon(_ hash: Data, icon: IconAppearance) async throws { + public func updatePeerIcon(_ hash: Data, icon: RNSAPI.IconAppearance) async throws { try await database.updatePeerIcon(hash, iconName: icon.iconName, fgColor: icon.foregroundColor, bgColor: icon.backgroundColor) } /// Get peer icon appearance for a conversation. - /// - /// - Parameter hash: Destination hash (16 bytes) - /// - Returns: IconAppearance if set, nil otherwise - /// - Throws: DatabaseError - public func getPeerIcon(_ hash: Data) async throws -> IconAppearance? { - try await database.getPeerIcon(hash) + public func getPeerIcon(_ hash: Data) async throws -> RNSAPI.IconAppearance? { + try await database.getPeerIcon(hash).map(Self.mapIcon) } // MARK: - Reply & Reaction Operations @@ -166,97 +140,360 @@ public actor MessageRepository { } /// Get a single message record by ID (lightweight). - public func getMessageRecord(id: Data) async throws -> MessageRecord? { - try await database.getMessageRecord(id: id) + public func getMessageRecord(id: Data) async throws -> RNSAPI.MessageRecord? { + try await database.getMessageRecord(id: id).map(Self.mapRecord) } // MARK: - Message Operations - /// Fetch messages for a specific conversation. + /// Fetch messages for a conversation (LXMessage form). /// - /// Returns messages ordered by timestamp descending (newest first). - /// - /// - Parameters: - /// - conversationHash: Destination hash (16 bytes) of the conversation - /// - limit: Maximum number of messages to return (default 50) - /// - offset: Number of messages to skip (default 0) - /// - Returns: Array of LXMessage ordered by timestamp descending - /// - Throws: DatabaseError or LXMFError if query fails - public func fetchMessages(for conversationHash: Data, limit: Int = 50, offset: Int = 0) async throws -> [LXMessage] { - try await database.getMessages(forConversation: conversationHash, limit: limit, offset: offset) + /// Rebuilt from the lightweight GRDB `MessageRecord` rows via `mapToLXMessage`, + /// which recovers the field map whether the row stores a packed field map + /// (app / Python path) or the signed LXMF wire (Swift / NE path). Ordered + /// newest-first to match `getMessages`. + public func fetchMessages(for conversationHash: Data, limit: Int = 50, offset: Int = 0) async throws -> [RNSAPI.LXMessage] { + try await database.getMessageRecords(forConversation: conversationHash, limit: limit, offset: offset) + .map(Self.mapToLXMessage) } - /// Fetch raw message records for a conversation (no LXMessage unpacking). + /// Fetch raw message records for a conversation (no wire unpacking). /// - /// Returns lightweight MessageRecord structs directly from database, - /// avoiding expensive MessagePack + SHA256 + Ed25519 operations. - /// Use this for UI display where only metadata is needed. - /// - /// - Parameters: - /// - conversationHash: Destination hash (16 bytes) of the conversation - /// - limit: Maximum number of records to return (default 50) - /// - offset: Number of records to skip (default 0) - /// - Returns: Array of MessageRecord ordered by timestamp descending - /// - Throws: DatabaseError if query fails - public func fetchMessageRecords(for conversationHash: Data, limit: Int = 50, offset: Int = 0) async throws -> [MessageRecord] { + /// This is the hot read path the chat UI uses. Returns RNSAPI + /// `MessageRecord`s mapped directly from the GRDB rows. + public func fetchMessageRecords(for conversationHash: Data, limit: Int = 50, offset: Int = 0) async throws -> [RNSAPI.MessageRecord] { try await database.getMessageRecords(forConversation: conversationHash, limit: limit, offset: offset) + .map(Self.mapRecord) } - /// Save a new outbound message. - /// - /// Persists the message to database and updates the conversation record. + /// Save a message (outbound from the app, or Python-path inbound). /// - /// - Parameter message: LXMessage to save (must be packed) - /// - Throws: DatabaseError or LXMFError if save fails - public func saveMessage(_ message: LXMessage) async throws { - try await database.saveMessage(message) + /// Bridges the RNSAPI `LXMessage` into the GRDB store via a synthetic + /// `LXMFSwift.LXMessage` whose `packed` carries the MessagePack-encoded + /// field map (NOT the signed LXMF wire — the app doesn't have it). The GRDB + /// `MessageRecord` then stores that field map in its `packed_lxmf` column, + /// which `mapRecord` passes back through as the RNSAPI `packedLxmf` field + /// map so the chat UI's `LxmfFieldCodec.unpack` can recover attachments. + public func saveMessage(_ message: RNSAPI.LXMessage) async throws { + try await database.saveMessage(Self.mapToGRDBMessage(message)) } - /// Get message by ID. - /// - /// - Parameter id: Message hash (32 bytes) - /// - Returns: LXMessage if found, nil otherwise - /// - Throws: DatabaseError or LXMFError if retrieval fails - public func getMessage(id: Data) async throws -> LXMessage? { - try await database.getMessage(id: id) + /// Get message by ID (LXMessage form), rebuilt from the GRDB record. + public func getMessage(id: Data) async throws -> RNSAPI.LXMessage? { + try await database.getMessageRecord(id: id).map(Self.mapToLXMessage) } /// Check if message exists. - /// - /// - Parameter id: Message hash (32 bytes) - /// - Returns: True if message exists in database - /// - Throws: DatabaseError if query fails public func hasMessage(id: Data) async throws -> Bool { try await database.hasMessage(id: id) } /// Update message delivery state. + public func updateMessageState(id: Data, state: RNSAPI.LXMessageState) async throws { + try await database.updateMessageState(id: id, state: Self.mapStateToGRDB(state)) + } + + /// Load pending outbound messages (state == .outbound). + public func loadPendingOutbound() async throws -> [RNSAPI.LXMessage] { + try await loadOutbound(matching: .outbound) + } + + /// Load failed outbound messages (state == .failed). + public func loadFailedOutbound() async throws -> [RNSAPI.LXMessage] { + try await loadOutbound(matching: .failed) + } + + /// Shared helper: load outbound messages in a given GRDB state by walking + /// the GRDB `loadPendingOutbound` / `loadFailedOutbound` record IDs and + /// re-fetching their raw records, so the result maps through the field-map + /// bridge rather than the wire-unpack path. + private func loadOutbound(matching state: LXMFSwift.LXMessageState) async throws -> [RNSAPI.LXMessage] { + // The GRDB store only exposes outbound/failed via LXMessage-returning + // methods (which wire-unpack). Those would throw on app-written rows + // whose `packed_lxmf` is a field map, not real wire — so instead fetch + // the matching records by ID through `getMessageRecord` and bridge. + let messages: [LXMFSwift.LXMessage] + switch state { + case .outbound: messages = (try? await database.loadPendingOutbound()) ?? [] + case .failed: messages = (try? await database.loadFailedOutbound()) ?? [] + default: messages = [] + } + // For each (successfully-unpacked) message, re-fetch its lightweight + // record so the RNSAPI mapping is uniform with the read paths above. + var out: [RNSAPI.LXMessage] = [] + for m in messages { + if let rec = try await database.getMessageRecord(id: m.hash) { + out.append(Self.mapToLXMessage(rec)) + } + } + return out + } +} + +// MARK: - Adapters (LXMFSwift <- -> RNSAPI) +// +// Pure `static` functions so the round-trip mapping can be unit-tested +// directly (see MessageRepositoryAdapterTests). Direction in the name reflects +// the conversion direction: `map*` = GRDB → RNSAPI; `mapTo*` = RNSAPI → GRDB. + +extension MessageRepository { + + // MARK: Conversation + + /// GRDB `ConversationRecord` → RNSAPI `ConversationRecord`. + static func mapConversation(_ c: LXMFSwift.ConversationRecord) -> RNSAPI.ConversationRecord { + RNSAPI.ConversationRecord( + hash: c.destinationHash, + displayName: c.displayName ?? "", + isFavorite: c.isFavorite, + isPinned: c.isPinned, + lastMessageAt: Date(timeIntervalSince1970: c.lastMessageTimestamp), + lastMessage: c.lastMessagePreview, + unreadCount: c.unreadCount, + iconName: c.iconName, + iconFgColor: c.iconFgColor, + iconBgColor: c.iconBgColor + ) + } + + // MARK: Icon + + /// GRDB `IconAppearance` → RNSAPI `IconAppearance`. + static func mapIcon(_ i: LXMFSwift.IconAppearance) -> RNSAPI.IconAppearance { + RNSAPI.IconAppearance( + iconName: i.iconName, + foregroundColor: i.foregroundColor, + backgroundColor: i.backgroundColor + ) + } + + // MARK: Message record + + /// Recover an LXMF field map from a GRDB row's `packed_lxmf`, regardless of + /// whether that column holds a MessagePack **field map** (app / Python-path + /// rows, written via `LxmfFieldCodec.pack`) or the signed LXMF **wire** + /// (rows the Swift / Network-Extension backend persists — `LXMRouter` stores + /// `LXMessage.packed`, the on-wire bytes, into `packed_lxmf`). + /// + /// ── Discriminator (wire vs field map) — order is load-bearing ── + /// We attempt the WIRE decode *first* (`LXMessage.unpackFromBytes`) and only + /// fall back to the field-map codec. This direction is deliberate: + /// + /// • `unpackFromBytes` is strict: it requires `count > 96` (dest 16 + src + /// 16 + sig 64) AND the trailing msgpack to be an *array* whose [0] is a + /// numeric timestamp and [1]/[2] are binary/string title+content. A + /// field map is a top-level msgpack *map*; small ones (text-only = + /// empty `Data()`, or just an icon/reply) fail the size guard, and a + /// large one (image/file ≥ 96 B) has its byte-tail-past-96 fed to the + /// msgpack parser, which essentially never yields a 4+ element array + /// with those exact element types. So a field map is not mistaken for + /// wire. + /// • The reverse order would NOT be safe: `LxmfFieldCodec.unpack` reads a + /// single top-level msgpack value from byte 0 and *ignores trailing + /// bytes* (see `MsgPack.unpack`). On wire, byte 0 is an arbitrary + /// destination-hash byte; whenever it lands in the fixmap range + /// (0x80–0x8f, ~1/16 of rows) `unpack` happily decodes a bogus map from + /// the following hash/sig bytes — so a wire row would be misread as a + /// field map and its attachments dropped. + /// + /// Signature is intentionally NOT re-validated here: `unpackFromBytes` only + /// verifies the signature when given a `sourceIdentity` (we pass `nil`), and + /// `LXMRouter` already validated it at receive time — at render time we only + /// need to *extract* fields. With `nil` identity, `unpackFromBytes` still + /// fully populates `.fields` (it just marks the message source-unverified). + static func recoverFields(from packedLxmf: Data) -> [UInt8: Any]? { + // WIRE first (strict). A wire row carries its fields inside the signed + // payload; extract them without re-validating the signature. + if let wire = try? LXMFSwift.LXMessage.unpackFromBytes(packedLxmf, sourceIdentity: nil), + let fields = wire.fields, !fields.isEmpty { + return fields + } + // Otherwise treat the bytes as a MessagePack field map (app / Python + // path), or wire with no fields → nil. + return LxmfFieldCodec.unpack(packedLxmf) + } + + /// Normalize a row's `packed_lxmf` to the MessagePack **field map** the chat + /// UI consumes (`LxmfFieldCodec.unpack` in `MessageBubble`/`Message(from:)`). + /// Wire rows are unpacked and re-packed as a field map; field-map rows (and + /// empty / no-field bytes) are already in the right shape and pass through + /// untouched, so only the wire branch does extra work. + static func normalizedFieldMap(_ packedLxmf: Data) -> Data { + // WIRE first, with the SAME strict discriminator as `recoverFields`: + // we must NOT gate on `LxmfFieldCodec.unpack(...) != nil` here, because + // that codec ignores trailing bytes and can spuriously decode a bogus + // map from a wire row whose leading hash byte is a fixmap marker + // (~1/16) — which would leave the wire bytes un-normalized and the + // attachments unrendered. Re-pack only genuine wire-with-fields. + if let wire = try? LXMFSwift.LXMessage.unpackFromBytes(packedLxmf, sourceIdentity: nil), + let fields = wire.fields, !fields.isEmpty { + return LxmfFieldCodec.pack(fields) + } + // Field map (app / Python path), empty, or wire-without-fields: already + // the shape the UI handles — hand it back verbatim. + return packedLxmf + } + + /// GRDB `MessageRecord` → RNSAPI `MessageRecord`. /// - /// - Parameters: - /// - id: Message hash (32 bytes) - /// - state: New delivery state - /// - Throws: DatabaseError if update fails - public func updateMessageState(id: Data, state: LXMessageState) async throws { - try await database.updateMessageState(id: id, state: state) + /// `packedLxmf` is normalized to a MessagePack field map: app / Python-path + /// rows already store one (passed through verbatim), while Swift / NE rows + /// store the signed LXMF wire — those are unpacked and re-packed as a field + /// map so the chat UI's `LxmfFieldCodec.unpack(record.packedLxmf)` recovers + /// their attachments/icons too. See `recoverFields` / `normalizedFieldMap`. + static func mapRecord(_ r: LXMFSwift.MessageRecord) -> RNSAPI.MessageRecord { + RNSAPI.MessageRecord( + id: r.messageId, + conversationHash: r.conversationHash, + content: r.content, + timestamp: r.timestamp, + direction: r.incoming ? .inbound : .outbound, + state: mapState(r.state).rawValue, + messageId: r.messageId, + sourceHash: r.sourceHash, + method: mapMethod(r.method).rawValue, + rssi: r.rssi, + snr: r.snr, + receivingInterface: r.receivingInterface, + replyToId: r.replyToId, + reactionsJson: r.reactionsJson, + packedLxmf: normalizedFieldMap(r.packedLxmf) + ) + } + + /// GRDB `MessageRecord` → RNSAPI `LXMessage` (via the field-map bridge). + static func mapToLXMessage(_ r: LXMFSwift.MessageRecord) -> RNSAPI.LXMessage { + // Recover fields whether `packed_lxmf` is a field map or the LXMF wire, + // so attachment/icon fields survive for Swift/NE-delivered rows too. + let fields = recoverFields(from: r.packedLxmf) + let msg = RNSAPI.LXMessage( + destinationHash: r.destinationHash, + sourceIdentity: nil, + content: r.content, + title: r.title, + fields: fields, + desiredMethod: mapMethod(r.method) + ) + msg.sourceHash = r.sourceHash + msg.hash = r.messageId + msg.timestamp = r.timestamp + msg.incoming = r.incoming + msg.state = mapState(r.state) + msg.method = mapMethod(r.method) + msg.rssi = r.rssi + msg.snr = r.snr + msg.q = r.q + msg.receivingInterface = r.receivingInterface + // Keep `packed` as the field map (matching `msg.fields` and the A0 + // bridge contract): wire rows are normalized so this stays coherent. + msg.packed = normalizedFieldMap(r.packedLxmf) + return msg } - /// Load pending outbound messages. + /// RNSAPI `LXMessage` → GRDB `LXMessage` (for the save path). /// - /// Returns messages in OUTBOUND state waiting to be sent. + /// Uses the GRDB no-identity outbound init, then carries the field map in + /// `packed` so `MessageRecord(from:)` persists it into `packed_lxmf`. The + /// conversation key (incoming → sourceHash, outbound → destinationHash) is + /// preserved by setting `incoming` to match. + static func mapToGRDBMessage(_ m: RNSAPI.LXMessage) -> LXMFSwift.LXMessage { + var out = LXMFSwift.LXMessage( + destinationHash: m.destinationHash, + sourceHash: m.sourceHash, + content: m.content, + title: m.title, + timestamp: m.timestamp, + state: mapStateToGRDB(m.state), + incoming: m.incoming + ) + out.hash = m.hash + out.method = mapMethodToGRDB(m.method) + out.rssi = m.rssi + out.snr = m.snr + out.q = m.q + out.receivingInterface = m.receivingInterface + out.fields = m.fields + // Carry the MessagePack field map as `packed` so `MessageRecord(from:)` + // (which requires non-nil `packed` and copies it to `packed_lxmf`) + // succeeds without the signed LXMF wire. Empty Data when no fields, + // matching `LxmfFieldCodec.pack`'s empty-map convention. + out.packed = (m.fields?.isEmpty == false) ? LxmfFieldCodec.pack(m.fields!) : Data() + return out + } + + // MARK: State + + /// GRDB `LXMessageState` (UInt8) → RNSAPI `LXMessageState` (semantic). /// - /// - Returns: Array of LXMessage with state == .outbound - /// - Throws: DatabaseError or LXMFError if query fails - public func loadPendingOutbound() async throws -> [LXMessage] { - try await database.loadPendingOutbound() + /// GRDB has `generating`/`rejected`/`cancelled` with no RNSAPI peer: + /// `generating` → `.draft`; `rejected`/`cancelled` → `.failed`. + static func mapState(_ s: LXMFSwift.LXMessageState) -> RNSAPI.LXMessageState { + switch s { + case .generating: return .draft + case .outbound: return .outbound + case .sending: return .sending + case .sent: return .sent + case .delivered: return .delivered + case .rejected: return .failed + case .cancelled: return .failed + case .failed: return .failed + } + } + + /// Map a raw GRDB state byte → RNSAPI `LXMessageState`. Unknown bytes fall + /// back to `.sent` (matches the chat UI's `default` arm in + /// `Message(from:)`). + static func mapState(_ raw: UInt8) -> RNSAPI.LXMessageState { + guard let s = LXMFSwift.LXMessageState(rawValue: raw) else { return .sent } + return mapState(s) } - /// Load failed outbound messages. + /// RNSAPI `LXMessageState` → GRDB `LXMessageState`. /// - /// Returns messages in FAILED state for retry or inspection. + /// RNSAPI `.received` (inbound) maps to GRDB `.delivered` (the GRDB store + /// has no inbound-specific state; `incoming` carries that distinction). + static func mapStateToGRDB(_ s: RNSAPI.LXMessageState) -> LXMFSwift.LXMessageState { + switch s { + case .draft: return .generating + case .outbound: return .outbound + case .sending: return .sending + case .sent: return .sent + case .delivered: return .delivered + case .failed: return .failed + case .received: return .delivered + } + } + + // MARK: Method + + /// GRDB `LXDeliveryMethod` (UInt8) → RNSAPI `LXDeliveryMethod`. + static func mapMethod(_ m: LXMFSwift.LXDeliveryMethod) -> RNSAPI.LXDeliveryMethod { + switch m { + case .opportunistic: return .opportunistic + case .direct: return .direct + case .propagated: return .propagated + case .paper: return .paper + } + } + + /// Map a raw GRDB method byte → RNSAPI `LXDeliveryMethod`. Unknown bytes + /// fall back to `.unknown`. + static func mapMethod(_ raw: UInt8) -> RNSAPI.LXDeliveryMethod { + guard let m = LXMFSwift.LXDeliveryMethod(rawValue: raw) else { return .unknown } + return mapMethod(m) + } + + /// RNSAPI `LXDeliveryMethod` → GRDB `LXDeliveryMethod`. /// - /// - Returns: Array of LXMessage with state == .failed - /// - Throws: DatabaseError or LXMFError if query fails - public func loadFailedOutbound() async throws -> [LXMessage] { - try await database.loadFailedOutbound() + /// RNSAPI `.unknown` has no GRDB peer; default to `.opportunistic` (the + /// canonical LXMF default delivery method). + static func mapMethodToGRDB(_ m: RNSAPI.LXDeliveryMethod) -> LXMFSwift.LXDeliveryMethod { + switch m { + case .opportunistic: return .opportunistic + case .direct: return .direct + case .propagated: return .propagated + case .paper: return .paper + case .unknown: return .opportunistic + } } } diff --git a/Sources/ColumbaApp/Services/ModelBBLEService.swift b/Sources/ColumbaApp/Services/ModelBBLEService.swift new file mode 100644 index 00000000..c85c6637 --- /dev/null +++ b/Sources/ColumbaApp/Services/ModelBBLEService.swift @@ -0,0 +1,69 @@ +// +// ModelBBLEService.swift +// ColumbaApp +// +// App side of the Model B BLE seam. CoreBluetooth can't run in the Network +// Extension, so the app hosts the REAL reticulum-swift `CoreBluetoothBLEDriver` +// and an `AppGroupBLEServer` that bridges it to the NE's `BLEInterface` over the +// App-Group (the NE drives scan/advertise/connect via the seam; this side just +// runs the radio + forwards events back). See `ble_to_ne_driver_abstraction_plan`. +// +// IMPORTANT: this file `import ReticulumSwift` (NOT RNSAPI) so `CoreBluetoothBLEDriver` +// resolves to the real driver — `AppServices` uses RNSAPI's Compat stubs, hence the +// separate file (Swift imports are per-file). +// +// Single instance + idempotent start: `CoreBluetoothBLEDriver` registers a CB +// state-restoration identifier, so there must be exactly one. Under Model B this +// REPLACES `SwiftBLEBridge` as the app's CoreBluetooth owner — the caller gates out +// `SwiftBLEBridge.restoreAtLaunch()` when Model B is active so the two CB stacks +// don't fight over the same GATT service. +// + +import Foundation +import ReticulumSwift + +public final class ModelBBLEService: @unchecked Sendable { + + public static let shared = ModelBBLEService() + private init() {} + + private let lock = NSLock() + private var driver: CoreBluetoothBLEDriver? + private var transport: AppGroupBLESeamTransport? + private var server: AppGroupBLEServer? + + public var isRunning: Bool { lock.lock(); defer { lock.unlock() }; return driver != nil } + + /// Construct + start the CoreBluetooth driver and the App-Group server. Idempotent. + /// - Parameter identityHash: the 16-byte transport identity (the same one the NE + /// uses for its `BLEInterface`), so the GATT identity characteristic matches. + public func start(identityHash: Data) { + lock.lock(); defer { lock.unlock() } + guard driver == nil else { return } + precondition(identityHash.count == 16, "BLE transport identity must be 16 bytes") + + let drv = CoreBluetoothBLEDriver(identityHash: identityHash) + let tx = AppGroupBLESeamTransport(role: .app) + tx.start() + let srv = AppGroupBLEServer(transport: tx, driver: drv, log: { DiagLog.log($0) }) + srv.start() + // No scan/advertise here: the NE's BLEInterface.connect() drives those over + // the seam (so the two processes stay coordinated). This side only runs the + // radio + relays events. + + self.driver = drv + self.transport = tx + self.server = srv + DiagLog.log("[BLE] Model B BLE service started (CoreBluetoothBLEDriver + AppGroupBLEServer)") + } + + public func stop() { + lock.lock(); defer { lock.unlock() } + driver?.shutdown() + transport?.stop() + driver = nil + transport = nil + server = nil + DiagLog.log("[BLE] Model B BLE service stopped") + } +} diff --git a/Sources/ColumbaApp/Services/ModelBRNodeService.swift b/Sources/ColumbaApp/Services/ModelBRNodeService.swift new file mode 100644 index 00000000..2bb63bfe --- /dev/null +++ b/Sources/ColumbaApp/Services/ModelBRNodeService.swift @@ -0,0 +1,52 @@ +// +// ModelBRNodeService.swift +// ColumbaApp +// +// App side of the Model B RNode seam. CoreBluetooth can't run in the Network +// Extension, so the app hosts the REAL reticulum-swift `BLETransport` (the RNode NUS +// radio) via an `AppGroupRNodeServer` that bridges it to the NE's `RNodeInterface` +// over the App-Group (the NE drives connect/send/disconnect via the seam; this side +// runs the radio + forwards received bytes + state back). +// +// Parallels `ModelBBLEService`. The server lazily creates the `BLETransport` on the +// NE's first `connect` command, so this just needs to be running whenever the RNode +// is enabled. Under Model B this REPLACES the legacy `SwiftRNodeBridge` + the +// app-local `RNodeInterface` as the app's RNode CoreBluetooth owner. +// + +import Foundation + +public final class ModelBRNodeService: @unchecked Sendable { + + public static let shared = ModelBRNodeService() + private init() {} + + private let lock = NSLock() + private var wire: AppGroupRNodeSeamWire? + private var server: AppGroupRNodeServer? + + public var isRunning: Bool { lock.lock(); defer { lock.unlock() }; return server != nil } + + /// Construct + start the App-Group RNode server. Idempotent. The server begins + /// observing the seam and creates the real `BLETransport` on the NE's first + /// `connect` command. + public func start(onLinkStateChange: ((RNodeLinkState) -> Void)? = nil) { + lock.lock(); defer { lock.unlock() } + guard server == nil else { return } + let w = AppGroupRNodeSeamWire(role: .app) + let srv = AppGroupRNodeServer(wire: w, log: { DiagLog.log($0) }) + srv.onLinkStateChange = onLinkStateChange + srv.start() + self.wire = w + self.server = srv + DiagLog.log("[RNODE] Model B RNode service started (AppGroupRNodeServer)") + } + + public func stop() { + lock.lock(); defer { lock.unlock() } + server?.stop() + wire = nil + server = nil + DiagLog.log("[RNODE] Model B RNode service stopped") + } +} diff --git a/Sources/ColumbaApp/Services/NotificationObserver.swift b/Sources/ColumbaApp/Services/NotificationObserver.swift index 4162f6bf..55f44e90 100644 --- a/Sources/ColumbaApp/Services/NotificationObserver.swift +++ b/Sources/ColumbaApp/Services/NotificationObserver.swift @@ -2,30 +2,53 @@ // NotificationObserver.swift // ColumbaApp // -// Darwin notification listener for IPC from Network Extension. -// Uses CFNotificationCenter for cross-process communication. +// Darwin notification listener for IPC from the Network Extension. +// Uses CFNotificationCenter for cross-process communication, bridged to +// in-process NotificationCenter posts the UI observes. // import Foundation import RNSAPI -/// Observer for Darwin notifications from Network Extension. +/// Observer for Darwin notifications from the Network Extension. /// -/// Provides real-time notification when new messages arrive, allowing -/// the app to refresh UI immediately when the Network Extension receives -/// messages while backgrounded. +/// Under Model B the NE owns delivery + the BLE/interface state and PUSHES these +/// signals on change; the app reacts (fetch once) instead of polling. Two channels: +/// - `newMessageNotification` → inbound LXMF / delivery proof landed. +/// - `networkStateChangedNotification` → BLE/interface state changed (peer +/// connect/disconnect, interface up/down) — the cue for status/connection UIs. /// -/// Uses CFNotificationCenter (Darwin notifications) which work across -/// process boundaries, unlike NSNotificationCenter. +/// CFNotificationCenter (Darwin notifications) works across process boundaries, +/// unlike NSNotificationCenter; each is bridged to an in-process post for the UI. public final class NotificationObserver: @unchecked Sendable { - // MARK: - Constants + // MARK: - Darwin notification names (must match the NE's posters) - /// Darwin notification name posted when new LXMF message arrives. + /// Posted by the NE when a new inbound LXMF message / delivery proof lands. public static let newMessageNotification = "network.columba.newMessage" as CFString + /// Posted by the NE when network/BLE state changes (peer connect/disconnect, + /// interface up/down). + public static let networkStateChangedNotification = "network.columba.networkStateChanged" as CFString + + /// In-process notification re-posted from `networkStateChangedNotification`, + /// observed by the status / interface / BLE-connection view-models so they + /// refresh once on change instead of polling the NE on a timer. + public static let networkStateChangedInApp = Notification.Name("network.columba.networkStateChanged.inapp") + + /// Posted by the NE (Model B) as a propagation sync advances. The snapshot + /// (`PropagationSyncStateSnapshot`) rides the App-Group, since Darwin carries no + /// payload. + public static let propagationSyncStateChangedNotification = + SharedDefaultsConstants.propagationSyncStateChangedNotificationName as CFString + + /// In-process re-post of `propagationSyncStateChangedNotification`, observed by + /// `PropagationNodeManager` to drive the in-app sync sheet. + public static let propagationSyncStateChangedInApp = + Notification.Name("network.columba.propagationSyncStateChanged.inapp") + // MARK: - Properties - /// Callback invoked when new message notification is received. + /// Callback invoked when a new-message notification is received. private var callback: (@Sendable () -> Void)? // MARK: - Initialization @@ -35,6 +58,7 @@ public final class NotificationObserver: @unchecked Sendable { let center = CFNotificationCenterGetDarwinNotifyCenter() let observer = Unmanaged.passUnretained(self).toOpaque() + // New-message channel → callback + bridge to messageReceivedNotification. CFNotificationCenterAddObserver( center, observer, @@ -44,51 +68,104 @@ public final class NotificationObserver: @unchecked Sendable { .fromOpaque(observer) .takeUnretainedValue() self_.callback?() + // Bridge the cross-process Darwin signal to the in-process + // `messageReceivedNotification` the rest of the UI observes (the open + // thread's `MessagingViewModel`, message views). Under Model B the NE + // delivers inbound LXMF + receives delivery proofs and posts ONLY this + // Darwin notification — the app-local `IncomingMessageHandler` never + // fires — so without this re-post an open conversation never reloads + // inbound messages or advances the sent-message delivery checkmarks. + // `loadMessages` re-reads both, so no per-message userInfo is needed. + NotificationCenter.default.post( + name: IncomingMessageHandler.messageReceivedNotification, + object: nil + ) }, Self.newMessageNotification, nil, .deliverImmediately ) + + // Network/BLE-state channel → bridge to `networkStateChangedInApp`. Replaces + // the always-on 1-2s polls the status / interface / BLE-connection view-models + // used to hammer the NE with (a ~10/s app<->NE IPC flood); they now fetch once + // in response. No `self` needed — just forward the signal in-process. + CFNotificationCenterAddObserver( + center, + observer, + { _, _, _, _, _ in + NotificationCenter.default.post( + name: NotificationObserver.networkStateChangedInApp, + object: nil + ) + }, + Self.networkStateChangedNotification, + nil, + .deliverImmediately + ) + + // Propagation sync-state channel (Model B) → bridge to + // `propagationSyncStateChangedInApp`. `PropagationNodeManager` reads the + // App-Group snapshot in response and updates its `syncState` for the sheet. + CFNotificationCenterAddObserver( + center, + observer, + { _, _, _, _, _ in + NotificationCenter.default.post( + name: NotificationObserver.propagationSyncStateChangedInApp, + object: nil + ) + }, + Self.propagationSyncStateChangedNotification, + nil, + .deliverImmediately + ) } deinit { let center = CFNotificationCenterGetDarwinNotifyCenter() let observer = Unmanaged.passUnretained(self).toOpaque() CFNotificationCenterRemoveObserver( - center, - observer, - CFNotificationName(Self.newMessageNotification), - nil + center, observer, CFNotificationName(Self.newMessageNotification), nil + ) + CFNotificationCenterRemoveObserver( + center, observer, CFNotificationName(Self.networkStateChangedNotification), nil + ) + CFNotificationCenterRemoveObserver( + center, observer, CFNotificationName(Self.propagationSyncStateChangedNotification), nil ) } // MARK: - Public Methods - /// Register callback for new message notifications. - /// - /// The callback is invoked on the thread that posts the notification, - /// which may not be the main thread. Use @MainActor or DispatchQueue.main - /// if you need to update UI. + /// Register callback for new-message notifications. /// - /// - Parameter callback: Closure called when notification is received + /// The callback is invoked on the thread that posts the notification, which may + /// not be the main thread. Hop to @MainActor / DispatchQueue.main for UI. public func onNewMessage(_ callback: @escaping @Sendable () -> Void) { self.callback = callback } - // MARK: - Static Methods + // MARK: - Static posters (NE side) - /// Post new message notification. - /// - /// Called by Network Extension when new LXMF message is received and stored. - /// The main app observes this to refresh its message list. + /// Post the new-message notification. Called by the NE when a new LXMF message is + /// received and stored; the main app observes this to refresh its message list. public static func postNewMessage() { - let center = CFNotificationCenterGetDarwinNotifyCenter() CFNotificationCenterPostNotification( - center, + CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName(Self.newMessageNotification), - nil, - nil, - true + nil, nil, true + ) + } + + /// Post the network/BLE-state-changed notification. Called by the NE when a BLE + /// peer connects/disconnects or an interface changes state; status/connection UIs + /// observe `networkStateChangedInApp` and refresh once. + public static func postNetworkStateChanged() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(Self.networkStateChangedNotification), + nil, nil, true ) } } diff --git a/Sources/ColumbaApp/Services/PropagationNodeManager.swift b/Sources/ColumbaApp/Services/PropagationNodeManager.swift index b0eb99fc..42a84a2a 100644 --- a/Sources/ColumbaApp/Services/PropagationNodeManager.swift +++ b/Sources/ColumbaApp/Services/PropagationNodeManager.swift @@ -62,6 +62,11 @@ public final class PropagationNodeManager { /// Display name of the selected relay node. public var selectedNodeName: String? + /// Proof-of-work stamp cost the selected relay requires for uploads. Tracked + /// here (not just computed locally) so the Model B App-Group seam can carry the + /// correct cost to the NE; persisted across cold starts via SettingsRepository. + public private(set) var selectedNodeStampCost: Int = 0 + /// Whether to automatically select the best relay based on hop count. public var autoSelectEnabled: Bool = true @@ -94,6 +99,11 @@ public final class PropagationNodeManager { /// Task for periodic sync. private var periodicSyncTask: Task? + /// Observer token for the NE's propagation sync-state channel (Model B). The NE + /// owns the router/sync, so live progress arrives as App-Group snapshots bridged + /// to `propagationSyncStateChangedInApp`; we mirror them into `syncState`. + private var syncStateObserverToken: NSObjectProtocol? + // MARK: - Initialization public init(appServices: AppServices) { @@ -104,6 +114,19 @@ public final class PropagationNodeManager { /// Start listening for propagation node announces on the path table. public func startListening() { + // Model B: mirror the NE's sync-state snapshots into `syncState` so the in-app + // sync sheet reflects live progress (the NE owns the router; the app can't read + // its transfer state directly). + if syncStateObserverToken == nil { + syncStateObserverToken = NotificationCenter.default.addObserver( + forName: NotificationObserver.propagationSyncStateChangedInApp, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.applySyncStateSnapshot() } + } + } + guard let pathTable = appServices?.pathTable else { return } @@ -128,6 +151,36 @@ public final class PropagationNodeManager { public func stopListening() { listenTask?.cancel() listenTask = nil + if let token = syncStateObserverToken { + NotificationCenter.default.removeObserver(token) + syncStateObserverToken = nil + } + } + + /// Read the NE's latest sync-state snapshot (Model B) and mirror it into + /// `syncState`, which the in-app sync sheet observes. + private func applySyncStateSnapshot() { + guard let snap = PropagationSyncStateSnapshot.loadFromAppGroup() else { return } + syncState.state = Self.mapSnapshotPhase(snap.phase) + syncState.receivedMessages = snap.received + syncState.progress = snap.progress + syncState.errorDescription = snap.errorDescription + if snap.phase == .complete { + syncState.lastSync = Date() + lastSyncTime = syncState.lastSync + } + } + + /// Map the NE snapshot's coarse phase to the app's Compat sync state. + private static func mapSnapshotPhase(_ phase: PropagationSyncStateSnapshot.Phase) -> PropagationTransferState.State { + switch phase { + case .idle: return .idle + case .linking: return .linking + case .linked: return .linked + case .requesting, .receiving: return .transferring + case .complete: return .complete + case .failed: return .transferFailed + } } /// Process a path entry to check if it's a propagation node. @@ -161,9 +214,17 @@ public final class PropagationNodeManager { logger.info("Discovered propagation node: \(node.resolvedDisplayName) (\(hex.prefix(16))) hops=\(node.hopCount)") - // Auto-select if enabled + // Auto-select if enabled. if autoSelectEnabled { await autoSelectBestNode() + } else if let selectedHash = selectedNodeHash, node.hash == selectedHash { + // Manually-selected node: re-wire it now that its announce has landed. + // At loadPreferences the node isn't in knownNodes yet, so it's wired + // with a placeholder stampCost=0; a PROPAGATED upload then carries no + // stamp and a stamp-requiring PN rejects it — the message queues + // forever (the launch `nodeFound=false` log was the tell). selectNode + // re-resolves the node + pushes its real stamp cost to the router. + await selectNode(hash: selectedHash) } } @@ -210,6 +271,7 @@ public final class PropagationNodeManager { // only — Python's LXMF.LXMRouter.set_outbound_propagation_node // is what actually affects delivery. let stampCost = node?.info.stampCost ?? 0 + selectedNodeStampCost = stampCost if let backend = appServices?.pythonBackend { do { _ = try await backend.setPropagationNode(destHashHex: hash.toHex(), stampCost: stampCost) @@ -231,6 +293,7 @@ public final class PropagationNodeManager { selectedNodeHash = nil selectedNodeDeliveryHash = nil selectedNodeName = nil + selectedNodeStampCost = 0 if let backend = appServices?.pythonBackend { do { @@ -252,6 +315,30 @@ public final class PropagationNodeManager { /// /// If no propagation node is selected yet, auto-selects the best available node first. public func syncNow() async { + // Model B: the LXMF router lives in the NE — the app can't sync in-process. + // Ensure a PN is selected + its config is in the seam, then fire the sync-now + // Darwin trigger. Real progress arrives back via the sync-state channel + // (PropagationSyncStateSnapshot → syncState); the NE's overlap guard makes + // repeated taps safe. + if BackendPreference.modelB { + if selectedNodeHash == nil && autoSelectEnabled, + let best = knownNodes.first(where: { $0.isOnline }) ?? knownNodes.first { + await selectNode(hash: best.hash) + } + guard selectedNodeHash != nil else { + syncState.state = .noPath + syncState.errorDescription = "No propagation node available" + logger.warning("[SYNC] Model B: no propagation node, sync skipped") + return + } + publishPropagationSeam() // ensure the NE has the latest PN + stamp cost + syncState.state = .linking + syncState.errorDescription = nil + PropagationSeamConfig.postSyncNowNotification() + logger.info("[SYNC] Model B: posted sync-now to NE") + return + } + guard let backend = appServices?.pythonBackend else { logger.error("[SYNC] Python backend not available") syncState.state = .linkFailed @@ -320,6 +407,14 @@ public final class PropagationNodeManager { /// Start periodic sync on the configured interval. public func startPeriodicSync() { + // Model B: the NE owns the sync cadence (it owns the router). Publish the + // current interval/enabled to the seam; the NE's scheduler honors + // `periodicSyncEnabled` and re-kicks on the config-changed notification. + if BackendPreference.modelB { + publishPropagationSeam() + return + } + guard periodicSyncEnabled else { return } periodicSyncTask?.cancel() @@ -367,8 +462,13 @@ public final class PropagationNodeManager { DiagLog.log("[PROP_MGR] Restored relay: hash=\(hashHex.prefix(16)), name=\(selectedNodeName ?? "nil"), nodeFound=\(node != nil)") - // Wire to router (awaited directly, not fire-and-forget) - let stampCost = node?.info.stampCost ?? 0 + // Wire to router (awaited directly, not fire-and-forget). The node + // usually isn't in knownNodes yet at load (announce hasn't landed), + // so fall back to the persisted stamp cost for a correct cold start; + // processPathEntry re-resolves the live cost when the announce arrives. + let persistedCost = await settingsRepository.getManualRelayStampCost() ?? 0 + let stampCost = node?.info.stampCost ?? persistedCost + selectedNodeStampCost = stampCost await appServices?.router?.setOutboundPropagationNode(hash) await appServices?.router?.setPropagationStampCost(stampCost) } @@ -379,6 +479,9 @@ public final class PropagationNodeManager { } _ = defaultMethod // Used by SettingsViewModel + + // Model B: hand the restored PN + sync settings to the NE's router. + publishPropagationSeam() } /// Save preferences to SettingsRepository. @@ -391,6 +494,7 @@ public final class PropagationNodeManager { let hex = hash.map { String(format: "%02x", $0) }.joined() await settingsRepository.setManualRelayHash(hex) await settingsRepository.setManualRelayName(selectedNodeName) + await settingsRepository.setManualRelayStampCost(selectedNodeStampCost) if let deliveryHash = selectedNodeDeliveryHash { let deliveryHex = deliveryHash.map { String(format: "%02x", $0) }.joined() await settingsRepository.setManualRelayDeliveryHash(deliveryHex) @@ -401,11 +505,34 @@ public final class PropagationNodeManager { await settingsRepository.setManualRelayHash(nil) await settingsRepository.setManualRelayName(nil) await settingsRepository.setManualRelayDeliveryHash(nil) + await settingsRepository.setManualRelayStampCost(nil) } if let time = lastSyncTime { await settingsRepository.setLastSyncTimestamp(time.timeIntervalSince1970) } + + // Model B: republish the seam so PN selection / sync-setting edits reach the + // NE's router. No-op on the python build (the app owns the router there). + publishPropagationSeam() + } + + /// Model B: cross the App-Group seam to the NE's in-NE `LXMRouter`. The NE wires + /// the PN + sync settings onto its router and runs the periodic sync there (the + /// app can't call it directly). No-op on the python build, where the app owns the + /// router and `selectNode`/`syncNow` drive it in-process. + private func publishPropagationSeam() { + guard BackendPreference.modelB else { return } + if let hash = selectedNodeHash { + PropagationSeamConfig( + propagationNodeHash: hash, + stampCost: selectedNodeStampCost, + syncInterval: syncInterval, + periodicSyncEnabled: periodicSyncEnabled + ).saveToAppGroup() + } else { + PropagationSeamConfig.clearFromAppGroup() + } } } diff --git a/Sources/ColumbaApp/Services/PythonConfigWriter.swift b/Sources/ColumbaApp/Services/PythonConfigWriter.swift index 873bf257..d3153482 100644 --- a/Sources/ColumbaApp/Services/PythonConfigWriter.swift +++ b/Sources/ColumbaApp/Services/PythonConfigWriter.swift @@ -120,25 +120,16 @@ enum PythonConfigWriter { // OS auto-manages duty cycle). Surfaced for parity with // Android's BleConnections settings. lines.append(" ble_power_preset = balanced") - case .rnode(let cfg): - // Loaded from /interfaces/IOSRNodeInterface.py (copied at - // startup by deployIOSRNodePythonFilesIfPossible). That Python - // interface runs the KISS / RNode protocol and bridges serial I/O to - // Swift's SwiftRNodeBridge (CoreBluetooth Nordic-UART client) via the - // ctypes-bound `columba_rnode_*` C-ABI shims (Sources/SwiftBLEBridge/ - // RNodeNativeBindings.swift). Radio keys are written WITHOUT - // underscores (txpower / spreadingfactor / codingrate) to match what - // the ported interface parses and upstream RNS's RNodeInterface - // convention. iOS is BLE-only — no usb_* / port keys. - lines.append(" type = IOSRNodeInterface") - appendValue("target_device_name", cfg.deviceName, to: &lines) - lines.append(" frequency = \(cfg.frequency)") - lines.append(" bandwidth = \(cfg.bandwidth)") - lines.append(" txpower = \(cfg.txPower)") - lines.append(" spreadingfactor = \(cfg.spreadingFactor)") - lines.append(" codingrate = \(cfg.codingRate)") - if let st = cfg.stAlock { lines.append(" st_alock = \(st)") } - if let lt = cfg.ltAlock { lines.append(" lt_alock = \(lt)") } + case .rnode: + // RNode runs through the Model B seam on the Swift backend (radio in + // the app, RNS + KISS framing in the Network Extension) — the legacy + // Python IOSRNodeInterface path was removed. The Python backend has no + // RNode implementation, so emit a disabled placeholder: an RNode entry + // in a Python-backend config stays valid but inert. + lines.append(" type = TCPClientInterface # RNode moved to Model B (Swift NE); Python path retired") + lines.append(" target_host = 127.0.0.1") + lines.append(" target_port = 65535") + lines.append(" enabled = no") case .multipeer: // MultipeerConnectivity bridge not yet wired (separate effort, its // own branch — see rnode_interface_port_plan.md). Emit a disabled diff --git a/Sources/ColumbaApp/Services/SettingsRepository.swift b/Sources/ColumbaApp/Services/SettingsRepository.swift index 766ce2b0..56622c83 100644 --- a/Sources/ColumbaApp/Services/SettingsRepository.swift +++ b/Sources/ColumbaApp/Services/SettingsRepository.swift @@ -25,6 +25,7 @@ public actor SettingsRepository { static let manualRelayHash = "manualRelayHash" static let manualRelayDeliveryHash = "manualRelayDeliveryHash" static let manualRelayName = "manualRelayName" + static let manualRelayStampCost = "manualRelayStampCost" static let periodicSyncEnabled = "periodicSyncEnabled" static let syncIntervalSeconds = "syncIntervalSeconds" static let lastSyncTimestamp = "lastSyncTimestamp" @@ -164,6 +165,22 @@ public actor SettingsRepository { } } + /// Get the saved relay's proof-of-work stamp cost, or nil if none saved. + /// Persisted so the Model B App-Group seam carries the correct cost across a + /// cold start, before the PN's fresh announce re-resolves it. + public func getManualRelayStampCost() -> Int? { + defaults.object(forKey: Keys.manualRelayStampCost) as? Int + } + + /// Set the saved relay's proof-of-work stamp cost. + public func setManualRelayStampCost(_ cost: Int?) { + if let cost = cost { + defaults.set(cost, forKey: Keys.manualRelayStampCost) + } else { + defaults.removeObject(forKey: Keys.manualRelayStampCost) + } + } + /// Get whether periodic sync is enabled. public func getPeriodicSyncEnabled() -> Bool { defaults.bool(forKey: Keys.periodicSyncEnabled) diff --git a/Sources/ColumbaApp/Services/TunnelManager.swift b/Sources/ColumbaApp/Services/TunnelManager.swift index 22cd13e3..7c06f557 100644 --- a/Sources/ColumbaApp/Services/TunnelManager.swift +++ b/Sources/ColumbaApp/Services/TunnelManager.swift @@ -109,13 +109,22 @@ public final class TunnelManager: @unchecked Sendable { mgr.localizedDescription = "Columba Background Transport" mgr.isEnabled = true + // On-demand: relaunch the tunnel automatically after iOS terminates it + // (jetsam / reboot / user toggle) so background delivery resumes without + // the app being foregrounded — the NE can't wake itself, so a connect rule + // keeps it up whenever a network path exists (the deliver-while-locked + // posture; see Track C2/C4). NEOnDemandRuleConnect with no interface match + // applies on every interface (WiFi + cellular). + mgr.isOnDemandEnabled = true + mgr.onDemandRules = [NEOnDemandRuleConnect()] + try await mgr.saveToPreferences() try await mgr.loadFromPreferences() manager = mgr isEnabled = true status = mgr.connection.status - logger.info("Tunnel config installed") + logger.info("Tunnel config installed (on-demand connect enabled)") } /// Start the tunnel extension. @@ -135,10 +144,36 @@ public final class TunnelManager: @unchecked Sendable { logger.info("Tunnel started") } - /// Stop the tunnel extension. + /// Stop the tunnel session WITHOUT disarming on-demand. + /// + /// Note: `install()` arms `isOnDemandEnabled = true` + an + /// `NEOnDemandRuleConnect()` rule so iOS relaunches the NE after jetsam / + /// reboot. A bare `stopVPNTunnel()` therefore does NOT keep the tunnel down — + /// iOS re-connects via the armed rule. For the user-facing "Disable Background + /// Transport" affordance use `disable()`, which clears on-demand first. This + /// remains for transient internal stops where the auto-reconnect IS wanted. public func stop() { manager?.connection.stopVPNTunnel() - logger.info("Tunnel stopped") + logger.info("Tunnel stopped (on-demand still armed)") + } + + /// Fully disable background transport: clear the on-demand connect rule and + /// the enabled flag, persist, then stop the live session. + /// + /// Without clearing on-demand, "Disable Background Transport" is a no-op — + /// iOS auto-resumes the NE through the `NEOnDemandRuleConnect()` armed in + /// `install()`. Clearing `isOnDemandEnabled`/`onDemandRules`/`isEnabled` and + /// `saveToPreferences()` is what actually keeps it down. Re-enabling via + /// `start()` re-arms everything through `install()`. (ports #57 38f8d2e) + public func disable() async throws { + guard let manager else { return } + manager.isOnDemandEnabled = false + manager.onDemandRules = [] + manager.isEnabled = false + try await manager.saveToPreferences() + manager.connection.stopVPNTunnel() + isEnabled = false + logger.info("Tunnel disabled (on-demand cleared)") } /// Send a raw frame to the extension for transmission. @@ -150,7 +185,16 @@ public final class TunnelManager: @unchecked Sendable { /// - 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 { - guard let session = manager?.connection as? NETunnelProviderSession else { + // Bridge diagnostic: report the frame and whether a live NE session + // exists. `session=NIL` here means the frame is DROPPED below (no + // NETunnelProviderSession to forward it on). DiagLog is visible from + // this module (ColumbaApp), so mirror to it directly. NO-PII: tag + + // byte length only. Use the same `as?` the guard uses so the logged + // state and the drop decision can't disagree. + let session = manager?.connection as? NETunnelProviderSession + DiagLog.log("[BRIDGE-OUT] sendFrame tag=\(interfaceTag) len=\(data.count) session=\(session != nil ? "yes" : "NIL")") + guard let session else { + DiagLog.log("[BRIDGE-OUT] sendFrame DROPPED: no NETunnelProviderSession") return } @@ -164,6 +208,61 @@ public final class TunnelManager: @unchecked Sendable { } } + /// Track A5b — Model B IPC transport for `ProxyRnsBackend`. + /// + /// Send a `ProxyRequest` envelope (already magic+version-framed by + /// `ProxyIPC.encodeRequest`) to the extension and await its `ProxyResponse` + /// bytes, bridging `NETunnelProviderSession.sendProviderMessage`'s + /// completion-handler API into `async`. Returns the raw response `Data` the NE + /// hands back (an encoded `ProxyResponse`), or `nil` when there's no live + /// session or the send throws — the proxy maps `nil` onto an IPC-failure. + /// + /// `BackendFactory.make(proxySend:)` injects this as the proxy's `send` + /// closure when Model B is on (currently never — `BackendPreference.modelB` + /// defaults `false`), so this primitive is present + testable but inert until + /// A5c wires it live. + public func proxySend(_ data: Data) async -> Data? { + // The NE may still be coming up when the app first sends: proxy `start` + // (and any early announce/status) races the tunnel session reaching + // `.connected` at launch. `sendProviderMessage` on a non-`.connected` + // session throws → nil → the proxy reports `ipcFailed`, and a one-time + // launch race then leaves the proxy backend permanently "not started" + // (the announce button later throws `transportNotConnected`). So wait + // briefly for a live, connected session first — bounded, so a genuinely + // down tunnel still returns nil promptly. + guard let session = await connectedSession(timeoutMs: 8000) else { + logger.error("proxySend: no connected tunnel session") + return nil + } + return await withCheckedContinuation { (continuation: CheckedContinuation) in + do { + try session.sendProviderMessage(data) { response in + continuation.resume(returning: response) + } + } catch { + self.logger.error("proxySend failed: \(error)") + continuation.resume(returning: nil) + } + } + } + + /// Await a `.connected` `NETunnelProviderSession`, polling up to `timeoutMs`. + /// The NE/tunnel is often still `.connecting` for a moment right after the + /// app launches; this lets the first proxy round-trip succeed instead of + /// spuriously failing. Returns nil if no connected session appears in time. + private func connectedSession(timeoutMs: Int) async -> NETunnelProviderSession? { + var waited = 0 + let step = 200 + while true { + if let s = manager?.connection as? NETunnelProviderSession, s.status == .connected { + return s + } + if waited >= timeoutMs { return nil } + try? await Task.sleep(for: .milliseconds(step)) + waited += step + } + } + /// Whether the extension is currently running. public var isRunning: Bool { status == .connected diff --git a/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift b/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift index 01171ea2..d912de1f 100644 --- a/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/ContactsViewModel.swift @@ -43,7 +43,9 @@ public struct Contact: Identifiable, Sendable, Hashable { public let aspect: String? /// Icon identifier for the interface this announce was received on. - /// Returns "bluetooth" (MDI name) for BLE, SF Symbol names for others. + /// Returns "bluetooth" (MDI name) for BLE, "lucide:" for Lucide-font + /// glyphs (e.g. the RNode antenna, matching Android), SF Symbol names for others. + /// Rendering of each form is handled in ContactCard.interfaceIconView. public var interfaceIcon: String { guard let iface = interfaceId else { return "globe" } let lower = iface.lowercased() @@ -55,7 +57,7 @@ public struct Contact: Identifiable, Sendable, Hashable { // Both forms need recognizing (case-insensitive substring match). if lower.contains("bluetooth") || lower.contains("ble") || lower.contains("blepeer") { return "bluetooth" } - if lower.contains("rnode") { return "antenna.radiowaves.left.and.right" } + if lower.contains("rnode") { return "lucide:antenna" } if lower.contains("autointerface") || lower.contains("auto_discovery") || lower.contains("autointerfacepeer") { return "wifi" } if lower.contains("multipeer") || lower.contains("mpc") { return "apple.logo" } diff --git a/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift b/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift index 011a9e1c..3005aa02 100644 --- a/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift @@ -124,9 +124,25 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { /// Interface connection status (interface ID -> status) public var interfaceStatus: [String: InterfaceStatus] = [:] - /// Status observation task + /// Status observation task — polls APP-LOCAL interface state (MPC / + /// MultipeerConnectivity, Auto, RNode, and the Model-A local TCP/BLE + /// Compat actors). It does NOT touch the NE: the Model-B BLE badge is + /// refreshed event-driven via `networkStateChangedObserver` below. private var statusObserverTask: Task? + /// In-process observer for `NotificationObserver.networkStateChangedInApp`. + /// The NE PUSHES this on BLE/interface change; we fetch the NE-derived BLE + /// badge once per notification instead of polling the NE on the 1s timer. + private var networkStateChangedObserver: NSObjectProtocol? + + /// Latest NE-derived BLE badge values (Model B only), populated by the + /// event-driven `refreshNEBackedBLEStatus()` and consumed by the status + /// loop / `interfaceStatus` write. Under Model B the BLE radio + interface + /// live across the NE seam, so these are the only source of truth for the + /// badge; the 1s loop must NOT round-trip the NE to derive them. + @MainActor private var modelBBLEPeerCount: Int = 0 + @MainActor private var modelBBLEState: InterfaceState = .disconnected + // MARK: - Computed Properties /// Whether we're in edit mode (vs add mode) @@ -162,6 +178,17 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { self.appServices = appServices loadInterfaces() startStatusObserver() + startNetworkStateObserver() + } + + deinit { + // Tear down the app-local status poll and the NE push observer. The loop + // also self-exits via its `[weak self]` guard, but cancel explicitly so + // it stops promptly rather than after the next 1s sleep. + statusObserverTask?.cancel() + if let observer = networkStateChangedObserver { + NotificationCenter.default.removeObserver(observer) + } } // MARK: - List Operations @@ -371,7 +398,7 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { guard let self = self else { break } // Read @MainActor properties we need for actor lookups - let (tcpEntities, tcpIfaces, autoIf, bleIf, rnodeIf, mpcIf, enabledIfs) = await MainActor.run { + let (tcpEntities, tcpIfaces, autoIf, bleIf, rnodeIf, mpcIf, enabledIfs, appSvc) = await MainActor.run { ( self.repository.getEnabledInterfaces().filter { $0.type == .tcpClient }, self.appServices.tcpInterfaces, @@ -379,27 +406,40 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { self.appServices.bleInterface, self.appServices.rnodeInterface, self.appServices.mpcInterface, - self.repository.getEnabledInterfaces() + self.repository.getEnabledInterfaces(), + self.appServices ) } // Read TCP interface states off main thread var tcpUpdates: [(String, InterfaceStatus, String?)] = [] - for entity in tcpEntities { - if let iface = tcpIfaces[entity.id] { - let state = await iface.state - let err = await iface.lastErrorDescription - let status: InterfaceStatus - switch state { - case .connected: status = .connected - case .connecting: status = .connecting - case .reconnecting: status = .reconnecting - case .disconnected, .notConnected: status = .disconnected - case .connectionFailed, .sendFailed, .invalidConfig: status = .error + if BackendPreference.modelB { + // Model B: the app owns no local TCP interface — the NE owns + // the relay socket. Reflect the NE's relay status (via the + // proxy statusSnapshot) so the card isn't stuck "disconnected" + // while the NE relay is actually connected. + let relayOnline = await appSvc.neTcpRelayOnline() + let neStatus: InterfaceStatus = relayOnline ? .connected : .connecting + for entity in tcpEntities { + tcpUpdates.append((entity.id, neStatus, nil)) + } + } else { + for entity in tcpEntities { + if let iface = tcpIfaces[entity.id] { + let state = await iface.state + let err = await iface.lastErrorDescription + let status: InterfaceStatus + switch state { + case .connected: status = .connected + case .connecting: status = .connecting + case .reconnecting: status = .reconnecting + case .disconnected, .notConnected: status = .disconnected + case .connectionFailed, .sendFailed, .invalidConfig: status = .error + } + tcpUpdates.append((entity.id, status, err)) + } else { + tcpUpdates.append((entity.id, .disconnected, nil)) } - tcpUpdates.append((entity.id, status, err)) - } else { - tcpUpdates.append((entity.id, .disconnected, nil)) } } @@ -413,7 +453,17 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { var bleState: InterfaceState? var blePeerCount: Int? - if let ble = bleIf { + if BackendPreference.modelB { + // Model B: reticulum-swift's `BLEInterface` runs in the NE, not + // the app's Compat `bleIf`. This used to round-trip the NE every + // 1s (`appSvc.getBLEConnectionInfos()`) — part of the ~10/s app↔NE + // IPC flood we're eliminating. The badge is now EVENT-DRIVEN: the + // NE pushes `networkStateChangedInApp` on change and + // `refreshNEBackedBLEStatus()` fetches once into these cached + // values, which we just read back here (no NE I/O on the timer). + blePeerCount = await MainActor.run { self.modelBBLEPeerCount } + bleState = await MainActor.run { self.modelBBLEState } + } else if let ble = bleIf { bleState = await ble.state blePeerCount = await ble.peerCount } @@ -532,6 +582,63 @@ public final class InterfaceManagementViewModel: TCPClientWizardSaveSink { } } + /// Observe the NE's push channel for BLE/interface-state changes and refresh + /// the NE-derived BLE badge once per notification (plus one initial fetch), + /// instead of polling the NE on the 1s status loop. + /// + /// Only the NE-backed BLE badge moves here; APP-LOCAL interface state (MPC, + /// Auto, RNode, Model-A local TCP/BLE) stays on `startStatusObserver`'s timer + /// because it has no Darwin/push signal. Under Model A this is effectively a + /// no-op refresh (the badge comes from the local `bleIf` actor in the loop). + private func startNetworkStateObserver() { + // One initial refresh so the badge is correct before the first push. + Task { @MainActor [weak self] in + await self?.refreshNEBackedBLEStatus() + } + + // Refresh once per NE push. The NE coalesces state changes and posts + // `networkStateChangedInApp`; we fetch once in response (no timer). + networkStateChangedObserver = NotificationCenter.default.addObserver( + forName: NotificationObserver.networkStateChangedInApp, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + await self?.refreshNEBackedBLEStatus() + } + } + } + + /// Fetch the NE-derived BLE badge (`blePeerCount` / `bleState`) exactly once + /// and publish it to `interfaceStatus`. This is the ONLY place that calls the + /// NE's `getBLEConnectionInfos()`; it runs on NE push, never on a timer. + /// + /// Under Model A the NE doesn't own the BLE interface, so there's nothing to + /// pull over the seam — the local `bleIf` actor read in `startStatusObserver` + /// remains the source of truth and this returns early. + @MainActor + private func refreshNEBackedBLEStatus() async { + guard BackendPreference.modelB else { return } + + // Single NE round-trip (event-driven, replaces the per-second poll). + let count = await appServices.getBLEConnectionInfos().count + let state: InterfaceState = count > 0 ? .connected : .disconnected + + // Cache for the status loop's BLE-badge write (Model B branch reads these + // back instead of round-tripping the NE). + modelBBLEPeerCount = count + modelBBLEState = state + + // Publish immediately too, so the badge updates on the push rather than + // waiting up to ~1s for the next loop tick. Mirrors the loop's mapping: + // a live BLE peer ⇒ .connected, else .disconnected. + if let bleEntity = repository.getEnabledInterfaces().first(where: { $0.type == .ble }) { + // `interfaceStatus` values are `InterfaceStatus` (not `InterfaceState`); + // map the same way the status loop does: a live BLE peer ⇒ .connected. + interfaceStatus[bleEntity.id] = count > 0 ? .connected : .disconnected + } + } + // MARK: - Form Helpers private func resetConfigForm() { diff --git a/Sources/ColumbaApp/ViewModels/NetworkStatusViewModel.swift b/Sources/ColumbaApp/ViewModels/NetworkStatusViewModel.swift index 3a4c011a..4cc5f6de 100644 --- a/Sources/ColumbaApp/ViewModels/NetworkStatusViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/NetworkStatusViewModel.swift @@ -3,7 +3,8 @@ // ColumbaApp // // ViewModel for the Network Status screen. -// Polls transport for all registered interfaces and exposes them as observable state. +// Reads a snapshot of all registered interfaces and exposes them as observable state, +// refreshing in response to the NE's pushed network-state-changed notification. // import Foundation @@ -28,8 +29,10 @@ struct InterfaceInfo: Identifiable { /// ViewModel for the Network Status screen. /// -/// Polls ReticulumTransport every second to get a snapshot of all registered -/// interfaces, including AutoInterfacePeers, and exposes them as observable state. +/// Reads a snapshot of all registered interfaces, including AutoInterfacePeers, and +/// exposes them as observable state. Event-driven: refreshes once on init and then +/// once per `NotificationObserver.networkStateChangedInApp` push (peer +/// connect/disconnect, interface up/down) rather than polling on a timer. @available(iOS 17.0, macOS 14.0, *) @Observable final class NetworkStatusViewModel { @@ -56,33 +59,54 @@ final class NetworkStatusViewModel { // MARK: - Internal - private var pollTask: Task? + /// In-process observer token for `networkStateChangedInApp`. The NE pushes this + /// on BLE/interface state change (peer connect/disconnect, interface up/down); + /// we refresh once per push instead of polling the NE on a timer. + private var inProcessObserver: NSObjectProtocol? // MARK: - Init init(appServices: AppServices) { self.appServices = appServices - startPolling() + startObserving() } deinit { - pollTask?.cancel() + if let observer = inProcessObserver { + NotificationCenter.default.removeObserver(observer) + } } - // MARK: - Polling - - private func startPolling() { - pollTask?.cancel() - pollTask = Task.detached { [weak self] in - while !Task.isCancelled { - guard let self = self else { break } - await self.refresh() - try? await Task.sleep(nanoseconds: 1_000_000_000) + // MARK: - State updates + + /// Refresh once on each pushed network-state change, plus one initial refresh so + /// the first state loads immediately (it can change before the first push). + private func startObserving() { + inProcessObserver = NotificationCenter.default.addObserver( + forName: NotificationObserver.networkStateChangedInApp, + object: nil, + queue: .main + ) { [weak self] _ in + Task { [weak self] in + await self?.refresh() } } + + // Initial load — state can change before the first push arrives. + Task { [weak self] in + await self?.refresh() + } } func refresh() async { + // Model B: the app's local transport is a Compat stub — the real interfaces + // (relay, BLE mesh + peers) live in the NE. Read them over the proxy + // `statusSnapshot` IPC instead of the empty/stale app transport. + if BackendPreference.modelB { + await refreshFromNE() + return + } + // Read transport reference on MainActor let transport = await MainActor.run { appServices.transport } @@ -154,4 +178,78 @@ final class NetworkStatusViewModel { } } } + + /// Model B: read the NE's interfaces over the proxy `statusSnapshot` IPC and + /// reconstruct the rows (the app's local transport is a Compat stub and never + /// holds the relay / BLE mesh / BLE peers the NE actually runs). + private func refreshFromNE() async { + let backend = await MainActor.run { appServices.backend } + guard let backend else { + await MainActor.run { + isInitialized = false + networkStatus = "Backend not initialized" + interfaces = [] + } + return + } + guard let snap = await backend.statusSnapshot() else { + await MainActor.run { + isInitialized = false + networkStatus = "Network Extension not running" + interfaces = [] + } + return + } + + let infos: [InterfaceInfo] = snap.interfaces.map { iface in + let isBLEPeer = iface.isBLEPeer ?? false + let isAutoPeer = iface.isAutoPeer ?? false + let typeName: String + if isAutoPeer { typeName = "AutoInterfacePeer" } + else if isBLEPeer { typeName = "BLEPeer" } + else { typeName = Self.displayType(forRaw: iface.typeRaw) } + let addr = (iface.peerAddress?.isEmpty == false) ? iface.peerAddress : nil + let err = (iface.lastError?.isEmpty == false) ? iface.lastError : nil + return InterfaceInfo( + id: iface.sectionName, + name: iface.name, + type: typeName, + online: iface.online, + state: iface.online ? .connected : .disconnected, + isAutoInterfacePeer: isAutoPeer, + peerAddress: addr, + lastErrorDescription: err + ) + } + + let onlineCount = infos.filter(\.online).count + await MainActor.run { + isInitialized = true + interfaces = infos + if infos.isEmpty { + networkStatus = "No interfaces" + } else if onlineCount == infos.count { + networkStatus = "All interfaces online" + } else if onlineCount > 0 { + networkStatus = "\(onlineCount)/\(infos.count) interfaces online" + } else { + networkStatus = "All interfaces offline" + } + } + } + + /// Map a reticulum-swift `InterfaceType.rawValue` (the camelCase case name) to + /// the display label the Model A path uses, so the UI reads identically either way. + private static func displayType(forRaw raw: String?) -> String { + switch raw { + case "tcp": return "TCPClient" + case "udp": return "UDP" + case "i2p": return "I2P" + case "autoInterface": return "AutoInterface" + case "rnode": return "RNode" + case "ble": return "BLE" + case "multipeerConnectivity": return "Multipeer" + default: return raw ?? "Interface" + } + } } diff --git a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift index c919d0fe..15dd911e 100644 --- a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift @@ -375,12 +375,22 @@ public final class SettingsViewModel { } /// Load local settings from UserDefaults. - private func loadLocalSettings() { - let defaults = UserDefaults.standard - - // Register sane defaults so bool(forKey:) returns true for notifications - // even if the key was never explicitly written (e.g. pre-existing installs). - defaults.register(defaults: [ + /// Register app-side `UserDefaults.standard` defaults at process launch. + /// + /// `NotificationService` (the foreground notification path) gates on + /// `bool(forKey: "notifications_enabled")`, which returns `false` unless the + /// key is registered. Registration used to live ONLY in `loadLocalSettings()`, + /// which runs lazily the first time Settings is opened — so on a fresh install + /// that never visits Settings, `notifications_enabled` stayed unregistered and + /// the foreground notification path was silently suppressed. Call this from + /// `App.init()` so the defaults exist before any reader runs. Registration is + /// idempotent and process-wide. (ports #57 dc1024b) + /// + /// (Background notifications under Model B are posted by the Network Extension, + /// which gates only on system authorization — not this default — so it is + /// unaffected; this fixes the in-app/foreground path and is correct hygiene.) + static func registerLocalDefaults() { + UserDefaults.standard.register(defaults: [ "notifications_enabled": true, "show_message_previews": true, "play_sounds": true, @@ -390,6 +400,15 @@ public final class SettingsViewModel { "auto_announce_on_tcp_reconnect": true, "auto_announce_on_peer_spawned": true ]) + } + + private func loadLocalSettings() { + let defaults = UserDefaults.standard + + // Defaults are registered at launch (App.init → registerLocalDefaults); + // re-register here too since registration is idempotent and this VM may + // be exercised in isolation (previews / tests). + Self.registerLocalDefaults() blockUnknownSenders = defaults.bool(forKey: "block_unknown_senders") isNotificationsEnabled = defaults.bool(forKey: "notifications_enabled") @@ -480,13 +499,24 @@ public final class SettingsViewModel { /// Called from loadSettings() and periodically by the view. @MainActor public func refreshConnectionState() async { - isConnected = appServices.isConnected - isReconnecting = appServices.isReconnecting - reconnectError = appServices.connectionError + // In Model B the NE owns the TCP relay and the app owns no local TCP + // interface, so reading app interfaces would always report + // "disconnected". Reflect the NE relay's state (via the proxy) for TCP; + // app-owned radios (Auto / BLE / RNode) are read locally in both models. + let modelB = BackendPreference.modelB + + let tcpConnected: Bool + if modelB { + tcpConnected = await appServices.neTcpRelayOnline() + } else if let tcp = appServices.tcpInterface { + tcpConnected = await tcp.state == .connected + } else { + tcpConnected = false + } // Build connected interface string from all active interfaces var activeInterfaces: [String] = [] - if let tcp = appServices.tcpInterface, await tcp.state == .connected { + if tcpConnected { let interfaceRepo = InterfaceRepository() if let tcpEntity = interfaceRepo.getEnabledInterfaces().first(where: { $0.type == .tcpClient }), case .tcpClient(let config) = tcpEntity.config { @@ -502,7 +532,14 @@ public final class SettingsViewModel { if let rnode = appServices.rnodeInterface, await rnode.state == .connected { activeInterfaces.append("RNode") } - if let ble = appServices.bleInterface, await ble.state == .connected { + if modelB { + // Model B: BLE runs in the NE — count its native peers (over the proxy + // IPC) rather than the app's Compat `bleInterface`, which never has peers. + let bleCount = await appServices.getBLEConnectionInfos().count + if bleCount > 0 { + activeInterfaces.append("Bluetooth LE (\(bleCount) peer\(bleCount == 1 ? "" : "s"))") + } + } else if let ble = appServices.bleInterface, await ble.state == .connected { let count = await ble.peerCount if count > 0 { activeInterfaces.append("Bluetooth LE (\(count) peer\(count == 1 ? "" : "s"))") @@ -511,6 +548,13 @@ public final class SettingsViewModel { } } connectedInterface = activeInterfaces.isEmpty ? "No active interface" : activeInterfaces.joined(separator: ", ") + + // Overall state: in Model B "connected" = at least one active interface + // (the NE relay or an app-owned radio); otherwise defer to the app's own + // connection tracking (which owns the interfaces in Model A). + isConnected = modelB ? !activeInterfaces.isEmpty : appServices.isConnected + isReconnecting = modelB ? false : appServices.isReconnecting + reconnectError = modelB ? nil : appServices.connectionError } /// Sync auto-announce manager state with current settings. @@ -537,6 +581,10 @@ public final class SettingsViewModel { backendChangePending = true } + // Model B is no longer a user toggle — it's the sole architecture on the + // Swift build (see `BackendPreference.modelB`). `applyModelBSelection` and + // the `modelBEnabled` state were removed with the Settings toggle. + /// Update icon appearance and persist. @MainActor public func updateIconAppearance(_ icon: IconAppearance?) async { @@ -647,6 +695,10 @@ public final class SettingsViewModel { } else { propManager.stopPeriodicSync() } + // Persist + (Model B) republish the seam so interval/periodic edits reach + // the NE — including an interval-only change or disabling periodic sync, + // which the start/stop calls above don't push on their own. + await propManager.savePreferences() } } diff --git a/Sources/ColumbaApp/Views/Chats/ChatsView.swift b/Sources/ColumbaApp/Views/Chats/ChatsView.swift index a008b786..e6d59e9e 100644 --- a/Sources/ColumbaApp/Views/Chats/ChatsView.swift +++ b/Sources/ColumbaApp/Views/Chats/ChatsView.swift @@ -45,6 +45,9 @@ struct ChatsView: View { /// Conversation pending deletion (confirmation alert). @State private var deletingConversation: Conversation? + /// Controls the propagation-sync status sheet (auto-shown while a sync is active). + @State private var isSyncSheetPresented: Bool = false + // MARK: - Theme Colors private var backgroundColor: Color { Theme.backgroundPrimary } @@ -163,6 +166,15 @@ struct ChatsView: View { } message: { Text("This will permanently delete the conversation and all its messages.") } + // Auto-show the sync status sheet while a propagation sync is active (manual + // Sync Now / pull-to-refresh, or — under Model B — an NE-driven periodic sync). + // The sheet stays up through the terminal phase so the user sees the result. + .onChange(of: appServices.propagationManager?.syncState.isSyncing ?? false) { _, active in + if active { isSyncSheetPresented = true } + } + .sheet(isPresented: $isSyncSheetPresented) { + SyncStatusBottomSheet(state: appServices.propagationManager?.syncState ?? PropagationTransferState()) + } #if os(iOS) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in checkPendingNotification() diff --git a/Sources/ColumbaApp/Views/Components/Lucide.swift b/Sources/ColumbaApp/Views/Components/Lucide.swift new file mode 100644 index 00000000..d0ec1005 --- /dev/null +++ b/Sources/ColumbaApp/Views/Components/Lucide.swift @@ -0,0 +1,1692 @@ +// +// Lucide.swift +// Columba +// +// Lucide icon glyphs rendered from the bundled `lucide.ttf` font — the iOS +// counterpart to `MaterialDesignIcons`, mirroring its API exactly. Lucide is +// what Android Columba uses for interface icons (e.g. the RNode/LoRa antenna). +// +// Usage: Text(String(Lucide.character(for: "antenna")!)) +// .font(.custom(Lucide.fontName, size: 16)) +// +// Generated from lucide-static 0.544.0 (font/lucide.css). Lucide is ISC-licensed +// (see lucide-font-LICENSE.txt). Do not hand-edit — regenerate from the CSS. +// + +import Foundation + +enum Lucide { + /// Font name as registered in iOS (matches the TTF internal family name). + static let fontName = "lucide" + + private static let codepoints0: [String: UInt32] = [ + "a-arrow-down": 0xe589, + "a-arrow-up": 0xe58a, + "a-large-small": 0xe58b, + "accessibility": 0xe297, + "activity": 0xe038, + "air-vent": 0xe351, + "airplay": 0xe039, + "alarm-clock": 0xe03a, + "alarm-clock-check": 0xe1ec, + "alarm-clock-minus": 0xe1ed, + "alarm-clock-off": 0xe23b, + "alarm-clock-plus": 0xe1ee, + "alarm-smoke": 0xe57f, + "album": 0xe03b, + "align-center-horizontal": 0xe26c, + "align-center-vertical": 0xe26d, + "align-end-horizontal": 0xe26e, + "align-end-vertical": 0xe26f, + "align-horizontal-distribute-center": 0xe03c, + "align-horizontal-distribute-end": 0xe03d, + "align-horizontal-distribute-start": 0xe03e, + "align-horizontal-justify-center": 0xe272, + "align-horizontal-justify-end": 0xe273, + "align-horizontal-justify-start": 0xe274, + "align-horizontal-space-around": 0xe275, + "align-horizontal-space-between": 0xe276, + "align-start-horizontal": 0xe270, + "align-start-vertical": 0xe271, + "align-vertical-distribute-center": 0xe27e, + "align-vertical-distribute-end": 0xe27f, + "align-vertical-distribute-start": 0xe280, + "align-vertical-justify-center": 0xe277, + "align-vertical-justify-end": 0xe278, + "align-vertical-justify-start": 0xe279, + "align-vertical-space-around": 0xe27a, + "align-vertical-space-between": 0xe27b, + "ambulance": 0xe5bf, + "ampersand": 0xe4a0, + "ampersands": 0xe4a1, + "amphora": 0xe61f, + "anchor": 0xe03f, + "angry": 0xe2fc, + "annoyed": 0xe2fd, + "antenna": 0xe4e6, + "anvil": 0xe584, + "aperture": 0xe040, + "app-window": 0xe42a, + "app-window-mac": 0xe5d6, + "apple": 0xe352, + "archive": 0xe041, + "archive-restore": 0xe2cd, + "archive-x": 0xe510, + "armchair": 0xe2c0, + "arrow-big-down": 0xe1e1, + "arrow-big-down-dash": 0xe421, + "arrow-big-left": 0xe1e2, + "arrow-big-left-dash": 0xe422, + "arrow-big-right": 0xe1e3, + "arrow-big-right-dash": 0xe423, + "arrow-big-up": 0xe1e4, + "arrow-big-up-dash": 0xe424, + "arrow-down": 0xe042, + "arrow-down-0-1": 0xe417, + "arrow-down-1-0": 0xe418, + "arrow-down-a-z": 0xe419, + "arrow-down-from-line": 0xe458, + "arrow-down-left": 0xe043, + "arrow-down-narrow-wide": 0xe044, + "arrow-down-right": 0xe045, + "arrow-down-to-dot": 0xe451, + "arrow-down-to-line": 0xe459, + "arrow-down-up": 0xe046, + "arrow-down-wide-narrow": 0xe047, + "arrow-down-z-a": 0xe41a, + "arrow-left": 0xe048, + "arrow-left-from-line": 0xe45a, + "arrow-left-right": 0xe24a, + "arrow-left-to-line": 0xe45b, + "arrow-right": 0xe049, + "arrow-right-from-line": 0xe45c, + "arrow-right-left": 0xe41b, + "arrow-right-to-line": 0xe45d, + "arrow-up": 0xe04a, + "arrow-up-0-1": 0xe41c, + "arrow-up-1-0": 0xe41d, + "arrow-up-a-z": 0xe41e, + "arrow-up-down": 0xe381, + "arrow-up-from-dot": 0xe452, + "arrow-up-from-line": 0xe45e, + "arrow-up-left": 0xe04b, + "arrow-up-narrow-wide": 0xe04c, + "arrow-up-right": 0xe04d, + "arrow-up-to-line": 0xe45f, + "arrow-up-wide-narrow": 0xe41f, + "arrow-up-z-a": 0xe420, + "arrows-up-from-line": 0xe4d8, + "asterisk": 0xe1ef, + "at-sign": 0xe04e, + "atom": 0xe3db, + "audio-lines": 0xe55e, + "audio-waveform": 0xe55f, + "award": 0xe04f, + "axe": 0xe050, + "axis-3d": 0xe2fe, + "baby": 0xe2ce, + "backpack": 0xe2c8, + "badge": 0xe478, + "badge-alert": 0xe479, + "badge-cent": 0xe513, + "badge-check": 0xe241, + "badge-dollar-sign": 0xe47a, + "badge-euro": 0xe514, + "badge-indian-rupee": 0xe515, + "badge-info": 0xe47b, + "badge-japanese-yen": 0xe516, + "badge-minus": 0xe47c, + "badge-percent": 0xe47d, + "badge-plus": 0xe47e, + "badge-pound-sterling": 0xe517, + "badge-question-mark": 0xe47f, + "badge-russian-ruble": 0xe518, + "badge-swiss-franc": 0xe519, + "badge-turkish-lira": 0xe682, + "badge-x": 0xe480, + "baggage-claim": 0xe2c9, + "ban": 0xe051, + "banana": 0xe353, + "bandage": 0xe621, + "banknote": 0xe052, + "banknote-arrow-down": 0xe650, + "banknote-arrow-up": 0xe651, + "banknote-x": 0xe652, + "barcode": 0xe537, + "barrel": 0xe679, + "baseline": 0xe285, + "bath": 0xe2ab, + "battery": 0xe053, + "battery-charging": 0xe054, + "battery-full": 0xe055, + "battery-low": 0xe056, + "battery-medium": 0xe057, + "battery-plus": 0xe642, + "battery-warning": 0xe3b0, + "beaker": 0xe058, + "bean": 0xe393, + "bean-off": 0xe394, + "bed": 0xe2c1, + "bed-double": 0xe2c2, + "bed-single": 0xe2c3, + "beef": 0xe3a9, + "beer": 0xe2cf, + "beer-off": 0xe5dd, + "bell": 0xe059, + "bell-dot": 0xe42f, + "bell-electric": 0xe580, + "bell-minus": 0xe1f0, + "bell-off": 0xe05a, + "bell-plus": 0xe1f1, + "bell-ring": 0xe224, + "between-horizontal-end": 0xe595, + "between-horizontal-start": 0xe596, + "between-vertical-end": 0xe597, + "between-vertical-start": 0xe598, + "biceps-flexed": 0xe5ef, + "bike": 0xe1d2, + "binary": 0xe1f2, + "binoculars": 0xe625, + "biohazard": 0xe445, + "bird": 0xe3c9, + "bitcoin": 0xe05b, + "blend": 0xe5a0, + "blinds": 0xe3c4, + "blocks": 0xe4fe, + "bluetooth": 0xe05c, + "bluetooth-connected": 0xe1b8, + "bluetooth-off": 0xe1b9, + "bluetooth-searching": 0xe1ba, + "bold": 0xe05d, + "bolt": 0xe590, + "bomb": 0xe2ff, + "bone": 0xe35c, + "book": 0xe05e, + "book-a": 0xe548, + "book-alert": 0xe676, + "book-audio": 0xe549, + "book-check": 0xe54a, + "book-copy": 0xe3f0, + "book-dashed": 0xe3f1, + "book-down": 0xe3f2, + "book-headphones": 0xe54b, + "book-heart": 0xe54c, + "book-image": 0xe54d, + "book-key": 0xe3f3, + "book-lock": 0xe3f4, + "book-marked": 0xe3f5, + "book-minus": 0xe3f6, + "book-open": 0xe05f, + "book-open-check": 0xe385, + "book-open-text": 0xe54e, + "book-plus": 0xe3f7, + "book-text": 0xe54f, + "book-type": 0xe550, + "book-up": 0xe3f8, + "book-up-2": 0xe4aa, + "book-user": 0xe551, + "book-x": 0xe3f9, + "bookmark": 0xe060, + "bookmark-check": 0xe523, + "bookmark-minus": 0xe23c, + "bookmark-plus": 0xe23d, + "bookmark-x": 0xe524, + "boom-box": 0xe4f2, + "bot": 0xe1bb, + "bot-message-square": 0xe5d2, + "bot-off": 0xe5e4, + "bottle-wine": 0xe67f, + "bow-arrow": 0xe662, + "box": 0xe061, + "boxes": 0xe2d0, + "braces": 0xe36e, + "brackets": 0xe447, + "brain": 0xe3ca, + "brain-circuit": 0xe3cb, + "brain-cog": 0xe3cc, + "brick-wall": 0xe585, + "brick-wall-fire": 0xe657, + "brick-wall-shield": 0xe694, + "briefcase": 0xe062, + "briefcase-business": 0xe5d9, + "briefcase-conveyor-belt": 0xe62f, + "briefcase-medical": 0xe5da, + "bring-to-front": 0xe4f3, + "brush": 0xe1d3, + "brush-cleaning": 0xe66a, + "bubbles": 0xe658, + "bug": 0xe20c, + "bug-off": 0xe511, + "bug-play": 0xe512, + "building": 0xe1cc, + "building-2": 0xe290, + "bus": 0xe1d4, + "bus-front": 0xe4ff, + "cable": 0xe4e7, + "cable-car": 0xe500, + "cake": 0xe348, + "cake-slice": 0xe4bd, + "calculator": 0xe1bc, + "calendar": 0xe063, + "calendar-1": 0xe634, + "calendar-arrow-down": 0xe602, + "calendar-arrow-up": 0xe603, + "calendar-check": 0xe2b7, + "calendar-check-2": 0xe2b8, + "calendar-clock": 0xe304, + "calendar-cog": 0xe5f1, + "calendar-days": 0xe2b9, + "calendar-fold": 0xe5b8, + "calendar-heart": 0xe305, + "calendar-minus": 0xe2ba, + "calendar-minus-2": 0xe5b9, + "calendar-off": 0xe2bb, + "calendar-plus": 0xe2bc, + "calendar-plus-2": 0xe5ba, + "calendar-range": 0xe2bd, + "calendar-search": 0xe306, + "calendar-sync": 0xe63a, + "calendar-x": 0xe2be, + "calendar-x-2": 0xe2bf, + "camera": 0xe064, + "camera-off": 0xe065, + "candy": 0xe395, + "candy-cane": 0xe4be, + "candy-off": 0xe396, + "cannabis": 0xe5d8, + "captions": 0xe3a8, + "captions-off": 0xe5c5, + "car": 0xe1d5, + "car-front": 0xe501, + "car-taxi-front": 0xe502, + "caravan": 0xe53d, + "card-sim": 0xe675, + "carrot": 0xe25a, + "case-lower": 0xe3dc, + "case-sensitive": 0xe3dd, + "case-upper": 0xe3de, + "cassette-tape": 0xe4ce, + "cast": 0xe066, + "castle": 0xe3e4, + "cat": 0xe390, + "cctv": 0xe581, + "chart-area": 0xe4d7, + "chart-bar": 0xe2a2, + "chart-bar-big": 0xe4ab, + "chart-bar-decreasing": 0xe60b, + "chart-bar-increasing": 0xe60c, + "chart-bar-stacked": 0xe60d, + "chart-candlestick": 0xe4ac, + "chart-column": 0xe2a3, + "chart-column-big": 0xe4ad, + "chart-column-decreasing": 0xe067, + "chart-column-increasing": 0xe2a4, + "chart-column-stacked": 0xe60e, + "chart-gantt": 0xe628, + "chart-line": 0xe2a5, + "chart-network": 0xe60f, + "chart-no-axes-column": 0xe068, + "chart-no-axes-column-decreasing": 0xe069, + "chart-no-axes-column-increasing": 0xe06a, + "chart-no-axes-combined": 0xe610, + "chart-no-axes-gantt": 0xe4c8, + "chart-pie": 0xe06b, + "chart-scatter": 0xe48e, + "chart-spline": 0xe611, + "check": 0xe06c, + "check-check": 0xe392, + "check-line": 0xe66f, + "chef-hat": 0xe2ac, + "cherry": 0xe354, + "chevron-down": 0xe06d, + "chevron-first": 0xe243, + "chevron-last": 0xe244, + "chevron-left": 0xe06e, + "chevron-right": 0xe06f, + "chevron-up": 0xe070, + "chevrons-down": 0xe071, + "chevrons-down-up": 0xe228, + "chevrons-left": 0xe072, + "chevrons-left-right": 0xe293, + "chevrons-left-right-ellipsis": 0xe623, + "chevrons-right": 0xe073, + "chevrons-right-left": 0xe294, + "chevrons-up": 0xe074, + "chevrons-up-down": 0xe211, + "chromium": 0xe075, + "church": 0xe3e5, + "cigarette": 0xe2c6, + "cigarette-off": 0xe2c7, + "circle": 0xe076, + "circle-alert": 0xe077, + "circle-arrow-down": 0xe078, + "circle-arrow-left": 0xe079, + "circle-arrow-out-down-left": 0xe3fb, + "circle-arrow-out-down-right": 0xe3fc, + "circle-arrow-out-up-left": 0xe3fd, + "circle-arrow-out-up-right": 0xe3fe, + "circle-arrow-right": 0xe07a, + "circle-arrow-up": 0xe07b, + "circle-check": 0xe226, + "circle-check-big": 0xe07c, + "circle-chevron-down": 0xe4e1, + ] + + private static let codepoints1: [String: UInt32] = [ + "circle-chevron-left": 0xe4e2, + "circle-chevron-right": 0xe4e3, + "circle-chevron-up": 0xe4e4, + "circle-dashed": 0xe4b4, + "circle-divide": 0xe07d, + "circle-dollar-sign": 0xe481, + "circle-dot": 0xe349, + "circle-dot-dashed": 0xe4b5, + "circle-ellipsis": 0xe34a, + "circle-equal": 0xe404, + "circle-fading-arrow-up": 0xe61c, + "circle-fading-plus": 0xe5c0, + "circle-gauge": 0xe4e5, + "circle-minus": 0xe07e, + "circle-off": 0xe405, + "circle-parking": 0xe3cd, + "circle-parking-off": 0xe3ce, + "circle-pause": 0xe07f, + "circle-percent": 0xe51e, + "circle-play": 0xe080, + "circle-plus": 0xe081, + "circle-pound-sterling": 0xe671, + "circle-power": 0xe554, + "circle-question-mark": 0xe082, + "circle-slash": 0xe406, + "circle-slash-2": 0xe213, + "circle-small": 0xe644, + "circle-star": 0xe691, + "circle-stop": 0xe083, + "circle-user": 0xe465, + "circle-user-round": 0xe466, + "circle-x": 0xe084, + "circuit-board": 0xe407, + "citrus": 0xe379, + "clapperboard": 0xe29b, + "clipboard": 0xe085, + "clipboard-check": 0xe219, + "clipboard-clock": 0xe68c, + "clipboard-copy": 0xe225, + "clipboard-list": 0xe086, + "clipboard-minus": 0xe5c2, + "clipboard-paste": 0xe3ec, + "clipboard-pen": 0xe307, + "clipboard-pen-line": 0xe308, + "clipboard-plus": 0xe5c3, + "clipboard-type": 0xe309, + "clipboard-x": 0xe222, + "clock": 0xe087, + "clock-1": 0xe24b, + "clock-10": 0xe24c, + "clock-11": 0xe24d, + "clock-12": 0xe24e, + "clock-2": 0xe24f, + "clock-3": 0xe250, + "clock-4": 0xe251, + "clock-5": 0xe252, + "clock-6": 0xe253, + "clock-7": 0xe254, + "clock-8": 0xe255, + "clock-9": 0xe256, + "clock-alert": 0xe62e, + "clock-arrow-down": 0xe604, + "clock-arrow-up": 0xe605, + "clock-fading": 0xe64e, + "clock-plus": 0xe66b, + "closed-caption": 0xe68e, + "cloud": 0xe088, + "cloud-alert": 0xe637, + "cloud-check": 0xe672, + "cloud-cog": 0xe30a, + "cloud-download": 0xe089, + "cloud-drizzle": 0xe08a, + "cloud-fog": 0xe214, + "cloud-hail": 0xe08b, + "cloud-lightning": 0xe08c, + "cloud-moon": 0xe215, + "cloud-moon-rain": 0xe2fa, + "cloud-off": 0xe08d, + "cloud-rain": 0xe08e, + "cloud-rain-wind": 0xe08f, + "cloud-snow": 0xe090, + "cloud-sun": 0xe216, + "cloud-sun-rain": 0xe2fb, + "cloud-upload": 0xe091, + "cloudy": 0xe217, + "clover": 0xe092, + "club": 0xe49a, + "code": 0xe093, + "code-xml": 0xe206, + "codepen": 0xe094, + "codesandbox": 0xe095, + "coffee": 0xe096, + "cog": 0xe30b, + "coins": 0xe097, + "columns-2": 0xe098, + "columns-3": 0xe099, + "columns-3-cog": 0xe665, + "columns-4": 0xe58d, + "combine": 0xe450, + "command": 0xe09a, + "compass": 0xe09b, + "component": 0xe2ad, + "computer": 0xe4e8, + "concierge-bell": 0xe37c, + "cone": 0xe527, + "construction": 0xe3b8, + "contact": 0xe09c, + "contact-round": 0xe467, + "container": 0xe4d9, + "contrast": 0xe09d, + "cookie": 0xe26b, + "cooking-pot": 0xe588, + "copy": 0xe09e, + "copy-check": 0xe3ff, + "copy-minus": 0xe400, + "copy-plus": 0xe401, + "copy-slash": 0xe402, + "copy-x": 0xe403, + "copyleft": 0xe09f, + "copyright": 0xe0a0, + "corner-down-left": 0xe0a1, + "corner-down-right": 0xe0a2, + "corner-left-down": 0xe0a3, + "corner-left-up": 0xe0a4, + "corner-right-down": 0xe0a5, + "corner-right-up": 0xe0a6, + "corner-up-left": 0xe0a7, + "corner-up-right": 0xe0a8, + "cpu": 0xe0a9, + "creative-commons": 0xe3b6, + "credit-card": 0xe0aa, + "croissant": 0xe2ae, + "crop": 0xe0ab, + "cross": 0xe1e5, + "crosshair": 0xe0ac, + "crown": 0xe1d6, + "cuboid": 0xe528, + "cup-soda": 0xe2d1, + "currency": 0xe230, + "cylinder": 0xe529, + "dam": 0xe60a, + "database": 0xe0ad, + "database-backup": 0xe3af, + "database-zap": 0xe50f, + "decimals-arrow-left": 0xe660, + "decimals-arrow-right": 0xe661, + "delete": 0xe0ae, + "dessert": 0xe4bf, + "diameter": 0xe52a, + "diamond": 0xe2d2, + "diamond-minus": 0xe5e5, + "diamond-percent": 0xe51f, + "diamond-plus": 0xe5e6, + "dice-1": 0xe287, + "dice-2": 0xe288, + "dice-3": 0xe289, + "dice-4": 0xe28a, + "dice-5": 0xe28b, + "dice-6": 0xe28c, + "dices": 0xe2c5, + "diff": 0xe30c, + "disc": 0xe0af, + "disc-2": 0xe3fa, + "disc-3": 0xe498, + "disc-album": 0xe560, + "divide": 0xe0b0, + "dna": 0xe397, + "dna-off": 0xe398, + "dock": 0xe5d7, + "dog": 0xe391, + "dollar-sign": 0xe0b1, + "donut": 0xe4c0, + "door-closed": 0xe3d9, + "door-closed-locked": 0xe668, + "door-open": 0xe3da, + "dot": 0xe453, + "download": 0xe0b2, + "drafting-compass": 0xe52b, + "drama": 0xe525, + "dribbble": 0xe0b3, + "drill": 0xe591, + "drone": 0xe67a, + "droplet": 0xe0b4, + "droplet-off": 0xe63c, + "droplets": 0xe0b5, + "drum": 0xe561, + "drumstick": 0xe25b, + "dumbbell": 0xe3a5, + "ear": 0xe386, + "ear-off": 0xe387, + "earth": 0xe1f3, + "earth-lock": 0xe5d0, + "eclipse": 0xe5a1, + "egg": 0xe25d, + "egg-fried": 0xe355, + "egg-off": 0xe399, + "ellipsis": 0xe0b6, + "ellipsis-vertical": 0xe0b7, + "equal": 0xe1bd, + "equal-approximately": 0xe638, + "equal-not": 0xe1be, + "eraser": 0xe28f, + "ethernet-port": 0xe624, + "euro": 0xe0b8, + "ev-charger": 0xe69b, + "expand": 0xe21a, + "external-link": 0xe0b9, + "eye": 0xe0ba, + "eye-closed": 0xe632, + "eye-off": 0xe0bb, + "facebook": 0xe0bc, + "factory": 0xe29f, + "fan": 0xe37d, + "fast-forward": 0xe0bd, + "feather": 0xe0be, + "fence": 0xe586, + "ferris-wheel": 0xe483, + "figma": 0xe0bf, + "file": 0xe0c0, + "file-archive": 0xe30d, + "file-audio": 0xe30e, + "file-audio-2": 0xe30f, + "file-axis-3d": 0xe310, + "file-badge": 0xe311, + "file-badge-2": 0xe312, + "file-box": 0xe313, + "file-chart-column": 0xe314, + "file-chart-column-increasing": 0xe315, + "file-chart-line": 0xe316, + "file-chart-pie": 0xe317, + "file-check": 0xe0c1, + "file-check-2": 0xe0c2, + "file-clock": 0xe318, + "file-code": 0xe0c3, + "file-code-2": 0xe462, + "file-cog": 0xe319, + "file-diff": 0xe31a, + "file-digit": 0xe0c4, + "file-down": 0xe31b, + "file-heart": 0xe31c, + "file-image": 0xe31d, + "file-input": 0xe0c5, + "file-json": 0xe36f, + "file-json-2": 0xe370, + "file-key": 0xe31e, + "file-key-2": 0xe31f, + "file-lock": 0xe320, + "file-lock-2": 0xe321, + "file-minus": 0xe0c6, + "file-minus-2": 0xe0c7, + "file-music": 0xe562, + "file-output": 0xe0c8, + "file-pen": 0xe322, + "file-pen-line": 0xe323, + "file-play": 0xe324, + "file-plus": 0xe0c9, + "file-plus-2": 0xe0ca, + "file-question-mark": 0xe325, + "file-scan": 0xe326, + "file-search": 0xe0cb, + "file-search-2": 0xe327, + "file-sliders": 0xe5a4, + "file-spreadsheet": 0xe328, + "file-stack": 0xe4a5, + "file-symlink": 0xe329, + "file-terminal": 0xe32a, + "file-text": 0xe0cc, + "file-type": 0xe32b, + "file-type-2": 0xe371, + "file-up": 0xe32c, + "file-user": 0xe631, + "file-video-camera": 0xe32d, + "file-volume": 0xe32e, + "file-volume-2": 0xe32f, + "file-warning": 0xe330, + "file-x": 0xe0cd, + "file-x-2": 0xe0ce, + "files": 0xe0cf, + "film": 0xe0d0, + "fingerprint": 0xe2cb, + "fire-extinguisher": 0xe582, + "fish": 0xe3aa, + "fish-off": 0xe3b4, + "fish-symbol": 0xe4f8, + "flag": 0xe0d1, + "flag-off": 0xe292, + "flag-triangle-left": 0xe237, + "flag-triangle-right": 0xe238, + "flame": 0xe0d2, + "flame-kindling": 0xe53e, + "flashlight": 0xe0d3, + "flashlight-off": 0xe0d4, + "flask-conical": 0xe0d5, + "flask-conical-off": 0xe39a, + "flask-round": 0xe0d6, + "flip-horizontal": 0xe361, + "flip-horizontal-2": 0xe362, + "flip-vertical": 0xe363, + "flip-vertical-2": 0xe364, + "flower": 0xe2d3, + "flower-2": 0xe2d4, + "focus": 0xe29e, + "fold-horizontal": 0xe43f, + "fold-vertical": 0xe440, + "folder": 0xe0d7, + "folder-archive": 0xe331, + "folder-check": 0xe332, + "folder-clock": 0xe333, + "folder-closed": 0xe334, + "folder-code": 0xe5ff, + "folder-cog": 0xe335, + "folder-dot": 0xe4c9, + "folder-down": 0xe336, + "folder-git": 0xe40d, + "folder-git-2": 0xe40e, + "folder-heart": 0xe337, + "folder-input": 0xe338, + "folder-kanban": 0xe4ca, + "folder-key": 0xe339, + "folder-lock": 0xe33a, + "folder-minus": 0xe0d8, + "folder-open": 0xe247, + "folder-open-dot": 0xe4cb, + "folder-output": 0xe33b, + "folder-pen": 0xe33c, + "folder-plus": 0xe0d9, + "folder-root": 0xe4cc, + "folder-search": 0xe33d, + "folder-search-2": 0xe33e, + "folder-symlink": 0xe33f, + "folder-sync": 0xe4cd, + "folder-tree": 0xe340, + "folder-up": 0xe341, + "folder-x": 0xe342, + "folders": 0xe343, + "footprints": 0xe3bd, + "forklift": 0xe3c5, + "forward": 0xe229, + "frame": 0xe291, + "framer": 0xe0da, + "frown": 0xe0db, + "fuel": 0xe2af, + "fullscreen": 0xe538, + "funnel": 0xe0dc, + "funnel-plus": 0xe0dd, + "funnel-x": 0xe3b9, + "gallery-horizontal": 0xe4d2, + "gallery-horizontal-end": 0xe4d3, + "gallery-thumbnails": 0xe4d4, + "gallery-vertical": 0xe4d5, + ] + + private static let codepoints2: [String: UInt32] = [ + "gallery-vertical-end": 0xe4d6, + "gamepad": 0xe0de, + "gamepad-2": 0xe0df, + "gauge": 0xe1bf, + "gavel": 0xe0e0, + "gem": 0xe242, + "georgian-lari": 0xe67c, + "ghost": 0xe20e, + "gift": 0xe0e1, + "git-branch": 0xe0e2, + "git-branch-plus": 0xe1f4, + "git-commit-horizontal": 0xe0e3, + "git-commit-vertical": 0xe556, + "git-compare": 0xe35d, + "git-compare-arrows": 0xe557, + "git-fork": 0xe28d, + "git-graph": 0xe558, + "git-merge": 0xe0e4, + "git-pull-request": 0xe0e5, + "git-pull-request-arrow": 0xe559, + "git-pull-request-closed": 0xe35e, + "git-pull-request-create": 0xe55a, + "git-pull-request-create-arrow": 0xe55b, + "git-pull-request-draft": 0xe35f, + "github": 0xe0e6, + "gitlab": 0xe0e7, + "glass-water": 0xe2d5, + "glasses": 0xe20d, + "globe": 0xe0e8, + "globe-lock": 0xe5d1, + "goal": 0xe4a9, + "gpu": 0xe66e, + "graduation-cap": 0xe234, + "grape": 0xe356, + "grid-2x2": 0xe503, + "grid-2x2-check": 0xe5e8, + "grid-2x2-plus": 0xe62c, + "grid-2x2-x": 0xe5e9, + "grid-3x2": 0xe673, + "grid-3x3": 0xe0e9, + "grip": 0xe3b5, + "grip-horizontal": 0xe0ea, + "grip-vertical": 0xe0eb, + "group": 0xe468, + "guitar": 0xe563, + "ham": 0xe5db, + "hamburger": 0xe669, + "hammer": 0xe0ec, + "hand": 0xe1d7, + "hand-coins": 0xe5bc, + "hand-fist": 0xe68f, + "hand-grab": 0xe1e6, + "hand-heart": 0xe5bd, + "hand-helping": 0xe3bc, + "hand-metal": 0xe22c, + "hand-platter": 0xe5be, + "handbag": 0xe68d, + "handshake": 0xe5c4, + "hard-drive": 0xe0ed, + "hard-drive-download": 0xe4e9, + "hard-drive-upload": 0xe4ea, + "hard-hat": 0xe0ee, + "hash": 0xe0ef, + "hat-glasses": 0xe687, + "haze": 0xe0f0, + "hdmi-port": 0xe4eb, + "heading": 0xe388, + "heading-1": 0xe389, + "heading-2": 0xe38a, + "heading-3": 0xe38b, + "heading-4": 0xe38c, + "heading-5": 0xe38d, + "heading-6": 0xe38e, + "headphone-off": 0xe62d, + "headphones": 0xe0f1, + "headset": 0xe5c1, + "heart": 0xe0f2, + "heart-crack": 0xe2d6, + "heart-handshake": 0xe2d7, + "heart-minus": 0xe655, + "heart-off": 0xe295, + "heart-plus": 0xe656, + "heart-pulse": 0xe372, + "heater": 0xe592, + "hexagon": 0xe0f3, + "highlighter": 0xe0f4, + "history": 0xe1f5, + "hop": 0xe39b, + "hop-off": 0xe39c, + "hospital": 0xe5dc, + "hotel": 0xe3e6, + "hourglass": 0xe296, + "house": 0xe0f5, + "house-heart": 0xe699, + "house-plug": 0xe5f4, + "house-plus": 0xe5f5, + "house-wifi": 0xe640, + "ice-cream-bowl": 0xe3ab, + "ice-cream-cone": 0xe357, + "id-card": 0xe61b, + "id-card-lanyard": 0xe674, + "image": 0xe0f6, + "image-down": 0xe540, + "image-minus": 0xe1f6, + "image-off": 0xe1c0, + "image-play": 0xe5e3, + "image-plus": 0xe1f7, + "image-up": 0xe5cf, + "image-upscale": 0xe63b, + "images": 0xe5c8, + "import": 0xe22f, + "inbox": 0xe0f7, + "indian-rupee": 0xe0f8, + "infinity": 0xe1e7, + "info": 0xe0f9, + "inspection-panel": 0xe587, + "instagram": 0xe0fa, + "italic": 0xe0fb, + "iteration-ccw": 0xe427, + "iteration-cw": 0xe428, + "japanese-yen": 0xe0fc, + "joystick": 0xe359, + "kanban": 0xe4e0, + "kayak": 0xe693, + "key": 0xe0fd, + "key-round": 0xe4a7, + "key-square": 0xe4a8, + "keyboard": 0xe284, + "keyboard-music": 0xe564, + "keyboard-off": 0xe5e2, + "lamp": 0xe2d8, + "lamp-ceiling": 0xe2d9, + "lamp-desk": 0xe2da, + "lamp-floor": 0xe2db, + "lamp-wall-down": 0xe2dc, + "lamp-wall-up": 0xe2dd, + "land-plot": 0xe52c, + "landmark": 0xe23a, + "languages": 0xe0fe, + "laptop": 0xe1cd, + "laptop-minimal": 0xe1d8, + "laptop-minimal-check": 0xe636, + "lasso": 0xe1ce, + "lasso-select": 0xe1cf, + "laugh": 0xe300, + "layers": 0xe52d, + "layers-2": 0xe52e, + "layout-dashboard": 0xe1c1, + "layout-grid": 0xe0ff, + "layout-list": 0xe1d9, + "layout-panel-left": 0xe474, + "layout-panel-top": 0xe475, + "layout-template": 0xe207, + "leaf": 0xe2de, + "leafy-green": 0xe473, + "lectern": 0xe5ed, + "library": 0xe100, + "library-big": 0xe552, + "life-buoy": 0xe101, + "ligature": 0xe43e, + "lightbulb": 0xe1c2, + "lightbulb-off": 0xe208, + "line-squiggle": 0xe67e, + "link": 0xe102, + "link-2": 0xe103, + "link-2-off": 0xe104, + "linkedin": 0xe105, + "list": 0xe106, + "list-check": 0xe5fe, + "list-checks": 0xe1d0, + "list-chevrons-down-up": 0xe698, + "list-chevrons-up-down": 0xe69a, + "list-collapse": 0xe59f, + "list-end": 0xe2df, + "list-filter": 0xe464, + "list-filter-plus": 0xe63d, + "list-indent-decrease": 0xe107, + "list-indent-increase": 0xe108, + "list-minus": 0xe23e, + "list-music": 0xe2e0, + "list-ordered": 0xe1d1, + "list-plus": 0xe23f, + "list-restart": 0xe456, + "list-start": 0xe2e1, + "list-todo": 0xe4c7, + "list-tree": 0xe40c, + "list-video": 0xe2e2, + "list-x": 0xe240, + "loader": 0xe109, + "loader-circle": 0xe10a, + "loader-pinwheel": 0xe5ea, + "locate": 0xe1da, + "locate-fixed": 0xe1db, + "locate-off": 0xe282, + "lock": 0xe10b, + "lock-keyhole": 0xe535, + "lock-keyhole-open": 0xe536, + "lock-open": 0xe10c, + "log-in": 0xe10d, + "log-out": 0xe10e, + "logs": 0xe5f8, + "lollipop": 0xe4c1, + "luggage": 0xe2ca, + "magnet": 0xe2b5, + "mail": 0xe10f, + "mail-check": 0xe365, + "mail-minus": 0xe366, + "mail-open": 0xe367, + "mail-plus": 0xe368, + "mail-question-mark": 0xe369, + "mail-search": 0xe36a, + "mail-warning": 0xe36b, + "mail-x": 0xe36c, + "mailbox": 0xe3d8, + "mails": 0xe36d, + "map": 0xe110, + "map-minus": 0xe68a, + "map-pin": 0xe111, + "map-pin-check": 0xe613, + "map-pin-check-inside": 0xe614, + "map-pin-house": 0xe620, + "map-pin-minus": 0xe615, + "map-pin-minus-inside": 0xe616, + "map-pin-off": 0xe2a6, + "map-pin-pen": 0xe659, + "map-pin-plus": 0xe617, + "map-pin-plus-inside": 0xe618, + "map-pin-x": 0xe619, + "map-pin-x-inside": 0xe61a, + "map-pinned": 0xe541, + "map-plus": 0xe643, + "mars": 0xe645, + "mars-stroke": 0xe646, + "martini": 0xe2e3, + "maximize": 0xe112, + "maximize-2": 0xe113, + "medal": 0xe373, + "megaphone": 0xe235, + "megaphone-off": 0xe374, + "meh": 0xe114, + "memory-stick": 0xe449, + "menu": 0xe115, + "merge": 0xe443, + "message-circle": 0xe116, + "message-circle-code": 0xe566, + "message-circle-dashed": 0xe567, + "message-circle-heart": 0xe568, + "message-circle-more": 0xe569, + "message-circle-off": 0xe56a, + "message-circle-plus": 0xe56b, + "message-circle-question-mark": 0xe56c, + "message-circle-reply": 0xe56d, + "message-circle-warning": 0xe56e, + "message-circle-x": 0xe56f, + "message-square": 0xe117, + "message-square-code": 0xe570, + "message-square-dashed": 0xe40f, + "message-square-diff": 0xe571, + "message-square-dot": 0xe572, + "message-square-heart": 0xe573, + "message-square-lock": 0xe630, + "message-square-more": 0xe574, + "message-square-off": 0xe575, + "message-square-plus": 0xe410, + "message-square-quote": 0xe576, + "message-square-reply": 0xe577, + "message-square-share": 0xe578, + "message-square-text": 0xe579, + "message-square-warning": 0xe57a, + "message-square-x": 0xe57b, + "messages-square": 0xe411, + "mic": 0xe118, + "mic-off": 0xe119, + "mic-vocal": 0xe34d, + "microchip": 0xe61e, + "microscope": 0xe2e4, + "microwave": 0xe37e, + "milestone": 0xe298, + "milk": 0xe39d, + "milk-off": 0xe39e, + "minimize": 0xe11a, + "minimize-2": 0xe11b, + "minus": 0xe11c, + "monitor": 0xe11d, + "monitor-check": 0xe486, + "monitor-cog": 0xe607, + "monitor-dot": 0xe487, + "monitor-down": 0xe425, + "monitor-off": 0xe1dc, + "monitor-pause": 0xe488, + "monitor-play": 0xe489, + "monitor-smartphone": 0xe3a6, + "monitor-speaker": 0xe210, + "monitor-stop": 0xe48a, + "monitor-up": 0xe426, + "monitor-x": 0xe48b, + "moon": 0xe11e, + "moon-star": 0xe414, + "mountain": 0xe231, + "mountain-snow": 0xe232, + "mouse": 0xe28e, + "mouse-off": 0xe5df, + "mouse-pointer": 0xe11f, + "mouse-pointer-2": 0xe1c3, + "mouse-pointer-ban": 0xe5eb, + "mouse-pointer-click": 0xe120, + "move": 0xe121, + "move-3d": 0xe2e5, + "move-diagonal": 0xe1c4, + "move-diagonal-2": 0xe1c5, + "move-down": 0xe490, + "move-down-left": 0xe491, + "move-down-right": 0xe492, + "move-horizontal": 0xe1c6, + "move-left": 0xe493, + "move-right": 0xe494, + "move-up": 0xe495, + "move-up-left": 0xe496, + "move-up-right": 0xe497, + "move-vertical": 0xe1c7, + "music": 0xe122, + "music-2": 0xe34e, + "music-3": 0xe34f, + "music-4": 0xe350, + "navigation": 0xe123, + "navigation-2": 0xe124, + "navigation-2-off": 0xe2a7, + "navigation-off": 0xe2a8, + "network": 0xe125, + "newspaper": 0xe34c, + "nfc": 0xe3c7, + "non-binary": 0xe647, + "notebook": 0xe599, + "notebook-pen": 0xe59a, + "notebook-tabs": 0xe59b, + "notebook-text": 0xe59c, + "notepad-text": 0xe59d, + "notepad-text-dashed": 0xe59e, + "nut": 0xe39f, + "nut-off": 0xe3a0, + "octagon": 0xe126, + "octagon-alert": 0xe127, + "octagon-minus": 0xe62b, + "octagon-pause": 0xe21b, + "octagon-x": 0xe128, + "omega": 0xe61d, + "option": 0xe1f8, + "orbit": 0xe3eb, + "origami": 0xe5e7, + "package": 0xe129, + ] + + private static let codepoints3: [String: UInt32] = [ + "package-2": 0xe344, + "package-check": 0xe266, + "package-minus": 0xe267, + "package-open": 0xe2cc, + "package-plus": 0xe268, + "package-search": 0xe269, + "package-x": 0xe26a, + "paint-bucket": 0xe2e6, + "paint-roller": 0xe5a2, + "paintbrush": 0xe2e7, + "paintbrush-vertical": 0xe2e8, + "palette": 0xe1dd, + "panda": 0xe66c, + "panel-bottom": 0xe430, + "panel-bottom-close": 0xe431, + "panel-bottom-dashed": 0xe432, + "panel-bottom-open": 0xe433, + "panel-left": 0xe12a, + "panel-left-close": 0xe21c, + "panel-left-dashed": 0xe434, + "panel-left-open": 0xe21d, + "panel-left-right-dashed": 0xe696, + "panel-right": 0xe435, + "panel-right-close": 0xe436, + "panel-right-dashed": 0xe437, + "panel-right-open": 0xe438, + "panel-top": 0xe439, + "panel-top-bottom-dashed": 0xe697, + "panel-top-close": 0xe43a, + "panel-top-dashed": 0xe43b, + "panel-top-open": 0xe43c, + "panels-left-bottom": 0xe12b, + "panels-right-bottom": 0xe58c, + "panels-top-left": 0xe12c, + "paperclip": 0xe12d, + "parentheses": 0xe448, + "parking-meter": 0xe504, + "party-popper": 0xe347, + "pause": 0xe12e, + "paw-print": 0xe4f9, + "pc-case": 0xe44a, + "pen": 0xe12f, + "pen-line": 0xe130, + "pen-off": 0xe5f2, + "pen-tool": 0xe131, + "pencil": 0xe1f9, + "pencil-line": 0xe4f4, + "pencil-off": 0xe5f3, + "pencil-ruler": 0xe4f5, + "pentagon": 0xe52f, + "percent": 0xe132, + "person-standing": 0xe21e, + "philippine-peso": 0xe608, + "phone": 0xe133, + "phone-call": 0xe134, + "phone-forwarded": 0xe135, + "phone-incoming": 0xe136, + "phone-missed": 0xe137, + "phone-off": 0xe138, + "phone-outgoing": 0xe139, + "pi": 0xe476, + "piano": 0xe565, + "pickaxe": 0xe5ca, + "picture-in-picture": 0xe3b2, + "picture-in-picture-2": 0xe3b3, + "piggy-bank": 0xe13a, + "pilcrow": 0xe3a7, + "pilcrow-left": 0xe5e0, + "pilcrow-right": 0xe5e1, + "pill": 0xe3c1, + "pill-bottle": 0xe5ee, + "pin": 0xe259, + "pin-off": 0xe2b6, + "pipette": 0xe13b, + "pizza": 0xe358, + "plane": 0xe1de, + "plane-landing": 0xe3d1, + "plane-takeoff": 0xe3d2, + "play": 0xe13c, + "plug": 0xe383, + "plug-2": 0xe384, + "plug-zap": 0xe460, + "plus": 0xe13d, + "pocket": 0xe13e, + "pocket-knife": 0xe4a4, + "podcast": 0xe1fa, + "pointer": 0xe1e8, + "pointer-off": 0xe583, + "popcorn": 0xe4c2, + "popsicle": 0xe4c3, + "pound-sterling": 0xe13f, + "power": 0xe140, + "power-off": 0xe209, + "presentation": 0xe4b2, + "printer": 0xe141, + "printer-check": 0xe5f9, + "projector": 0xe4b3, + "proportions": 0xe5d3, + "puzzle": 0xe29c, + "pyramid": 0xe530, + "qr-code": 0xe1df, + "quote": 0xe239, + "rabbit": 0xe4fa, + "radar": 0xe49b, + "radiation": 0xe446, + "radical": 0xe5c6, + "radio": 0xe142, + "radio-receiver": 0xe1fb, + "radio-tower": 0xe408, + "radius": 0xe531, + "rail-symbol": 0xe505, + "rainbow": 0xe4c6, + "rat": 0xe3ef, + "ratio": 0xe4ec, + "receipt": 0xe3d7, + "receipt-cent": 0xe5a9, + "receipt-euro": 0xe5aa, + "receipt-indian-rupee": 0xe5ab, + "receipt-japanese-yen": 0xe5ac, + "receipt-pound-sterling": 0xe5ad, + "receipt-russian-ruble": 0xe5ae, + "receipt-swiss-franc": 0xe5af, + "receipt-text": 0xe5b0, + "receipt-turkish-lira": 0xe683, + "rectangle-circle": 0xe677, + "rectangle-ellipsis": 0xe21f, + "rectangle-goggles": 0xe65a, + "rectangle-horizontal": 0xe37a, + "rectangle-vertical": 0xe37b, + "recycle": 0xe2e9, + "redo": 0xe143, + "redo-2": 0xe2a0, + "redo-dot": 0xe454, + "refresh-ccw": 0xe144, + "refresh-ccw-dot": 0xe4b6, + "refresh-cw": 0xe145, + "refresh-cw-off": 0xe49c, + "refrigerator": 0xe37f, + "regex": 0xe1fc, + "remove-formatting": 0xe3b7, + "repeat": 0xe146, + "repeat-1": 0xe1fd, + "repeat-2": 0xe415, + "replace": 0xe3df, + "replace-all": 0xe3e0, + "reply": 0xe22a, + "reply-all": 0xe22b, + "rewind": 0xe147, + "ribbon": 0xe55c, + "rocket": 0xe286, + "rocking-chair": 0xe233, + "roller-coaster": 0xe484, + "rose": 0xe695, + "rotate-3d": 0xe2ea, + "rotate-ccw": 0xe148, + "rotate-ccw-key": 0xe654, + "rotate-ccw-square": 0xe5d4, + "rotate-cw": 0xe149, + "rotate-cw-square": 0xe5d5, + "route": 0xe542, + "route-off": 0xe543, + "router": 0xe3c3, + "rows-2": 0xe43d, + "rows-3": 0xe58e, + "rows-4": 0xe58f, + "rss": 0xe14a, + "ruler": 0xe14b, + "ruler-dimension-line": 0xe666, + "russian-ruble": 0xe14c, + "sailboat": 0xe382, + "salad": 0xe3ac, + "sandwich": 0xe3ad, + "satellite": 0xe44b, + "satellite-dish": 0xe44c, + "saudi-riyal": 0xe64f, + "save": 0xe14d, + "save-all": 0xe413, + "save-off": 0xe5f7, + "scale": 0xe212, + "scale-3d": 0xe2eb, + "scaling": 0xe2ec, + "scan": 0xe257, + "scan-barcode": 0xe539, + "scan-eye": 0xe53a, + "scan-face": 0xe375, + "scan-heart": 0xe63e, + "scan-line": 0xe258, + "scan-qr-code": 0xe5fa, + "scan-search": 0xe53b, + "scan-text": 0xe53c, + "school": 0xe3e7, + "scissors": 0xe14e, + "scissors-line-dashed": 0xe4ed, + "screen-share": 0xe14f, + "screen-share-off": 0xe150, + "scroll": 0xe2ed, + "scroll-text": 0xe463, + "search": 0xe151, + "search-check": 0xe4ae, + "search-code": 0xe4af, + "search-slash": 0xe4b0, + "search-x": 0xe4b1, + "section": 0xe5ec, + "send": 0xe152, + "send-horizontal": 0xe4f6, + "send-to-back": 0xe4f7, + "separator-horizontal": 0xe1c8, + "separator-vertical": 0xe1c9, + "server": 0xe153, + "server-cog": 0xe345, + "server-crash": 0xe1e9, + "server-off": 0xe1ea, + "settings": 0xe154, + "settings-2": 0xe245, + "shapes": 0xe4b7, + "share": 0xe155, + "share-2": 0xe156, + "sheet": 0xe157, + "shell": 0xe4fb, + "shield": 0xe158, + "shield-alert": 0xe1fe, + "shield-ban": 0xe159, + "shield-check": 0xe1ff, + "shield-ellipsis": 0xe51a, + "shield-half": 0xe51b, + "shield-minus": 0xe51c, + "shield-off": 0xe15a, + "shield-plus": 0xe51d, + "shield-question-mark": 0xe412, + "shield-user": 0xe64b, + "shield-x": 0xe200, + "ship": 0xe3be, + "ship-wheel": 0xe506, + "shirt": 0xe1ca, + "shopping-bag": 0xe15b, + "shopping-basket": 0xe4ee, + "shopping-cart": 0xe15c, + "shovel": 0xe15d, + "shower-head": 0xe380, + "shredder": 0xe65f, + "shrimp": 0xe64d, + "shrink": 0xe220, + "shrub": 0xe2ee, + "shuffle": 0xe15e, + "sigma": 0xe201, + "signal": 0xe25f, + "signal-high": 0xe260, + "signal-low": 0xe261, + "signal-medium": 0xe262, + "signal-zero": 0xe263, + "signature": 0xe5f6, + "signpost": 0xe544, + "signpost-big": 0xe545, + "siren": 0xe2ef, + "skip-back": 0xe15f, + "skip-forward": 0xe160, + "skull": 0xe221, + "slack": 0xe161, + "slash": 0xe521, + "slice": 0xe2f0, + "sliders-horizontal": 0xe29a, + "sliders-vertical": 0xe162, + "smartphone": 0xe163, + "smartphone-charging": 0xe22e, + "smartphone-nfc": 0xe3c8, + "smile": 0xe164, + "smile-plus": 0xe301, + "snail": 0xe4fc, + "snowflake": 0xe165, + "soap-dispenser-droplet": 0xe66d, + "sofa": 0xe2c4, + "soup": 0xe3ae, + "space": 0xe3e1, + "spade": 0xe49d, + "sparkle": 0xe482, + "sparkles": 0xe416, + "speaker": 0xe166, + "speech": 0xe522, + "spell-check": 0xe49e, + "spell-check-2": 0xe49f, + "spline": 0xe38f, + "spline-pointer": 0xe653, + "split": 0xe444, + "spool": 0xe67b, + "spotlight": 0xe686, + "spray-can": 0xe499, + "sprout": 0xe1eb, + "square": 0xe167, + "square-activity": 0xe4b8, + "square-arrow-down": 0xe42b, + "square-arrow-down-left": 0xe4b9, + "square-arrow-down-right": 0xe4ba, + "square-arrow-left": 0xe42c, + "square-arrow-out-down-left": 0xe5a5, + "square-arrow-out-down-right": 0xe5a6, + "square-arrow-out-up-left": 0xe5a7, + "square-arrow-out-up-right": 0xe5a8, + "square-arrow-right": 0xe42d, + "square-arrow-up": 0xe42e, + "square-arrow-up-left": 0xe4bb, + "square-arrow-up-right": 0xe4bc, + "square-asterisk": 0xe168, + "square-bottom-dashed-scissors": 0xe4ef, + "square-chart-gantt": 0xe169, + "square-check": 0xe55d, + "square-check-big": 0xe16a, + "square-chevron-down": 0xe3d3, + "square-chevron-left": 0xe3d4, + "square-chevron-right": 0xe3d5, + "square-chevron-up": 0xe3d6, + "square-code": 0xe16b, + "square-dashed": 0xe1cb, + "square-dashed-bottom": 0xe4c4, + "square-dashed-bottom-code": 0xe4c5, + "square-dashed-kanban": 0xe16c, + "square-dashed-mouse-pointer": 0xe50d, + "square-dashed-top-solid": 0xe670, + "square-divide": 0xe16d, + "square-dot": 0xe16e, + "square-equal": 0xe16f, + "square-function": 0xe22d, + "square-kanban": 0xe170, + "square-library": 0xe553, + "square-m": 0xe507, + "square-menu": 0xe457, + "square-minus": 0xe171, + "square-mouse-pointer": 0xe202, + "square-parking": 0xe3cf, + "square-parking-off": 0xe3d0, + "square-pause": 0xe688, + "square-pen": 0xe172, + "square-percent": 0xe520, + "square-pi": 0xe48c, + "square-pilcrow": 0xe48f, + "square-play": 0xe485, + "square-plus": 0xe173, + "square-power": 0xe555, + "square-radical": 0xe5c7, + "square-round-corner": 0xe64c, + "square-scissors": 0xe4f0, + "square-sigma": 0xe48d, + "square-slash": 0xe174, + "square-split-horizontal": 0xe3ba, + "square-split-vertical": 0xe3bb, + "square-square": 0xe612, + "square-stack": 0xe4a6, + "square-star": 0xe692, + "square-stop": 0xe689, + "square-terminal": 0xe20a, + "square-user": 0xe469, + ] + + private static let codepoints4: [String: UInt32] = [ + "square-user-round": 0xe46a, + "square-x": 0xe175, + "squares-exclude": 0xe65b, + "squares-intersect": 0xe65c, + "squares-subtract": 0xe65d, + "squares-unite": 0xe65e, + "squircle": 0xe57e, + "squircle-dashed": 0xe67d, + "squirrel": 0xe4a3, + "stamp": 0xe3bf, + "star": 0xe176, + "star-half": 0xe20b, + "star-off": 0xe2b0, + "step-back": 0xe3ed, + "step-forward": 0xe3ee, + "stethoscope": 0xe2f1, + "sticker": 0xe302, + "sticky-note": 0xe303, + "store": 0xe3e8, + "stretch-horizontal": 0xe27c, + "stretch-vertical": 0xe27d, + "strikethrough": 0xe177, + "subscript": 0xe25c, + "sun": 0xe178, + "sun-dim": 0xe299, + "sun-medium": 0xe2b1, + "sun-moon": 0xe2b2, + "sun-snow": 0xe376, + "sunrise": 0xe179, + "sunset": 0xe17a, + "superscript": 0xe25e, + "swatch-book": 0xe5a3, + "swiss-franc": 0xe17b, + "switch-camera": 0xe17c, + "sword": 0xe2b3, + "swords": 0xe2b4, + "syringe": 0xe2f2, + "table": 0xe17d, + "table-2": 0xe2f9, + "table-cells-merge": 0xe5cb, + "table-cells-split": 0xe5cc, + "table-columns-split": 0xe5cd, + "table-of-contents": 0xe622, + "table-properties": 0xe4df, + "table-rows-split": 0xe5ce, + "tablet": 0xe17e, + "tablet-smartphone": 0xe50e, + "tablets": 0xe3c2, + "tag": 0xe17f, + "tags": 0xe360, + "tally-1": 0xe4da, + "tally-2": 0xe4db, + "tally-3": 0xe4dc, + "tally-4": 0xe4dd, + "tally-5": 0xe4de, + "tangent": 0xe532, + "target": 0xe180, + "telescope": 0xe5c9, + "tent": 0xe227, + "tent-tree": 0xe53f, + "terminal": 0xe181, + "test-tube": 0xe409, + "test-tube-diagonal": 0xe40a, + "test-tubes": 0xe40b, + "text-align-center": 0xe182, + "text-align-end": 0xe183, + "text-align-justify": 0xe184, + "text-align-start": 0xe185, + "text-cursor": 0xe264, + "text-cursor-input": 0xe265, + "text-initial": 0xe609, + "text-quote": 0xe4a2, + "text-search": 0xe5b1, + "text-select": 0xe3e2, + "text-wrap": 0xe248, + "theater": 0xe526, + "thermometer": 0xe186, + "thermometer-snowflake": 0xe187, + "thermometer-sun": 0xe188, + "thumbs-down": 0xe189, + "thumbs-up": 0xe18a, + "ticket": 0xe20f, + "ticket-check": 0xe5b2, + "ticket-minus": 0xe5b3, + "ticket-percent": 0xe5b4, + "ticket-plus": 0xe5b5, + "ticket-slash": 0xe5b6, + "ticket-x": 0xe5b7, + "tickets": 0xe626, + "tickets-plane": 0xe627, + "timer": 0xe1e0, + "timer-off": 0xe249, + "timer-reset": 0xe236, + "toggle-left": 0xe18b, + "toggle-right": 0xe18c, + "toilet": 0xe639, + "tool-case": 0xe681, + "tornado": 0xe218, + "torus": 0xe533, + "touchpad": 0xe44d, + "touchpad-off": 0xe44e, + "tower-control": 0xe3c0, + "toy-brick": 0xe34b, + "tractor": 0xe508, + "traffic-cone": 0xe509, + "train-front": 0xe50a, + "train-front-tunnel": 0xe50b, + "train-track": 0xe50c, + "tram-front": 0xe2a9, + "transgender": 0xe648, + "trash": 0xe18d, + "trash-2": 0xe18e, + "tree-deciduous": 0xe2f3, + "tree-palm": 0xe281, + "tree-pine": 0xe2f4, + "trees": 0xe2f5, + "trello": 0xe18f, + "trending-down": 0xe190, + "trending-up": 0xe191, + "trending-up-down": 0xe629, + "triangle": 0xe192, + "triangle-alert": 0xe193, + "triangle-dashed": 0xe641, + "triangle-right": 0xe4f1, + "trophy": 0xe377, + "truck": 0xe194, + "truck-electric": 0xe663, + "turkish-lira": 0xe684, + "turntable": 0xe690, + "turtle": 0xe4fd, + "tv": 0xe195, + "tv-minimal": 0xe203, + "tv-minimal-play": 0xe5f0, + "twitch": 0xe196, + "twitter": 0xe197, + "type": 0xe198, + "type-outline": 0xe606, + "umbrella": 0xe199, + "umbrella-off": 0xe547, + "underline": 0xe19a, + "undo": 0xe19b, + "undo-2": 0xe2a1, + "undo-dot": 0xe455, + "unfold-horizontal": 0xe441, + "unfold-vertical": 0xe442, + "ungroup": 0xe46b, + "university": 0xe3e9, + "unlink": 0xe19c, + "unlink-2": 0xe19d, + "unplug": 0xe461, + "upload": 0xe19e, + "usb": 0xe35a, + "user": 0xe19f, + "user-check": 0xe1a0, + "user-cog": 0xe346, + "user-lock": 0xe664, + "user-minus": 0xe1a1, + "user-pen": 0xe600, + "user-plus": 0xe1a2, + "user-round": 0xe46c, + "user-round-check": 0xe46d, + "user-round-cog": 0xe46e, + "user-round-minus": 0xe46f, + "user-round-pen": 0xe601, + "user-round-plus": 0xe470, + "user-round-search": 0xe57c, + "user-round-x": 0xe471, + "user-search": 0xe57d, + "user-star": 0xe68b, + "user-x": 0xe1a3, + "users": 0xe1a4, + "users-round": 0xe472, + "utensils": 0xe2f6, + "utensils-crossed": 0xe2f7, + "utility-pole": 0xe3c6, + "variable": 0xe477, + "vault": 0xe593, + "vector-square": 0xe680, + "vegan": 0xe3a1, + "venetian-mask": 0xe2aa, + "venus": 0xe649, + "venus-and-mars": 0xe64a, + "vibrate": 0xe223, + "vibrate-off": 0xe29d, + "video": 0xe1a5, + "video-off": 0xe1a6, + "videotape": 0xe4cf, + "view": 0xe1a7, + "voicemail": 0xe1a8, + "volleyball": 0xe633, + "volume": 0xe1a9, + "volume-1": 0xe1aa, + "volume-2": 0xe1ab, + "volume-off": 0xe62a, + "volume-x": 0xe1ac, + "vote": 0xe3b1, + "wallet": 0xe204, + "wallet-cards": 0xe4d0, + "wallet-minimal": 0xe4d1, + "wallpaper": 0xe44f, + "wand": 0xe246, + "wand-sparkles": 0xe35b, + "warehouse": 0xe3ea, + "washing-machine": 0xe594, + "watch": 0xe1ad, + "waves": 0xe283, + "waves-ladder": 0xe63f, + "waypoints": 0xe546, + "webcam": 0xe205, + "webhook": 0xe378, + "webhook-off": 0xe5bb, + "weight": 0xe534, + "wheat": 0xe3a2, + "wheat-off": 0xe3a3, + "whole-word": 0xe3e3, + "wifi": 0xe1ae, + "wifi-cog": 0xe678, + "wifi-high": 0xe5fb, + "wifi-low": 0xe5fc, + "wifi-off": 0xe1af, + "wifi-pen": 0xe667, + "wifi-sync": 0xe685, + "wifi-zero": 0xe5fd, + "wind": 0xe1b0, + "wind-arrow-down": 0xe635, + "wine": 0xe2f8, + "wine-off": 0xe3a4, + "workflow": 0xe429, + "worm": 0xe5de, + "wrench": 0xe1b1, + "x": 0xe1b2, + "youtube": 0xe1b3, + "zap": 0xe1b4, + "zap-off": 0xe1b5, + "zoom-in": 0xe1b6, + "zoom-out": 0xe1b7, + ] + + static let codepoints: [String: UInt32] = { + var all: [String: UInt32] = [:] + for (k, v) in codepoints0 { all[k] = v } + for (k, v) in codepoints1 { all[k] = v } + for (k, v) in codepoints2 { all[k] = v } + for (k, v) in codepoints3 { all[k] = v } + for (k, v) in codepoints4 { all[k] = v } + return all + }() + + /// Returns the Unicode character for a Lucide icon name, or nil if not found. + static func character(for name: String) -> Character? { + guard let cp = codepoints[name], + let scalar = Unicode.Scalar(cp) else { return nil } + return Character(scalar) + } + + /// All icon names sorted alphabetically. + static let allNames: [String] = codepoints.keys.sorted() +} diff --git a/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift b/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift new file mode 100644 index 00000000..35af09f5 --- /dev/null +++ b/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift @@ -0,0 +1,139 @@ +// +// SyncStatusBottomSheet.swift +// ColumbaApp +// +// In-app sheet showing live LXMF propagation-sync progress, mirroring +// Columba-Android's `SyncStatusBottomSheet`. Driven by +// `PropagationNodeManager.syncState`; under Model B that state is fed by the NE's +// sync-state snapshots (the NE owns the router), under the python build by the +// in-process sync. Backend-agnostic — it just renders whatever `syncState` holds. +// + +import SwiftUI +import RNSAPI + +/// Bottom-sheet content rendering the current propagation-sync phase: a header, a +/// status row (icon + title + subtitle), and a progress bar while messages download. +@available(iOS 17.0, macOS 14.0, *) +struct SyncStatusBottomSheet: View { + /// Current sync state (observed from `PropagationNodeManager.syncState`). + let state: PropagationTransferState + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(Theme.accentColor) + Text("Propagation Node Sync") + .font(.title3.weight(.bold)) + .foregroundColor(Theme.textPrimary) + } + + Spacer().frame(height: 24) + + // Status row + statusRow + + // Progress bar (only while actively receiving with known progress) + if showProgressBar { + Spacer().frame(height: 16) + ProgressView(value: min(max(state.progress, 0), 1)) + .tint(Theme.accentColor) + Spacer().frame(height: 8) + Text("\(Int((min(max(state.progress, 0), 1)) * 100))%") + .font(.subheadline) + .foregroundColor(Theme.textSecondary) + } + } + .padding(.horizontal, 24) + .padding(.top, 28) + .padding(.bottom, 36) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.backgroundPrimary.ignoresSafeArea()) + .presentationDetents([.height(220)]) + .presentationDragIndicator(.visible) + } + + // MARK: - Status row + + @ViewBuilder + private var statusRow: some View { + HStack(alignment: .center, spacing: 16) { + statusIcon + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + .foregroundColor(Theme.textPrimary) + Text(subtitle) + .font(.subheadline) + .foregroundColor(Theme.textSecondary) + } + Spacer(minLength: 0) + } + } + + @ViewBuilder + private var statusIcon: some View { + switch state.state { + case .linking, .linked, .transferring: + ProgressView() + .progressViewStyle(.circular) + .tint(Theme.accentColor) + case .complete: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(Theme.success) + case .idle: + Image(systemName: "checkmark.circle") + .font(.system(size: 24)) + .foregroundColor(Theme.accentColor) + case .noPath, .linkFailed, .transferFailed: + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 24)) + .foregroundColor(Theme.error) + } + } + + // MARK: - Content + + private var showProgressBar: Bool { + state.state == .transferring && state.progress > 0 + } + + private var title: String { + switch state.state { + case .idle: return "Ready" + case .linking: return "Connecting" + case .linked: return "Connected" + case .transferring: return "Receiving" + case .complete: return "Download complete" + case .noPath: return "No path to relay" + case .linkFailed: return "Connection failed" + case .transferFailed: return "Sync failed" + } + } + + private var subtitle: String { + switch state.state { + case .idle: + return "Not currently syncing" + case .linking: + return "Establishing secure connection…" + case .linked: + return "Connected, preparing request…" + case .transferring: + return "Downloading messages…" + case .complete: + return state.receivedMessages > 0 + ? "\(state.receivedMessages) new message\(state.receivedMessages == 1 ? "" : "s")" + : "No new messages" + case .noPath: + return state.errorDescription ?? "Couldn't find a route to the propagation node" + case .linkFailed, .transferFailed: + return state.errorDescription ?? "Please try again" + } + } +} diff --git a/Sources/ColumbaApp/Views/Contacts/ContactCard.swift b/Sources/ColumbaApp/Views/Contacts/ContactCard.swift index e010819e..6959824e 100644 --- a/Sources/ColumbaApp/Views/Contacts/ContactCard.swift +++ b/Sources/ColumbaApp/Views/Contacts/ContactCard.swift @@ -294,13 +294,21 @@ struct ContactCard: View { @ViewBuilder private var interfaceIconView: some View { - if contact.interfaceIcon == "bluetooth", - let ch = MaterialDesignIcons.character(for: "bluetooth") { + let icon = contact.interfaceIcon + // "lucide:" → Lucide font glyph (e.g. the RNode antenna, matching + // Android); "bluetooth" → Material Design Icons glyph; else SF Symbol. + if icon.hasPrefix("lucide:"), + let ch = Lucide.character(for: String(icon.dropFirst("lucide:".count))) { + Text(String(ch)) + .font(.custom(Lucide.fontName, size: 13)) + .foregroundStyle(Theme.textDisabled) + } else if icon == "bluetooth", + let ch = MaterialDesignIcons.character(for: "bluetooth") { Text(String(ch)) .font(.custom(MaterialDesignIcons.fontName, size: 12)) .foregroundStyle(.blue.opacity(0.7)) } else { - Image(systemName: contact.interfaceIcon) + Image(systemName: icon) .font(.caption) .foregroundStyle(Theme.textDisabled) } diff --git a/Sources/ColumbaApp/Views/Contacts/NetworkAnnouncesTab.swift b/Sources/ColumbaApp/Views/Contacts/NetworkAnnouncesTab.swift index 32bb4a58..e16f73f4 100644 --- a/Sources/ColumbaApp/Views/Contacts/NetworkAnnouncesTab.swift +++ b/Sources/ColumbaApp/Views/Contacts/NetworkAnnouncesTab.swift @@ -150,6 +150,7 @@ struct NetworkAnnouncesTab: View { label: filter.rawValue, icon: interfaceIcon(for: filter), mdiIcon: filter == .ble ? "bluetooth" : nil, + lucideIcon: filter == .rnode ? "antenna" : nil, isSelected: viewModel.interfaceFilter == filter ) { withAnimation(.easeInOut(duration: 0.2)) { @@ -165,10 +166,13 @@ struct NetworkAnnouncesTab: View { .padding(.top, 8) } - private func filterCapsule(label: String, icon: String?, mdiIcon: String? = nil, isSelected: Bool, action: @escaping () -> Void) -> some View { + private func filterCapsule(label: String, icon: String?, mdiIcon: String? = nil, lucideIcon: String? = nil, isSelected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 4) { - if let mdiIcon, let ch = MaterialDesignIcons.character(for: mdiIcon) { + if let lucideIcon, let ch = Lucide.character(for: lucideIcon) { + Text(String(ch)) + .font(.custom(Lucide.fontName, size: 12)) + } else if let mdiIcon, let ch = MaterialDesignIcons.character(for: mdiIcon) { Text(String(ch)) .font(.custom(MaterialDesignIcons.fontName, size: 12)) } else if let icon = icon { @@ -209,7 +213,7 @@ struct NetworkAnnouncesTab: View { case .tcp: return "globe" case .wifi: return "wifi" case .ble: return nil // MDI bluetooth icon used instead - case .rnode: return "antenna.radiowaves.left.and.right" + case .rnode: return nil // Lucide antenna glyph used instead } } diff --git a/Sources/ColumbaApp/Views/Map/MapView.swift b/Sources/ColumbaApp/Views/Map/MapView.swift index 094e9319..f3bf164d 100644 --- a/Sources/ColumbaApp/Views/Map/MapView.swift +++ b/Sources/ColumbaApp/Views/Map/MapView.swift @@ -154,8 +154,11 @@ struct MapView: View { } private func loadContacts() async { - guard let db = appServices.database else { return } - let repo = MessageRepository(database: db) + // Read conversations from the GRDB canonical store (Track A0) via the + // repository AppServices builds during initialize(). MapView must not + // import LXMFSwift, so it reuses that instance instead of constructing + // its own MessageRepository(grdbPath:). + guard let repo = appServices.messageRepository else { return } contacts = (try? await repo.fetchConversations()) ?? [] } diff --git a/Sources/ColumbaApp/Views/Settings/BLEConnectionsView.swift b/Sources/ColumbaApp/Views/Settings/BLEConnectionsView.swift index f6bce8a6..816b803e 100644 --- a/Sources/ColumbaApp/Views/Settings/BLEConnectionsView.swift +++ b/Sources/ColumbaApp/Views/Settings/BLEConnectionsView.swift @@ -65,7 +65,16 @@ struct BLEConnectionsView: View { isLoading = false startPeriodicRefresh() } + // Event-driven: the NE pushes `networkStateChangedInApp` when a BLE peer + // connects/disconnects, so the connection LIST updates immediately instead + // of waiting for the poll. `onReceive` delivers on the main run loop and its + // subscription is auto-cancelled when the view disappears (no observer leak). + .onReceive(NotificationCenter.default.publisher(for: NotificationObserver.networkStateChangedInApp)) { _ in + Task { await refresh() } + } .onDisappear { + // Tear down the live-metrics poll. The `onReceive` subscription above is + // torn down automatically by SwiftUI when the view disappears. refreshTimer?.invalidate() refreshTimer = nil } @@ -338,8 +347,12 @@ struct BLEConnectionsView: View { connections = await appServices.getBLEConnectionInfos() } + /// Slow while-visible poll for the live per-peer metrics (bytes / RSSI), which + /// change continuously with no discrete event to push. The connection list itself + /// is event-driven via `networkStateChangedInApp`; this only keeps the live + /// counters ticking, so 4s is plenty. Started on appear, invalidated on disappear. private func startPeriodicRefresh() { - refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in + refreshTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { _ in Task { @MainActor in await refresh() } diff --git a/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift b/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift new file mode 100644 index 00000000..87e3836b --- /dev/null +++ b/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift @@ -0,0 +1,393 @@ +#if ENABLE_NETWORK_EXTENSION +// +// BackgroundTransportView.swift +// ColumbaApp +// +// Opt-in explainer + enable/disable screen for the background transport +// Network Extension (NEPacketTunnelProvider). Track C6 (UI portion). +// +// This is an ADVANCED, opt-in feature: it requires a paid Apple Developer +// account (the NE entitlement) and explicit user consent to install a local +// VPN configuration profile. It is therefore presented as a dedicated +// explainer screen reached from Settings (see `backgroundTransportCard` in +// SettingsView) rather than forced into the mandatory onboarding flow. +// +// The whole file is `ENABLE_NETWORK_EXTENSION`-gated because `TunnelManager` +// only exists under that flag. +// + +import SwiftUI +import RNSAPI +import NetworkExtension + +/// Explains the background transport in plain language and lets the user +/// enable (install + start) or disable (stop) the Network Extension tunnel. +/// +/// Presented as a sheet from `SettingsView.backgroundTransportCard()`. +@available(iOS 17.0, macOS 14.0, *) +struct BackgroundTransportView: View { + + // MARK: - State + + /// The tunnel manager driving install/start/stop. `@Bindable` so the + /// view re-renders as `TunnelManager.status` (an `@Observable` property) + /// changes from the `NEVPNStatusDidChange` observer. + @Bindable var tunnel: TunnelManager + + /// Set when `install()`/`start()` throws so we can surface the reason. + @State private var errorMessage: String? + + /// True while an install/start round-trip is in flight (the user tapped + /// Enable and we're awaiting `saveToPreferences` + the profile-install + /// system prompt). Distinct from the `.connecting` VPN status, which only + /// begins once the tunnel actually starts. + @State private var isWorking = false + + /// Dismiss handler supplied by the presenter. + let onDismiss: () -> Void + + // MARK: - Body + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + headerCard + statusCard + explainerCard + badgeCard + privacyCard + if let errorMessage { + errorCard(errorMessage) + } + actionButton + Text("Requires installing a local VPN configuration. iOS will ask for your permission the first time you enable this.") + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 4) + } + .padding(16) + } + .background(Theme.backgroundPrimary) + .navigationTitle("Background Transport") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { onDismiss() } + } + } + } + } + + // MARK: - Header Card + + private var headerCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(Theme.accentColor) + .frame(width: 44, height: 44) + .background(Theme.accentColor.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 4) { + Text("Stay reachable in the background") + .font(.headline) + .foregroundStyle(Theme.textPrimary) + Text("Keep mesh delivery alive while Columba is closed or your phone is locked.") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(16) + .glassCard() + } + + // MARK: - Status Card + + private var statusCard: some View { + HStack(spacing: 10) { + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + + Text(statusLabel) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Theme.textPrimary) + + Spacer() + + if showsActivity { + ProgressView() + .controlSize(.small) + .tint(Theme.accentColor) + } + } + .padding(16) + .glassCard() + } + + // MARK: - Explainer Card + + private var explainerCard: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader(icon: "questionmark.circle", title: "What it does") + + explainerRow( + icon: "tray.and.arrow.down.fill", + text: "Receives messages, calls, and announcements even when Columba isn't open." + ) + explainerRow( + icon: "point.3.connected.trianglepath.dotted", + text: "Keeps your TCP and local-network (LAN) links connected to the Reticulum mesh in the background." + ) + explainerRow( + icon: "bolt.fill", + text: "Uses more battery and data than running only in the foreground." + ) + } + .padding(16) + .glassCard() + } + + // MARK: - Status-Bar Badge Card + + private var badgeCard: some View { + VStack(alignment: .leading, spacing: 10) { + sectionHeader(icon: "rectangle.topthird.inset.filled", title: "The VPN badge") + + HStack(spacing: 10) { + Text("VPN") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Theme.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 4)) + + Text("While this is on, iOS shows a VPN badge in your status bar.") + .font(.subheadline) + .foregroundStyle(Theme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + + Text("The badge is iOS telling you a packet tunnel is active. It stays visible the whole time background transport is enabled.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .glassCard() + } + + // MARK: - Privacy Card + + private var privacyCard: some View { + VStack(alignment: .leading, spacing: 10) { + sectionHeader(icon: "lock.shield.fill", title: "Not a commercial VPN") + + Text("Columba uses Apple's VPN mechanism only as the way to run a background packet tunnel for the mesh. It is not a commercial VPN service.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + explainerRow( + icon: "checkmark.shield.fill", + text: "Your internet traffic is not proxied, routed through, or monetized by Columba." + ) + explainerRow( + icon: "iphone", + text: "The tunnel runs entirely on your device to carry Reticulum traffic. Nothing else is intercepted." + ) + } + .padding(16) + .glassCard() + } + + // MARK: - Error Card + + private func errorCard(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Theme.error) + Text("Couldn't enable") + .font(.headline) + .foregroundStyle(Theme.error) + } + + Text(message) + .font(.callout) + .foregroundStyle(Theme.textPrimary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .glassCard() + } + + // MARK: - Action Button + + @ViewBuilder + private var actionButton: some View { + if isEnabledState { + // Disable: clear on-demand + stop (a bare stop() would auto-reconnect). + Button { + errorMessage = nil + Task { + do { try await tunnel.disable() } + catch { errorMessage = error.localizedDescription } + } + } label: { + Text("Disable Background Transport") + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Theme.error) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusLarge)) + } + } else { + // Enable: install the profile (also arms on-demand) then start. + Button { + enable() + } label: { + Group { + if isWorking { + ProgressView().tint(.white) + } else { + Text("Enable Background Transport") + } + } + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Theme.accentGradient) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusLarge)) + } + .disabled(isWorking) + } + } + + // MARK: - Actions + + private func enable() { + errorMessage = nil + isWorking = true + Task { + do { + // install() saves/updates the VPN profile (triggering the iOS + // permission prompt the first time) and arms on-demand connect; + // start() then brings the tunnel up. start() itself falls back + // to install() if no manager is loaded, but we call install() + // explicitly so the profile is (re)written with the current + // on-demand rules before starting. + try await tunnel.install() + try await tunnel.start() + } catch { + errorMessage = error.localizedDescription + } + isWorking = false + } + } + + // MARK: - Status Derivation + + /// Whether the tunnel is in an "on" state from the user's perspective — + /// connected, mid-connect, reasserting, or installed-and-enabled. Used to + /// flip the primary action between Enable and Disable. + private var isEnabledState: Bool { + switch tunnel.status { + case .connected, .connecting, .reasserting, .disconnecting: + return true + case .disconnected, .invalid: + return false + @unknown default: + return tunnel.isEnabled + } + } + + private var showsActivity: Bool { + if isWorking { return true } + switch tunnel.status { + case .connecting, .reasserting, .disconnecting: + return true + default: + return false + } + } + + private var statusLabel: String { + if isWorking { return "Installing…" } + switch tunnel.status { + case .invalid: + return "Not configured" + case .disconnected: + return "Off" + case .connecting: + return "Connecting…" + case .connected: + return "Active" + case .reasserting: + return "Reconnecting…" + case .disconnecting: + return "Disconnecting…" + @unknown default: + return tunnel.isEnabled ? "Enabled" : "Off" + } + } + + private var statusColor: Color { + if isWorking { return Theme.warning } + switch tunnel.status { + case .connected: + return Theme.success + case .connecting, .reasserting, .disconnecting: + return Theme.warning + case .invalid: + return Theme.error + case .disconnected: + return Theme.textSecondary + @unknown default: + return Theme.textSecondary + } + } + + // MARK: - Reusable Bits + + private func sectionHeader(icon: String, title: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Theme.accentColor) + Text(title) + .font(.headline) + .foregroundStyle(Theme.textPrimary) + Spacer() + } + } + + private func explainerRow(icon: String, text: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundStyle(Theme.accentColor) + .frame(width: 24) + Text(text) + .font(.subheadline) + .foregroundStyle(Theme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + } +} +#endif diff --git a/Sources/ColumbaApp/Views/Settings/RNodeWizard/RNodeProbeScanner.swift b/Sources/ColumbaApp/Views/Settings/RNodeWizard/RNodeProbeScanner.swift index 694abf40..88fd5d93 100644 --- a/Sources/ColumbaApp/Views/Settings/RNodeWizard/RNodeProbeScanner.swift +++ b/Sources/ColumbaApp/Views/Settings/RNodeWizard/RNodeProbeScanner.swift @@ -17,9 +17,9 @@ import os /// KISS framing + the subset of RNode command bytes the wizard's BLE probe /// needs to verify a peripheral is an RNode. Values are the RNode/KISS -/// protocol constants — source of truth is `app/rnode/IOSRNodeInterface.py`'s -/// `KISS` class (and Android's `rnode_interface.py`), kept byte-identical so -/// the detect handshake interoperates. +/// protocol constants — source of truth is reticulum-swift's `RNodeInterface` +/// (and Android's `rnode_interface.py`), kept byte-identical so the detect +/// handshake interoperates. private enum KISS { static let FEND: UInt8 = 0xC0 } diff --git a/Sources/ColumbaApp/Views/Settings/SettingsView.swift b/Sources/ColumbaApp/Views/Settings/SettingsView.swift index 531a78a0..948089f2 100644 --- a/Sources/ColumbaApp/Views/Settings/SettingsView.swift +++ b/Sources/ColumbaApp/Views/Settings/SettingsView.swift @@ -12,6 +12,10 @@ import RNSAPI import CoreLocation import UIKit #endif +#if ENABLE_NETWORK_EXTENSION +// For NEVPNStatus, used by the background-transport status helpers below. +import NetworkExtension +#endif /// Main settings screen view. /// @@ -44,6 +48,10 @@ struct SettingsView: View { @State private var showNetworkStatus = false @State private var showBLEConnections = false @State private var showDataMigration = false + #if ENABLE_NETWORK_EXTENSION + /// Presents the background-transport explainer / enable sheet. + @State private var showBackgroundTransport = false + #endif @State private var interfaceRepository: InterfaceRepository? /// Persisted across body re-evaluations so showRNodeWizard=true is not lost /// when SettingsView re-renders due to connection status polling changes. @@ -432,6 +440,7 @@ struct SettingsView: View { } .pickerStyle(.segmented) + if vm.backendChangePending { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") @@ -478,14 +487,22 @@ struct SettingsView: View { Spacer() + // Quick toggle for users who've already set this up. + // Enabling installs the VPN profile (which also arms + // on-demand connect) before starting, matching the + // explainer screen's Enable path. The full explainer + + // first-time consent lives behind "Learn more & set up". Toggle("", isOn: Binding( get: { tunnel.isRunning }, set: { newValue in Task { if newValue { + try? await tunnel.install() try? await tunnel.start() } else { - tunnel.stop() + // disable() clears on-demand so iOS won't + // auto-reconnect (a bare stop() would). + try? await tunnel.disable() } } } @@ -500,16 +517,59 @@ struct SettingsView: View { HStack(spacing: 6) { Circle() - .fill(tunnel.isRunning ? Theme.success : Theme.textSecondary) + .fill(backgroundTransportStatusColor(tunnel)) .frame(width: 8, height: 8) - Text(tunnel.isRunning ? "Running" : "Stopped") + Text(backgroundTransportStatusLabel(tunnel)) .font(.caption) - .foregroundStyle(tunnel.isRunning ? Theme.success : Theme.textSecondary) + .foregroundStyle(backgroundTransportStatusColor(tunnel)) + } + + Button { + showBackgroundTransport = true + } label: { + HStack(spacing: 8) { + Image(systemName: "info.circle") + .font(.system(size: 14, weight: .medium)) + Text("Learn more & set up") + .font(.system(size: 15, weight: .medium)) + } + .foregroundStyle(Theme.textPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Theme.backgroundTertiary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) } } .padding(16) .glassCard() + .sheet(isPresented: $showBackgroundTransport) { + BackgroundTransportView(tunnel: tunnel) { + showBackgroundTransport = false + } + } + } + } + + private func backgroundTransportStatusLabel(_ tunnel: TunnelManager) -> String { + switch tunnel.status { + case .connected: return "Running" + case .connecting: return "Connecting…" + case .reasserting: return "Reconnecting…" + case .disconnecting: return "Disconnecting…" + case .invalid: return "Not configured" + case .disconnected: return "Stopped" + @unknown default: return tunnel.isEnabled ? "Enabled" : "Stopped" + } + } + + private func backgroundTransportStatusColor(_ tunnel: TunnelManager) -> Color { + switch tunnel.status { + case .connected: return Theme.success + case .connecting, .reasserting, .disconnecting: return Theme.warning + case .invalid: return Theme.error + case .disconnected: return Theme.textSecondary + @unknown default: return Theme.textSecondary } } #endif diff --git a/Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements b/Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements index 78bc3dbd..fcb9c1d4 100644 --- a/Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements +++ b/Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements @@ -6,6 +6,12 @@ group.network.columba.Columba + + keychain-access-groups + + $(AppIdentifierPrefix)network.columba.Columba.shared + com.apple.developer.networking.networkextension packet-tunnel-provider diff --git a/Sources/ColumbaNetworkExtension/NEReticulumNode.swift b/Sources/ColumbaNetworkExtension/NEReticulumNode.swift new file mode 100644 index 00000000..4691067f --- /dev/null +++ b/Sources/ColumbaNetworkExtension/NEReticulumNode.swift @@ -0,0 +1,1635 @@ +// +// NEReticulumNode.swift +// ColumbaNetworkExtension +// +// Track A5a — the NE-side RNS + LXMF node core (Model B keystone). +// +// A minimal Reticulum + LXMF node that runs INSIDE the Network Extension so the +// NE can complete LXMF delivery itself (persist + notify) while the host app is +// suspended, instead of acting as a dumb TCP pipe back to the app. This is the +// Model B counterpart to the app-side `SwiftRNSBackend`: the node setup +// (transport + router + lxmf.delivery destination + App-Group bridge interface) +// mirrors `SwiftRNSBackend.start()` directly on reticulum-swift / LXMF-swift. +// +// SCOPE (A5a + C3): +// • node setup (transport / router / delivery destination), +// • shared-identity load from the App's keychain group, +// • App-Group GRDB path computation (LXMF-swift owns the store), +// • AppGroupBridgeInterface registration on the transport, +// • live TCP/relay interface registration (Track C3) reading the relay config +// from the App-Group `interfacesKey` (the SAME config the PoC reads), +// • inbound delivery → (LXMF-swift persists) → local notification + DB-changed +// Darwin notification so the app refreshes, +// • app→NE IPC dispatch (A5b) + durable-outbox drain (A5c). +// Follow-up (TODO(C3-followup)): Auto/multicast + non-TCP interface kinds, and +// WiFi↔cellular path-change reconnect parity with the PoC path. +// +// GATING (unified switch, Track C3): activation is guarded behind +// `NEReticulumNode.modelBNodeEnabled`, now a RUNTIME read of the SHARED App-Group +// flag `modelBBackgroundNE` — the SAME key `BackendPreference.modelB` reads, so +// the app (ProxyRnsBackend selection) and the NE (this node) share ONE switch. +// **It DEFAULTS FALSE**: while off, the node is NOT started from `startTunnel` +// and the PoC dumb-pipe (`PacketTunnelProvider`'s NWConnection path) remains the +// shipping fallback — starting the node alongside it would run-conflict +// (double-binding the relay, duplicate delivery). When the user flips the flag +// `true` (opt-in device-test), the node becomes the live delivery path and the +// PoC `applyConfigs()` is skipped (see `PacketTunnelProvider.startTunnel`). +// +// ── COLLISION RULE (HARD — bit us in A0) ───────────────────────────────────── +// This file imports ONLY: Foundation, UserNotifications, ReticulumSwift, +// LXMFSwift. It MUST NOT import RNSAPI or RNSBackendSwift — RNSAPI's Compat layer +// re-declares Identity / Destination / Link / ReticulumTransport / LXMRouter / +// NetworkInterface / etc., and those modules are not even linked into the NE. +// All reticulum-swift / LXMF-swift types below are referenced UNQUALIFIED (only +// ReticulumSwift + LXMFSwift are in scope, so they are unambiguous), matching +// `AppGroupBridgeInterface.swift`. Do NOT add `import Network` / +// `import NetworkExtension` here: in that combination `NWPath` is ambiguous, and +// this file needs neither — the C3 TCP-relay path reads the endpoint as a plain +// Foundation `(String, UInt16)` and hands it to reticulum-swift's `TCPInterface` +// (which owns the socket internally), so no `Network` type ever crosses here. +// +// ── NO-PII CONTRACT ────────────────────────────────────────────────────────── +// All logging goes through `ExtensionDiagLog.log` (never NSLog directly here), +// and carries envelope / metadata only: destination-hash SHORT PREFIXES +// (≤ 8 hex chars), never plaintext, never private-key / full-identity material, +// never host / port / on-device paths. The local notification carries at most a +// short sender-hash prefix as title and a truncated content preview as body. +// + +import Foundation +import UserNotifications +import ReticulumSwift +import LXMFSwift + +/// In-NE Reticulum + LXMF node. Owns a `ReticulumTransport`, an `LXMRouter` (and +/// its GRDB store), the `lxmf.delivery` destination, and the App-Group bridge +/// interface, and turns inbound LXMF deliveries into a persisted message (done by +/// LXMF-swift) plus a local notification + DB-changed Darwin notification. +/// +/// An `actor` so its mutable stack (transport / router / destination) is isolated +/// across the async start/stop lifecycle without manual locking. +actor NEReticulumNode { + + // MARK: - Model B gate + + /// Master gate for the Model B in-NE node. Hardcoded `true`: Model B is the + /// SOLE architecture on the build that compiles the NE in + /// (ENABLE_NETWORK_EXTENSION ⇔ COLUMBA_BACKEND_SWIFT) — the extension exists + /// solely to own this node. Mirrors the app-side `BackendPreference.modelB`, + /// which is likewise build-flag `true`. + /// + /// (Formerly a runtime read of the shared App-Group flag `modelBBackgroundNE`; + /// hardcoding it removed the cross-process flag race that used to leave the NE + /// in sniff mode while the app came up as the proxy → `ipcFailed`. The PoC + /// dumb-pipe it used to gate has since been deleted from `PacketTunnelProvider`.) + static var modelBNodeEnabled: Bool { + return true + } + + // MARK: - Keychain identity coordinates (MUST match the app's A3 code) + // + // The app (ColumbaApp `AppServices` / `IdentityManager`, via RNSAPI's + // `Identity.saveToKeychain(service:account:accessGroup:)`) stores the raw + // 64-byte RNS private-key blob as a `kSecClassGenericPassword` item under + // these exact service / account names, in the SHARED keychain access group + // so this extension can read the SAME identity. We replicate the read here + // (rather than calling app code, which lives in the app target and imports + // RNSAPI) via a direct `SecItemCopyMatching`. + + /// Keychain `kSecAttrService` — matches `AppServices.keychainService`. + private static let keychainService = "com.columba.identity" + /// Keychain `kSecAttrAccount` — matches `AppServices.keychainAccount`. + private static let keychainAccount = "reticulum-identity" + /// Suffix of the shared keychain access group — matches + /// `AppServices.keychainGroupSuffix`. The full group is + /// `.network.columba.Columba.shared`, where the team-id + /// prefix is resolved at runtime (never hardcoded — no deployment PII). + private static let keychainGroupSuffix = "network.columba.Columba.shared" + + // MARK: - Darwin notification posted to the app on new inbound message + // + // The app's `NotificationObserver` (app target, imports RNSAPI) observes this + // exact Darwin notification name and refreshes the message UI when it fires + // (see `ChatsViewModel.onNewMessage`). The app's own inbound path posts it via + // `NotificationObserver.postNewMessage()`. We can't call that type from the NE, + // so we post the identical raw name directly, mirroring how + // `AppGroupBridgeInterface` posts its Darwin notifications. + + /// Must equal `NotificationObserver.newMessageNotification` + /// (`"network.columba.newMessage"`). + private static let newMessageDarwinName = "network.columba.newMessage" + + /// Must equal `NotificationObserver.networkStateChangedNotification`. Posted when + /// BLE/interface state changes (peer connect/disconnect, interface up/down) so the + /// app's status/connection UIs refresh ONCE instead of polling the NE on a 1-2s + /// timer — which produced a ~10/s app<->NE IPC flood. Event-driven, not polled. + private static let networkStateChangedDarwinName = "network.columba.networkStateChanged" + + /// Post the network/BLE-state-changed Darwin notification to the app. + static func postNetworkStateChangedDarwinNotification() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(networkStateChangedDarwinName as CFString), + nil, nil, true + ) + } + + // MARK: - Local-notification identifiers + + /// `UNUserNotificationCenter` request identifier prefix for inbound-message + /// notifications posted by the NE. `fileprivate` so `NEDeliveryDelegate` + /// (a separate type in this file) can read it. + fileprivate static let notificationIdPrefix = "ne.lxmf.inbound." + + // MARK: - Stack (reticulum-swift / LXMF-swift), unqualified per the collision rule + + private var identity: Identity? + private var pathTable: PathTable? + private var transport: ReticulumTransport? + private var router: LXMRouter? + private var deliveryDestination: Destination? + private var bridge: AppGroupBridgeInterface? + /// Model B BLE: reticulum-swift's `BLEInterface` runs here, driven by an + /// `AppGroupBLEDriver` that marshals the `BLEDriver` seam to the app (which + /// owns CoreBluetooth). Retained so they outlive `start()`. + private var bleSeamTransport: AppGroupBLESeamTransport? + private var bleInterface: BLEInterface? + + /// Model B RNode: reticulum-swift's `RNodeInterface` runs here over an App-Group + /// seam transport (the CoreBluetooth NUS radio runs in the app). Rebuilt when the + /// app-written `RNodeSeamConfig` changes. + private var rnodeInterface: RNodeInterface? + /// The detached `addInterface` (→ `RNodeInterface.connect()`, which starts the + /// seam wire + its `rnodeSeamA2N` Darwin observer) is kept off the setup critical + /// path. Track it so `setupRNodeInterface()` (reconfig) and `stop()` can cancel a + /// prior one — otherwise a late `connect()` registers a second observer on an + /// orphaned/superseded interface and steals KISS frames. Mirrors `announceTask`. + private var rnodeAddInterfaceTask: Task? + private var rnodeConfigObserverRegistered = false + + /// Model B propagation: the app writes the selected propagation node + sync settings + /// to `PropagationSeamConfig`; the NE wires them onto `router` and runs the periodic + /// sync here (the in-NE router owns delivery, so the app can't sync directly). + private var propagationObserversRegistered = false + private var propagationSyncTask: Task? + private var syncInFlight = false + + /// Retained so the @MainActor delegate isn't deallocated while the router + /// holds it weakly. + private var delegate: NEDeliveryDelegate? + + /// Periodic self-announce loop. Model B: the NE owns `lxmf.delivery` and the + /// app may be suspended, so the node must announce its OWN delivery destination + /// (on start, once the relay connects, then on the user's interval) — otherwise + /// peers/transport nodes never learn a path to it. Cancelled in `stop()`. + private var announceTask: Task? + + /// `true` once `start()` has fully wired the node. Guards against double-start. + private(set) var isRunning = false + + /// Synchronous re-entrancy latch for `start()` — set before its first `await` so + /// concurrent start attempts (the ProxyRnsBackend `.start` retry storm) can't race + /// past the `isRunning` guard during the long init. See `start()`. + private var isStarting = false + + /// Set by `stop()` even when `isRunning` is still false (i.e. a stop arriving + /// mid-`start()`, before `isRunning = true`). `start()` checks it at the end and + /// honors the stop with a full teardown, so the node doesn't finish initializing + /// into an orphaned-but-observing state. See `start()` / `stop()`. + private var stopRequested = false + + init() {} + + // MARK: - Lifecycle + + /// Bring up the in-NE Reticulum + LXMF node. Mirrors `SwiftRNSBackend.start()`. + /// + /// Returns `false` (a no-op) when the shared identity can't be read yet (the + /// app hasn't created it) — the caller should treat that as "not ready", + /// never as a crash. Throws only on a genuine setup failure (router/db open). + /// + /// NOTE: callers in `startTunnel` MUST gate this behind + /// `NEReticulumNode.modelBNodeEnabled` (the runtime App-Group flag, default + /// `false`) and, when active, skip the PoC `applyConfigs()` so the relay isn't + /// double-bound — see the type doc and `PacketTunnelProvider.startTunnel`. + @discardableResult + func start() async throws -> Bool { + guard !isRunning else { return true } + // Re-entrancy guard. Actors suspend at EVERY `await`, and `isRunning = true` + // is not set until the very end of start() — after 10+ awaits (GRDB/LXMRouter + // open, path-table, registerDeliveryDestination, addInterface, …). The trigger + // is real: PacketTunnelProvider fires `Task { start() }` and the app's + // ProxyRnsBackend then retries `.start` up to 30×/400ms (each a fresh + // `Task { start() }`); since init takes well over 400ms, a second call would + // slip past `!isRunning` mid-init and open the same App-Group GRDB twice + + // register a duplicate lxmf.delivery destination on a second transport, + // clobbering refs and leaking the orphan. `isStarting` is claimed + // SYNCHRONOUSLY here (before the first await); `defer` releases it so a failed + // start (e.g. identity not yet created) can be retried. Concurrent entrants + // get `false` until the first start sets `isRunning`. + guard !isStarting else { return false } + isStarting = true + stopRequested = false // fresh start cycle; a prior stop doesn't cancel it + defer { isStarting = false } + + // 1. Shared identity from the app's keychain group. Absent ⇒ app hasn't + // created one yet; bail cleanly (no notification, no crash). + guard let id = Self.loadSharedIdentity() else { + ExtensionDiagLog.log("NEReticulumNode: shared identity unavailable — not starting (app has not created it yet)") + return false + } + self.identity = id + + // 2. App-Group GRDB store path (LXMF-swift owns the store at this path). + let dbPath = Self.appGroupLXMFDatabasePath(identityHashHex: id.hexHash) + ExtensionDiagLog.log("NEReticulumNode: starting (identity=\(Self.hashPrefix(id.hexHash)))") + + // 3. Path table + transport (mirror SwiftRNSBackend.start step 2). + // Persist learned routes to the App-Group container so they survive NE + // restarts — Python RNS persists its `destination_table` the same way; without + // it every boot starts routeless and can't reach a peer until it re-announces. + // `PathTable(databasePath:)` opens this DB NE-safe on iOS (WAL + busy_timeout + + // data-protection CompleteUntilFirstUserAuthentication on the db/-wal/-shm), + // matching the LXMF store. Degrade to in-memory if the store can't be opened. + let pathDbPath = Self.appGroupPathTableDatabasePath(identityHashHex: id.hexHash) + let pt: PathTable + if let persistent = try? PathTable(databasePath: pathDbPath) { + pt = persistent + } else { + ExtensionDiagLog.log("NEReticulumNode: path table persistence unavailable — using in-memory") + pt = PathTable() + } + self.pathTable = pt + let tp = ReticulumTransport(pathTable: pt) + self.transport = tp + await tp.registerPathRequestHandler() + + // 4. LXMRouter — owns its own LXMF GRDB store at `dbPath` and persists + // validated inbound messages automatically before the delegate fires + // (mirror SwiftRNSBackend.start step 3). + let rt = try await LXMRouter(identity: id, databasePath: dbPath) + self.router = rt + + // 5. lxmf.delivery destination + ratchets (mirror step 4). + let dest = Destination( + identity: id, appName: "lxmf", aspects: ["delivery"], type: .single, direction: .in + ) + self.deliveryDestination = dest + await tp.registerDestination(dest) + let ratchetPath = Self.appGroupRatchetStoragePath(identityHashHex: id.hexHash) + try await dest.enableRatchets(storagePath: ratchetPath) + + // 6. Wire router → transport + ratchets + delivery + delegate (mirror step 5). + await rt.setTransport(tp) + await rt.setRatchetManager(dest.ratchetManager) + try await rt.registerDeliveryDestination(dest) + let d = await MainActor.run { NEDeliveryDelegate(databasePath: dbPath) } + self.delegate = d + await rt.setDelegate(d) + + // 7. Register the App-Group bridge interface so the NE's transport is + // reachable over the app's radios (BLE mesh / RNode) via the IPC + // queues. `hwMtu` here is a conservative placeholder; a follow-up can + // supply the active radio's negotiated MTU (TODO(C3-followup)). + // The bridge `connect()`s itself when `addInterface` runs it. + let br = AppGroupBridgeInterface( + appGroupIdentifier: appGroupIdentifier, + targetRadio: .bleMesh, + hwMtu: Self.bridgePlaceholderHWMTU + ) + self.bridge = br + do { + try await tp.addInterface(br) + } catch { + // Non-fatal: the node can still deliver over the TCP relay (below). + ExtensionDiagLog.log("NEReticulumNode: AppGroupBridge addInterface failed (non-fatal): \(String(describing: error))") + } + + // Model B BLE mesh: run reticulum-swift's `BLEInterface` here — it owns + // fragmentation, per-peer `BLEPeerInterface` spawning, and the identity + // handshake — driven by an `AppGroupBLEDriver` that marshals the `BLEDriver` + // seam to the app process, which runs the real `CoreBluetoothBLEDriver` + // (the NE sandbox can't drive CoreBluetooth). `BLEInterface` spawns a + // `BLEPeerInterface` per connected peer; register/unregister those on the + // transport via the peer callbacks. Supersedes the AppGroupBridge's BLE-mesh + // role above; retire that once this path is validated on-device (TODO). + ExtensionDiagLog.log("NEReticulumNode: BLE setup begin (identity=\(id.hash.count)B)") + // `BLEInterface` preconditions a 16-byte identity — guard so a bad length + // can't crash the NE on start. + if id.hash.count == 16 { + let bleTx = AppGroupBLESeamTransport(role: .networkExtension) + bleTx.start() + self.bleSeamTransport = bleTx + let bleCfg = InterfaceConfig( + id: "ne-ble-mesh", name: "BLE Mesh", type: .ble, + enabled: true, mode: .full, host: "", port: 0 + ) + let bleIface = BLEInterface( + config: bleCfg, + driver: AppGroupBLEDriver(transport: bleTx), + transportIdentity: id.hash + ) + // `Task.detached` (NOT a bare `Task {}`): a bare task inherits this + // node-actor's executor, which is kept continuously busy servicing the + // app's proxy IPC — so the registration would starve and never run. + await bleIface.setPeerCallbacks( + onPeerAdded: { peer in + NEReticulumNode.postNetworkStateChangedDarwinNotification() + Task.detached { try? await tp.addInterface(peer) } + }, + onPeerRemoved: { peerId in + NEReticulumNode.postNetworkStateChangedDarwinNotification() + Task.detached { await tp.removeInterface(id: peerId) } + } + ) + self.bleInterface = bleIface + ExtensionDiagLog.log("NEReticulumNode: BLE interface built; registering off the critical path") + // OFF THE CRITICAL PATH: `addInterface` → `BLEInterface.connect()` must + // never gate the node's delivery bring-up (the TCP relay below). Even if + // BLE setup stalls, the node still delivers over the relay. + Task.detached { + do { + try await tp.addInterface(bleIface) + ExtensionDiagLog.log("NEReticulumNode: BLE mesh interface registered (driver seam)") + } catch { + ExtensionDiagLog.log("NEReticulumNode: BLE addInterface failed (non-fatal): \(String(describing: error))") + } + } + } else { + ExtensionDiagLog.log("NEReticulumNode: BLE skipped — identity not 16 bytes (\(id.hash.count))") + } + + // C3: live TCP / relay interface. Read the relay config from the SAME + // App-Group UserDefaults the PoC dumb-pipe reads + // (`SharedDefaultsConstants.interfacesKey`), construct a reticulum-swift + // `TCPInterface` to it, and register it on the transport. `addInterface` + // sets the delegate + `connect()`s the interface itself (same as the + // AppGroupBridge above), so the node OWNS this relay connection. When the + // node is active `PacketTunnelProvider` skips its PoC `applyConfigs()` so + // there's no double-bound relay socket (see `startTunnel`). Mirrors + // `SwiftRNSBackend.buildAndAdd`'s `.tcpClient` case. + // + // SCOPE: only the TCP (`tcpClient`) relay is wired live here. Auto / + // multicast and the other interface kinds the app supports are a + // follow-up (TODO(C3-followup)); the AppGroupBridge above already carries + // the app's radios (BLE mesh / RNode) into the node. + // iOS NE egress fix (see reticulum-swift port-deviations.md): prohibit + // the virtual (.other = our own utun packet tunnel) interface on the + // relay's NWConnection so it egresses a physical interface (wifi/ + // cellular) and actually reaches the relay — a stock NWParameters.tcp + // connection created inside the provider reports a phantom `.ready` but + // produces no SYN/socket on the relay host (verified on-device via + // tcpdump+lsof), black-holed in our own tunnel. Process-global; set + // before the interface connects. + TCPTransport.bypassTunnelEgress = true + + if let tcp = Self.loadTCPRelayConfig() { + do { + let cfg = InterfaceConfig( + id: "ne-tcp-relay", + name: "NE TCP Relay", + type: .tcp, + enabled: true, + mode: .full, + host: tcp.host, + port: tcp.port + ) + let tcpIface = try TCPInterface(config: cfg) + try await tp.addInterface(tcpIface) + // NO-PII: never log tcp.host / tcp.port (the relay endpoint). + ExtensionDiagLog.log("NEReticulumNode: TCP relay interface registered") + } catch { + // Non-fatal: the node can still deliver over the AppGroupBridge + // (radio) even if the relay socket can't be brought up. + ExtensionDiagLog.log("NEReticulumNode: TCP relay addInterface failed (non-fatal): \(String(describing: error))") + } + } else { + ExtensionDiagLog.log("NEReticulumNode: no TCP relay configured — node running on AppGroupBridge only") + } + + // TODO(C3-followup): reconnect parity. The PoC path + // (`PacketTunnelProvider`) drives capped-backoff reconnects + a path + // monitor for its relay socket; reticulum-swift's `TCPInterface` has its + // own `ExponentialBackoff` auto-reconnect, so the node's relay self-heals, + // but it does NOT yet react to a WiFi↔cellular path change the way the PoC + // path-monitor does. Acceptable for device-testing; wire a path-change + // rebind here if interface switches prove flaky under Model B. + + // Model B RNode: run reticulum-swift's `RNodeInterface` (KISS framing) here, + // over an `AppGroupRNodeSeamTransport` that marshals the raw serial stream to + // the app's real `BLETransport` (NUS radio) — the NE sandbox can't drive + // CoreBluetooth. Built from the App-Group `RNodeSeamConfig` the app writes; + // rebuilt when that config changes (enable / disable / re-tune). + await setupRNodeInterface() + startRNodeConfigObserver() + + isRunning = true + ExtensionDiagLog.log("NEReticulumNode: started (delivery dest=\(Self.hashPrefix(dest.hexHash)))") + + // 7b. Self-announce. In Model B the NE owns `lxmf.delivery` and the app may + // be suspended, so the node announces its OWN delivery destination — + // peers/transport nodes learn the path from this. Wait for the relay to + // connect (so the first announce traverses TCP, not just the radio + // bridge), then re-announce on the user's configured interval. + // + // 7c. Re-announce whenever the relay (re)connects. A relay that restarted + // loses its path table, so without this our delivery dest would be + // unreachable until the periodic interval elapsed. Mirrors the app's + // AutoAnnounceManager "on TCP reconnect" trigger; reticulum-swift + // rate-limits announces per interface, so a flapping link can't spam. + await tp.setOnInterfaceConnected { [weak self] interfaceId in + guard interfaceId == "ne-tcp-relay" else { return } + await self?.onRelayReconnected() + } + startAnnounceScheduler() + + // Model B propagation: wire the app-selected propagation node onto the router + + // run periodic sync here (the in-NE router owns delivery; the app can't sync + // directly). Built from the App-Group `PropagationSeamConfig`; re-applied when + // that config changes, and synced on demand via the sync-now Darwin notification. + await applyPropagationConfig() + startPropagationObservers() + startPropagationSyncScheduler() + + // 8. A5c — drain the durable App-Group outbox. While the NE was down the + // app persisted any outbound LXMF sends here (ProxyRnsBackend on IPC + // failure); now that transport + router + delivery destination are up, + // replay each one through the same `sendLxmfForIPC(...)` path a live IPC + // send would take. Re-sending is safe: LXMF-swift dedups inbound by + // message hash receiver-side, and we only enqueue sends the NE never + // accepted. Failures are logged + skipped (the rest still drain) — we do + // NOT re-append, because `drainAll()` already cleared the file and a + // `handleOutbound` throw is a pack/sign error that a verbatim retry on + // the next start would just hit again (an unbounded requeue loop). This + // is the simpler correct option; the cost is dropping an entry that + // cannot be packed at all, which the optimistic UI row will surface as + // not-delivered. Run after `isRunning = true` so the node is observably + // started even if the drain is slow. + await drainOutbox() + + // Honor a stop() that arrived while we were initializing (it was dropped by + // stop()'s `guard isRunning` because `isRunning` wasn't set yet). Now that the + // node is fully up — observers + schedulers + seam transports registered — + // run the full teardown so nothing is left orphaned + observing. + if stopRequested { + ExtensionDiagLog.log("NEReticulumNode: stop requested during start — tearing down") + await stop() + return false + } + + return true + } + + // MARK: - Model B RNode + + /// (Re)build the NE-side `RNodeInterface` from the app-written `RNodeSeamConfig`. + /// Idempotent: tears down any existing RNode interface first, then rebuilds if a + /// config is present (enabled) or leaves it torn down if absent (disabled). + private func setupRNodeInterface() async { + guard let tp = transport else { return } + + // Cancel any in-flight detached addInterface from a prior setup BEFORE tearing + // down the existing interface — otherwise that task's late connect() would + // re-register a second rnodeSeamA2N observer on the superseded interface. + rnodeAddInterfaceTask?.cancel() + rnodeAddInterfaceTask = nil + + // Tear down any existing RNode interface (reload / disable). + if let existing = rnodeInterface { + await existing.disconnect() + await tp.removeInterface(id: existing.id) + rnodeInterface = nil + NEReticulumNode.postNetworkStateChangedDarwinNotification() + } + + guard let cfg = RNodeSeamConfig.loadFromAppGroup() else { + ExtensionDiagLog.log("NEReticulumNode: no RNode configured") + return + } + + do { + let ifaceCfg = InterfaceConfig( + id: "ne-rnode", name: "RNode", type: .rnode, + enabled: true, mode: .full, host: cfg.deviceName, port: 0 + ) + let radio = RadioConfig( + frequency: cfg.frequency, + bandwidth: cfg.bandwidth, + txPower: cfg.txPower, + spreadingFactor: cfg.spreadingFactor, + codingRate: cfg.codingRate, + stAlock: cfg.stAlock, + ltAlock: cfg.ltAlock + ) + let iface = try RNodeInterface(config: ifaceCfg, transportFactory: { deviceName in + AppGroupRNodeSeamTransport(deviceName: deviceName) + }) + try await iface.configureRadio(radio) + rnodeInterface = iface + ExtensionDiagLog.log("NEReticulumNode: RNode interface built (device set, registering off critical path)") + NEReticulumNode.postNetworkStateChangedDarwinNotification() + // OFF THE CRITICAL PATH: addInterface → RNodeInterface.connect() must not gate + // setup. Tracked + cancellable: if stop() or a reconfig supersedes this + // interface while we're still connecting, the registered seam observer would + // otherwise orphan and steal frames — so check cancellation and UNDO the + // connect (disconnect → wire.stop() removes the observer) on the captured + // `tp`/`iface` (still valid even after the actor nil'd its own refs). + rnodeAddInterfaceTask = Task.detached { + guard !Task.isCancelled else { return } + do { + try await tp.addInterface(iface) + if Task.isCancelled { + await iface.disconnect() + await tp.removeInterface(id: iface.id) + ExtensionDiagLog.log("NEReticulumNode: RNode addInterface superseded — rolled back") + return + } + ExtensionDiagLog.log("NEReticulumNode: RNode interface registered (seam transport)") + } catch { + ExtensionDiagLog.log("NEReticulumNode: RNode addInterface failed (non-fatal): \(String(describing: error))") + } + } + } catch { + ExtensionDiagLog.log("NEReticulumNode: RNode setup failed (non-fatal): \(String(describing: error))") + } + } + + /// Observe the app's `rnodeConfigChanged` Darwin notification → rebuild the RNode + /// interface so enabling / disabling / re-tuning the RNode takes effect without a + /// tunnel restart. + private func startRNodeConfigObserver() { + guard !rnodeConfigObserverRegistered else { return } + rnodeConfigObserverRegistered = true + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + center, observer, + { _, observer, _, _, _ in + guard let observer else { return } + let node = Unmanaged.fromOpaque(observer).takeUnretainedValue() + Task { await node.setupRNodeInterface() } + }, + SharedDefaultsConstants.rnodeConfigChangedNotificationName as CFString, + nil, .deliverImmediately + ) + } + + // MARK: - Model B propagation (LXMF) + + /// Wire the app-selected propagation node onto the router (Model B). The app writes + /// `PropagationSeamConfig`; we apply it on start + whenever it changes. NO-PII: logs + /// a short dest-hash prefix only. + private func applyPropagationConfig() async { + guard let router else { return } + guard let cfg = PropagationSeamConfig.loadFromAppGroup() else { + await router.setOutboundPropagationNode(nil) + await router.setPropagationStampCost(0) + ExtensionDiagLog.log("NEReticulumNode: no propagation node configured") + return + } + await router.setOutboundPropagationNode(cfg.propagationNodeHash) + await router.setPropagationStampCost(cfg.stampCost) + let pn = cfg.propagationNodeHash.map { (h: Data) in + Self.hashPrefix(h.map { String(format: "%02x", $0) }.joined()) + } ?? "nil" + ExtensionDiagLog.log("NEReticulumNode: PN set (\(pn)) stamp=\(cfg.stampCost) interval=\(Int(cfg.effectiveSyncInterval))s periodic=\(cfg.periodicSyncEnabled)") + } + + /// Observe the app's propagation Darwin notifications: config-changed (re-apply + + /// restart the sync loop) and sync-now (run one immediate sync). + private func startPropagationObservers() { + guard !propagationObserversRegistered else { return } + propagationObserversRegistered = true + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + center, observer, + { _, observer, _, _, _ in + guard let observer else { return } + let node = Unmanaged.fromOpaque(observer).takeUnretainedValue() + Task { + await node.applyPropagationConfig() + await node.startPropagationSyncScheduler() // interval/enabled may have changed + } + }, + SharedDefaultsConstants.propagationConfigChangedNotificationName as CFString, + nil, .deliverImmediately + ) + CFNotificationCenterAddObserver( + center, observer, + { _, observer, _, _, _ in + guard let observer else { return } + let node = Unmanaged.fromOpaque(observer).takeUnretainedValue() + Task { await node.runOneSyncFireAndForget() } + }, + SharedDefaultsConstants.propagationSyncNowNotificationName as CFString, + nil, .deliverImmediately + ) + } + + /// Periodic propagation sync loop. Mirrors `startAnnounceScheduler`: wait for the + /// relay, then sync every `syncInterval`. A SEPARATE task from the announce loop; + /// each sync is fire-and-forget (see `runOneSyncFireAndForget`) so the 120s + /// SYNC_TIMEOUT never blocks the loop or IPC. + private func startPropagationSyncScheduler() { + propagationSyncTask?.cancel() + propagationSyncTask = Task { [weak self] in + guard let self else { return } + _ = await self.waitForRelayConnected(timeoutMs: 15_000) + while !Task.isCancelled { + guard let cfg = PropagationSeamConfig.loadFromAppGroup(), + cfg.propagationNodeHash != nil, + cfg.periodicSyncEnabled else { + // No PN / periodic disabled → idle-poll; the config observer + // restarts this task when the selection or interval changes. + do { try await Task.sleep(for: .seconds(300)) } catch { return } + continue + } + // Floor the cadence so a 0 / near-0 syncInterval can't busy-loop this + // task (continuous IPC + relay traffic); see PropagationSeamConfig. + do { try await Task.sleep(for: .seconds(cfg.effectiveSyncInterval)) } catch { return } + if Task.isCancelled { return } + await self.runOneSyncFireAndForget() + } + } + } + + /// Run one propagation sync without ever blocking the caller/loop: a detached child + /// task does the work, a 150s watchdog (> the 120s SYNC_TIMEOUT) cancels a wedged + /// transfer, and `syncInFlight` prevents overlap. + private func runOneSyncFireAndForget() async { + guard isRunning, !syncInFlight else { return } + // Claim the in-flight slot SYNCHRONOUSLY (before the first await). The original + // order set `syncInFlight = true` only AFTER `await waitForRelayConnected`, so a + // second call — the periodic scheduler racing a manual "Sync Now" Darwin trigger, + // or two sync-now notifications in quick succession — could slip past the + // `!syncInFlight` guard during that 2s await and start an OVERLAPPING sync, the + // exact thing the flag exists to prevent. `defer` releases it on every exit + // (including the relay-down early return below). + syncInFlight = true + defer { syncInFlight = false } + guard await waitForRelayConnected(timeoutMs: 2_000) else { + ExtensionDiagLog.log("NEReticulumNode: propagation sync skipped — relay down") + return + } + guard let router else { return } + ExtensionDiagLog.log("NEReticulumNode: propagation sync starting") + let work = Task.detached { try? await router.syncFromPropagationNode() } + let watchdog = Task.detached { + try? await Task.sleep(for: .seconds(150)) + if !Task.isCancelled { work.cancel() } + } + _ = await work.value + watchdog.cancel() + } + + /// Replay every entry the app persisted to the durable App-Group outbox while + /// the NE was down (A5c). Called at the end of `start()`. NO-PII: logs counts + /// and dest-hash short prefixes only. + private func drainOutbox() async { + let pending = OutboxQueue().drainAll() + guard !pending.isEmpty else { return } + ExtensionDiagLog.log("NEReticulumNode: draining outbox (\(pending.count) pending send(s))") + + var sent = 0 + var failed = 0 + for entry in pending { + // `sendLxmfForIPC` does not throw (it returns a `ProxySendOutcome`); + // treat anything other than `.queued` as a failed replay for the count. + let outcome = await sendLxmfForIPC( + destHashHex: entry.destHashHex, + content: entry.content, + method: entry.method, + fieldsData: entry.fieldsData ?? Data() + ) + if outcome.kind == .queued { + sent += 1 + } else { + failed += 1 + ExtensionDiagLog.log("NEReticulumNode: outbox replay not queued (dest=\(Self.hashPrefix(entry.destHashHex)), kind=\(outcome.kind.rawValue)) — dropped") + } + } + ExtensionDiagLog.log("NEReticulumNode: outbox drain complete (queued=\(sent), dropped=\(failed))") + } + + /// Tear the node down. Mirrors `SwiftRNSBackend.stop()`'s teardown (drop the + /// stack so the actors deinit). Best-effort and idempotent. + func stop() async { + // Record the stop request unconditionally — even if `isRunning` is still + // false because a `start()` is mid-flight (between `isStarting` and the final + // `isRunning = true`). Without this the guard below would silently DROP the + // stop, `start()` would finish + register Darwin observers, and the orphaned + // actor (PacketTunnelProvider already nil'd its ref) would be deallocated with + // live `passUnretained(self)` observers → use-after-free. `start()` checks this + // at the end and runs a full teardown. (The `deinit` is a final safety net.) + stopRequested = true + guard isRunning else { return } + isRunning = false + announceTask?.cancel() + announceTask = nil + propagationSyncTask?.cancel() + propagationSyncTask = nil + + // Remove EVERY Darwin observer this node registered directly with + // `Unmanaged.passUnretained(self)` — the RNode-config observer + // (startRNodeConfigObserver) and BOTH propagation observers + // (startPropagationObservers: config-changed + sync-now). They hold a RAW, + // non-owning pointer to `self`. PacketTunnelProvider builds a fresh node per + // `startTunnel` but iOS REUSES the process, so any surviving observer would, + // on the next `rnodeConfigChanged`/`propagationConfigChanged`/`syncNow` + // notification, call `fromOpaque(...).takeUnretainedValue()` on the deinited + // actor — a use-after-free. `RemoveEveryObserver` clears all of them in one + // call (and is robust to any future self-registered observer); reset the + // guards so a subsequent `start()` re-registers cleanly. (The BLE and RNode + // SEAM observers are owned by their seam wires, torn down separately below.) + let darwinCenter = CFNotificationCenterGetDarwinNotifyCenter() + CFNotificationCenterRemoveEveryObserver(darwinCenter, Unmanaged.passUnretained(self).toOpaque()) + rnodeConfigObserverRegistered = false + propagationObserversRegistered = false + + if let br = bridge { + await br.disconnect() + } + // Tear down the BLE seam. Without this the seam transport's Darwin observer + // (and the AppGroupBLEDriver behind it) LEAK across a same-process NE restart + // (e.g. a VPN reconnect — PacketTunnelProvider makes a new node per startTunnel + // but iOS reuses the process): the orphaned observer keeps draining the app→NE + // queue (readAllAndClear) and STEALS discovery/connection/fragment events from + // the freshly-started node, which then spawns its BLE interface but never sees a + // peer. Stop the transport (removes the observer + ends the driver inbound loop) + // and drop the interface. Do NOT `disconnect()` the interface — that `.shutdown`s + // the app's shared CoreBluetooth radio over the seam, which must outlive the NE. + bleSeamTransport?.stop() + bleSeamTransport = nil + bleInterface = nil + + // Tear down the RNode seam the same way — identical frame-stealing / dangling + // hazard. `rnodeInterface` wraps an `AppGroupRNodeSeamTransport` whose + // `AppGroupRNodeSeamWire` registers a `rnodeSeamA2N` Darwin observer in start(); + // `disconnect()` → wire.stop() → CFNotificationCenterRemoveObserver. Without it + // the orphaned observer drains the app→NE RNode queue and steals KISS frames from + // the freshly-started node on NE restart, and dangles once the wire deinits. Do + // this while `transport` is still alive so `removeInterface` can detach it. + // Cancel any in-flight detached addInterface first so a late connect() can't + // re-register the seam observer after we tear the interface down (its own + // post-connect cancellation check then rolls back via the captured refs). + rnodeAddInterfaceTask?.cancel() + rnodeAddInterfaceTask = nil + if let ri = rnodeInterface { + await ri.disconnect() + await transport?.removeInterface(id: ri.id) + rnodeInterface = nil + } + + router = nil + transport = nil + pathTable = nil + deliveryDestination = nil + bridge = nil + delegate = nil + identity = nil + ExtensionDiagLog.log("NEReticulumNode: stopped") + } + + /// Final safety net for the node's own Darwin observers. `stop()` + the + /// `stopRequested` path normally remove them, but if the actor is ever + /// deallocated without a clean stop (e.g. an orphaning path we didn't foresee), + /// the `Unmanaged.passUnretained(self)` registrations would dangle and the next + /// config notification would fire `fromOpaque(...)` on freed memory. Removing + /// them here (the same idiom as `NotificationObserver.deinit`) makes that + /// impossible: `self` is still a valid pointer during deinit, and + /// `RemoveEveryObserver` clears every registration made with it. + deinit { + CFNotificationCenterRemoveEveryObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque() + ) + } + + // MARK: - Shared identity (replicates the app's A3 keychain read) + + /// Read the raw 64-byte RNS private key from the SHARED keychain access group + /// (the same `service` / `account` / `accessGroup` the app's A3 code writes) + /// and construct a `ReticulumSwift.Identity` from it. Returns `nil` when no + /// item is present (app hasn't created the identity yet) or the access group + /// can't be resolved (e.g. unsigned/simulator build with no entitlement). + /// + /// Replicates RNSAPI's `Identity.loadFromKeychain(service:account:accessGroup:)` + /// query directly — we must NOT import RNSAPI here (collision rule), and that + /// overload lives in the RNSAPI Compat layer. + static func loadSharedIdentity() -> Identity? { + guard let accessGroup = sharedKeychainAccessGroup() else { + ExtensionDiagLog.log("NEReticulumNode: shared keychain access group unresolved (unsigned build?) — cannot load identity") + return nil + } + + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + query[kSecAttrAccessGroup as String] = accessGroup + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let data = item as? Data else { + ExtensionDiagLog.log("NEReticulumNode: keychain item present but not Data — identity load failed") + return nil + } + do { + return try Identity(privateKeyBytes: data) + } catch { + ExtensionDiagLog.log("NEReticulumNode: identity bytes rejected by Identity(privateKeyBytes:): \(String(describing: error))") + return nil + } + case errSecItemNotFound: + return nil + default: + // Log the OSStatus code only (a small integer — no PII). + ExtensionDiagLog.log("NEReticulumNode: keychain read failed (OSStatus=\(status))") + return nil + } + } + + /// The shared keychain access group, resolved at runtime so the team-id + /// prefix isn't hardcoded (no deployment PII). Mirrors + /// `AppServices.sharedKeychainAccessGroup()`. Returns `nil` on unsigned / + /// simulator builds where the entitlement isn't enforced. + private static func sharedKeychainAccessGroup() -> String? { + // Prefer the group the APP resolved + shared via the App Group. The in-NE + // keychain probe (below) is unreliable when the device is locked — exactly + // when background delivery must run — and on first NE start before the app + // has launched. The app (running unlocked) resolves it once and writes it + // here, so the NE doesn't have to probe. + if let shared = UserDefaults(suiteName: appGroupIdentifier)? + .string(forKey: "resolvedSharedKeychainGroup"), !shared.isEmpty { + return shared + } + guard let prefix = keychainAccessGroupPrefix() else { return nil } + return "\(prefix).\(keychainGroupSuffix)" + } + + /// Resolve the app-identifier (team-id) prefix by reading the access group + /// the system assigns to a fresh generic-password probe item (the standard + /// "bundle seed id" probe). Mirrors `AppServices.keychainAccessGroupPrefix()`. + private static func keychainAccessGroupPrefix() -> String? { + let probe: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "columba.bundleSeedProbe", + kSecAttrService as String: "columba.bundleSeedProbe", + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + var status = SecItemCopyMatching(probe as CFDictionary, &result) + if status == errSecItemNotFound { + status = SecItemAdd(probe as CFDictionary, &result) + } + // The probe item exists ONLY to read the system-assigned access group; delete it + // now so it doesn't accumulate in the keychain for the install's lifetime (mirror + // of AppServices.keychainAccessGroupPrefix). Re-resolution re-adds it cheaply. + SecItemDelete([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "columba.bundleSeedProbe", + kSecAttrService as String: "columba.bundleSeedProbe", + ] as CFDictionary) + guard status == errSecSuccess, + let attrs = result as? [String: Any], + let group = attrs[kSecAttrAccessGroup as String] as? String, + let prefix = group.components(separatedBy: ".").first, + !prefix.isEmpty else { + return nil + } + return prefix + } + + // MARK: - App-Group paths + // + // The app's `AppServices.grdbDatabaseFilePath(for:)` roots the canonical LXMF + // store at `/Columba/python-/lxmf-swift.db`. + // That directory is PROCESS-LOCAL (the app and the NE have different + // Application Support containers), so the NE cannot reach the app's copy. + // For Model B the canonical store lives in the SHARED App-Group container so + // BOTH processes open the same GRDB file; the per-identity `python-` + // subdirectory layout and the `lxmf-swift.db` filename are preserved exactly + // so `MessageRepository(grdbPath:)` resolves to the identical file. This is + // the path the app converges onto under the App-Group-sharing work (A2 / the + // LXMF-swift `feat/lxmfdb-appgroup-sharing` branch); A5a writes here so NE + // deliveries land in the shared store the UI reads. + + /// Path to the App-Group-shared canonical `lxmf-swift.db` for `identityHashHex` + /// (the raw identity hash — NOT the lxmf.delivery destination hash, matching + /// `AppServices.grdbDatabaseFilePath(for:)`). Delegates to the SHARED + /// `AppGroupPaths` helper so the NE and the app provably compute the identical + /// path (the whole point of A2). Falls back to the NE's temporary directory if + /// the App-Group container is unavailable (shouldn't happen in production — + /// same fallback posture as `SharedFrameQueue`). + static func appGroupLXMFDatabasePath(identityHashHex: String) -> String { + if let url = AppGroupPaths.lxmfDatabaseURL(identityHashHex: identityHashHex) { + return url.path + } + return tmpFallbackDirectory(named: "python-\(identityHashHex)") + .appendingPathComponent("lxmf-swift.db").path + } + + /// Path to the App-Group-shared SQLite path table (learned routes) for + /// `identityHashHex`, co-located with the LXMF store + ratchets. Persisting + /// paths across NE restarts mirrors Python RNS's on-disk `destination_table`; + /// without it every boot starts routeless and can't reach a peer until it + /// re-announces. + static func appGroupPathTableDatabasePath(identityHashHex: String) -> String { + if let url = AppGroupPaths.perIdentityDirectoryURL(identityHashHex: identityHashHex) { + return url.appendingPathComponent("pathtable.db").path + } + return tmpFallbackDirectory(named: "python-\(identityHashHex)") + .appendingPathComponent("pathtable.db").path + } + + /// Path to the App-Group-shared ratchet storage for `identityHashHex`, + /// alongside the GRDB store so all per-identity state co-locates in the shared + /// container. (`SwiftRNSBackend` keeps ratchets next to its db under + /// `configDir`; we mirror that under the App-Group `python-` dir.) + /// Delegates to the SHARED `AppGroupPaths` helper (see above). + static func appGroupRatchetStoragePath(identityHashHex: String) -> String { + if let url = AppGroupPaths.ratchetStorageURL(identityHashHex: identityHashHex) { + return url.path + } + return tmpFallbackDirectory(named: "python-\(identityHashHex)") + .appendingPathComponent("ratchets").path + } + + /// Resolve (creating if needed) `/Columba//`, used only when the + /// App-Group container is unavailable (shouldn't happen in production with the + /// App-Group entitlement present). No path is logged — NO-PII. + private static func tmpFallbackDirectory(named name: String) -> URL { + ExtensionDiagLog.log("NEReticulumNode: App-Group container unavailable — falling back to tmp for the LXMF store") + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("Columba", isDirectory: true) + .appendingPathComponent(name, isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + // MARK: - Helpers + + /// Conservative placeholder hardware MTU for the bridge interface until a + /// follow-up supplies the active radio's negotiated MTU (TODO(C3-followup)). + /// Sized for a typical BLE-mesh payload so the link MDU never exceeds what the + /// radio can carry. + private static let bridgePlaceholderHWMTU = 500 + + /// Read the enabled `tcpClient` relay endpoint from the SHARED App-Group + /// UserDefaults (the same `SharedDefaultsConstants.interfacesKey` JSON the PoC + /// dumb-pipe parses in `PacketTunnelProvider.loadInterfaceConfigs`). Returns + /// the first enabled TCP relay's `(host, port)`, or `nil` if none is + /// configured. Foundation-only JSON parse — the node does NOT import `Network` + /// (collision rule), so it surfaces a plain `(String, UInt16)` that `start()` + /// feeds to a reticulum-swift `TCPInterface`. NO-PII: never logs host/port. + static func loadTCPRelayConfig() -> (host: String, port: UInt16)? { + let defaults = UserDefaults(suiteName: appGroupIdentifier) ?? .standard + guard let data = defaults.data(forKey: SharedDefaultsConstants.interfacesKey), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return nil + } + for entity in array { + guard let enabled = entity["enabled"] as? Bool, enabled, + let configWrapper = entity["config"] as? [String: Any], + let type = configWrapper["type"] as? String, type == "tcpClient", + let config = configWrapper["config"] as? [String: Any], + let host = config["targetHost"] as? String, + let port = config["targetPort"] as? Int, + // Bound the port: `UInt16(truncatingIfNeeded:)` would silently WRAP an + // out-of-range value (65536 → 0, 131071 → 65535), so a misconfigured + // relay would dial port 0 / a wrong port instead of being skipped. + // Skip bad entries (and 0) so a later valid relay can still win. + port > 0, port <= 65535 else { + continue + } + return (host: host, port: UInt16(port)) + } + return nil + } + + /// Short, NO-PII hash prefix (≤ 8 hex chars) for logging. + fileprivate static func hashPrefix(_ hex: String) -> String { + String(hex.prefix(8)) + } + + // MARK: - A5b IPC dispatch (Model B app→NE send path) + // + // Thin node-ops invoked by `PacketTunnelProvider.handleAppMessage` when it + // decodes a `ProxyRequest` envelope (see `ProxyIPC`, Shared/Foundation-only). + // The app's `ProxyRnsBackend` marshals the matching `RnsBackend` methods to + // these. Each returns a Foundation-only result the dispatcher encodes into a + // `ProxyResponse`; node-not-running is handled by the dispatcher (it only + // calls these on a non-nil, started node). These mirror the corresponding + // `SwiftRNSBackend` methods, but here on the NE's own stack — keeping the + // NE's RNSAPI-free collision posture (no RNSAPI types cross this boundary). + // + // SCOPE: the node is constructed/started only when `modelBNodeEnabled` is true + // (the runtime App-Group flag, default `false`), so in the default shipping + // build these are never reached; they go live when the user opts into Model B. + + /// Lowercase-hex of the learned `lxmf.delivery` destination + identity, for + /// the `.start` response. `nil` before `start()`. + func localInfoForIPC() -> ProxyLocalInfo? { + guard let identity, let dest = deliveryDestination else { return nil } + return ProxyLocalInfo(identityHash: identity.hexHash, destinationHash: dest.hexHash) + } + + /// Emit an `lxmf.delivery` announce (mirrors `SwiftRNSBackend.announce`). + /// Canonical LXMF (>= 0.5.0) app_data: msgpack([display_name_bytes, stamp_cost]). + @discardableResult + func announceForIPC(displayName: String) async -> Bool { + // msgpack([display_name_utf8_bytes, null]) — stamp_cost nil, matching the + // app-side `SwiftRNSBackend.announce`. + let appData = packMsgPack(.array([.binary(Data(displayName.utf8)), .null])) + return await emitAnnounceForIPC(on: deliveryDestination, appData: appData, withRatchet: true) + } + + /// Telephony announce is out of A5b scope: the A5a node owns only the + /// `lxmf.delivery` destination (no `lxst.telephony` destination), so there's + /// nothing to announce here. Returns false so the proxy degrades cleanly. + /// (Model B: telephony stays app-local / not owned by the NE node yet.) + @discardableResult + func announceTelephonyForIPC(displayName: String) async -> Bool { + ExtensionDiagLog.log("NEReticulumNode: announceTelephony not owned by NE node (A5b) — no-op") + return false + } + + private func emitAnnounceForIPC(on destination: Destination?, appData: Data, withRatchet: Bool) async -> Bool { + guard let transport, let destination else { + ExtensionDiagLog.log("NEReticulumNode: emitAnnounce SKIPPED — node not started (no transport/destination)") + return false + } + destination.appData = appData + var ratchetPub: Data? = nil + if withRatchet, let mgr = destination.ratchetManager { + await mgr.rotateIfNeeded() + ratchetPub = await mgr.currentRatchetPublicBytes() + } + do { + let announce = Announce(destination: destination, ratchet: ratchetPub) + let packet = try announce.buildPacket() + try await transport.send(packet: packet) + // Diagnostic: confirm the emit + show interface connection states — if the + // TCP relay interface is "down" the announce can't reach the Mac even + // though the socket is established. + let snaps = await transport.getInterfaceSnapshots() + let ifaces = snaps.map { "\($0.id.prefix(14))=\($0.state == .connected ? "conn" : "down")" } + .joined(separator: ",") + ExtensionDiagLog.log("NEReticulumNode: announce EMITTED dest=\(destination.hexHash.prefix(8)) \(packet.size)B ifaces=[\(ifaces)]") + return true + } catch { + ExtensionDiagLog.log("NEReticulumNode: announce send failed: \(String(describing: error))") + return false + } + } + + // MARK: - Self-announce (Model B) + + /// Launch the periodic self-announce loop. Idempotent: cancels any prior task. + private func startAnnounceScheduler() { + announceTask?.cancel() + announceTask = Task { [weak self] in + guard let self else { return } + // Wait for the relay to connect so the FIRST announce actually goes out + // the TCP interface (transport-node path), not only the radio bridge. + // Best-effort: if the relay never connects (radio-only node), announce + // anyway once the timeout elapses. + let connected = await self.waitForRelayConnected(timeoutMs: 15_000) + ExtensionDiagLog.log("NEReticulumNode: announce scheduler — relay connected=\(connected) before first announce") + await self.selfAnnounce() + // Periodic re-announce on the user's configured interval (default 3h), + // mirroring the app's AutoAnnounceManager cadence. + while !Task.isCancelled { + let hours = await self.configuredAnnounceIntervalHours() + do { + try await Task.sleep(for: .seconds(hours * 3600)) + } catch { + return // cancelled + } + if Task.isCancelled { return } + await self.selfAnnounce() + } + } + } + + /// Emit one `lxmf.delivery` announce using the user's shared display name. + private func selfAnnounce() async { + let ok = await announceForIPC(displayName: sharedDisplayName()) + ExtensionDiagLog.log("NEReticulumNode: self-announce (ok=\(ok))") + } + + /// The relay interface reached `.connected` (initial connect, or a reconnect + /// after the relay/daemon restarted). Re-announce so the relay relearns our + /// delivery destination promptly. No-op once the node is torn down. + private func onRelayReconnected() async { + guard isRunning else { return } + ExtensionDiagLog.log("NEReticulumNode: relay (re)connected — re-announcing delivery dest") + await selfAnnounce() + // Pull any propagated mail queued at the PN while we were disconnected. + await runOneSyncFireAndForget() + } + + /// Poll the transport's interface snapshots until the relay (`ne-tcp-relay`) + /// reports `.connected`, or `timeoutMs` elapses. Returns whether it connected. + private func waitForRelayConnected(timeoutMs: Int) async -> Bool { + guard let transport else { return false } + var waited = 0 + let stepMs = 500 + while waited < timeoutMs { + let snaps = await transport.getInterfaceSnapshots() + if snaps.contains(where: { $0.id == "ne-tcp-relay" && $0.state == .connected }) { + return true + } + do { + try await Task.sleep(for: .milliseconds(stepMs)) + } catch { + return false + } + waited += stepMs + } + return false + } + + /// The user's announce display name from the shared App-Group defaults + /// (`SettingsRepository` key `"displayName"`), falling back to the identity + /// hash prefix (matching the app's anonymous-peer naming). + private func sharedDisplayName() -> String { + if let s = UserDefaults(suiteName: appGroupIdentifier)?.string(forKey: "displayName"), + !s.isEmpty { + return s + } + return String((identity?.hexHash ?? "").prefix(8)) + } + + /// The user's configured announce interval in hours (App-Group key + /// `"announce_interval_hours"`, default 3), matching `AutoAnnounceManager`. + private func configuredAnnounceIntervalHours() -> Int { + let h = UserDefaults(suiteName: appGroupIdentifier)?.integer(forKey: "announce_interval_hours") ?? 0 + return h > 0 ? h : 3 + } + + /// Flush the router's pending state to its GRDB store + /// (mirrors `SwiftRNSBackend.persist`). + @discardableResult + func persistForIPC() async -> Bool { + await router?.persistPendingState() + return true + } + + /// Lowercase-hex destination hashes this node has registered — just the + /// `lxmf.delivery` destination in A5a (no telephony destination on the NE + /// node yet). Mirrors `SwiftRNSBackend.registeredDestinationHashes`. + func registeredDestinationHashesForIPC() -> [String] { + [deliveryDestination].compactMap { $0?.hexHash } + } + + /// Transport diagnostic snapshot as a Foundation-only JSON object whose keys + /// match `RNSAPI.StatusSnapshot`'s `snake_case` `CodingKeys`, so the app + /// decodes the `.ok` payload straight into `StatusSnapshot`. We build the + /// JSON inline (rather than encoding an RNSAPI type) to honor the collision + /// rule (the NE never imports RNSAPI). + func statusSnapshotJSONForIPC() async -> Data? { + guard let transport else { return nil } + let snaps = await transport.getInterfaceSnapshots() + let interfaces: [[String: Any]] = snaps.map { s in + [ + "section_name": s.id, + "name": s.name, + "online": s.state == .connected, + "rx_bytes": 0, + "tx_bytes": 0, + // Model B: the app's Network Status view can't see the NE's transport, + // so carry enough to reconstruct each interface row (type / peer / error). + "type": s.type.rawValue, + "is_ble_peer": s.isBLEPeerInterface, + "is_auto_peer": s.isAutoInterfacePeer, + "peer_address": s.peerAddress ?? "", + "last_error": s.lastErrorDescription ?? "", + ] + } + let destCount = await transport.destinationCount + let pathCount = await transport.getPathTable().count + let object: [String: Any] = [ + "started": identity != nil, + "interfaces": interfaces, + "destination_table_size": destCount, + "path_table_size": pathCount, + ] + return try? JSONSerialization.data(withJSONObject: object) + } + + /// Native Model B BLE peer snapshot. reticulum-swift's `BLEInterface` runs in + /// the NE and owns the peers; map its `BLEConnectionInfo` onto the Codable + /// `BLEPeerSnapshot` wire DTO so the app's BLE connections screen can render + /// the real native peers (the app can't enumerate them — the radio + the + /// `BLEInterface` both live across the seam). Returns JSON `[BLEPeerSnapshot]`, + /// or nil when the BLE interface isn't up. See `ble_to_ne_driver_abstraction_plan`. + func bleConnectionsJSONForIPC() async -> Data? { + guard let bleInterface else { return nil } + let infos = await bleInterface.getConnectionInfos() + let snapshots: [BLEPeerSnapshot] = infos.map { info in + BLEPeerSnapshot( + identityHash: info.identityHash, + isOutgoing: info.isOutgoing, + rssi: info.rssi, + mtu: info.mtu, + connectedAt: info.connectedAt, + lastActivity: info.lastActivity, + bytesSent: info.bytesSent, + bytesReceived: info.bytesReceived, + packetsSent: info.packetsSent, + packetsReceived: info.packetsReceived + ) + } + return try? JSONEncoder().encode(snapshots) + } + + /// Heard-announce snapshot (Model B incoming-announce bridge). The NE owns + /// the transport, so the app can't hear announces itself — it polls this and + /// re-emits `.announce` events. Mirrors the PathTable read in + /// `SwiftRNSBackend.startAnnouncePolling` (only entries whose aspect we + /// recognise — lxmf.delivery / lxmf.propagation / lxst.telephony / + /// nomadnetwork.node — carry a `detectedAspect`). Returns JSON + /// `[ProxyHeardAnnounce]`. + func heardAnnouncesJSONForIPC() async -> Data? { + guard let pathTable else { return nil } + let entries = await pathTable.allEntries() + let announces: [ProxyHeardAnnounce] = entries.compactMap { entry in + guard let aspect = entry.detectedAspect else { return nil } + return ProxyHeardAnnounce( + destHashHex: entry.destinationHash.hexHash, + appDataHex: (entry.appData ?? Data()).hexHash, + aspect: aspect, + publicKeysHex: entry.publicKeys.hexHash, + interfaceName: entry.interfaceId, + hops: Int(entry.hopCount), + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + return try? JSONEncoder().encode(announces) + } + + /// Send an LXMF message on the NE node (mirrors + /// `SwiftRNSBackend.sendLxmfMessage`, but the field map arrives pre-packed as + /// MessagePack `fieldsData` from the app — the NE unpacks it to `[UInt8: Any]` + /// for `LXMessage.fields` rather than rebuilding it from typed params, since + /// it can't import RNSAPI's `LxmfFieldCodec`). `method` is the + /// `RNSAPI.LXDeliveryMethod` raw value string. Returns a Foundation-only + /// `ProxySendOutcome`. + func sendLxmfForIPC(destHashHex: String, content: String, method: String, fieldsData: Data) async -> ProxySendOutcome { + guard let router, let id = identity else { return ProxySendOutcome(kind: .notStarted) } + guard let destHash = Self.hexToData(destHashHex), !destHash.isEmpty else { + return ProxySendOutcome(kind: .badHash) + } + let fields = Self.unpackFieldMap(fieldsData) + var msg = LXMessage( + destinationHash: destHash, + sourceIdentity: id, + content: Data(content.utf8), + title: Data(), + fields: fields.isEmpty ? nil : fields, + desiredMethod: Self.deliveryMethod(method) + ) + do { + try await router.handleOutbound(&msg) + return ProxySendOutcome(kind: .queued, detail: msg.hash.hexHash) + } catch { + return ProxySendOutcome(kind: .other, detail: String(describing: error)) + } + } + + // MARK: - A5b dispatch helpers + + /// Map the `RNSAPI.LXDeliveryMethod` raw value string to LXMF-swift's enum. + /// Defaults to opportunistic for unknown/paper (matching `SwiftRNSBackend`'s + /// `lxmfMethod`, which only distinguishes direct / propagated / else). + private static func deliveryMethod(_ raw: String) -> LXDeliveryMethod { + switch raw { + case "direct": return .direct + case "propagated": return .propagated + default: return .opportunistic + } + } + + /// Unpack the app's MessagePack field bytes (produced by RNSAPI's + /// `LxmfFieldCodec.pack`, standard MessagePack) into `[UInt8: Any]` for + /// `LXMessage.fields`. Mirrors `LxmfFieldCodec.unpack`'s shape but uses + /// reticulum-swift's `unpackMsgPack` (the NE can't import RNSAPI). Empty / + /// malformed / non-map input yields an empty map. + private static func unpackFieldMap(_ data: Data) -> [UInt8: Any] { + guard !data.isEmpty, let value = try? unpackMsgPack(data), case .map(let m) = value else { + return [:] + } + var out: [UInt8: Any] = [:] + for (k, v) in m { + guard let key = uint8Key(k) else { continue } + out[key] = anyValue(from: v) + } + return out + } + + /// Coerce a MessagePack map key to a `UInt8` LXMF field id. + private static func uint8Key(_ v: MessagePackValue) -> UInt8? { + switch v { + case .uint(let u) where u <= UInt64(UInt8.max): return UInt8(u) + case .int(let i) where i >= 0 && i <= Int64(UInt8.max): return UInt8(i) + default: return nil + } + } + + /// Convert a `MessagePackValue` to the `Any` representation LXMF-swift's + /// `LXMessage.fields` expects: `binary → Data`, `string → String`, + /// `array → [Any]`, nested `map → [UInt8: Any]` (LXMF field sub-maps are + /// id-keyed, e.g. FIELD_REACTION), scalars → their Swift value. + private static func anyValue(from v: MessagePackValue) -> Any { + switch v { + case .null: return NSNull() + case .bool(let b): return b + case .int(let i): return i + case .uint(let u): return u + case .float(let f): return f + case .double(let d): return d + case .string(let s): return s + case .binary(let d): return d + case .array(let a): return a.map { anyValue(from: $0) } + case .map(let m): + // Prefer a UInt8-keyed sub-map (LXMF sub-fields); fall back to a + // string-keyed dictionary if the keys aren't field ids. + var idKeyed: [UInt8: Any] = [:] + var ok = true + for (k, val) in m { + if let key = uint8Key(k) { idKeyed[key] = anyValue(from: val) } + else { ok = false; break } + } + if ok { return idKeyed } + var strKeyed: [String: Any] = [:] + for (k, val) in m { + if case .string(let s) = k { strKeyed[s] = anyValue(from: val) } + } + return strKeyed + } + } + + /// Decode a hex string to `Data` (mirrors `SwiftRNSBackend.hexData`; the NE + /// has no RNSAPI hex helper and reticulum-swift's would be ambiguous). + private static func hexToData(_ hex: String) -> Data? { + guard hex.count % 2 == 0 else { return nil } + var out = Data(capacity: hex.count / 2) + var i = hex.startIndex + while i < hex.endIndex { + let j = hex.index(i, offsetBy: 2) + guard let b = UInt8(hex[i.. String? { + guard let db = lookupDB, + let record = try? await db.getConversation(hash: sourceHash), + let name = record.displayName, + !name.isEmpty else { return nil } + return name + } + + func router(_ router: LXMRouter, didUpdateMessage message: LXMessage) { + if message.state == .delivered { + ExtensionDiagLog.log("NEReticulumNode: outbound message delivered (hash=\(NEReticulumNode.hashPrefix(message.hash.hexHash)))") + } + // Under full Model B the NE owns outbound sending (`.lxmfSend` IPC), so the + // proof/state transitions for sent messages land HERE, not in the app. The + // app reads message state from the shared LXMF store, so it must be told to + // refresh or the sent-message checkmarks never advance past the single tick. + Self.postNewMessageDarwinNotification() + } + + func router(_ router: LXMRouter, didFailMessage message: LXMessage, reason: LXMFError) { + ExtensionDiagLog.log("NEReticulumNode: message failed (hash=\(NEReticulumNode.hashPrefix(message.hash.hexHash)))") + Self.postNewMessageDarwinNotification() + } + + func router(_ router: LXMRouter, didConfirmDelivery messageHash: Data) { + ExtensionDiagLog.log("NEReticulumNode: delivery confirmed (hash=\(NEReticulumNode.hashPrefix(messageHash.hexHash)))") + Self.postNewMessageDarwinNotification() + } + + // Model B propagation sync state → app. Darwin carries no payload, so the + // snapshot rides the App-Group `propagationSyncStateKey`; the app's observer + // reads it on `propagationSyncStateChanged` and drives the in-app sync sheet. + // NO push for sync progress — message-arrival push stays on the inbound path. + + func router(_ router: LXMRouter, didUpdateSyncState state: PropagationTransferState) { + PropagationSyncStateSnapshot( + phase: Self.phase(for: state.state), + progress: state.progress, + received: state.receivedMessages, + total: state.totalMessages, + errorDescription: state.errorDescription + ).saveToAppGroup() + } + + func router(_ router: LXMRouter, didCompleteSyncWithNewMessages newMessages: Int) { + ExtensionDiagLog.log("NEReticulumNode: propagation sync complete, \(newMessages) new") + PropagationSyncStateSnapshot( + phase: .complete, + progress: 1.0, + received: newMessages, + total: newMessages + ).saveToAppGroup() + // Belt-and-suspenders UI refresh; the per-message inbound path already + // posts this + the local notification as each synced message persists. + if newMessages > 0 { + Self.postNewMessageDarwinNotification() + } + } + + /// Map the lib's fine-grained `PropagationState` onto the coarse phases the + /// app's sync sheet renders. + private static func phase(for s: PropagationState) -> PropagationSyncStateSnapshot.Phase { + switch s { + case .idle: return .idle + case .pathRequested, .linkEstablishing: return .linking + case .linkEstablished: return .linked + case .requestSent: return .requesting + case .receiving, .responseReceived: return .receiving + case .complete: return .complete + case .noPath, .linkFailed, .transferFailed: return .failed + } + } + + // MARK: - Notification + + /// Post a local notification for an inbound message, gated on the host's + /// notification authorization. Title is the resolved sender display name (the + /// caller falls back to a short hash prefix); body is a truncated content + /// preview. Grouped per-conversation via `threadIdentifier`. + private static func postInboundNotification( + senderDisplay: String, + preview: String, + threadId: String + ) async { + let center = UNUserNotificationCenter.current() + + // Honor the host's authorization: only `.authorized` posts. We do NOT + // request authorization from the NE (the app owns the prompt) and we do + // NOT read the app's per-type notification preference UserDefaults here — + // A5a keeps the NE-side notification minimal; richer preference handling + // can mirror `NotificationService` later if needed. + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .authorized else { + ExtensionDiagLog.log("NEReticulumNode: notifications not authorized — skipping local notification") + return + } + + let content = UNMutableNotificationContent() + content.title = senderDisplay + content.body = preview.isEmpty ? "New message" : preview + content.threadIdentifier = threadId + content.sound = .default + + let request = UNNotificationRequest( + identifier: NEReticulumNode.notificationIdPrefix + UUID().uuidString, + content: content, + trigger: nil // deliver immediately + ) + do { + try await center.add(request) + // Success marker — ENVELOPE ONLY, per ExtensionDiagLog's NO-PII contract. + // Log the sender HASH prefix only; do NOT log the resolved display name + // (PII) or the decrypted `preview` (message plaintext). `ext-diag.log` is + // device-extractable (`devicectl … copy from`), so persisting plaintext + // here would defeat LXMF end-to-end encryption. The notification BODY still + // shows the preview to the USER — that is the intended UX; only the + // persisted diagnostic log must stay envelope-only. Correlate a specific + // inbound by its sender-hash prefix (matches the `from=…` marker above). + ExtensionDiagLog.log("NEReticulumNode: posted inbound notification (from=\(NEReticulumNode.hashPrefix(threadId)))") + } catch { + ExtensionDiagLog.log("NEReticulumNode: failed to post local notification: \(String(describing: error))") + } + } + + /// Build a short, UTF-8 content preview (truncated). Non-UTF-8 / empty content + /// yields an empty string (caller substitutes a generic body). + private static func previewText(from content: Data) -> String { + guard let text = String(data: content, encoding: .utf8), !text.isEmpty else { + return "" + } + if text.count <= previewLimit { return text } + return String(text.prefix(previewLimit)) + "…" + } + + /// Post the DB-changed Darwin notification the app's `NotificationObserver` + /// listens for. Mirrors `AppGroupBridgeInterface`'s Darwin-post pattern; + /// posts the identical raw name `NotificationObserver` uses, since that type + /// (app target / RNSAPI) isn't reachable from the NE. + private static func postNewMessageDarwinNotification() { + let center = CFNotificationCenterGetDarwinNotifyCenter() + CFNotificationCenterPostNotification( + center, + CFNotificationName(NEReticulumNode.newMessageDarwinNameCF), + nil, + nil, + true + ) + } +} + +// MARK: - Local hex helper +// +// LXMF-swift / reticulum-swift expose `Data.hexHash` (see `SwiftRNSBackend`'s +// local equivalent), but to avoid depending on whether that extension is `public` +// in the linked versions, define a small file-local one. Named distinctly so it +// can't collide if a public `hexHash` is also visible. +private extension Data { + var hexHash: String { map { String(format: "%02x", $0) }.joined() } +} + +fileprivate extension NEReticulumNode { + /// `CFString` form of the DB-changed Darwin notification name. `fileprivate` + /// so `NEDeliveryDelegate` (a separate type in this file) can read it; the + /// same-file, same-type extension can still see the `private` + /// `newMessageDarwinName` it wraps. + static var newMessageDarwinNameCF: CFString { newMessageDarwinName as CFString } +} diff --git a/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift b/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift index f12a68c5..22bec8aa 100644 --- a/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift +++ b/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift @@ -2,12 +2,19 @@ // PacketTunnelProvider.swift // ColumbaNetworkExtension // -// Minimal NEPacketTunnelProvider that keeps TCP and AutoInterface NWConnections -// alive while the main app is backgrounded. No Reticulum protocol knowledge, -// no crypto, no LXMF parsing — just raw frame forwarding via a shared queue file. +// NEPacketTunnelProvider host for the Model B in-NE Reticulum + LXMF node. +// Model B is the SOLE architecture on the build that compiles the NE in +// (ENABLE_NETWORK_EXTENSION ⇔ COLUMBA_BACKEND_SWIFT): the extension exists +// solely to own and keep alive `NEReticulumNode` — the background LXMF +// delivery path — while the main app is backgrounded. It carries NO raw-frame +// forwarding: the node owns its own TCP relay interface + the AppGroupBridge, +// and the app→NE send path is the `ProxyRequest`/`ProxyResponse` IPC handled in +// `handleAppMessage` below. // -// Inbound: TCP/Auto data → HDLC deframe (TCP only) → SharedFrameQueue → Darwin notif -// Outbound: App sends via sendProviderMessage → extension sends on NWConnection +// (The earlier "Model A" PoC dumb-pipe — NWConnection TCP/Auto frame forwarding +// over a shared HDLC queue, with an NWPathMonitor + a Darwin config-change +// observer + exponential reconnect backoff — was removed once Model B became the +// only architecture. See git history if that raw-relay code is ever needed.) // import Foundation @@ -16,72 +23,35 @@ import NetworkExtension class PacketTunnelProvider: NEPacketTunnelProvider { - // MARK: - Constants + // MARK: - Model B node - /// Notification posted to the app when inbound frames are queued. - private static let packetReadyNotification = SharedDefaultsConstants.packetReadyNotificationName - /// Notification observed when the app writes interface-config - /// changes; triggers a reload so unrelated interfaces stay - /// connected while a single relay is added/removed/edited. - private static let configChangedNotification = SharedDefaultsConstants.configChangedNotificationName - private static let interfacesKey = SharedDefaultsConstants.interfacesKey - - // MARK: - Properties - - private var tcpConnection: NWConnection? - private var autoListener: NWConnectionGroup? - private lazy var frameQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier) - - /// Currently-applied TCP endpoint (used to diff config changes - /// from the app). nil when no TCP interface is configured. - /// Mutated only on `configQueue` to avoid races with Darwin - /// notification callbacks arriving on a Mach-port thread. - private var currentTCP: (host: String, port: UInt16)? - - /// Currently-applied AutoInterface group id. nil when no Auto - /// interface is configured. Mutated only on `configQueue`. - private var currentAutoGroupId: String? - - /// Serial queue serializing all config-state mutations and the - /// associated NWConnection lifecycle calls so a Darwin - /// notification fired by the app (`configChanged`) can't race - /// `startTunnel` / `stopTunnel` / NWConnection state handlers. - private let configQueue = DispatchQueue(label: "network.columba.tunnel.config") - - /// HDLC receive buffer for TCP stream framing - private var tcpReceiveBuffer = Data() - - /// HDLC constants - private static let FLAG: UInt8 = 0x7E - private static let ESC: UInt8 = 0x7D - private static let ESC_MASK: UInt8 = 0x20 + /// The in-NE Reticulum + LXMF node — the LIVE background delivery path. It + /// owns its own TCP relay interface (read from the shared App-Group config) + /// and the AppGroupBridge, and services the app's `ProxyRequest` IPC. + /// Constructed + started in `startTunnel`, torn down in `stopTunnel`; `nil` + /// only before start / after stop. `NEReticulumNode.modelBNodeEnabled` is + /// hardcoded `true` — the NE exists solely to host this node. + private var reticulumNode: NEReticulumNode? // MARK: - Tunnel Lifecycle override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { - NSLog("[EXT] startTunnel called") - - // Apply current interface configs. - applyConfigs() - - // Subscribe to live config changes so the user adding / - // removing / editing an interface in the app updates the - // extension's sockets without a tunnel restart. The handler - // diffs and only restarts what actually changed. - let center = CFNotificationCenterGetDarwinNotifyCenter() - let observer = Unmanaged.passUnretained(self).toOpaque() - CFNotificationCenterAddObserver( - center, - observer, - { _, observer, _, _, _ in - guard let observer else { return } - let provider = Unmanaged.fromOpaque(observer).takeUnretainedValue() - provider.applyConfigs() - }, - Self.configChangedNotification as CFString, - nil, - .deliverImmediately - ) + ExtensionDiagLog.log("startTunnel called") + + // Construct + start the in-NE Reticulum + LXMF node (Track A5a + C3). It + // owns its own TCP relay interface (read from the same App-Group config) + // + the AppGroupBridge. `start()` is a clean no-op if the shared identity + // isn't available yet. + ExtensionDiagLog.log("startTunnel: Model B — in-NE node owns delivery") + let node = NEReticulumNode() + self.reticulumNode = node + Task { + do { + _ = try await node.start() + } catch { + ExtensionDiagLog.log("startTunnel: NEReticulumNode.start failed: \(String(describing: error))") + } + } // Set up dummy tunnel settings (required by NEPacketTunnelProvider) let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") @@ -90,416 +60,154 @@ class PacketTunnelProvider: NEPacketTunnelProvider { setTunnelNetworkSettings(settings) { error in if let error { - NSLog("[EXT] Failed to set tunnel settings: \(error)") - } - completionHandler(error) - } - } - - /// Read the current interface configs from shared UserDefaults - /// and bring up / tear down the matching `NWConnection`s. - /// - /// Diffs against what's already running so a single relay change - /// doesn't disrupt unrelated interfaces. Called both on - /// `startTunnel` and on the `configChanged` Darwin notification. - /// Always serialized onto `configQueue` so a Darwin callback - /// arriving on a Mach-port thread can't race `startTunnel` / - /// `stopTunnel` / NWConnection state handlers mutating the same - /// properties. - private func applyConfigs() { - configQueue.async { [weak self] in - self?.applyConfigsLocked() - } - } - - /// 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() - } - - /// Body of `applyConfigs` — runs on `configQueue`. Mutates - /// `currentTCP` / `currentAutoGroupId` / `tcpConnection` / - /// `autoListener` 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) - } - } else if currentTCP != nil { - NSLog("[EXT] TCP config removed; tearing down connection") - teardownTCPConnectionLocked() - currentTCP = nil - } - - // Auto: same diff. - if let groupId = configs.autoGroupId { - if currentAutoGroupId == groupId { - // No change. + ExtensionDiagLog.log("Failed to set tunnel settings: \(error)") } else { - NSLog("[EXT] Auto config (re)applying: groupId=\(groupId)") - autoListener?.cancel() - autoListener = nil - startAutoListener(groupId: groupId) - currentAutoGroupId = groupId + ExtensionDiagLog.log("tunnel settings applied") } - } else if currentAutoGroupId != nil { - NSLog("[EXT] Auto config removed; tearing down listener") - autoListener?.cancel() - autoListener = nil - currentAutoGroupId = nil + completionHandler(error) } } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - NSLog("[EXT] stopTunnel reason=\(reason.rawValue)") + ExtensionDiagLog.log("stopTunnel reason=\(reason.rawValue)") - // Serialize teardown through the same queue `applyConfigs` uses - // so we can't race a config-change notification arriving on the - // Mach-port thread mid-shutdown. `sync` (rather than `async`) - // keeps the existing contract that the completion handler - // fires only after teardown has finished. - configQueue.sync { - teardownTCPConnectionLocked() - autoListener?.cancel() - autoListener = nil - currentTCP = nil - currentAutoGroupId = nil + // Track C3: tear down the in-NE node. Stopping the node drops its TCP + // relay interface + AppGroupBridge. Fire-and-forget — teardown is + // best-effort and the completion handler must not block on it. + if let node = reticulumNode { + reticulumNode = nil + Task { await node.stop() } } - // Remove the config-changed observer registered in startTunnel. - let center = CFNotificationCenterGetDarwinNotifyCenter() - let observer = Unmanaged.passUnretained(self).toOpaque() - CFNotificationCenterRemoveObserver( - center, - observer, - CFNotificationName(Self.configChangedNotification as CFString), - nil - ) - completionHandler() } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { - // Format: [1-byte interface tag][N-byte HDLC-framed data] - guard messageData.count >= 2 else { - completionHandler?(nil) + // ── Track A5b (Model B app→NE send path) ──────────────────────────────── + // The app talks to the NE node exclusively via `ProxyRequest` envelopes, + // marked by a leading magic byte (`ProxyIPC.magic` = 0xF5). Decode + reply + // with an encoded `ProxyResponse`. Any non-ProxyRequest message is ignored + // (the PoC raw-frame forwarding it used to carry is gone). + if ProxyIPC.isProxyRequest(messageData) { + handleProxyRequest(messageData, completionHandler: completionHandler) return } - - let interfaceTag = messageData[0] - let frameData = messageData.dropFirst() - - // Read the connection / listener under configQueue so we can't - // observe a half-mutated state while applyConfigsLocked() is - // diffing or stopTunnel() is tearing things down. - configQueue.async { [weak self] in - guard let self else { completionHandler?(nil); return } - switch interfaceTag { - case FrameInterfaceTag.tcp.rawValue: - self.tcpConnection?.send(content: frameData, completion: .contentProcessed { error in - if let error { - NSLog("[EXT] TCP send error: \(error)") - } - }) - case FrameInterfaceTag.auto.rawValue: - // Auto frames are sent as UDP datagrams via the connection group - self.autoListener?.send(content: frameData) { error in - if let error { - NSLog("[EXT] Auto send error: \(error)") - } - } - default: - NSLog("[EXT] Unknown interface tag: \(interfaceTag)") - } - completionHandler?(nil) - } - } - - override func sleep(completionHandler: @escaping () -> Void) { - NSLog("[EXT] sleep") - completionHandler() - } - - override func wake() { - NSLog("[EXT] wake") - // Re-apply configs through the serial queue so a dropped TCP - // connection (cancelled / failed) gets restarted without - // racing applyConfigsLocked / stopTunnel writes. The diff - // logic in applyConfigsLocked is a no-op when nothing - // changed, so re-applying on wake is cheap. - configQueue.async { [weak self] in - guard let self else { return } - // Treat cancelled / failed / nil connections as gone so - // applyConfigsLocked starts a fresh one rather than seeing - // the cached endpoint as already-applied. Use the helper - // so the receive buffer is reset alongside the connection - // — see `teardownTCPConnectionLocked`. - switch self.tcpConnection?.state { - case .cancelled, .failed, .none: - self.teardownTCPConnectionLocked() - self.currentTCP = nil - default: - break - } - self.applyConfigsLocked() - } + completionHandler?(nil) } - // MARK: - TCP Connection - - private func startTCPConnection(host: String, port: UInt16) { - let nwHost = NWEndpoint.Host(host) - let nwPort = NWEndpoint.Port(rawValue: port)! - let params = NWParameters.tcp - params.requiredInterfaceType = .other // Allow any interface + // MARK: - Track A5b — Model B app→NE IPC dispatch - let connection = NWConnection(host: nwHost, port: nwPort, using: params) - self.tcpConnection = connection - - connection.stateUpdateHandler = { [weak self] state in - NSLog("[EXT] TCP state: \(state)") - switch state { - case .ready: - self?.receiveTCPData() - case .failed(let error): - NSLog("[EXT] TCP failed: \(error), reconnecting in 5s") - // Reconnect must go through configQueue — otherwise the - // .failed handler's main-queue write to `tcpConnection` - // would race `applyConfigsLocked` writing the same - // property. Routing through `applyConfigs` re-reads the - // current config, clears the stale connection, and - // starts a fresh one all on the serial queue. - guard let self else { return } - self.configQueue.async { - self.teardownTCPConnectionLocked() - self.currentTCP = nil - } - DispatchQueue.global().asyncAfter(deadline: .now() + 5) { [weak self] in - self?.applyConfigs() - } - case .waiting(let error): - NSLog("[EXT] TCP waiting: \(error)") - default: - break - } - } - - // Run state callbacks AND receive callbacks on configQueue so - // the receive buffer (`tcpReceiveBuffer`) and connection - // pointer are touched only from one serial context. Without - // this, a `.main` receive completion could race - // `teardownTCPConnectionLocked` resetting the buffer on - // configQueue and the clear would silently lose to a stale - // append, corrupting the next session's HDLC framing. - connection.start(queue: configQueue) - } - - /// Continuation of inbound TCP receive. Must run on `configQueue` - /// because it both reads `tcpConnection` and feeds `handleTCPData` - /// which touches `tcpReceiveBuffer` — both serialized there. - private func receiveTCPData() { - tcpConnection?.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in - // Callback runs on the connection's queue (configQueue - // since startTCPConnection switched it). No extra dispatch - // needed. - if let data, !data.isEmpty { - self?.handleTCPData(data) - } - - if isComplete { - NSLog("[EXT] TCP connection complete (EOF)") - return - } - - if let error { - NSLog("[EXT] TCP receive error: \(error)") - return - } - - // Continue receiving - self?.receiveTCPData() - } - } - - /// Buffer TCP data and extract HDLC frames. Runs on configQueue - /// (called from `receiveTCPData`'s completion which now executes - /// on configQueue too). - private func handleTCPData(_ data: Data) { - tcpReceiveBuffer.append(data) - - // Extract complete HDLC frames - let frames = extractHDLCFrames(from: &tcpReceiveBuffer) - - for frame in frames { - frameQueue.append(frame: frame, interfaceTag: FrameInterfaceTag.tcp.rawValue) - } - - if !frames.isEmpty { - postDarwinNotification() - } - } - - // MARK: - AutoInterface Multicast Listener - - private func startAutoListener(groupId: String) { - // AutoInterface uses link-local multicast on a well-known group/port - // The discovery and data ports match ReticulumSwift AutoInterface defaults - let discoveryPort: UInt16 = 29716 - let multicastGroup: NWMulticastGroup + /// Decode a `ProxyRequest` envelope and dispatch it to the in-NE + /// `NEReticulumNode`, replying through `completionHandler` with an encoded + /// `ProxyResponse`. Only called from `handleAppMessage` once the magic prefix + /// has matched. If the node isn't running (e.g. not yet started), every op + /// replies `.unsupported` so the app degrades gracefully. + /// + /// `ProxyRequest` / `ProxyResponse` / `ProxyLocalInfo` / `ProxySendOutcome` + /// live in the Foundation-only `ProxyIPC` (Shared target, linked into the NE), + /// so this honors the NE's RNSAPI-free collision rule. + private func handleProxyRequest(_ data: Data, completionHandler: ((Data?) -> Void)?) { + // A malformed envelope (magic matched but JSON body undecodable) is a + // protocol error, not a PoC frame — reply `.error` rather than falling + // through (the magic byte already proved intent). + let request: ProxyRequest? do { - multicastGroup = try NWMulticastGroup(for: [ - .hostPort(host: .ipv6(IPv6Address("ff02::1")!), port: NWEndpoint.Port(rawValue: discoveryPort)!) - ]) + request = try ProxyIPC.decodeRequest(data) } catch { - NSLog("[EXT] Failed to create multicast group: %@", "\(error)") + completionHandler?(ProxyIPC.encodeResponse(.error("malformed ProxyRequest"))) return } - - let params = NWParameters.udp - params.allowLocalEndpointReuse = true - params.requiredInterfaceType = .other - - let group = NWConnectionGroup(with: multicastGroup, using: params) - self.autoListener = group - - group.stateUpdateHandler = { state in - NSLog("[EXT] Auto multicast state: \(state)") + guard let request else { + completionHandler?(ProxyIPC.encodeResponse(.error("unrecognized ProxyRequest envelope"))) + return } - group.setReceiveHandler(maximumMessageSize: 2048, rejectOversizedMessages: false) { [weak self] message, content, isComplete in - guard let content, !content.isEmpty else { return } - - // Auto frames are complete UDP datagrams (no HDLC framing needed) - self?.frameQueue.append(frame: content, interfaceTag: FrameInterfaceTag.auto.rawValue) - self?.postDarwinNotification() + // Snapshot the node reference. Nil ⇒ the Model B node isn't running + // (not yet started): reply `.unsupported`. + guard let node = reticulumNode else { + completionHandler?(ProxyIPC.encodeResponse(.unsupported)) + return } - group.start(queue: .main) + Task { + let response = await Self.dispatch(request, to: node) + completionHandler?(ProxyIPC.encodeResponse(response)) + } } - // MARK: - HDLC Frame Extraction - - /// Extract complete HDLC frames from a TCP buffer. - /// Mirrors the logic in ReticulumSwift/Protocol/HDLC.swift. - private func extractHDLCFrames(from buffer: inout Data) -> [Data] { - var frames: [Data] = [] - - while true { - guard let startIdx = buffer.firstIndex(of: Self.FLAG) else { break } + /// Route a decoded `ProxyRequest` to the node and build its `ProxyResponse`. + /// `nonisolated`/`static` so it can be awaited from the detached `Task` above + /// without capturing `self`. + private static func dispatch(_ request: ProxyRequest, to node: NEReticulumNode) async -> ProxyResponse { + switch request { + case .start: + // The node loads its own shared identity + store path; the display + // name isn't needed to *start* (announce carries it). A start that + // can't bring up the node (no identity yet) ⇒ `.unsupported`. + do { + let started = try await node.start() + guard started, let info = await node.localInfoForIPC() else { + return .unsupported + } + let payload = try? JSONEncoder().encode(info) + return .ok(payload) + } catch { + return .error(String(describing: error)) + } - let searchStart = buffer.index(after: startIdx) - guard searchStart < buffer.endIndex, - let endIdx = buffer[searchStart...].firstIndex(of: Self.FLAG) else { break } + case .stop: + await node.stop() + return .ok(nil) - let frameContent = buffer[(buffer.index(after: startIdx)).. Data? { - var result = Data() - result.reserveCapacity(data.count) - var escapeNext = false + case .heardAnnounces: + guard let json = await node.heardAnnouncesJSONForIPC() else { + return .ok(nil) + } + return .ok(json) - for byte in data { - if escapeNext { - result.append(byte ^ Self.ESC_MASK) - escapeNext = false - } else if byte == Self.ESC { - escapeNext = true - } else { - result.append(byte) + case .bleConnections: + guard let json = await node.bleConnectionsJSONForIPC() else { + return .ok(nil) } - } + return .ok(json) - return escapeNext ? nil : result - } + case .persist: + let ok = await node.persistForIPC() + return ok ? .ok(nil) : .error("persist failed") - // MARK: - Darwin Notifications + case .registeredDestinationHashes: + let hashes = await node.registeredDestinationHashesForIPC() + return .ok(try? JSONEncoder().encode(hashes)) - private func postDarwinNotification() { - let center = CFNotificationCenterGetDarwinNotifyCenter() - CFNotificationCenterPostNotification( - center, - CFNotificationName(Self.packetReadyNotification as CFString), - nil, - nil, - true - ) + case .lxmfSend(let destHashHex, let content, let method, let fieldsData): + let outcome = await node.sendLxmfForIPC( + destHashHex: destHashHex, content: content, method: method, fieldsData: fieldsData) + return .ok(try? JSONEncoder().encode(outcome)) + } } - // MARK: - Config Loading - - private struct InterfaceConfigs { - var tcp: (host: String, port: UInt16)? - var autoGroupId: String? + override func sleep(completionHandler: @escaping () -> Void) { + ExtensionDiagLog.log("sleep") + completionHandler() } - /// Load interface configs from shared UserDefaults. - /// Parses the same JSON format as InterfaceRepository. - private func loadInterfaceConfigs(from defaults: UserDefaults) -> InterfaceConfigs { - var result = InterfaceConfigs() - - guard let data = defaults.data(forKey: Self.interfacesKey) else { - NSLog("[EXT] No interface configs found") - return result - } - - // Parse the JSON array — we only need type + config fields - guard let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { - NSLog("[EXT] Failed to parse interface configs") - return result - } - - for entity in array { - guard let enabled = entity["enabled"] as? Bool, enabled, - let configWrapper = entity["config"] as? [String: Any], - let type = configWrapper["type"] as? String, - let config = configWrapper["config"] as? [String: Any] else { - continue - } - - switch type { - case "tcpClient": - if let host = config["targetHost"] as? String, - let port = config["targetPort"] as? Int { - result.tcp = (host: host, port: UInt16(port)) - NSLog("[EXT] Found TCP config: \(host):\(port)") - } - case "autoInterface": - let groupId = config["groupId"] as? String ?? "reticulum" - result.autoGroupId = groupId - NSLog("[EXT] Found Auto config: groupId=\(groupId)") - default: - break - } - } - - return result + override func wake() { + ExtensionDiagLog.log("wake") + // Model B: the in-NE node owns the relay and its `TCPInterface` + // self-reconnects, so there's nothing to re-apply on wake. } } diff --git a/Sources/PythonBridge/PythonBridge.swift b/Sources/PythonBridge/PythonBridge.swift index f3992b88..cfb5725b 100644 --- a/Sources/PythonBridge/PythonBridge.swift +++ b/Sources/PythonBridge/PythonBridge.swift @@ -973,28 +973,9 @@ public extension PythonBridge { } } - /// Fire a Python RNode bridge callback ("data" / "state"). Fire-and-forget, - /// same machinery as `invokeBLECallback` but resolves the callable through - /// rns_bridge's `_rnode_get_callback`. Called by `PythonRNodeCallbackBridge` - /// from SwiftRNodeBridge's CoreBluetooth delegate; safe from any queue. - func invokeRNodeCallback(slot: String, args: [BLEArg]) { - queue.async { [self] in - _ = PythonRuntime.shared.withGIL { () -> Int in - self.invokeBLECallbackLocked( - slot: slot, - args: args, - getterName: "_rnode_get_callback" - ) - return 0 - } - } - } - /// MUST be called with the GIL held. Returns the raw `PyObject*` result if /// `wantResult` is true (caller owns the ref), else nil. `getterName` selects - /// the rns_bridge slot-lookup function — `_ble_get_callback` for the mesh, - /// `_rnode_get_callback` for the RNode NUS client (same invocation machinery, - /// different registry). + /// the rns_bridge slot-lookup function (`_ble_get_callback` for the mesh). @discardableResult private func invokeBLECallbackLocked( slot: String, diff --git a/Sources/PythonBridge/PythonRNodeCallbackBridge.swift b/Sources/PythonBridge/PythonRNodeCallbackBridge.swift deleted file mode 100644 index a7569432..00000000 --- a/Sources/PythonBridge/PythonRNodeCallbackBridge.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// PythonRNodeCallbackBridge.swift -// Columba (ColumbaApp target — wired in the pbxproj alongside PythonBridge) -// -// Glue between SwiftRNodeBridge's `RNodeCallbackInvoker` protocol and -// PythonBridge's `invokeRNodeCallback`. Conforming class lives in the pbxproj -// target (NOT the SwiftBLEBridge SwiftPM target) because invocation routes -// through PythonBridge, which depends on Python.h. Mirror of -// PythonBLECallbackBridge. -// - -import Foundation -import SwiftBLEBridge - -/// Bridges SwiftRNodeBridge's RNode event slots to PythonBridge's GIL-aware -/// invocation. Pass an instance to `SwiftRNodeBridge.setCallbackInvoker(_:)` -/// once the Python runtime is up. -public final class PythonRNodeCallbackBridge: RNodeCallbackInvoker, @unchecked Sendable { - - private let pythonBridge: PythonBridge - - public init(pythonBridge: PythonBridge) { - self.pythonBridge = pythonBridge - } - - public func invoke(slot: RNodeCallbackSlot, args: [Any]) { - pythonBridge.invokeRNodeCallback(slot: slot.rawValue, args: convert(args)) - } - - // MARK: - Arg conversion - - /// The two RNode slots pass a small, fixed set of types: - /// "data" → (Data) - /// "state" → (Bool, String) - /// Bool is matched before Int (a Bool never matches `as Int` in Swift, but - /// keeping it first documents intent). Unknown types fall through as a - /// labelled string so misuse is loud rather than silently dropped. - private func convert(_ args: [Any]) -> [BLEArg] { - args.map { value in - switch value { - case let s as String: - return .string(s) - case let b as Bool: - return .bool(b) - case let d as Data: - return .bytes(d) - case let i as Int: - return .int(i) - case is NSNull: - return .none - default: - return .string("") - } - } - } -} diff --git a/Sources/RNSAPI/Compat.swift b/Sources/RNSAPI/Compat.swift index 6689317c..d12a2dfb 100644 --- a/Sources/RNSAPI/Compat.swift +++ b/Sources/RNSAPI/Compat.swift @@ -1608,7 +1608,6 @@ public final class ReticulumTransport: @unchecked Sendable { public var initiateLinkHook: (@Sendable (Destination, Identity) async throws -> Link)? - public func registeredDestinationHashes() -> [String] { [] } public func registeredLinkCallbackHashes() -> [String] { [] } public func registerDestination(_ destination: Destination) async { await registerDestinationHook?(destination) @@ -1932,6 +1931,15 @@ public final class AutoInterface: NetworkInterface, @unchecked Sendable { public func connect() async throws {} public func disconnect() async {} + + // No-op tunnel-mode stubs, mirroring TCPInterface (Compat.swift:1916-1917). + // The Compat façade has no underlying ReticulumSwift interface to install the + // outbound hook on — the real tunnel-mode wiring (routing interface outbound + // through the App-Group NE bridge) lives on the Swift backend's ReticulumSwift + // interfaces and is wired NE-side (Track A5 / the app<->NE seam in C3). Present + // so the ENABLE_NETWORK_EXTENSION-gated applyTunnelModeToInterfaces compiles. + public func beginTunnelMode(send hook: @escaping @Sendable (Data) async -> Void) async {} + public func endTunnelMode() async {} } /// Stub BLE driver — full implementation lands when BLE comes back online. diff --git a/Sources/RNSAPI/Models/Identity.swift b/Sources/RNSAPI/Models/Identity.swift index 822d536a..bdeceef8 100644 --- a/Sources/RNSAPI/Models/Identity.swift +++ b/Sources/RNSAPI/Models/Identity.swift @@ -110,13 +110,15 @@ public struct Identity: Equatable, Sendable { /// Save the 64-byte private-key blob to Keychain under the given /// service / account. Caller-supplied service+account keys let /// `IdentityManager` namespace per-identity entries. - public func saveToKeychain(service: String, account: String) throws { + public func saveToKeychain(service: String, account: String, accessGroup: String? = nil) throws { guard let pk = privateKeyBytes else { throw IdentityError.noPrivateKeys } - let baseQuery: [String: Any] = [ + var baseQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] + // Shared keychain group so the app + Network Extension resolve the SAME item. + if let accessGroup { baseQuery[kSecAttrAccessGroup as String] = accessGroup } let attrs: [String: Any] = baseQuery.merging([ kSecValueData as String: pk, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, @@ -135,14 +137,15 @@ public struct Identity: Equatable, Sendable { /// Load identity from Keychain. Returns `nil` if no item is stored at /// the (service, account) pair. - public static func loadFromKeychain(service: String, account: String) throws -> Identity? { - let query: [String: Any] = [ + public static func loadFromKeychain(service: String, account: String, accessGroup: String? = nil) throws -> Identity? { + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) switch status { @@ -157,12 +160,13 @@ public struct Identity: Equatable, Sendable { } @discardableResult - public static func deleteFromKeychain(service: String, account: String) -> Bool { - let query: [String: Any] = [ + public static func deleteFromKeychain(service: String, account: String, accessGroup: String? = nil) -> Bool { + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } let status = SecItemDelete(query as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound } diff --git a/Sources/RNSAPI/Protocols/RnsBackend.swift b/Sources/RNSAPI/Protocols/RnsBackend.swift index 82d1fd15..9201baf5 100644 --- a/Sources/RNSAPI/Protocols/RnsBackend.swift +++ b/Sources/RNSAPI/Protocols/RnsBackend.swift @@ -152,13 +152,27 @@ public struct StatusSnapshot: Decodable, Sendable { public let online: Bool public let rxBytes: Int public let txBytes: Int + // Model B enrichment (nil on the Model A local-transport path): lets the + // Network Status view reconstruct each row from the NE's interfaces. + public let typeRaw: String? + public let isBLEPeer: Bool? + public let isAutoPeer: Bool? + public let peerAddress: String? + public let lastError: String? - public init(sectionName: String, name: String, online: Bool, rxBytes: Int, txBytes: Int) { + public init(sectionName: String, name: String, online: Bool, rxBytes: Int, txBytes: Int, + typeRaw: String? = nil, isBLEPeer: Bool? = nil, isAutoPeer: Bool? = nil, + peerAddress: String? = nil, lastError: String? = nil) { self.sectionName = sectionName self.name = name self.online = online self.rxBytes = rxBytes self.txBytes = txBytes + self.typeRaw = typeRaw + self.isBLEPeer = isBLEPeer + self.isAutoPeer = isAutoPeer + self.peerAddress = peerAddress + self.lastError = lastError } enum CodingKeys: String, CodingKey { @@ -166,6 +180,11 @@ public struct StatusSnapshot: Decodable, Sendable { case name, online case rxBytes = "rx_bytes" case txBytes = "tx_bytes" + case typeRaw = "type" + case isBLEPeer = "is_ble_peer" + case isAutoPeer = "is_auto_peer" + case peerAddress = "peer_address" + case lastError = "last_error" } public init(from decoder: Decoder) throws { @@ -175,6 +194,11 @@ public struct StatusSnapshot: Decodable, Sendable { self.online = (try? c.decode(Bool.self, forKey: .online)) ?? false self.rxBytes = (try? c.decode(Int.self, forKey: .rxBytes)) ?? 0 self.txBytes = (try? c.decode(Int.self, forKey: .txBytes)) ?? 0 + self.typeRaw = try? c.decode(String.self, forKey: .typeRaw) + self.isBLEPeer = try? c.decode(Bool.self, forKey: .isBLEPeer) + self.isAutoPeer = try? c.decode(Bool.self, forKey: .isAutoPeer) + self.peerAddress = try? c.decode(String.self, forKey: .peerAddress) + self.lastError = try? c.decode(String.self, forKey: .lastError) } } @@ -209,6 +233,25 @@ public protocol RnsCore: AnyObject, Sendable { @discardableResult func announceTelephony(displayName: String) async throws -> Bool func statusSnapshot() async -> StatusSnapshot? @discardableResult func persist() async -> Bool + + /// Lowercase-hex destination hashes this node has actually registered + /// (its `lxmf.delivery` destination, plus `lxst.telephony` where the backend + /// surfaces it). Backend-neutral so the Network Extension's sniff-only + /// destination filter matches the same set both backends register. Empty + /// before `start`. + func registeredDestinationHashes() async -> [String] + + /// Native Model B BLE peers (reticulum-swift's `BLEInterface` runs in the NE, + /// which owns the peers). The dedicated BLE connections screen polls this. + /// Backends without a native BLE interface return `[]` (default below) — only + /// `ProxyRnsBackend` overrides it to query the NE over the proxy IPC. + func bleConnections() async -> [BLEConnectionInfo] +} + +public extension RnsCore { + /// Default: no native BLE interface ⇒ no peers. Keeps the non-Model-B backends + /// (Swift / Python) conforming without each needing a stub. + func bleConnections() async -> [BLEConnectionInfo] { [] } } /// RNS.Link operations backing LXST voice (the Swift state machine drives these; diff --git a/Sources/RNSBackendProxy/ProxyRnsBackend.swift b/Sources/RNSBackendProxy/ProxyRnsBackend.swift new file mode 100644 index 00000000..b5e8390e --- /dev/null +++ b/Sources/RNSBackendProxy/ProxyRnsBackend.swift @@ -0,0 +1,555 @@ +// +// ProxyRnsBackend.swift +// Columba (RNSBackendProxy — compiled into ColumbaApp) +// +// Track A5b — the app-side thin client for the Model B send path. +// +// Under Model B the Network Extension owns the canonical `lxmf.delivery` +// destination + node (A5a's `NEReticulumNode`). The app therefore must NOT run +// its own destination-owning backend — instead `BackendFactory` hands the app +// this `ProxyRnsBackend`, which conforms to the full `RnsBackend` protocol but +// *marshals* node-owning operations to the NE over IPC (`ProxyIPC` envelopes +// sent through an injected async send closure) rather than touching a local +// reticulum-swift / LXMF-swift stack. +// +// ── ALWAYS-THE-NODE INVARIANT ──────────────────────────────────────────────── +// This type owns NO destination, identity, transport, or router. When Model B +// is enabled the NE is the single owner of the `lxmf.delivery` destination; if +// the app also started a `SwiftRNSBackend`/`PythonRNSBackend` they would BOTH +// register the same destination and double-deliver. `BackendFactory` enforces +// this by returning EITHER a destination-owning backend OR this proxy, never +// both (see `BackendFactory.make()`). +// +// ── COLLISION RULE (HARD) ──────────────────────────────────────────────────── +// This file imports RNSAPI ONLY (for the `RnsBackend` protocol surface + +// `StartParams` / `LocalInfo` / `SendOutcome` / `StatusSnapshot` / `BackendEvent` +// / `LXDeliveryMethod` / `RnsFileAttachment` / `IconAppearance`). It MUST NOT +// import ReticulumSwift or LXMFSwift: it only marshals already-serialized data +// across the seam (hex strings, MessagePack-packed field bytes via RNSAPI's +// `LxmfFieldCodec`, JSON), and never constructs a protocol object +// (Identity/Destination/Link/LXMessage/…). `ProxyIPC` itself is Foundation-only +// (Shared target). +// + +import Foundation +import os +import RNSAPI + +/// Errors raised by the Model B proxy for operations that are NOT marshaled to +/// the NE (they run NE-side under the in-extension node, or aren't part of the +/// A5b skeleton yet). +public enum BackendError: Error, LocalizedError, Equatable { + /// The called method has no app-side meaning under Model B: it operates on + /// node-owned state that lives entirely in the NE and isn't proxied (yet). + /// `feature` names the method for diagnostics. + case unsupportedInProxy(feature: String) + /// The IPC round-trip itself failed (no response / undecodable response) — + /// distinct from the NE answering `.unsupported`/`.error`. + case ipcFailed(operation: String) + + public var errorDescription: String? { + switch self { + case .unsupportedInProxy(let feature): + return "Unsupported in Model B proxy: \(feature)" + case .ipcFailed(let operation): + return "Model B IPC failed: \(operation)" + } + } +} + +/// App-side `RnsBackend` that proxies node-owning operations to the NE under +/// Model B. Conforms to the FULL protocol; only the key node ops marshal, the +/// rest throw `BackendError.unsupportedInProxy` or no-op (each annotated). +/// +/// `@unchecked Sendable`: the only mutable state is `cachedLocalInfo`, guarded by +/// `stateLock` (never held across an `await`), matching `SwiftRNSBackend`'s +/// class-with-lock posture. +@available(iOS 17.0, macOS 14.0, *) +public final class ProxyRnsBackend: RnsBackend, @unchecked Sendable { + + private static let log = Logger(subsystem: "network.columba.Columba", category: "ProxyRnsBackend") + + /// Injected IPC transport: encode-send-receive a single request/response. + /// Decoupled from `TunnelManager` (which wraps `sendProviderMessage`'s + /// completion handler in a continuation) so the proxy is testable with a + /// stub closure. Returns the raw response `Data` (the NE's encoded + /// `ProxyResponse`), or `nil` on a transport-level failure. + private let send: @Sendable (Data) async -> Data? + + /// Last `LocalInfo` learned from a `.start` response. The protocol's + /// `localInfo` is synchronous (`get`-only), so cache the async-fetched value + /// here. Guarded by `stateLock`. + private var cachedLocalInfo: LocalInfo? + /// Polls the NE's heard-announce snapshot over IPC and re-emits `.announce` + /// events on `eventStream` — the Model B incoming-announce bridge, since the + /// app owns no transport to hear announces itself. Guarded by `stateLock`; + /// cancelled in `stop()`. + private var announcePoller: Task? + /// Bumped by `stop()` so an in-flight `start()` handshake loop (up to ~12s of + /// retries) that completes AFTER a `stop()` does not resurrect `cachedLocalInfo` + /// or restart the announce poller. `start()` captures the generation up front and + /// re-checks it before committing. Guarded by `stateLock`. + private var startGeneration = 0 + private let stateLock = NSLock() + + /// The neutral event stream. Under Model B the NE owns inbound delivery and + /// notifies the app via the App-Group store + Darwin notification (A5a), NOT + /// via this stream — so the stream is intentionally inert here (no events are + /// yielded). It exists only to satisfy `RnsCore.events`; A5c/the live wiring + /// can later bridge NE-pushed events onto `eventContinuation`. + private let eventStream: AsyncStream + private let eventContinuation: AsyncStream.Continuation + + public init(send: @escaping @Sendable (Data) async -> Data?) { + self.send = send + (eventStream, eventContinuation) = AsyncStream.makeStream() + } + + // MARK: - IPC helper + + /// Encode + send a request, returning the decoded `ProxyResponse`. Throws + /// `BackendError.ipcFailed` when the envelope can't be encoded or no/garbled + /// response comes back; returns the `ProxyResponse` (including `.error` / + /// `.unsupported`) otherwise so callers can map those onto their own return + /// shapes. + private func roundTrip(_ request: ProxyRequest, op: String) async throws -> ProxyResponse { + let wire: Data + do { + wire = try ProxyIPC.encodeRequest(request) + } catch { + throw BackendError.ipcFailed(operation: op) + } + let reply = await send(wire) + guard let response = ProxyIPC.decodeResponse(reply) else { + throw BackendError.ipcFailed(operation: op) + } + return response + } + + // MARK: - RnsCore + + public var localInfo: LocalInfo? { + stateLock.lock(); defer { stateLock.unlock() } + return cachedLocalInfo + } + + public var events: AsyncStream { eventStream } + + @discardableResult + public func start(_ params: StartParams) async throws -> LocalInfo { + // The NE loads the shared identity + computes the App-Group store path + // itself (A5a); only the display name needs to cross the seam. + // + // Model B is the only architecture now, so on a cold start or a jetsam + // relaunch the NE node may still be initializing (shared-identity load → + // transport → LXMRouter/GRDB open) when the app first reaches here. Rather + // than fail the whole backend, retry the `.start` handshake until the node + // answers `.ok`, bounded to ~12s. Both `.unsupported` (node not up yet) + // and a failed IPC round-trip retry; a real backend `.error` does not. + let stepMs: UInt64 = 400 + let maxAttempts = 30 + var lastError: Error = RNSError.backendNotReady + // Snapshot the generation up front; if stop() bumps it while we're still + // handshaking, abandon the result instead of resurrecting cleared state. + stateLock.lock(); let myGeneration = startGeneration; stateLock.unlock() + for attempt in 0..= a.timestamp { continue } + lastSeen[a.destHashHex] = a.timestamp + cont.yield(.announce( + destHash: a.destHashHex, + appDataHex: a.appDataHex, + aspect: a.aspect, + publicKeysHex: a.publicKeysHex, + interfaceName: a.interfaceName, + hops: a.hops, + t: Date(timeIntervalSince1970: a.timestamp) + )) + } + } + } + stateLock.unlock() + } + + @discardableResult + public func announce(displayName: String) async throws -> Bool { + try await marshalBool(.announce(displayName: displayName), op: "announce") + } + + @discardableResult + public func announceTelephony(displayName: String) async throws -> Bool { + try await marshalBool(.announceTelephony(displayName: displayName), op: "announceTelephony") + } + + public func statusSnapshot() async -> StatusSnapshot? { + // Best-effort: a failed round-trip / unsupported reply yields nil (same + // contract as the other backends when the stack isn't up). + guard let response = try? await roundTrip(.statusSnapshot, op: "statusSnapshot"), + case .ok(let payload) = response, let payload else { + return nil + } + return try? JSONDecoder().decode(StatusSnapshot.self, from: payload) + } + + public func bleConnections() async -> [BLEConnectionInfo] { + // Native Model B BLE peers live in the NE's reticulum-swift `BLEInterface`. + // Round-trip the snapshot DTO and map it onto the UI `BLEConnectionInfo` + // (deriving displayName / connectionType / signalQuality the same way the + // Model A path does in `AppServices.getBLEConnectionInfos`). + guard let response = try? await roundTrip(.bleConnections, op: "bleConnections"), + case .ok(let payload) = response, let payload, + let snapshots = try? JSONDecoder().decode([BLEPeerSnapshot].self, from: payload) else { + return [] + } + let now = Date() + return snapshots.map { s in + BLEConnectionInfo( + identityHex: s.identityHash, + identityHash: s.identityHash, + displayName: String(s.identityHash.prefix(8)), + rssi: s.rssi, + connected: true, + lastSeen: s.lastActivity, + lastActivity: s.lastActivity, + connectionType: s.isOutgoing ? "central" : "peripheral", + connectionDuration: max(0, now.timeIntervalSince(s.connectedAt)), + isOutgoing: s.isOutgoing, + mtu: s.mtu, + bytesSent: s.bytesSent, + bytesReceived: s.bytesReceived, + packetsSent: s.packetsSent, + packetsReceived: s.packetsReceived, + signalQuality: Self.signalQuality(forRssi: s.rssi) + ) + } + } + + /// RSSI dBm → coarse signal bucket (60/75/90 steps), matching the Model A + /// mapping in `AppServices`. + private static func signalQuality(forRssi rssi: Int) -> SignalQuality { + let absRssi = abs(rssi) + if absRssi < 60 { return .excellent } + if absRssi < 75 { return .good } + if absRssi < 90 { return .fair } + return .poor + } + + @discardableResult + public func persist() async -> Bool { + guard let response = try? await roundTrip(.persist, op: "persist") else { return false } + if case .ok = response { return true } + return false + } + + public func registeredDestinationHashes() async -> [String] { + guard let response = try? await roundTrip(.registeredDestinationHashes, op: "registeredDestinationHashes"), + case .ok(let payload) = response, let payload, + let hashes = try? JSONDecoder().decode([String].self, from: payload) else { + return [] + } + return hashes + } + + // MARK: - RnsLxmf + + @discardableResult + public func sendLxmfMessage( + destHashHex: String, + content: String, + method: LXDeliveryMethod, + imageData: Data?, + imageFormat: String?, + fileAttachments: [RnsFileAttachment]?, + iconAppearance: IconAppearance?, + replyToMessageHashHex: String?, + replyQuotedContent: String?, + extraFields: [UInt8: Data]? + ) async throws -> SendOutcome { + // Assemble the canonical on-wire LXMF field map APP-SIDE (RNSAPI's + // `LxmfFieldCodec` is in scope here; the NE-side dispatch is NOT — it + // doesn't import RNSAPI) and pass it across the seam as MessagePack + // bytes. The NE rebuilds the `LXMessage` from `(destHashHex, content, + // method, fieldsData)`. + let fields = LxmfFieldCodec.buildFieldMap( + imageData: imageData, imageFormat: imageFormat, + fileAttachments: fileAttachments, iconAppearance: iconAppearance, + replyToMessageHashHex: replyToMessageHashHex, replyQuotedContent: replyQuotedContent, + extraFields: extraFields) + let fieldsData = fields.isEmpty ? Data() : LxmfFieldCodec.pack(fields) + + // A5c — durable outbox. The round-trip throws `BackendError.ipcFailed` + // when the NE is unreachable (nil / garbled reply). Catch that here and + // treat it the same as the NE answering `.error` / `.unsupported`: the NE + // did NOT accept the send, so persist it to the App-Group outbox and + // return optimistically (`.queued`) — the NE replays it on its next start. + let response: ProxyResponse + do { + response = try await roundTrip( + .lxmfSend(destHashHex: destHashHex, content: content, method: method.rawValue, fieldsData: fieldsData), + op: "lxmfSend") + } catch { + // Transport-level failure (no/garbled response) — NE down/unreachable. + return enqueueToOutbox(destHashHex: destHashHex, content: content, method: method.rawValue, fieldsData: fieldsData) + } + switch response { + case .ok(let payload): + guard let payload, + let outcome = try? JSONDecoder().decode(ProxySendOutcome.self, from: payload) else { + return .other("malformed send response") + } + // Live IPC success — behave exactly as before (real LXMF hash from NE). + return Self.sendOutcome(from: outcome) + case .error, .unsupported: + // NE answered but did NOT accept the send (node not running / send + // rejected). Persist for replay rather than dropping it. + return enqueueToOutbox(destHashHex: destHashHex, content: content, method: method.rawValue, fieldsData: fieldsData) + } + } + + /// Persist an undelivered send to the durable App-Group outbox and return an + /// optimistic `.queued` outcome so the UI shows it pending (the NE replays the + /// queue on its next `start()`, A5c). + /// + /// `messageHashHex` is stored `nil`: the real LXMF hash is computed NE-side at + /// pack time and this proxy (RNSAPI-only, no `Identity`/LXMF-swift) cannot + /// derive it — see `OutboxEntry.messageHashHex`. The returned `.queued` hash is + /// therefore empty, matching the existing "no real hash yet" shape (the live + /// path's `ProxySendOutcome.detail` is likewise empty until the NE packs). + private func enqueueToOutbox(destHashHex: String, content: String, method: String, fieldsData: Data) -> SendOutcome { + let entry = OutboxEntry( + destHashHex: destHashHex, + content: content, + method: method, + fieldsData: fieldsData.isEmpty ? nil : fieldsData, + messageHashHex: nil, + createdAt: Date().timeIntervalSince1970 + ) + OutboxQueue().append(entry) + let destPrefix = String(destHashHex.prefix(8)) + Self.log.info("Model B NE unreachable — queued LXMF send to durable outbox (dest=\(destPrefix, privacy: .public)…)") + return .queued(messageHash: "") + } + + @discardableResult + public func sendReaction(destHashHex: String, targetMessageHashHex: String, emoji: String) async throws -> SendOutcome { + // Model B: not proxied yet (would ride the same lxmf-send path NE-side; + // out of the A5b skeleton). Treat as not-started so the UI degrades like + // a stopped backend rather than crashing. + throw BackendError.unsupportedInProxy(feature: "sendReaction") + } + + @discardableResult + public func setPropagationNode(destHashHex: String, stampCost: Int) async throws -> Bool { + // Model B: runs NE-side / not proxied yet. + throw BackendError.unsupportedInProxy(feature: "setPropagationNode") + } + + public func propagationSync(timeout: TimeInterval) async throws -> PropagationSyncResult { + // Model B: runs NE-side / not proxied yet. + PropagationSyncResult(ok: false, state: .noNode, receivedMessages: 0, reason: "not proxied (Model B)") + } + + // MARK: - RnsTelemetry + + @discardableResult + public func sendLocationTelemetry(destHashHex: String, packed: Data, customMeta: Data?) async throws -> SendOutcome { + // Model B: not proxied yet (would route via the NE lxmf-send path with + // FIELD_TELEMETRY 0x02; out of the A5b skeleton). + throw BackendError.unsupportedInProxy(feature: "sendLocationTelemetry") + } + + // Collector-host mode is honest-unsupported on the Swift stack too — no-op + // returning false here (Model B: runs NE-side / not proxied yet). + public func setTelemetryCollectorMode(enabled: Bool) async -> Bool { false } + public func storeOwnTelemetry(packed: Data) async -> Bool { false } + public func setTelemetryAllowedRequesters(_ allowedHashesHex: Set) async -> Bool { false } + + // MARK: - RnsNomadnet + + public func fetchNomadNetPage( + destHashHex: String, + path: String, + timeout: TimeInterval, + formFields: [String: String]? + ) async throws -> NomadNetFetchResult { + // Model B: runs NE-side / not proxied yet. + NomadNetFetchResult(ok: false, status: .notStarted, data: Data(), contentType: "") + } + + // MARK: - RnsTelephony + // + // Voice links are driven by the in-process LXST state machine and require a + // live local RNS.Link — they CANNOT be proxied frame-by-frame at acceptable + // latency, so under Model B telephony stays app-local (out of A5b scope). + // Each throws so a missed UI capability gate fails loud rather than silently + // dropping audio. (Model B: runs NE-side / not proxied yet.) + + public func openLink(destHashHex: String, aspect: String) async throws -> (ok: Bool, linkId: Int, reason: String) { + throw BackendError.unsupportedInProxy(feature: "openLink") + } + + @discardableResult + public func linkSend(linkId: Int, data: Data) async throws -> Bool { + throw BackendError.unsupportedInProxy(feature: "linkSend") + } + + @discardableResult + public func linkIdentify(linkId: Int) async throws -> Bool { + throw BackendError.unsupportedInProxy(feature: "linkIdentify") + } + + @discardableResult + public func linkTeardown(linkId: Int) async throws -> Bool { + throw BackendError.unsupportedInProxy(feature: "linkTeardown") + } + + // MARK: - RnsTransportAdmin + // + // Interfaces are owned by the NE's transport under Model B (the NE binds the + // radios); live add/remove from the app isn't proxied yet. (Model B: runs + // NE-side / not proxied yet.) + + @discardableResult + public func addInterface(name: String) async throws -> (ok: Bool, reason: String) { + throw BackendError.unsupportedInProxy(feature: "addInterface") + } + + @discardableResult + public func removeInterface(name: String) async throws -> (ok: Bool, reason: String) { + throw BackendError.unsupportedInProxy(feature: "removeInterface") + } + + // MARK: - Capabilities + + /// Same backend-id as the native stack — under Model B the NE runs + /// reticulum-swift / LXMF-swift, so versions match `SwiftRNSBackend`. The + /// proxy declares hot-reload OFF (interface admin isn't proxied) and + /// telemetry unsupported (not proxied in A5b); refine when A5c wires the + /// remaining ops. + public var capabilities: BackendCapabilities { + BackendCapabilities( + backendId: .swiftNative, + versions: .init(reticulum: "0.2.3", lxmf: "0.3.4", lxst: nil, bleReticulum: nil), + interfaces: .init(hotReloadInterfaces: false), + telemetry: .init( + collectorHostMode: .unsupported, + storeOwnTelemetry: .unsupported, + allowedRequestersFilter: .unsupported, + degradationHint: "Model B proxy: telemetry/propagation/telephony/nomadnet/interface-admin are not proxied to the NE yet (A5b skeleton)." + ), + performance: .init(batteryProfileTuning: .unsupported, sharedInstanceAvailabilityChecks: false) + ) + } + + // MARK: - Mapping helpers + + /// Marshal a request whose `.ok` payload is a JSON-encoded `Bool`. + private func marshalBool(_ request: ProxyRequest, op: String) async throws -> Bool { + let response = try await roundTrip(request, op: op) + switch response { + case .ok(let payload): + guard let payload, let value = try? JSONDecoder().decode(Bool.self, from: payload) else { + return false + } + return value + case .error(let message): + throw RNSError.generic(message: message, stackTraceText: nil) + case .unsupported: + throw RNSError.backendNotReady + } + } + + private static func sendOutcome(from outcome: ProxySendOutcome) -> SendOutcome { + switch outcome.kind { + case .queued: return .queued(messageHash: outcome.detail ?? "") + case .requestingPath: return .requestingPath + case .badHash: return .badHash + case .notStarted: return .notStarted + case .other: return .other(outcome.detail ?? "") + } + } +} diff --git a/Sources/RNSBackendPy/PythonRNSBackend.swift b/Sources/RNSBackendPy/PythonRNSBackend.swift index 01c704dc..6fa1949b 100644 --- a/Sources/RNSBackendPy/PythonRNSBackend.swift +++ b/Sources/RNSBackendPy/PythonRNSBackend.swift @@ -238,6 +238,18 @@ public final class PythonRNSBackend: RnsBackend, @unchecked Sendable { return Self.map(s) } + /// Lowercase-hex destination hashes this backend has registered. Python's + /// `start` registers both the `lxmf.delivery` and `lxst.telephony` + /// destinations, but the bridge only surfaces the delivery hash (via + /// `LocalInfo.destination_hash`), so that's what we return from the cached + /// `localInfo` rather than adding a bridge round-trip for the telephony hash. + /// The delivery hash is lowercase hex (rns_bridge.py uses `.hex()`), matching + /// the Swift backend's convention. Empty before `start`. + public func registeredDestinationHashes() async -> [String] { + guard let delivery = localInfo?.destinationHash, !delivery.isEmpty else { return [] } + return [delivery] + } + /// Force RNS to flush its path table + known destinations to disk. RNS only /// persists on a 12h timer / clean exit, which iOS skips — call on background. @discardableResult diff --git a/Sources/RNSBackendSwift/SwiftRNSBackend.swift b/Sources/RNSBackendSwift/SwiftRNSBackend.swift index f9b188fd..e57be82d 100644 --- a/Sources/RNSBackendSwift/SwiftRNSBackend.swift +++ b/Sources/RNSBackendSwift/SwiftRNSBackend.swift @@ -573,6 +573,15 @@ public final class SwiftRNSBackend: RnsBackend, @unchecked Sendable { ) } + /// The destinations this backend registered on its transport in `start()`: + /// the `lxmf.delivery` destination and the `lxst.telephony` destination. + /// Both `hexHash` values are lowercase hex (reticulum-swift `%02x`). Empty + /// before `start` (both are nil). Mirrors what the Python backend reports so + /// the NE's sniff-only filter matches the same set in either backend. + public func registeredDestinationHashes() async -> [String] { + [deliveryDestination, telephonyDestination].compactMap { $0?.hexHash } + } + // MARK: - NomadNet (one-shot page fetch over a fresh RNS Link) // // Ported from main's NomadNetBrowserService: resolve a path + node identity, diff --git a/Sources/Shared/AppGroupBLEDriver.swift b/Sources/Shared/AppGroupBLEDriver.swift new file mode 100644 index 00000000..9860220e --- /dev/null +++ b/Sources/Shared/AppGroupBLEDriver.swift @@ -0,0 +1,223 @@ +// +// AppGroupBLEDriver.swift +// Shared +// +// NE side of the Model B BLE seam. The NE runs reticulum-swift's `BLEInterface`, +// which drives a `BLEDriver`. CoreBluetooth can't run in the NE sandbox, so this +// driver doesn't touch CoreBluetooth — it **marshals the `BLEDriver` + +// `BLEPeerConnection` protocol surface across the App-Group** to the app, which +// runs the real `CoreBluetoothBLEDriver`. Commands go out as +// `BLEDriverSeamMessage`s; the app's stream events + value-replies come back and +// are dispatched here (feeding the three driver streams + resuming the +// reqId-correlated `await`s). See `ble_to_ne_driver_abstraction_plan` (vault). +// +// Transport-agnostic: takes a `BLESeamTransport` so it's unit-testable with an +// in-memory loopback. The production transport rides the `a2e`/`e2a` +// `SharedFrameQueue`s (NE: send→e2a, inbound←a2e). +// + +import Foundation +import ReticulumSwift + +// `BLESeamTransport` + `BLESeamError` live in BLEDriverSeam.swift (pure Foundation) +// so the concrete `AppGroupBLESeamTransport` can be built/tested without pulling +// in ReticulumSwift. + +private extension NSLock { + func sync(_ body: () -> R) -> R { lock(); defer { unlock() }; return body() } +} + +/// `BLEDriver` whose operations are marshaled to the app over a `BLESeamTransport`. +public final class AppGroupBLEDriver: BLEDriver, @unchecked Sendable { + + private let transport: BLESeamTransport + private let lock = NSLock() + + // Driver streams (fed from app→NE events). + private let _discoveredPeers: AsyncStream + private let discoveredCont: AsyncStream.Continuation + private let _incomingConnections: AsyncStream + private let incomingCont: AsyncStream.Continuation + private let _connectionLost: AsyncStream + private let connectionLostCont: AsyncStream.Continuation + + // Request/response correlation for the value-returning calls. + private var nextReqId: UInt32 = 1 + private var pending: [UInt32: CheckedContinuation] = [:] + + // Live connections, keyed by address — to route inbound fragments + lifecycle. + private var connections: [String: AppGroupBLEPeerConnection] = [:] + + // Cached local state (the protocol's getters are synchronous; the real values + // live in the app process, so we cache the last reported snapshot). + private var cachedLocalAddress: String? + private var cachedIsRunning = false + + public init(transport: BLESeamTransport) { + self.transport = transport + (_discoveredPeers, discoveredCont) = AsyncStream.makeStream(of: DiscoveredPeer.self) + (_incomingConnections, incomingCont) = AsyncStream.makeStream(of: (any BLEPeerConnection).self) + (_connectionLost, connectionLostCont) = AsyncStream.makeStream(of: String.self) + + let inbound = transport.inbound + Task { [weak self] in + for await message in inbound { self?.handleInbound(message) } + // Inbound ended (the seam transport was stopped on NE teardown). Finish the + // driver streams so `BLEInterface`'s consumer tasks exit cleanly instead of + // blocking forever on a stream that will never yield again. + self?.discoveredCont.finish() + self?.incomingCont.finish() + self?.connectionLostCont.finish() + } + } + + // MARK: BLEDriver — streams & state + + public var discoveredPeers: AsyncStream { _discoveredPeers } + public var incomingConnections: AsyncStream { _incomingConnections } + public var connectionLost: AsyncStream { _connectionLost } + public var localAddress: String? { lock.sync { cachedLocalAddress } } + public var isRunning: Bool { lock.sync { cachedIsRunning } } + + // MARK: BLEDriver — commands + + public func startAdvertising() async throws { transport.send(.startAdvertising) } + public func stopAdvertising() async { transport.send(.stopAdvertising) } + public func startScanning() async throws { transport.send(.startScanning) } + public func stopScanning() async { transport.send(.stopScanning) } + public func disconnect(address: String) async { transport.send(.disconnect(address: address)) } + public func shutdown() { transport.send(.shutdown) } + + public func connect(address: String) async throws -> any BLEPeerConnection { + let reply = try await request { .connect(reqId: $0, address: address) } + guard case let .connectResult(_, addr, mtu, identity, error) = reply else { + throw BLESeamError.unexpectedReply + } + if let error { throw BLESeamError.driver(error) } + return makeConnection(address: addr, mtu: Int(mtu), identity: identity) + } + + /// Ask the app for the latest local address / running state and cache it. + public func refreshLocalState() async { + guard let reply = try? await request({ .queryLocalState(reqId: $0) }), + case let .queryLocalStateResult(_, addr, running) = reply else { return } + lock.sync { cachedLocalAddress = addr; cachedIsRunning = running } + } + + // MARK: Back-channel for AppGroupBLEPeerConnection + + /// Fire-and-forget send on behalf of a connection (sendFragment / writeIdentity / close). + func connectionSend(_ message: BLEDriverSeamMessage) { transport.send(message) } + + /// reqId round-trip on behalf of a connection (readIdentity / readRemoteRssi). + func connectionRequest(_ make: (UInt32) -> BLEDriverSeamMessage) async throws -> BLEDriverSeamMessage { + try await request(make) + } + + // MARK: Internals + + private func request(_ make: (UInt32) -> BLEDriverSeamMessage) async throws -> BLEDriverSeamMessage { + let reqId = lock.sync { () -> UInt32 in let r = nextReqId; nextReqId &+= 1; return r } + return try await withCheckedThrowingContinuation { cont in + lock.sync { pending[reqId] = cont } + transport.send(make(reqId)) + } + } + + private func makeConnection(address: String, mtu: Int, identity: Data?) -> AppGroupBLEPeerConnection { + let conn = AppGroupBLEPeerConnection(address: address, mtu: mtu, identity: identity, driver: self) + lock.sync { connections[address] = conn } + return conn + } + + private func handleInbound(_ message: BLEDriverSeamMessage) { + switch message { + case let .discovered(address, rssi, identity): + discoveredCont.yield(DiscoveredPeer(address: address, rssi: Int(rssi), identity: identity)) + + case let .incomingConnection(address, mtu, identity): + incomingCont.yield(makeConnection(address: address, mtu: Int(mtu), identity: identity)) + + case let .connectionLost(address): + let conn = lock.sync { connections.removeValue(forKey: address) } + conn?.finish() + connectionLostCont.yield(address) + + case let .receivedFragment(address, data): + let conn = lock.sync { connections[address] } + conn?.deliver(data) + + case let .queryLocalStateResult(reqId, addr, running): + lock.sync { cachedLocalAddress = addr; cachedIsRunning = running } + resume(reqId, with: message) + + case let .connectResult(reqId, _, _, _, _), + let .readIdentityResult(reqId, _, _), + let .readRemoteRssiResult(reqId, _, _): + resume(reqId, with: message) + + default: + break // commands never arrive on the NE's inbound channel + } + } + + private func resume(_ reqId: UInt32, with message: BLEDriverSeamMessage) { + let cont = lock.sync { pending.removeValue(forKey: reqId) } + cont?.resume(returning: message) + } +} + +/// `BLEPeerConnection` whose ops are marshaled to the app via the parent driver. +public final class AppGroupBLEPeerConnection: BLEPeerConnection, @unchecked Sendable { + + public let address: String + private let lock = NSLock() + private var cachedMtu: Int + private var cachedIdentity: Data? + private unowned let driver: AppGroupBLEDriver + + private let _receivedFragments: AsyncStream + private let receivedCont: AsyncStream.Continuation + + init(address: String, mtu: Int, identity: Data?, driver: AppGroupBLEDriver) { + self.address = address + self.cachedMtu = mtu + self.cachedIdentity = identity + self.driver = driver + (_receivedFragments, receivedCont) = AsyncStream.makeStream(of: Data.self) + } + + public var mtu: Int { lock.sync { cachedMtu } } + public var identity: Data? { lock.sync { cachedIdentity } } + public var receivedFragments: AsyncStream { _receivedFragments } + + public func sendFragment(_ data: Data) async throws { + driver.connectionSend(.sendFragment(address: address, data: data)) + } + + public func readIdentity() async throws -> Data { + let reply = try await driver.connectionRequest { .readIdentity(reqId: $0, address: address) } + guard case let .readIdentityResult(_, identity, error) = reply else { throw BLESeamError.unexpectedReply } + if let error { throw BLESeamError.driver(error) } + guard let identity else { throw BLESeamError.driver("no identity") } + lock.sync { cachedIdentity = identity } + return identity + } + + public func writeIdentity(_ identity: Data) async throws { + driver.connectionSend(.writeIdentity(address: address, identity: identity)) + } + + public func readRemoteRssi() async throws -> Int { + let reply = try await driver.connectionRequest { .readRemoteRssi(reqId: $0, address: address) } + guard case let .readRemoteRssiResult(_, rssi, error) = reply else { throw BLESeamError.unexpectedReply } + if let error { throw BLESeamError.driver(error) } + return Int(rssi) + } + + public func close() { driver.connectionSend(.closeConnection(address: address)) } + + // Driver-internal: route inbound fragments + end the stream on disconnect. + func deliver(_ data: Data) { receivedCont.yield(data) } + func finish() { receivedCont.finish() } +} diff --git a/Sources/Shared/AppGroupBLESeamTransport.swift b/Sources/Shared/AppGroupBLESeamTransport.swift new file mode 100644 index 00000000..d9ea7a5a --- /dev/null +++ b/Sources/Shared/AppGroupBLESeamTransport.swift @@ -0,0 +1,118 @@ +// +// AppGroupBLESeamTransport.swift +// Shared +// +// Production `BLESeamTransport` for the Model B BLE seam. Rides two dedicated +// App-Group `SharedFrameQueue`s (so the BLE control/data stream never intermixes +// with `AppGroupBridgeInterface`'s radio-frame a2e/e2a), each woken by its own +// Darwin notification — the same proven file-lock + notify mechanism the rest of +// Model B uses. +// +// role .networkExtension : send → bleSeamN2A (notify N2A) ; inbound ← bleSeamA2N (observe A2N) +// role .app : send → bleSeamA2N (notify A2N) ; inbound ← bleSeamN2A (observe N2A) +// +// Pure Foundation/CoreFoundation (no ReticulumSwift), so it's unit-testable with +// two instances in one process looping back through temp-dir-backed queues. +// + +import Foundation + +public final class AppGroupBLESeamTransport: BLESeamTransport, @unchecked Sendable { + + public enum Role { case networkExtension, app } + + private let sendQueue: SharedFrameQueue + private let inboundQueue: SharedFrameQueue + private let sendNotification: String + private let inboundNotification: String + + private let _inbound: AsyncStream + private let inboundCont: AsyncStream.Continuation + private var observerRegistered = false + + public init(role: Role, appGroupIdentifier: String = appGroupIdentifier) { + switch role { + case .networkExtension: + sendQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier, name: SharedFrameQueueName.bleSeamN2A) + inboundQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier, name: SharedFrameQueueName.bleSeamA2N) + sendNotification = SharedDefaultsConstants.bleSeamN2ANotificationName + inboundNotification = SharedDefaultsConstants.bleSeamA2NNotificationName + case .app: + sendQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier, name: SharedFrameQueueName.bleSeamA2N) + inboundQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier, name: SharedFrameQueueName.bleSeamN2A) + sendNotification = SharedDefaultsConstants.bleSeamA2NNotificationName + inboundNotification = SharedDefaultsConstants.bleSeamN2ANotificationName + } + (_inbound, inboundCont) = AsyncStream.makeStream(of: BLEDriverSeamMessage.self) + } + + /// Begin observing the inbound queue. Call once after construction. (Separate + /// from `init` so `self` is fully initialized before the C callback can fire.) + public func start() { + guard !observerRegistered else { return } + observerRegistered = true + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + center, observer, + { _, observer, _, _, _ in + guard let observer else { return } + Unmanaged.fromOpaque(observer) + .takeUnretainedValue() + .drainInbound() + }, + inboundNotification as CFString, + nil, + .deliverImmediately + ) + // Drain anything queued before the observer was registered. + drainInbound() + } + + public func stop() { + guard observerRegistered else { return } + observerRegistered = false + let center = CFNotificationCenterGetDarwinNotifyCenter() + CFNotificationCenterRemoveObserver( + center, + Unmanaged.passUnretained(self).toOpaque(), + CFNotificationName(inboundNotification as CFString), + nil + ) + inboundCont.finish() + } + + // MARK: BLESeamTransport + + public func send(_ message: BLEDriverSeamMessage) { + sendQueue.append(frame: message.encode(), interfaceTag: FrameInterfaceTag.bleControl.rawValue) + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(sendNotification as CFString), + nil, nil, true + ) + } + + public var inbound: AsyncStream { _inbound } + + /// Drain the inbound queue immediately, bypassing the Darwin-notification + /// wakeup. Belt-and-suspenders for a missed notification (and the + /// deterministic hook unit tests drive instead of run-loop timing). Returns + /// the messages drained (also yielded to `inbound`). + @discardableResult + public func drainNow() -> [BLEDriverSeamMessage] { drainInbound() } + + // MARK: Internals + + @discardableResult + private func drainInbound() -> [BLEDriverSeamMessage] { + var drained: [BLEDriverSeamMessage] = [] + for frame in inboundQueue.readAllAndClear() { + guard frame.interfaceTag == FrameInterfaceTag.bleControl.rawValue, + let message = try? BLEDriverSeamMessage(decoding: frame.data) else { continue } + inboundCont.yield(message) + drained.append(message) + } + return drained + } +} diff --git a/Sources/Shared/AppGroupBLEServer.swift b/Sources/Shared/AppGroupBLEServer.swift new file mode 100644 index 00000000..e8ae2ee2 --- /dev/null +++ b/Sources/Shared/AppGroupBLEServer.swift @@ -0,0 +1,163 @@ +// +// AppGroupBLEServer.swift +// Shared +// +// App side of the Model B BLE seam — the mirror of `AppGroupBLEDriver`. The app +// hosts the real `BLEDriver` (reticulum-swift's `CoreBluetoothBLEDriver`, since +// CoreBluetooth can't run in the NE). This server consumes the NE's commands off +// the seam, drives the local driver, and forwards the driver's three streams + +// each connection's `receivedFragments` + the reqId-correlated replies back to +// the NE. Takes `any BLEDriver` so it's driver-agnostic (real CB driver in +// production; a mock in tests). See `ble_to_ne_driver_abstraction_plan` (vault). +// + +import Foundation +import ReticulumSwift + +public final class AppGroupBLEServer: @unchecked Sendable { + + private let transport: BLESeamTransport + private let driver: any BLEDriver + private let lock = NSLock() + private var connections: [String: any BLEPeerConnection] = [:] + /// Optional log sink (the app passes `DiagLog.log`; Shared can't reference it). + private let log: (@Sendable (String) -> Void)? + + public init(transport: BLESeamTransport, driver: any BLEDriver, + log: (@Sendable (String) -> Void)? = nil) { + self.transport = transport + self.driver = driver + self.log = log + } + + /// Begin forwarding the driver's streams to the NE and consuming NE commands. + public func start() { + log?("[BLE] server: started — forwarding driver streams over the seam") + Task { [transport, driver, log] in + var seenLog = Set() // log first sighting only (yields fire ~10x/s/peer) + for await peer in driver.discoveredPeers { + if seenLog.insert(peer.address).inserted { + log?("[BLE] server: discovered \(peer.address.prefix(8)) rssi=\(peer.rssi) → seam") + } + transport.send(.discovered(address: peer.address, + rssi: Int16(clamping: peer.rssi), + identity: peer.identity)) + } + } + Task { [weak self] in + guard let self else { return } + for await conn in self.driver.incomingConnections { + self.log?("[BLE] server: incoming connection \(conn.address.prefix(8)) mtu=\(conn.mtu) → seam") + self.register(conn) + self.transport.send(.incomingConnection(address: conn.address, + mtu: UInt16(clamping: conn.mtu), + identity: conn.identity)) + } + } + Task { [weak self] in + guard let self else { return } + for await address in self.driver.connectionLost { + self.log?("[BLE] server: connection lost \(address.prefix(8))") + self.unregister(address) + self.transport.send(.connectionLost(address: address)) + } + } + Task { [weak self] in + guard let self else { return } + for await message in self.transport.inbound { await self.handle(message) } + } + } + + // MARK: Connection registry + fragment forwarding + + private func register(_ conn: any BLEPeerConnection) { + let address = conn.address + lock.sync { connections[address] = conn } + Task { [transport] in + for await fragment in conn.receivedFragments { + transport.send(.receivedFragment(address: address, data: fragment)) + } + } + } + + private func unregister(_ address: String) { lock.sync { _ = connections.removeValue(forKey: address) } } + private func connection(_ address: String) -> (any BLEPeerConnection)? { lock.sync { connections[address] } } + + // MARK: Command dispatch (NE → driver) + + private func handle(_ message: BLEDriverSeamMessage) async { + switch message { + case .startAdvertising: + do { try await driver.startAdvertising(); log?("[BLE] server: startAdvertising OK") } + catch { log?("[BLE] server: startAdvertising FAILED: \(error)") } + case .stopAdvertising: await driver.stopAdvertising() + case .startScanning: + do { try await driver.startScanning(); log?("[BLE] server: startScanning OK (localAddr=\(driver.localAddress ?? "nil"))") } + catch { log?("[BLE] server: startScanning FAILED: \(error)") } + case .stopScanning: await driver.stopScanning() + case .shutdown: driver.shutdown() + case let .disconnect(address): await driver.disconnect(address: address) + + case let .connect(reqId, address): + log?("[BLE] server: connect → \(address.prefix(8)) (central)") + do { + let conn = try await driver.connect(address: address) + log?("[BLE] server: connect OK \(address.prefix(8)) mtu=\(conn.mtu)") + register(conn) + transport.send(.connectResult(reqId: reqId, address: conn.address, + mtu: UInt16(clamping: conn.mtu), + identity: conn.identity, error: nil)) + } catch { + log?("[BLE] server: connect FAILED \(address.prefix(8)): \(error)") + transport.send(.connectResult(reqId: reqId, address: address, mtu: 0, + identity: nil, error: String(describing: error))) + } + + case let .queryLocalState(reqId): + transport.send(.queryLocalStateResult(reqId: reqId, + localAddress: driver.localAddress, + isRunning: driver.isRunning)) + + case let .sendFragment(address, data): + try? await connection(address)?.sendFragment(data) + + case let .writeIdentity(address, identity): + log?("[BLE] server: writeIdentity → \(address.prefix(8)) (\(identity.count)B)") + do { try await connection(address)?.writeIdentity(identity); log?("[BLE] server: writeIdentity OK \(address.prefix(8))") } + catch { log?("[BLE] server: writeIdentity FAILED \(address.prefix(8)): \(error)") } + + case let .closeConnection(address): + connection(address)?.close() + unregister(address) + + case let .readIdentity(reqId, address): + log?("[BLE] server: readIdentity → \(address.prefix(8))") + guard let conn = connection(address) else { + log?("[BLE] server: readIdentity NO-CONN \(address.prefix(8))") + transport.send(.readIdentityResult(reqId: reqId, identity: nil, error: "no connection")); return + } + do { + let id = try await conn.readIdentity() + log?("[BLE] server: readIdentity OK \(address.prefix(8)) (\(id.count)B)") + transport.send(.readIdentityResult(reqId: reqId, identity: id, error: nil)) + } catch { + log?("[BLE] server: readIdentity FAILED \(address.prefix(8)): \(error)") + transport.send(.readIdentityResult(reqId: reqId, identity: nil, error: String(describing: error))) + } + + case let .readRemoteRssi(reqId, address): + guard let conn = connection(address) else { + transport.send(.readRemoteRssiResult(reqId: reqId, rssi: 0, error: "no connection")); return + } + do { transport.send(.readRemoteRssiResult(reqId: reqId, rssi: Int16(clamping: try await conn.readRemoteRssi()), error: nil)) } + catch { transport.send(.readRemoteRssiResult(reqId: reqId, rssi: 0, error: String(describing: error))) } + + default: + break // results/events flow app→NE; the server never receives them + } + } +} + +private extension NSLock { + func sync(_ body: () -> R) -> R { lock(); defer { unlock() }; return body() } +} diff --git a/Sources/Shared/AppGroupBridgeInterface.swift b/Sources/Shared/AppGroupBridgeInterface.swift new file mode 100644 index 00000000..f0cd68c7 --- /dev/null +++ b/Sources/Shared/AppGroupBridgeInterface.swift @@ -0,0 +1,237 @@ +// +// AppGroupBridgeInterface.swift +// Columba Shared +// +// Model B IPC bridge: a ReticulumSwift `NetworkInterface` that carries RNS +// frames across the App-Group boundary between the main app (which owns the +// BLE/RNode radios) and the Network Extension (which owns the single RNS +// node). This lets the NE's RNS instance be reachable over both TCP (direct, +// via the NE's own TCP interfaces) AND radio (via this bridge + the app's +// radio relay). +// +// Directions (see `SharedFrameQueue` for the queue layout): +// • send(_:) — NE→app. The NE's transport wants to transmit `data`; +// the bridge HDLC-frames it, enqueues to the `e2a` queue +// tagged with the target radio, and pokes the app. +// • deliverInbound(_:) — app→NE. The app drained a radio-received (already +// HDLC-deframed) packet from the `a2e` queue; the bridge +// hands it to the transport delegate as an inbound packet. +// +// COLLISION RULE: this file conforms to `ReticulumSwift.NetworkInterface`, so +// it imports ReticulumSwift. It MUST import ONLY ReticulumSwift + Foundation +// and NOT RNSAPI — RNSAPI's Compat layer re-declares NetworkInterface / +// Destination / Link / etc., and co-importing both produces an un-fixable +// ambiguity cascade. All ReticulumSwift types below are referenced unqualified +// (only ReticulumSwift is in scope, so they are unambiguous). +// +// REGISTRATION (Track A5, NOT done here): registering this interface into the +// NE's `ReticulumTransport` and running the app-side radio relay loop (drain +// `e2a` → transmit on radio; receive on radio → deframe → enqueue `a2e` → +// post `radioFrameReady`) depends on the NE running the RNS backend (Model B). +// A1 delivers only the conforming interface type + the bidirectional queue, +// compile-validated. The NE target does not yet link ReticulumSwift, so this +// file is currently a member of the ColumbaApp target only; when A5 links +// ReticulumSwift into the ColumbaNetworkExtension target, add this file to that +// target's Sources phase as well. +// + +import Foundation +import ReticulumSwift + +/// App-Group IPC bridge presented to a `ReticulumTransport` as a single +/// `NetworkInterface`. Frames flowing out of the transport (`send`) are queued +/// for the peer process to transmit on a radio; radio receptions delivered by +/// the peer process (`deliverInbound`) are surfaced to the transport delegate. +/// +/// An `actor` for the same reasons `TCPInterface` is: the protocol is +/// `Sendable` and the delegate is held weakly behind an actor-isolated wrapper. +public actor AppGroupBridgeInterface: @preconcurrency NetworkInterface { + + // MARK: - NetworkInterface conformance + + /// Stable identifier for the single bridge interface. + public let id: String + + /// Full-mode interface configuration. `.full` propagates announces in both + /// directions (radio↔TCP), which is the whole point of the bridge: the NE's + /// RNS node should relay announces between its TCP peers and the radio mesh. + public let config: InterfaceConfig + + /// Current connection state. Reflects whether the IPC channel is live: + /// `.connected` once `connect()` has been called (the App-Group queues are + /// always reachable in-process), `.disconnected` after `disconnect()`. + public private(set) var state: InterfaceState = .disconnected + + /// Hardware MTU — the radio's negotiated MTU. Caps the link MDU during + /// MTU discovery so the NE never hands the app a frame the radio can't + /// transmit. Supplied at init by whoever knows the active radio's MTU. + public let hwMtu: Int + + // MARK: - Bridge state + + /// Which radio NE-originated frames (`send`) should be transmitted on, and + /// the tag stamped onto `e2a` queue entries so the app's relay knows where + /// to route them. + private let targetRadio: FrameInterfaceTag + + /// App→NE / NE→app queue handles. `send` writes to `e2a`; the peer process + /// drains it. `deliverInbound` is fed by the local process draining `a2e`. + private let e2aQueue: SharedFrameQueue + + /// Darwin notification posted after writing to `e2a` so the peer wakes and + /// drains promptly. + private let outboundNotificationName: String + + // MARK: - Delegate + + /// Weak wrapper so the actor doesn't retain the transport delegate. + /// Mirrors `TCPInterface`'s pattern (weak refs are atomically nil-safe). + private var delegateRef: WeakBridgeDelegate? + + /// Delegate for interface events (the `ReticulumTransport` wrapper). + public var delegate: InterfaceDelegate? { + get { delegateRef?.delegate } + set { delegateRef = newValue.map { WeakBridgeDelegate($0) } } + } + + // MARK: - Initialization + + /// Create the App-Group bridge interface. + /// + /// - Parameters: + /// - id: Interface identifier. Defaults to `"appgroup-bridge"`. + /// - appGroupIdentifier: App Group container shared with the peer process. + /// - targetRadio: Radio that `send`-direction frames are transmitted on + /// (and the tag written to the `e2a` queue). Defaults to `.bleMesh`. + /// - hwMtu: The radio's negotiated hardware MTU (caps link MDU). + /// - mode: Interface mode. Defaults to `.full` (announces propagate both + /// ways). + public init( + id: String = "appgroup-bridge", + appGroupIdentifier: String, + targetRadio: FrameInterfaceTag = .bleMesh, + hwMtu: Int, + mode: InterfaceMode = .full + ) { + self.id = id + self.hwMtu = hwMtu + self.targetRadio = targetRadio + self.e2aQueue = SharedFrameQueue( + appGroupIdentifier: appGroupIdentifier, + name: SharedFrameQueueName.e2a + ) + self.outboundNotificationName = SharedDefaultsConstants.packetReadyNotificationName + self.config = InterfaceConfig( + id: id, + name: "App-Group Bridge", + type: .tcp, + enabled: true, + mode: mode, + host: "", + port: 0, + ifac: nil + ) + } + + // MARK: - Lifecycle + + /// "Connect" the bridge. The App-Group queues are always reachable in + /// process, so this just marks the IPC channel live and notifies the + /// delegate of the state change. + public func connect() async throws { + guard state == .disconnected else { return } + state = .connected + notifyStateChange() + } + + /// "Disconnect" the bridge — marks the IPC channel down. Queued frames are + /// left in place for the peer to drain. + public func disconnect() async { + guard state != .disconnected else { return } + state = .disconnected + notifyStateChange() + } + + // MARK: - Outbound (NE→app) + + /// Send a packet out through the bridge (NE→app direction). + /// + /// HDLC-frames the payload (matching `TCPInterface.send`, which frames with + /// `HDLC.frame` before handing bytes to its transport), enqueues the framed + /// bytes onto the `e2a` queue tagged with the target radio, then posts the + /// NE→app Darwin notification so the peer process drains and transmits. + /// + /// - Parameter data: Raw, unframed Reticulum packet to transmit. + public func send(_ data: Data) async throws { + guard state == .connected else { + throw InterfaceError.notConnected + } + let framed = HDLC.frame(data) + e2aQueue.append(frame: framed, interfaceTag: targetRadio.rawValue) + postOutboundNotification() + } + + // MARK: - Inbound (app→NE) + + /// Deliver a radio-received packet into the transport (app→NE direction). + /// + /// Called by the local process's relay loop after it drains a frame from + /// the `a2e` queue. The frame is expected to be ALREADY HDLC-deframed — the + /// relay that owns the radio strips framing before enqueueing, mirroring how + /// `PacketTunnelProvider` runs `extractHDLCFrames` on inbound TCP before + /// writing to the queue. The bridge therefore forwards `frame` straight to + /// the delegate as a complete packet, exactly as `TCPInterface` does once it + /// has extracted a frame. + /// + /// - Parameter frame: Complete, unframed inbound Reticulum packet. + public func deliverInbound(_ frame: Data) { + guard let delegate = delegateRef?.delegate else { return } + delegate.interface(id: id, didReceivePacket: frame) + } + + // MARK: - Delegate plumbing + + /// Set the delegate for receiving interface events. Satisfies the + /// `NetworkInterface` protocol requirement. + public func setDelegate(_ delegate: InterfaceDelegate) async { + self.delegate = delegate + } + + /// Notify the delegate of the current state. Mirrors `TCPInterface`. + private func notifyStateChange() { + let currentState = state + let interfaceId = id + guard let delegate = delegateRef?.delegate else { return } + delegate.interface(id: interfaceId, didChangeState: currentState) + } + + // MARK: - Darwin notification + + /// Post the NE→app Darwin notification so the peer process drains `e2a`. + private func postOutboundNotification() { + let center = CFNotificationCenterGetDarwinNotifyCenter() + CFNotificationCenterPostNotification( + center, + CFNotificationName(outboundNotificationName as CFString), + nil, + nil, + true + ) + } +} + +// MARK: - WeakBridgeDelegate + +/// Weak wrapper for the delegate reference held inside the actor. +/// +/// `@unchecked Sendable` because weak references are inherently thread-safe +/// (they become nil atomically when the referent is deallocated). Named +/// distinctly from `TCPInterface`'s private `WeakDelegate` to avoid any +/// same-module collision should both files ever land in one target. +private final class WeakBridgeDelegate: @unchecked Sendable { + weak var delegate: InterfaceDelegate? + + init(_ delegate: InterfaceDelegate) { + self.delegate = delegate + } +} diff --git a/Sources/Shared/AppGroupPaths.swift b/Sources/Shared/AppGroupPaths.swift new file mode 100644 index 00000000..41062922 --- /dev/null +++ b/Sources/Shared/AppGroupPaths.swift @@ -0,0 +1,108 @@ +// +// AppGroupPaths.swift +// Columba Shared +// +// Single source of truth for the App-Group-shared on-disk paths the app and the +// Network Extension MUST agree on (Model B). Before this file existed, the two +// processes each computed the canonical LXMF store path independently — the app +// in `AppServices.grdbDatabaseFilePath(for:)`, the NE in +// `NEReticulumNode.appGroupLXMFDatabasePath(identityHashHex:)` — and any drift in +// the layout meant they opened DIFFERENT GRDB files and never converged. This +// enum is the one place that layout is defined; BOTH sides delegate here so they +// CANNOT drift. +// +// Layout (rooted at the App-Group container so both processes reach the same +// files — the app's Application Support container is process-local and the NE +// cannot see it): +// +// /Columba/python-/lxmf-swift.db (+ -wal / -shm) +// /Columba/python-/ratchets +// +// `identityHashHex` is the RAW identity hash (`identity.hexHash`) — NOT the +// lxmf.delivery destination hash — exactly as `AppServices` keyed it before, so +// the per-identity subdirectory resolves to the identical file on both sides. +// +// This file imports ONLY Foundation and is compiled into BOTH the ColumbaApp and +// ColumbaNetworkExtension targets (like `SharedFrameQueue` / `ExtensionDiagLog`). +// It MUST stay free of ReticulumSwift / LXMFSwift / RNSAPI so it compiles in the +// NE target (which links none of those) and so the app's `AppServices` can use it +// WITHOUT gaining an `import LXMFSwift` (the A0 collision rule). +// + +import Foundation + +/// App-Group-shared path helper — the single source of truth for the canonical +/// LXMF store and ratchet-storage locations both the app and the Network Extension +/// open. See the file header for the exact layout and why it's centralized. +/// +/// Foundation-only by contract (no Reticulum/LXMF/RNSAPI imports) so it is safe in +/// both targets. +public enum AppGroupPaths { + + /// Subdirectory under the App-Group container holding all Columba state. + private static let columbaDirectoryName = "Columba" + + /// LXMF GRDB store filename — matches the name the app previously used at the + /// process-local path and the name the NE hardcodes, so the two converge. + private static let lxmfDatabaseFileName = "lxmf-swift.db" + + /// Ratchet-storage filename (LXMF/Reticulum writes the ratchet state here), + /// co-located with the GRDB store under the per-identity directory. + private static let ratchetStorageFileName = "ratchets" + + // MARK: - Public API + + /// The App-Group container root, or `nil` when the container is unavailable + /// (e.g. an unsigned / simulator build with no App-Group entitlement). All the + /// other helpers return `nil` in that case rather than silently falling back to + /// a process-local path — a process-local path is exactly the drift this enum + /// exists to prevent. Callers decide how to handle the `nil` (the NE logs and + /// uses tmp; the app keeps its existing process-local path). + public static func containerURL() -> URL? { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + } + + /// URL of the App-Group-shared canonical `lxmf-swift.db` for `identityHashHex` + /// (the raw identity hash — NOT the lxmf.delivery destination hash). Creates the + /// intermediate `Columba/python-/` directory as a side effect. Returns + /// `nil` if the App-Group container is unavailable. + /// + /// - Parameter identityHashHex: Hex of the raw identity hash (`identity.hexHash`). + public static func lxmfDatabaseURL(identityHashHex: String) -> URL? { + guard let dir = perIdentityDirectoryURL(identityHashHex: identityHashHex) else { + return nil + } + return dir.appendingPathComponent(lxmfDatabaseFileName) + } + + /// URL of the App-Group-shared ratchet storage for `identityHashHex`, alongside + /// the GRDB store so all per-identity state co-locates in the shared container. + /// Creates the intermediate directory as a side effect. Returns `nil` if the + /// App-Group container is unavailable. + /// + /// - Parameter identityHashHex: Hex of the raw identity hash (`identity.hexHash`). + public static func ratchetStorageURL(identityHashHex: String) -> URL? { + guard let dir = perIdentityDirectoryURL(identityHashHex: identityHashHex) else { + return nil + } + return dir.appendingPathComponent(ratchetStorageFileName) + } + + /// Resolve (creating if needed) the per-identity directory + /// `/Columba/python-/`. Returns `nil` when + /// the App-Group container is unavailable. The directory-create is best-effort + /// (a failure here surfaces later when the store/ratchet open fails, with a + /// clearer error than an opaque path string would give). + /// + /// - Parameter identityHashHex: Hex of the raw identity hash (`identity.hexHash`). + public static func perIdentityDirectoryURL(identityHashHex: String) -> URL? { + guard let container = containerURL() else { return nil } + let dir = container + .appendingPathComponent(columbaDirectoryName, isDirectory: true) + .appendingPathComponent("python-\(identityHashHex)", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } +} diff --git a/Sources/Shared/AppGroupRNodeSeamTransport.swift b/Sources/Shared/AppGroupRNodeSeamTransport.swift new file mode 100644 index 00000000..d7388fad --- /dev/null +++ b/Sources/Shared/AppGroupRNodeSeamTransport.swift @@ -0,0 +1,132 @@ +// +// AppGroupRNodeSeamTransport.swift +// Shared +// +// NE-side reticulum-swift `Transport` for the Model B RNode seam. Marshals the +// `Transport` surface (connect / send / disconnect / state / onDataReceived) across +// the App-Group to the app's real `BLETransport` over an `RNodeSeamWire`. Injected +// into `RNodeInterface` via its `transportFactory`, replacing the direct-CoreBluetooth +// `BLETransport` — so the radio runs in the app process (Model B) while +// `RNodeInterface` + KISS framing run here in the NE. +// +// Mirrors how `AppGroupBLEDriver` marshals the `BLEDriver` surface for the BLE mesh, +// but tiny: RNode is a single serial stream. `send` carries a `reqId` so the NE's +// `send(_:completion:)` resumes only when the app's real write completes — RNode flow +// control depends on write-completion (`RNodeInterface.sendViaTransport`). +// + +import Foundation +import ReticulumSwift + +public final class AppGroupRNodeSeamTransport: Transport, @unchecked Sendable { + + private let wire: AppGroupRNodeSeamWire + private var inboundTask: Task? + + private let lock = NSLock() + private var pendingSends: [UInt32: (Error?) -> Void] = [:] + private var nextReqId: UInt32 = 0 + + // The Transport callbacks are set by `RNodeInterface.setupTransport` (via the KISS + // wrapper) BEFORE `connect()` runs, so the inbound task — started in `connect()` — + // reads them without a data race. + public private(set) var state: TransportState = .disconnected + public var onStateChange: ((TransportState) -> Void)? + public var onDataReceived: ((Data) -> Void)? + + /// The RNode device name to target — passed through to the app's `BLETransport`. + /// Empty string = connect to the first RNode found. + private let deviceName: String + + public init( + deviceName: String, + wire: AppGroupRNodeSeamWire = AppGroupRNodeSeamWire(role: .networkExtension) + ) { + self.deviceName = deviceName + self.wire = wire + } + + // MARK: - Transport + + public func connect() { + ExtensionDiagLog.log("[RNODE] seam(NE): connect(device='\(deviceName)')") + inboundTask = Task { [weak self] in + guard let self else { return } + for await message in self.wire.inbound { + self.handle(message) + } + } + wire.start() + setState(.connecting) + wire.send(.connect(deviceName: deviceName)) + } + + public func send(_ data: Data, completion: ((Error?) -> Void)?) { + lock.lock() + let reqId = nextReqId + nextReqId &+= 1 + if let completion { pendingSends[reqId] = completion } + lock.unlock() + wire.send(.send(reqId: reqId, data: data)) + } + + public func disconnect() { + ExtensionDiagLog.log("[RNODE] seam(NE): disconnect") + wire.send(.disconnect) + inboundTask?.cancel() + inboundTask = nil + wire.stop() + // Fail any in-flight sends so the awaiting `send` continuations don't hang. + lock.lock() + let pending = pendingSends + pendingSends.removeAll() + lock.unlock() + for (_, completion) in pending { completion(RNodeSeamTransportError.disconnected) } + setState(.disconnected) + } + + // MARK: - Inbound (app → NE) + + private func handle(_ message: RNodeSeamMessage) { + switch message { + case let .dataReceived(data): + onDataReceived?(data) + case let .stateChanged(linkState): + ExtensionDiagLog.log("[RNODE] seam(NE): radio state -> \(linkState)") + setState(linkState.transportState) + case let .sendResult(reqId, error): + lock.lock() + let completion = pendingSends.removeValue(forKey: reqId) + lock.unlock() + completion?(error.map { RNodeSeamTransportError.appWrite($0) }) + case .connect, .send, .disconnect: + break // NE→app commands; the NE never receives these inbound. + } + } + + private func setState(_ newState: TransportState) { + state = newState + onStateChange?(newState) + } +} + +/// Errors surfaced by the RNode seam transport. +public enum RNodeSeamTransportError: Error { + /// The seam was torn down with sends still in flight. + case disconnected + /// The app-side radio reported a write failure (string carries the underlying error). + case appWrite(String) + /// The app-side radio link failed. + case linkFailed +} + +private extension RNodeLinkState { + var transportState: TransportState { + switch self { + case .disconnected: return .disconnected + case .connecting: return .connecting + case .connected: return .connected + case .failed: return .failed(RNodeSeamTransportError.linkFailed) + } + } +} diff --git a/Sources/Shared/AppGroupRNodeSeamWire.swift b/Sources/Shared/AppGroupRNodeSeamWire.swift new file mode 100644 index 00000000..95268fb3 --- /dev/null +++ b/Sources/Shared/AppGroupRNodeSeamWire.swift @@ -0,0 +1,116 @@ +// +// AppGroupRNodeSeamWire.swift +// Shared +// +// Production `RNodeSeamWire` for the Model B RNode serial seam. Rides two dedicated +// App-Group `SharedFrameQueue`s (separate from the BLE seam + the radio-frame a2e/e2a +// queues), each woken by its own Darwin notification — the same file-lock + notify +// mechanism the rest of Model B uses. +// +// role .networkExtension : send → rnodeSeamN2A (notify N2A) ; inbound ← rnodeSeamA2N (observe A2N) +// role .app : send → rnodeSeamA2N (notify A2N) ; inbound ← rnodeSeamN2A (observe N2A) +// +// Pure Foundation/CoreFoundation (no ReticulumSwift), so it's unit-testable with two +// instances in one process looping back through temp-dir-backed queues. Mirrors +// `AppGroupBLESeamTransport`. +// + +import Foundation + +public final class AppGroupRNodeSeamWire: RNodeSeamWire, @unchecked Sendable { + + public enum Role { case networkExtension, app } + + private let sendQueue: SharedFrameQueue + private let inboundQueue: SharedFrameQueue + private let sendNotification: String + private let inboundNotification: String + + private let _inbound: AsyncStream + private let inboundCont: AsyncStream.Continuation + private var observerRegistered = false + + public init(role: Role, appGroupIdentifier: String = appGroupIdentifier) { + switch role { + case .networkExtension: + sendQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier, name: SharedFrameQueueName.rnodeSeamN2A) + inboundQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier, name: SharedFrameQueueName.rnodeSeamA2N) + sendNotification = SharedDefaultsConstants.rnodeSeamN2ANotificationName + inboundNotification = SharedDefaultsConstants.rnodeSeamA2NNotificationName + case .app: + sendQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier, name: SharedFrameQueueName.rnodeSeamA2N) + inboundQueue = SharedFrameQueue(appGroupIdentifier: appGroupIdentifier, name: SharedFrameQueueName.rnodeSeamN2A) + sendNotification = SharedDefaultsConstants.rnodeSeamA2NNotificationName + inboundNotification = SharedDefaultsConstants.rnodeSeamN2ANotificationName + } + (_inbound, inboundCont) = AsyncStream.makeStream(of: RNodeSeamMessage.self) + } + + /// Begin observing the inbound queue. Call once after construction. (Separate + /// from `init` so `self` is fully initialized before the C callback can fire.) + public func start() { + guard !observerRegistered else { return } + observerRegistered = true + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + center, observer, + { _, observer, _, _, _ in + guard let observer else { return } + Unmanaged.fromOpaque(observer) + .takeUnretainedValue() + .drainInbound() + }, + inboundNotification as CFString, + nil, + .deliverImmediately + ) + // Drain anything queued before the observer was registered. + drainInbound() + } + + public func stop() { + guard observerRegistered else { return } + observerRegistered = false + let center = CFNotificationCenterGetDarwinNotifyCenter() + CFNotificationCenterRemoveObserver( + center, + Unmanaged.passUnretained(self).toOpaque(), + CFNotificationName(inboundNotification as CFString), + nil + ) + inboundCont.finish() + } + + // MARK: RNodeSeamWire + + public func send(_ message: RNodeSeamMessage) { + sendQueue.append(frame: message.encode(), interfaceTag: FrameInterfaceTag.rnodeControl.rawValue) + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(sendNotification as CFString), + nil, nil, true + ) + } + + public var inbound: AsyncStream { _inbound } + + /// Drain the inbound queue immediately, bypassing the Darwin-notification wakeup. + /// Belt-and-suspenders for a missed notification (and the deterministic unit tests). + @discardableResult + public func drainNow() -> [RNodeSeamMessage] { drainInbound() } + + // MARK: Internals + + @discardableResult + private func drainInbound() -> [RNodeSeamMessage] { + var drained: [RNodeSeamMessage] = [] + for frame in inboundQueue.readAllAndClear() { + guard frame.interfaceTag == FrameInterfaceTag.rnodeControl.rawValue, + let message = try? RNodeSeamMessage(decoding: frame.data) else { continue } + inboundCont.yield(message) + drained.append(message) + } + return drained + } +} diff --git a/Sources/Shared/AppGroupRNodeServer.swift b/Sources/Shared/AppGroupRNodeServer.swift new file mode 100644 index 00000000..8bd448b5 --- /dev/null +++ b/Sources/Shared/AppGroupRNodeServer.swift @@ -0,0 +1,128 @@ +// +// AppGroupRNodeServer.swift +// Shared +// +// App-side server for the Model B RNode seam. Owns the real CoreBluetooth RNode radio +// (reticulum-swift `BLETransport`: NUS scan, MTU chunking, write backpressure, +// background state) and drives it from NE commands arriving over an `RNodeSeamWire`, +// forwarding received serial bytes + radio state-changes back to the NE. +// +// The app-side counterpart to `AppGroupRNodeSeamTransport`. Mirrors `AppGroupBLEServer` +// for the BLE mesh, but tiny — one serial stream, no per-peer addressing. +// + +import Foundation +import ReticulumSwift + +public final class AppGroupRNodeServer: @unchecked Sendable { + + private let wire: AppGroupRNodeSeamWire + private let log: ((String) -> Void)? + + /// App-local mirror of the radio's link-state changes (in addition to forwarding + /// them to the NE), so the app can surface RNode connection state in its own UI — + /// the NE owns the authoritative `RNodeInterface`, but the BLE link state is a good + /// proxy and the app has it directly here. + public var onLinkStateChange: ((RNodeLinkState) -> Void)? + + private let lock = NSLock() + private var transport: BLETransport? + private var transportDeviceName: String? + + private var inboundTask: Task? + + public init(wire: AppGroupRNodeSeamWire, log: ((String) -> Void)? = nil) { + self.wire = wire + self.log = log + } + + /// Begin observing the seam + serving NE commands. Call once after construction. + public func start() { + wire.start() + inboundTask = Task { [weak self] in + guard let self else { return } + for await message in self.wire.inbound { + self.handle(message) + } + } + } + + public func stop() { + inboundTask?.cancel() + inboundTask = nil + lock.lock() + let t = transport + transport = nil + transportDeviceName = nil + lock.unlock() + t?.disconnect() + wire.stop() + } + + // MARK: - NE → app commands + + private func handle(_ message: RNodeSeamMessage) { + switch message { + case let .connect(deviceName): + connectRadio(deviceName: deviceName) + case let .send(reqId, data): + sendToRadio(reqId: reqId, data: data) + case .disconnect: + lock.lock(); let t = transport; lock.unlock() + log?("[RNODE] server: disconnect radio") + t?.disconnect() + case .dataReceived, .stateChanged, .sendResult: + break // app→NE events; the app never receives these inbound. + } + } + + private func connectRadio(deviceName: String) { + lock.lock() + if transport == nil || transportDeviceName != deviceName { + // (Re)create the radio for this device and wire its callbacks once. + // BLETransport reuses its CBCentralManager across connect()/disconnect(), + // so we only rebuild it when the target device changes. + transport?.disconnect() + let name = deviceName.isEmpty ? nil : deviceName + let radio = BLETransport(deviceName: name) + radio.onDataReceived = { [weak self] data in + self?.wire.send(.dataReceived(data: data)) + } + radio.onStateChange = { [weak self] state in + let link = state.linkState + self?.log?("[RNODE] server: radio BLE state -> \(link)") + self?.wire.send(.stateChanged(state: link)) + self?.onLinkStateChange?(link) + } + transport = radio + transportDeviceName = deviceName + } + let radio = transport + lock.unlock() + log?("[RNODE] server: connect radio '\(deviceName)'") + radio?.connect() + } + + private func sendToRadio(reqId: UInt32, data: Data) { + lock.lock(); let radio = transport; lock.unlock() + guard let radio else { + wire.send(.sendResult(reqId: reqId, error: "rnode radio not connected")) + return + } + radio.send(data) { [weak self] error in + if let error { self?.log?("[RNODE] server: send reqId=\(reqId) \(data.count)B FAILED: \(error.localizedDescription)") } + self?.wire.send(.sendResult(reqId: reqId, error: error?.localizedDescription)) + } + } +} + +private extension TransportState { + var linkState: RNodeLinkState { + switch self { + case .disconnected: return .disconnected + case .connecting: return .connecting + case .connected: return .connected + case .failed: return .failed + } + } +} diff --git a/Sources/Shared/BLEDriverSeam.swift b/Sources/Shared/BLEDriverSeam.swift new file mode 100644 index 00000000..ae468d0b --- /dev/null +++ b/Sources/Shared/BLEDriverSeam.swift @@ -0,0 +1,227 @@ +// +// BLEDriverSeam.swift +// Shared +// +// The NE↔app marshaling for Model B BLE. reticulum-swift already ships the +// proven Swift BLE mesh stack (`BLEInterface`, `BLEPeerInterface`, +// `CoreBluetoothBLEDriver`, `BLEDriver`/`BLEPeerConnection`, fragmentation). The +// ONLY missing piece for Model B is the cross-process seam, because RNS runs in +// the Network Extension but CoreBluetooth must run in the app: +// +// NE: ReticulumSwift.BLEInterface ──uses──▶ AppGroupBLEDriver : BLEDriver +// AppGroupBLEPeerConnection : BLEPeerConnection +// │ (App-Group) +// app: CoreBluetoothBLEDriver (real) ◀── App-Group server ─┘ +// +// This file defines the WIRE between them: a transport-agnostic message enum +// marshaling the `BLEDriver` + `BLEPeerConnection` protocol surface, plus a +// compact binary codec. Direction is by transport, not by type: +// • Commands (NE→app) ride the `e2a` queue, tag `.bleControl`. +// • Events (app→NE) ride the `a2e` queue, tag `.bleControl`. +// • Fragments (both) ride the queues tag `.bleMesh`, body `[addr][fragment]` +// — high-rate, so `sendFragment`/`receivedFragment` are ALSO modeled here for +// completeness but the transport routes them as raw frames, not via this codec. +// +// Methods that return a value across the process boundary (`connect`, +// `readIdentity`, `readRemoteRssi`, the local-state query) carry a `reqId` so the +// NE can resume the awaiting continuation when the matching `*Result` arrives. +// Per locked decision #1, the synchronous-decision callbacks (`should_connect`, +// duplicate-identity) live entirely app-side and never cross this seam. +// + +import Foundation + +// MARK: - Message + +/// One message on the BLE driver seam. `reqId` correlates a request with its +/// `*Result` reply for the value-returning driver/connection methods. +public enum BLEDriverSeamMessage: Equatable, Sendable { + // ── Commands: NE → app (drive `CoreBluetoothBLEDriver`) ── + case startAdvertising + case stopAdvertising + case startScanning + case stopScanning + case connect(reqId: UInt32, address: String) + case disconnect(address: String) + case shutdown + case queryLocalState(reqId: UInt32) // localAddress + isRunning + // per-connection commands (address identifies the BLEPeerConnection) + case sendFragment(address: String, data: Data) // data-path (see header) + case readIdentity(reqId: UInt32, address: String) + case writeIdentity(address: String, identity: Data) + case readRemoteRssi(reqId: UInt32, address: String) + case closeConnection(address: String) + + // ── Events / results: app → NE (feed `BLEInterface`'s streams) ── + case discovered(address: String, rssi: Int16, identity: Data?) + case incomingConnection(address: String, mtu: UInt16, identity: Data?) + case connectionLost(address: String) + case receivedFragment(address: String, data: Data) // data-path (see header) + case connectResult(reqId: UInt32, address: String, mtu: UInt16, identity: Data?, error: String?) + case readIdentityResult(reqId: UInt32, identity: Data?, error: String?) + case readRemoteRssiResult(reqId: UInt32, rssi: Int16, error: String?) + case queryLocalStateResult(reqId: UInt32, localAddress: String?, isRunning: Bool) + + fileprivate enum Tag: UInt8 { + case startAdvertising = 1, stopAdvertising, startScanning, stopScanning + case connect, disconnect, shutdown, queryLocalState + case sendFragment, readIdentity, writeIdentity, readRemoteRssi, closeConnection + case discovered = 64, incomingConnection, connectionLost, receivedFragment + case connectResult, readIdentityResult, readRemoteRssiResult, queryLocalStateResult + } + + // MARK: Encode + + public func encode() -> Data { + var w = SeamWriter() + switch self { + case .startAdvertising: w.tag(.startAdvertising) + case .stopAdvertising: w.tag(.stopAdvertising) + case .startScanning: w.tag(.startScanning) + case .stopScanning: w.tag(.stopScanning) + case .shutdown: w.tag(.shutdown) + case let .connect(reqId, address): + w.tag(.connect); w.u32(reqId); w.str(address) + case let .disconnect(address): + w.tag(.disconnect); w.str(address) + case let .queryLocalState(reqId): + w.tag(.queryLocalState); w.u32(reqId) + case let .sendFragment(address, data): + w.tag(.sendFragment); w.str(address); w.data(data) + case let .readIdentity(reqId, address): + w.tag(.readIdentity); w.u32(reqId); w.str(address) + case let .writeIdentity(address, identity): + w.tag(.writeIdentity); w.str(address); w.data(identity) + case let .readRemoteRssi(reqId, address): + w.tag(.readRemoteRssi); w.u32(reqId); w.str(address) + case let .closeConnection(address): + w.tag(.closeConnection); w.str(address) + case let .discovered(address, rssi, identity): + w.tag(.discovered); w.str(address); w.i16(rssi); w.optData(identity) + case let .incomingConnection(address, mtu, identity): + w.tag(.incomingConnection); w.str(address); w.u16(mtu); w.optData(identity) + case let .connectionLost(address): + w.tag(.connectionLost); w.str(address) + case let .receivedFragment(address, data): + w.tag(.receivedFragment); w.str(address); w.data(data) + case let .connectResult(reqId, address, mtu, identity, error): + w.tag(.connectResult); w.u32(reqId); w.str(address); w.u16(mtu); w.optData(identity); w.optStr(error) + case let .readIdentityResult(reqId, identity, error): + w.tag(.readIdentityResult); w.u32(reqId); w.optData(identity); w.optStr(error) + case let .readRemoteRssiResult(reqId, rssi, error): + w.tag(.readRemoteRssiResult); w.u32(reqId); w.i16(rssi); w.optStr(error) + case let .queryLocalStateResult(reqId, localAddress, isRunning): + w.tag(.queryLocalStateResult); w.u32(reqId); w.optStr(localAddress); w.bool(isRunning) + } + return w.out + } + + // MARK: Decode + + public init(decoding data: Data) throws { + var r = SeamReader(data) + let raw = try r.u8() + guard let tag = Tag(rawValue: raw) else { throw SeamError.unknownTag(raw) } + switch tag { + case .startAdvertising: self = .startAdvertising + case .stopAdvertising: self = .stopAdvertising + case .startScanning: self = .startScanning + case .stopScanning: self = .stopScanning + case .shutdown: self = .shutdown + case .connect: self = .connect(reqId: try r.u32(), address: try r.str()) + case .disconnect: self = .disconnect(address: try r.str()) + case .queryLocalState: self = .queryLocalState(reqId: try r.u32()) + case .sendFragment: self = .sendFragment(address: try r.str(), data: try r.data()) + case .readIdentity: self = .readIdentity(reqId: try r.u32(), address: try r.str()) + case .writeIdentity: self = .writeIdentity(address: try r.str(), identity: try r.data()) + case .readRemoteRssi: self = .readRemoteRssi(reqId: try r.u32(), address: try r.str()) + case .closeConnection: self = .closeConnection(address: try r.str()) + case .discovered: self = .discovered(address: try r.str(), rssi: try r.i16(), identity: try r.optData()) + case .incomingConnection: self = .incomingConnection(address: try r.str(), mtu: try r.u16(), identity: try r.optData()) + case .connectionLost: self = .connectionLost(address: try r.str()) + case .receivedFragment: self = .receivedFragment(address: try r.str(), data: try r.data()) + case .connectResult: self = .connectResult(reqId: try r.u32(), address: try r.str(), mtu: try r.u16(), identity: try r.optData(), error: try r.optStr()) + case .readIdentityResult: self = .readIdentityResult(reqId: try r.u32(), identity: try r.optData(), error: try r.optStr()) + case .readRemoteRssiResult: self = .readRemoteRssiResult(reqId: try r.u32(), rssi: try r.i16(), error: try r.optStr()) + case .queryLocalStateResult: self = .queryLocalStateResult(reqId: try r.u32(), localAddress: try r.optStr(), isRunning: try r.bool()) + } + try r.expectEnd() + } +} + +public enum SeamError: Error, Equatable { + case unknownTag(UInt8) + case truncated + case trailingBytes(Int) + case badUTF8 +} + +// MARK: - Binary writer / reader (big-endian; UInt16-length-prefixed blobs) + +struct SeamWriter { + var out = Data() + fileprivate mutating func tag(_ t: BLEDriverSeamMessage.Tag) { out.append(t.rawValue) } + mutating func u8(_ v: UInt8) { out.append(v) } + mutating func bool(_ v: Bool) { out.append(v ? 1 : 0) } + mutating func u16(_ v: UInt16) { out.append(UInt8(v >> 8)); out.append(UInt8(v & 0xFF)) } + mutating func i16(_ v: Int16) { u16(UInt16(bitPattern: v)) } + mutating func u32(_ v: UInt32) { + out.append(UInt8((v >> 24) & 0xFF)); out.append(UInt8((v >> 16) & 0xFF)) + out.append(UInt8((v >> 8) & 0xFF)); out.append(UInt8(v & 0xFF)) + } + /// UInt16-length-prefixed blob (max 65535 — fine for fragments/identities/addresses). + mutating func data(_ d: Data) { + precondition(d.count <= 0xFFFF, "seam blob too large (\(d.count))") + u16(UInt16(d.count)); out.append(d) + } + mutating func str(_ s: String) { data(Data(s.utf8)) } + mutating func optData(_ d: Data?) { if let d { bool(true); data(d) } else { bool(false) } } + mutating func optStr(_ s: String?) { if let s { bool(true); str(s) } else { bool(false) } } +} + +struct SeamReader { + private let d: Data + private var i: Int + init(_ data: Data) { self.d = data; self.i = data.startIndex } + mutating func u8() throws -> UInt8 { + guard i < d.endIndex else { throw SeamError.truncated } + defer { i += 1 }; return d[i] + } + mutating func bool() throws -> Bool { try u8() != 0 } + mutating func u16() throws -> UInt16 { let h = try u8(), l = try u8(); return UInt16(h) << 8 | UInt16(l) } + mutating func i16() throws -> Int16 { Int16(bitPattern: try u16()) } + mutating func u32() throws -> UInt32 { + let a = try u8(), b = try u8(), c = try u8(), e = try u8() + return UInt32(a) << 24 | UInt32(b) << 16 | UInt32(c) << 8 | UInt32(e) + } + mutating func data() throws -> Data { + let n = Int(try u16()) + guard d.endIndex - i >= n else { throw SeamError.truncated } + defer { i += n }; return d.subdata(in: i..<(i + n)) + } + mutating func str() throws -> String { + guard let s = String(data: try data(), encoding: .utf8) else { throw SeamError.badUTF8 } + return s + } + mutating func optData() throws -> Data? { try bool() ? try data() : nil } + mutating func optStr() throws -> String? { try bool() ? try str() : nil } + func expectEnd() throws { if i != d.endIndex { throw SeamError.trailingBytes(d.endIndex - i) } } +} + +// MARK: - Transport abstraction + +/// Carries `BLEDriverSeamMessage`s across the App-Group. NE: `send` → the NE→app +/// queue, `inbound` ← the app→NE queue. App: reversed. Injected so both the NE +/// driver and the app server are unit-testable with an in-memory loopback. +public protocol BLESeamTransport: AnyObject, Sendable { + func send(_ message: BLEDriverSeamMessage) + /// Decoded messages arriving from the other process. + var inbound: AsyncStream { get } +} + +public enum BLESeamError: Error, Sendable { + /// A reply arrived but wasn't the result type the request expected. + case unexpectedReply + /// The remote (app-side) driver reported a failure. + case driver(String) +} diff --git a/Sources/Shared/ExtensionDiagLog.swift b/Sources/Shared/ExtensionDiagLog.swift new file mode 100644 index 00000000..58a3eeb8 --- /dev/null +++ b/Sources/Shared/ExtensionDiagLog.swift @@ -0,0 +1,123 @@ +// +// ExtensionDiagLog.swift +// Columba Shared +// +// Append-only diagnostic logger for the Network Extension, backed by a file in +// the App-Group container (`ext-diag.log`). The NE is sandboxed and its +// unified-log output does not reliably reach the host over WiFi-only devices, +// so the NE writes its diagnostics here; the main app copies this file into its +// Documents directory on launch (`copyExtensionDiagToDocuments()`), making it +// retrievable via `devicectl ... copy from --domain-type appDataContainer`. +// +// ── NO-PII CONTRACT (HARD RULE) ────────────────────────────────────────────── +// Lines written here carry ENVELOPE / METADATA ONLY. They MUST NOT contain: +// • message plaintext or any frame / packet payload bytes, +// • identity material (private keys, full identity hashes), +// • LAN IPs, relay host:port, or on-device home / container paths. +// Destination hashes are logged as SHORT PREFIXES (≤ 8 hex chars) only. TCP +// relays are referenced abstractly (e.g. "TCP relay") — never host or port. +// This file is the durable observability channel for on-device NE verification; +// keeping it PII-free is the entire point of this phase. +// +// This file imports ONLY Foundation and is compiled into BOTH the ColumbaApp +// and ColumbaNetworkExtension targets. Like `SharedFrameQueue`, it must stay +// free of ReticulumSwift / RNSAPI so it compiles in the NE target (which links +// neither). +// + +import Foundation + +/// Append-only, thread-safe diagnostic logger writing to the App-Group container +/// file `ext-diag.log`. Mirrors `DiagLog` (the app's Documents/diag.log logger) +/// but targets the shared container so the sandboxed Network Extension can write +/// it and the host app can copy it out. +/// +/// NO-PII: only envelope/metadata — see the file header contract. +public enum ExtensionDiagLog { + + /// App-Group container file the NE appends to and the app reads back. + /// Defined here (not derived from a Reticulum type) so the file stays + /// Foundation-only and usable from the NE target. + public static let fileURL: URL? = { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) else { + return nil + } + return containerURL.appendingPathComponent("ext-diag.log") + }() + + /// Serializes appends/reads/clears so concurrent NE callbacks (TCP receive, + /// Darwin-notification config reloads, NWConnection state handlers — all on + /// different queues) can't interleave a write. A lock (not a serial queue) so + /// `log` stays synchronous, matching `DiagLog.log`'s call-site ergonomics. + private static let lock = NSLock() + + /// Append one ISO8601-timestamped line. Creates the file on first write and + /// sets `completeUntilFirstUserAuthentication` protection so the NE can keep + /// writing while the device is locked-after-first-unlock (consistent with the + /// deliver-while-locked posture). Best-effort: failures are swallowed (this is + /// a diagnostics side-channel and must never destabilize the NE). + /// + /// NO-PII: callers must pass envelope/metadata only — see the file header. + public static func log(_ message: String) { + // Keep ASL/unified-log output too (useful over USB), mirroring DiagLog. + NSLog("[EXT] %@", message) + + guard let fileURL else { return } + let ts = ISO8601DateFormatter().string(from: Date()) + let line = "[\(ts)] \(message)\n" + guard let data = line.data(using: .utf8) else { return } + + lock.lock() + defer { lock.unlock() } + + if FileManager.default.fileExists(atPath: fileURL.path) { + if let fh = try? FileHandle(forWritingTo: fileURL) { + fh.seekToEndOfFile() + fh.write(data) + fh.closeFile() + } + } else { + // `createFile` lets us stamp file protection atomically with creation + // so there's no window where the file exists at default protection. + FileManager.default.createFile( + atPath: fileURL.path, + contents: data, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + ) + } + // Re-assert protection on every append: an empty/zero-length file created + // by another process (or a prior run) may carry default protection, which + // would block writes on a locked device. Cheap and idempotent. + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) + } + + /// Truncate the log to empty. Used by the host before a fresh capture run. + public static func clear() { + guard let fileURL else { return } + lock.lock() + defer { lock.unlock() } + try? Data().write(to: fileURL, options: .atomic) + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) + } + + /// Read back the full log text (for the host copy-out / inspection). Returns + /// an empty string when the container or file is unavailable. + public static func recentText() -> String { + guard let fileURL else { return "" } + lock.lock() + defer { lock.unlock() } + guard let data = try? Data(contentsOf: fileURL), + let text = String(data: data, encoding: .utf8) else { + return "" + } + return text + } +} diff --git a/Sources/Shared/OutboxQueue.swift b/Sources/Shared/OutboxQueue.swift new file mode 100644 index 00000000..3e053930 --- /dev/null +++ b/Sources/Shared/OutboxQueue.swift @@ -0,0 +1,305 @@ +// +// OutboxQueue.swift +// Columba Shared (compiled into BOTH ColumbaApp and ColumbaNetworkExtension) +// +// Track A5c — the durable App-Group outbox for the Model B send path. +// +// Under Model B the app composes outbound LXMF messages but does NOT own a +// local node — it marshals each send to the NE over `ProxyIPC` +// (`ProxyRnsBackend.sendLxmfMessage`). When the NE is stopped / unreachable that +// round-trip fails, and without persistence the message would be silently +// dropped. This file is the durable buffer that prevents that loss: +// +// • ENQUEUE (app side, `ProxyRnsBackend`): on an IPC failure (nil reply, or the +// NE answering `.error` / `.unsupported`, i.e. the NE did NOT accept the +// send) the proxy appends an `OutboxEntry` here and returns optimistically so +// the UI shows the message pending instead of failed. +// +// • DRAIN (NE side, `NEReticulumNode.start`): once the in-NE node is fully up +// (transport + router + delivery destination), it `drainAll()`s the queue and +// replays each entry through its existing `sendLxmfForIPC(...)` path. The send +// is then packed + signed + queued by LXMF-swift exactly as a live IPC send +// would have been. +// +// ── DURABILITY MODEL ───────────────────────────────────────────────────────── +// This MIRRORS `SharedFrameQueue` exactly: an append-only file in the App-Group +// container, each record length-framed (`[4-byte big-endian length][payload]`), +// guarded by a POSIX `flock`-style advisory lock on a sibling `.lock` file so the +// app (appending) and the NE (draining) never corrupt the file when they touch it +// concurrently. The payload here is the JSON encoding of one `OutboxEntry` (vs. +// `SharedFrameQueue`'s raw frame bytes + interface tag). `drainAll()` is the +// read-all-and-clear analogue of `readAllAndClear()`. +// +// ── COLLISION RULE (HARD) ──────────────────────────────────────────────────── +// This file imports ONLY Foundation. It is linked into BOTH targets (like +// `SharedFrameQueue` / `ExtensionDiagLog` / `AppGroupPaths` / `ProxyIPC`), so it +// MUST NOT pull in RNSAPI / ReticulumSwift / LXMFSwift — none of which are even +// linked into the NE. An `OutboxEntry` therefore carries ONLY already-serialized +// scalars / `Data` (the same shape `ProxyRequest.lxmfSend` crosses the seam with), +// never a protocol object. The enqueue site (`ProxyRnsBackend`, imports RNSAPI +// only) and the drain site (`NEReticulumNode`, imports ReticulumSwift + LXMFSwift) +// both keep their own import sets; this Foundation-only seam is what lets them +// share the queue without either gaining the other's imports. +// + +import Foundation + +// MARK: - OutboxEntry + +/// One pending outbound LXMF send, persisted while the NE is down so it can be +/// replayed on the next NE start. The fields mirror `ProxyRequest.lxmfSend` +/// (`destHashHex` / `content` / `method` / `fieldsData`) so the drain site can +/// hand them straight to `NEReticulumNode.sendLxmfForIPC(...)` with no remapping. +/// +/// `Codable` via Foundation's synthesized conformance; `Data` rides as base64 and +/// every other field is a JSON-native scalar, keeping the record (and this whole +/// file) Foundation-only. +public struct OutboxEntry: Codable, Sendable, Equatable { + + /// Lowercase-hex `lxmf.delivery` destination hash (mirrors + /// `ProxyRequest.lxmfSend.destHashHex`). + public let destHashHex: String + + /// Plaintext message body. Stored as `String` to match + /// `ProxyRequest.lxmfSend.content` (the NE wraps it as `Data(content.utf8)`); + /// JSON encodes it directly, no base64. + public let content: String + + /// `RNSAPI.LXDeliveryMethod` raw value ("opportunistic" / "direct" / + /// "propagated" / …), exactly as `ProxyRequest.lxmfSend.method` carries it. The + /// NE maps it back via its `deliveryMethod(_:)` helper. + public let method: String + + /// MessagePack-packed canonical LXMF field map (image / attachments / icon / + /// reply / extras), pre-assembled APP-SIDE by `LxmfFieldCodec` — identical to + /// `ProxyRequest.lxmfSend.fieldsData`. `nil` (or empty) means no fields. Stored + /// optional here (rather than the wire type's non-optional empty-`Data`) so a + /// no-fields entry serializes compactly; the drain site treats nil as empty. + public let fieldsData: Data? + + /// App-computed message hash hex for dedup / reconciliation, when one is + /// available — otherwise `nil`. + /// + /// In the Model B proxy path this is **always nil today**, and that is correct, + /// not a TODO stub. The canonical LXMF message hash is + /// `SHA256(destHash + sourceHash + msgpack([timestamp, title, content, fields]))` + /// (see LXMF-swift `LXMessage.pack`), where `timestamp` is assigned at PACK + /// time. Packing happens NE-side at drain (`sendLxmfForIPC` → `LXMRouter + /// .handleOutbound`), and the proxy that enqueues here imports RNSAPI ONLY — it + /// has no `Identity`, no LXMF-swift, and no pack-time timestamp, so it cannot + /// compute the real hash. (The app's only "optimistic" id is a random `UUID` in + /// `MessagingViewModel`, which is never passed down to the backend.) Dedup does + /// NOT depend on this field: re-send safety is the receiver's responsibility + /// (LXMF-swift caches seen inbound message hashes for ~1h and rejects + /// duplicates), and the enqueue condition is gated to cases where the NE did NOT + /// accept the send. The field is retained — optional — so a future track that + /// threads the app's local id down to the proxy can populate it without a + /// schema migration. + public let messageHashHex: String? + + /// Wall-clock enqueue time (`Date().timeIntervalSince1970`), for diagnostics / + /// future staleness pruning. NOT the LXMF pack timestamp (that's assigned + /// NE-side at drain). + public let createdAt: Double + + public init( + destHashHex: String, + content: String, + method: String, + fieldsData: Data?, + messageHashHex: String?, + createdAt: Double + ) { + self.destHashHex = destHashHex + self.content = content + self.method = method + self.fieldsData = fieldsData + self.messageHashHex = messageHashHex + self.createdAt = createdAt + } +} + +// MARK: - OutboxQueue + +/// Durable App-Group queue of pending outbound LXMF sends. The app appends on an +/// IPC failure; the NE drains (read-all-and-clear) once its node is up. +/// +/// Direct structural mirror of `SharedFrameQueue`: a length-framed append-only +/// file in the App-Group container, made thread- AND process-safe by a POSIX +/// advisory lock on a sibling `.lock` file. The only differences are that each +/// record's payload is the JSON of one `OutboxEntry` (so there is no per-record +/// interface tag, hence a 4-byte header rather than 5) and that the read API is +/// named `drainAll()` and returns `[OutboxEntry]`. +/// +/// `@unchecked Sendable` for the same reason as `SharedFrameQueue`: the only stored +/// state is the immutable `fileURL`; all mutation is serialized under the file +/// lock. +public final class OutboxQueue: @unchecked Sendable { + + // MARK: - Constants + + /// Default file name in the App-Group container holding the durable outbox. + public static let defaultFileName = "outbox" + + /// Header size: 4 bytes big-endian length (no interface tag, unlike + /// `SharedFrameQueue`'s 5-byte header). + private static let headerSize = 4 + + // MARK: - Properties + + /// Path to the outbox file in the shared container. + private let fileURL: URL + + // MARK: - Initialization + + /// Create the outbox queue in the given App-Group container. + /// + /// - Parameters: + /// - appGroupIdentifier: The App-Group identifier (defaults to the shared + /// `appGroupIdentifier` constant both targets already use). + /// - name: File name within the container. Defaults to `defaultFileName` + /// (`"outbox"`). Each name gets its own backing file + its own `.lock` file. + public init(appGroupIdentifier: String = appGroupIdentifier, name: String = OutboxQueue.defaultFileName) { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) else { + // Fallback to tmp if the App-Group container is unavailable (shouldn't + // happen in production — same fallback posture as `SharedFrameQueue`). + self.fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(name) + return + } + self.fileURL = containerURL.appendingPathComponent(name) + } + + // MARK: - Public API + + /// Append one pending send to the durable outbox (called by the app on an IPC + /// failure). Thread- and process-safe via the POSIX file lock; concurrent + /// appenders are serialized by the lock. + /// + /// JSON-encodes `entry`, frames it with a 4-byte big-endian length, and writes + /// it to the end of the file. An entry that fails to encode (effectively + /// unreachable — `OutboxEntry` is all-Codable) is dropped silently rather than + /// corrupting the stream. + public func append(_ entry: OutboxEntry) { + guard let payload = try? JSONEncoder().encode(entry) else { return } + + let length = UInt32(payload.count) + var header = Data(count: Self.headerSize) + header[0] = UInt8((length >> 24) & 0xFF) + header[1] = UInt8((length >> 16) & 0xFF) + header[2] = UInt8((length >> 8) & 0xFF) + header[3] = UInt8(length & 0xFF) + + withFileLock { + let fh: FileHandle + if FileManager.default.fileExists(atPath: fileURL.path) { + guard let handle = try? FileHandle(forWritingTo: fileURL) else { return } + fh = handle + } else { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + guard let handle = try? FileHandle(forWritingTo: fileURL) else { return } + fh = handle + } + fh.seekToEndOfFile() + fh.write(header) + fh.write(payload) + fh.closeFile() + } + } + + /// Read every pending entry and clear the queue (called by the NE once its node + /// is up). Atomically reads all records and truncates the file under the lock, + /// so an `append` racing the drain either lands fully before the read or fully + /// after the truncate — never half-consumed. + /// + /// Malformed / truncated tail records are skipped (parsing stops), matching + /// `SharedFrameQueue.readAllAndClear`; a record whose JSON fails to decode is + /// skipped individually but parsing continues past it. + /// + /// - Returns: All decoded entries in append order, possibly empty. + public func drainAll() -> [OutboxEntry] { + var entries: [OutboxEntry] = [] + + withFileLock { + guard FileManager.default.fileExists(atPath: fileURL.path), + let data = try? Data(contentsOf: fileURL), + !data.isEmpty else { + return + } + + var offset = 0 + while offset + Self.headerSize <= data.count { + let length = Int( + (UInt32(data[offset]) << 24) | + (UInt32(data[offset + 1]) << 16) | + (UInt32(data[offset + 2]) << 8) | + UInt32(data[offset + 3]) + ) + offset += Self.headerSize + + guard offset + length <= data.count else { + // Truncated trailing record — stop parsing. + break + } + + let recordData = data[offset..<(offset + length)] + if let entry = try? JSONDecoder().decode(OutboxEntry.self, from: Data(recordData)) { + entries.append(entry) + } + // A record that fails to decode is skipped, but we still advance by + // its framed length so the rest of the stream stays parseable. + offset += length + } + + // Truncate the file (read-all-and-clear). + try? Data().write(to: fileURL, options: .atomic) + } + + return entries + } + + /// True if the outbox file exists and is non-empty, without reading it. + /// Mirrors `SharedFrameQueue.hasFrames`. + public var hasEntries: Bool { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return false } + guard let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path), + let size = attrs[.size] as? UInt64 else { return false } + return size > 0 + } + + // MARK: - File Locking + + /// Execute a closure while holding a POSIX exclusive lock on a sibling `.lock` + /// file. Identical strategy to `SharedFrameQueue.withFileLock` — a separate lock + /// file keeps the advisory lock off the data file itself, and a separate `.lock` + /// per queue name means the outbox never contends with the frame queues. + private func withFileLock(_ body: () -> Void) { + let lockPath = fileURL.path + ".lock" + + if !FileManager.default.fileExists(atPath: lockPath) { + FileManager.default.createFile(atPath: lockPath, contents: nil) + } + + let lockFd = Darwin.open(lockPath, O_RDWR) + guard lockFd >= 0 else { + // Can't open the lock file — run without the lock (best effort), same + // as `SharedFrameQueue`. + body() + return + } + + var fl = flock() + fl.l_type = Int16(F_WRLCK) + fl.l_whence = Int16(SEEK_SET) + fl.l_start = 0 + fl.l_len = 0 + _ = fcntl(lockFd, F_SETLKW, &fl) + + body() + + fl.l_type = Int16(F_UNLCK) + _ = fcntl(lockFd, F_SETLK, &fl) + Darwin.close(lockFd) + } +} diff --git a/Sources/Shared/PropagationSeam.swift b/Sources/Shared/PropagationSeam.swift new file mode 100644 index 00000000..ed7fab16 --- /dev/null +++ b/Sources/Shared/PropagationSeam.swift @@ -0,0 +1,170 @@ +// +// PropagationSeam.swift +// Columba Shared +// +// App→NE seam for LXMF propagation under Model B. The LXMF router runs in the +// Network Extension, so the app's selected propagation node + sync settings cross the +// App-Group seam here, and the NE writes sync progress back the same way. Foundation- +// only (compiled into BOTH the app and the NE), mirroring `RNodeSeam`. +// + +import Foundation + +/// Snapshot of the app's selected propagation node + sync settings, persisted to the +/// App-Group `propagationConfigKey` and read by the NE, which wires it onto its in-NE +/// `LXMRouter` via `setOutboundPropagationNode` / `setPropagationStampCost`. Written by +/// the app's `PropagationNodeManager` (Model B only); the python backend keeps its own +/// in-process path. +public struct PropagationSeamConfig: Codable, Equatable, Sendable { + /// The selected propagation node's destination hash (lxmf.propagation aspect), or + /// nil when none is selected (clear the router's PN). + public var propagationNodeHash: Data? + /// Proof-of-work cost the PN requires for uploads (from its announce app_data). + public var stampCost: Int + /// Desired periodic-sync interval, in seconds. Use `effectiveSyncInterval` for the + /// scheduler — a raw 0 / near-0 value (default/corrupted App-Group entry) would + /// otherwise busy-loop the NE sync task. + public var syncInterval: TimeInterval + /// Whether the NE should run periodic sync (vs sync-on-demand only). + public var periodicSyncEnabled: Bool + + /// Hard floor for the periodic-sync cadence. The NE's `startPropagationSyncScheduler` + /// sleeps `syncInterval` between syncs; a zero/near-zero value — reachable via a + /// corrupted or unset App-Group entry (Codable decoding bypasses `init`, so an + /// init-time clamp would not catch it) — would spin the loop at the ~2s relay-recheck + /// floor, generating continuous IPC + relay traffic. Manual "Sync Now" and + /// reconnect-triggered syncs are unaffected. + public static let minSyncInterval: TimeInterval = 30 + + /// `syncInterval` floored to `minSyncInterval`. Always use this for the periodic + /// scheduler so no config path (including a direct Codable decode) can busy-loop it. + public var effectiveSyncInterval: TimeInterval { Swift.max(Self.minSyncInterval, syncInterval) } + + public init( + propagationNodeHash: Data?, + stampCost: Int, + syncInterval: TimeInterval, + periodicSyncEnabled: Bool + ) { + self.propagationNodeHash = propagationNodeHash + self.stampCost = stampCost + self.syncInterval = syncInterval + self.periodicSyncEnabled = periodicSyncEnabled + } + + // MARK: App-Group persistence + + /// Read the persisted propagation config, or nil if none is selected. + public static func loadFromAppGroup(_ group: String = appGroupIdentifier) -> PropagationSeamConfig? { + guard let defaults = UserDefaults(suiteName: group), + let data = defaults.data(forKey: SharedDefaultsConstants.propagationConfigKey), + let config = try? JSONDecoder().decode(PropagationSeamConfig.self, from: data) else { + return nil + } + return config + } + + /// Persist this config to the App-Group (app side) + post the change notification. + public func saveToAppGroup(_ group: String = appGroupIdentifier) { + guard let defaults = UserDefaults(suiteName: group), + let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: SharedDefaultsConstants.propagationConfigKey) + Self.postChangedNotification() + } + + /// Clear the persisted config (PN deselected) + post the change notification. + public static func clearFromAppGroup(_ group: String = appGroupIdentifier) { + UserDefaults(suiteName: group)?.removeObject(forKey: SharedDefaultsConstants.propagationConfigKey) + postChangedNotification() + } + + private static func postChangedNotification() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(SharedDefaultsConstants.propagationConfigChangedNotificationName as CFString), + nil, nil, true + ) + } + + /// Ask the NE to run one immediate propagation sync (the "Sync Now" button — the app + /// can't call the NE's router directly). Fire-and-forget Darwin notification. + public static func postSyncNowNotification() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(SharedDefaultsConstants.propagationSyncNowNotificationName as CFString), + nil, nil, true + ) + } +} + +/// Compact, Foundation-only snapshot of an in-progress propagation sync, written by the +/// NE to the App-Group `propagationSyncStateKey` as the sync advances. The app reads it +/// (on the `propagationSyncStateChanged` Darwin notification) to drive the in-app sync +/// sheet, mapping it to its `PropagationTransferState`. Darwin carries no payload, so the +/// state rides this key. +public struct PropagationSyncStateSnapshot: Codable, Equatable, Sendable { + /// Coarse phase the UI renders rows for. Raw values are stable across the JSON seam. + public enum Phase: String, Codable, Sendable { + case idle, linking, linked, requesting, receiving, complete, failed + + /// Whether a sync is currently in flight (drives showing/hiding the sync sheet). + public var isActive: Bool { + switch self { + case .linking, .linked, .requesting, .receiving: return true + case .idle, .complete, .failed: return false + } + } + } + + public var phase: Phase + public var progress: Double // 0.0 ... 1.0 + public var received: Int + public var total: Int + public var errorDescription: String? + + public init( + phase: Phase, + progress: Double = 0, + received: Int = 0, + total: Int = 0, + errorDescription: String? = nil + ) { + self.phase = phase + self.progress = progress + self.received = received + self.total = total + self.errorDescription = errorDescription + } + + // MARK: App-Group persistence + + public static func loadFromAppGroup(_ group: String = appGroupIdentifier) -> PropagationSyncStateSnapshot? { + guard let defaults = UserDefaults(suiteName: group), + let data = defaults.data(forKey: SharedDefaultsConstants.propagationSyncStateKey), + let snap = try? JSONDecoder().decode(PropagationSyncStateSnapshot.self, from: data) else { + return nil + } + return snap + } + + /// Persist this snapshot (NE side) + post the change notification. + public func saveToAppGroup(_ group: String = appGroupIdentifier) { + guard let defaults = UserDefaults(suiteName: group), + let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: SharedDefaultsConstants.propagationSyncStateKey) + Self.postChangedNotification() + } + + public static func clearFromAppGroup(_ group: String = appGroupIdentifier) { + UserDefaults(suiteName: group)?.removeObject(forKey: SharedDefaultsConstants.propagationSyncStateKey) + postChangedNotification() + } + + private static func postChangedNotification() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(SharedDefaultsConstants.propagationSyncStateChangedNotificationName as CFString), + nil, nil, true + ) + } +} diff --git a/Sources/Shared/ProxyIPC.swift b/Sources/Shared/ProxyIPC.swift new file mode 100644 index 00000000..43037c71 --- /dev/null +++ b/Sources/Shared/ProxyIPC.swift @@ -0,0 +1,411 @@ +// +// ProxyIPC.swift +// Shared (compiled into BOTH ColumbaApp and ColumbaNetworkExtension) +// +// Track A5b — the app↔NE IPC envelope for the Model B send path. +// +// In Model B the Network Extension owns the canonical `lxmf.delivery` +// destination + node (A5a's `NEReticulumNode`); the app becomes a thin client +// that marshals node-owning operations (start / stop / announce / status / +// persist / lxmf-send / …) to the NE over `NETunnelProviderSession +// .sendProviderMessage`. This file defines the request/response wire types and +// their JSON codec used by both ends: +// • app side → `ProxyRnsBackend` encodes a `ProxyRequest`, sends it, decodes +// the `ProxyResponse`; +// • NE side → `PacketTunnelProvider.handleAppMessage` try-decodes a +// `ProxyRequest`, dispatches to `NEReticulumNode`, encodes a +// `ProxyResponse`. +// +// ── COLLISION RULE (HARD) ──────────────────────────────────────────────────── +// This file imports Foundation ONLY. It is linked into BOTH targets, so it must +// not pull in RNSAPI / ReticulumSwift / LXMFSwift (RNSAPI's Compat layer +// redeclares reticulum-swift type names, and those modules aren't even linked +// into the NE). Node-owning operations therefore cross the seam as already- +// serialized scalars / `Data` (hex strings, msgpack-packed field bytes, JSON), +// never as protocol objects — exactly how `BackendEvent` / the `RnsLxmf` seam +// keep things `Sendable`. +// +// ── FRAMING / DISAMBIGUATION ───────────────────────────────────────────────── +// The PoC dumb-pipe (`PacketTunnelProvider.handleAppMessage`) interprets the +// FIRST byte of an inbound message as a `FrameInterfaceTag` (tcp = 0x01, +// auto = 0x02) and forwards the remainder onto an NWConnection. A ProxyRequest +// must be unambiguously distinguishable from such a frame so the NE can +// detect-or-fall-through. We reserve a dedicated leading MAGIC byte +// (`ProxyIPC.magic` = 0xF5) that the PoC tag space never uses, followed by a +// protocol VERSION byte, then the JSON-encoded `ProxyRequest`. `handleAppMessage` +// checks the magic prefix first; only on a match does it parse a ProxyRequest, +// otherwise it falls through to the existing frame-forwarding path untouched. +// + +import Foundation + +// MARK: - Envelope framing + +/// Wire-framing constants + helpers for the app↔NE Model B IPC. Foundation-only +/// so it links into both targets. A request on the wire is: +/// +/// [0xF5 magic][0x01 version][ JSON(ProxyRequest) … ] +/// +/// and a response is the bare `JSON(ProxyResponse)` returned through the +/// `sendProviderMessage` completion handler (the response channel is already +/// 1:1 with the request, so it needs no magic/version framing — but it carries +/// its own `ProxyResponse` tag). +public enum ProxyIPC { + + /// Leading byte that marks a message as a Model B `ProxyRequest` envelope + /// rather than a raw PoC interface frame. Chosen well outside the + /// `FrameInterfaceTag` value space (tcp = 0x01, auto = 0x02) so + /// `handleAppMessage` can branch on the first byte with zero ambiguity. + public static let magic: UInt8 = 0xF5 + + /// Envelope format version. Bump if the framing (not the Codable payload, + /// which evolves additively) ever changes incompatibly. + public static let version: UInt8 = 0x01 + + /// JSON encoder/decoder shared by both ends. Plain JSON keeps the codec + /// Foundation-only (no MessagePack dependency at the seam) and is trivially + /// debuggable; `Data` payloads inside the request/response ride as base64 + /// via `Data`'s default `Codable` conformance. + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + + // MARK: Request framing + + /// Encode a `ProxyRequest` into a magic+version-prefixed wire message. + public static func encodeRequest(_ request: ProxyRequest) throws -> Data { + var data = Data([magic, version]) + data.append(try encoder.encode(request)) + return data + } + + /// True if `data` carries the Model B envelope magic prefix (i.e. it's a + /// `ProxyRequest`, not a PoC frame). Cheap first-byte check used by + /// `handleAppMessage` before attempting a decode. + public static func isProxyRequest(_ data: Data) -> Bool { + guard let first = data.first else { return false } + return first == magic + } + + /// Decode a magic+version-prefixed wire message back into a `ProxyRequest`. + /// Returns `nil` if the magic/version prefix is absent or the version is + /// unknown (caller falls through to the PoC path), and throws only if the + /// prefix matched but the JSON body failed to decode. + public static func decodeRequest(_ data: Data) throws -> ProxyRequest? { + guard data.count >= 2, data[data.startIndex] == magic else { return nil } + guard data[data.startIndex + 1] == version else { return nil } + let body = data.dropFirst(2) + return try decoder.decode(ProxyRequest.self, from: Data(body)) + } + + // MARK: Response framing + + /// Encode a `ProxyResponse` for the `sendProviderMessage` completion handler. + public static func encodeResponse(_ response: ProxyResponse) -> Data { + // The response is best-effort: if encoding ever failed we still want to + // hand the app SOMETHING decodable, so fall back to a hand-rolled + // `.error` JSON. (`ProxyResponse` is closed + all-Codable, so the throw + // path is effectively unreachable; the fallback just removes `try` from + // every NE call site.) + if let data = try? encoder.encode(response) { return data } + return Data(#"{"error":"encode-failed"}"#.utf8) + } + + /// Decode a `ProxyResponse` returned by the NE. Returns `nil` on a nil / + /// undecodable reply so the app can surface a transport-level failure. + public static func decodeResponse(_ data: Data?) -> ProxyResponse? { + guard let data, !data.isEmpty else { return nil } + return try? decoder.decode(ProxyResponse.self, from: data) + } +} + +// MARK: - Request + +/// A node-owning operation the app marshals to the NE under Model B. The set is +/// intentionally the A5b skeleton — the key lifecycle / announce / status / +/// persist / registered-hashes ops plus the primary `lxmfSend`. Richer ops +/// (reactions, telemetry, propagation sync, telephony links, nomadnet, transport +/// admin) are NOT proxied yet; `ProxyRnsBackend` answers those locally with an +/// `unsupportedInProxy` throw or a sensible no-op (see that type), so they never +/// reach this enum. +/// +/// `Codable` via a discriminated union (`op` tag + flat associated fields). All +/// field types are JSON-native scalars or `Data` (base64), keeping the seam +/// Foundation-only and `Sendable`. +public enum ProxyRequest: Codable, Sendable, Equatable { + + /// Boot the NE node (mirrors `RnsCore.start`). The NE already loads the + /// shared identity from the keychain group and computes the App-Group store + /// path itself, so only the display name is marshaled; the response payload + /// carries the learned `LocalInfo` (see `ProxyLocalInfo`). + case start(displayName: String) + + /// Tear the NE node down (mirrors `RnsCore.stop`). + case stop + + /// Emit an `lxmf.delivery` announce with the given display name + /// (mirrors `RnsCore.announce`). Response payload: a Bool-as-JSON. + case announce(displayName: String) + + /// Emit an `lxst.telephony` announce (mirrors `RnsCore.announceTelephony`). + case announceTelephony(displayName: String) + + /// Transport diagnostic snapshot (mirrors `RnsCore.statusSnapshot`). + /// Response payload: JSON-encoded `StatusSnapshot` (the NE encodes it with + /// the same `snake_case` CodingKeys `StatusSnapshot` already declares, so the + /// app decodes it straight back into `StatusSnapshot`). + case statusSnapshot + + /// Flush pending router state to disk (mirrors `RnsCore.persist`). + case persist + + /// Lowercase-hex destination hashes the NE node has registered + /// (mirrors `RnsCore.registeredDestinationHashes`). Response payload: + /// JSON `[String]`. + case registeredDestinationHashes + + /// Heard-announce snapshot: the NE's PathTable entries for known aspects + /// (lxmf.delivery / lxmf.propagation / lxst.telephony / nomadnetwork.node). + /// The NE owns the transport in Model B, so the app can't hear announces + /// itself — it polls this and re-emits `.announce` BackendEvents, exactly + /// what `SwiftRNSBackend`'s PathTable poller does locally in Model A. + /// Response payload: JSON `[ProxyHeardAnnounce]`. + case heardAnnounces + + /// Send an LXMF message (mirrors `RnsLxmf.sendLxmfMessage`). The structured + /// fields the typed seam carries (image / attachments / icon / reply) are + /// pre-assembled by the APP into the canonical on-wire field map and passed + /// as MessagePack-packed `fieldsData` (empty = no fields) so the NE doesn't + /// need RNSAPI's `LxmfFieldCodec` at this seam. `method` is the + /// `LXDeliveryMethod` raw value ("opportunistic" / "direct" / "propagated" + /// / …). Response payload: JSON-encoded `ProxySendOutcome`. + case lxmfSend(destHashHex: String, content: String, method: String, fieldsData: Data) + + /// Native Model B BLE peer snapshot. The NE owns reticulum-swift's + /// `BLEInterface` in Model B (the app can't enumerate BLE peers itself), so + /// the BLE connections screen polls this. Response payload: JSON + /// `[BLEPeerSnapshot]`. + case bleConnections + + // MARK: Codable (discriminated union) + + private enum CodingKeys: String, CodingKey { + case op, displayName, destHashHex, content, method, fieldsData + } + + /// Stable discriminator strings (decoupled from the Swift case names so a + /// rename can't silently break the wire). + private enum Op: String, Codable { + case start, stop, announce, announceTelephony, statusSnapshot + case persist, registeredDestinationHashes, lxmfSend, heardAnnounces + case bleConnections + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .start(let displayName): + try c.encode(Op.start, forKey: .op) + try c.encode(displayName, forKey: .displayName) + case .stop: + try c.encode(Op.stop, forKey: .op) + case .announce(let displayName): + try c.encode(Op.announce, forKey: .op) + try c.encode(displayName, forKey: .displayName) + case .announceTelephony(let displayName): + try c.encode(Op.announceTelephony, forKey: .op) + try c.encode(displayName, forKey: .displayName) + case .statusSnapshot: + try c.encode(Op.statusSnapshot, forKey: .op) + case .persist: + try c.encode(Op.persist, forKey: .op) + case .registeredDestinationHashes: + try c.encode(Op.registeredDestinationHashes, forKey: .op) + case .heardAnnounces: + try c.encode(Op.heardAnnounces, forKey: .op) + case .lxmfSend(let destHashHex, let content, let method, let fieldsData): + try c.encode(Op.lxmfSend, forKey: .op) + try c.encode(destHashHex, forKey: .destHashHex) + try c.encode(content, forKey: .content) + try c.encode(method, forKey: .method) + try c.encode(fieldsData, forKey: .fieldsData) + case .bleConnections: + try c.encode(Op.bleConnections, forKey: .op) + } + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let op = try c.decode(Op.self, forKey: .op) + switch op { + case .start: + self = .start(displayName: try c.decode(String.self, forKey: .displayName)) + case .stop: + self = .stop + case .announce: + self = .announce(displayName: try c.decode(String.self, forKey: .displayName)) + case .announceTelephony: + self = .announceTelephony(displayName: try c.decode(String.self, forKey: .displayName)) + case .statusSnapshot: + self = .statusSnapshot + case .persist: + self = .persist + case .registeredDestinationHashes: + self = .registeredDestinationHashes + case .heardAnnounces: + self = .heardAnnounces + case .lxmfSend: + self = .lxmfSend( + destHashHex: try c.decode(String.self, forKey: .destHashHex), + content: try c.decode(String.self, forKey: .content), + method: try c.decode(String.self, forKey: .method), + fieldsData: try c.decode(Data.self, forKey: .fieldsData) + ) + case .bleConnections: + self = .bleConnections + } + } +} + +/// Wire DTO for one native Model B BLE peer (the NE's reticulum-swift +/// `BLEInterface.getConnectionInfos()` mapped to a `Codable` shape). The app maps +/// these onto its `BLEConnectionInfo` UI model in `ProxyRnsBackend.bleConnections()`. +public struct BLEPeerSnapshot: Codable, Sendable, Equatable { + public let identityHash: String + public let isOutgoing: Bool + public let rssi: Int + public let mtu: Int + public let connectedAt: Date + public let lastActivity: Date + public let bytesSent: Int + public let bytesReceived: Int + public let packetsSent: Int + public let packetsReceived: Int + + public init(identityHash: String, isOutgoing: Bool, rssi: Int, mtu: Int, + connectedAt: Date, lastActivity: Date, bytesSent: Int, + bytesReceived: Int, packetsSent: Int, packetsReceived: Int) { + self.identityHash = identityHash + self.isOutgoing = isOutgoing + self.rssi = rssi + self.mtu = mtu + self.connectedAt = connectedAt + self.lastActivity = lastActivity + self.bytesSent = bytesSent + self.bytesReceived = bytesReceived + self.packetsSent = packetsSent + self.packetsReceived = packetsReceived + } +} + +// MARK: - Response + +/// The NE's reply to a `ProxyRequest`. `.ok` optionally carries an op-specific +/// JSON payload (e.g. an encoded `ProxyLocalInfo` for `.start`, a `Bool` for +/// `.announce`, `StatusSnapshot` JSON for `.statusSnapshot`); `.error` carries a +/// human-readable reason; `.unsupported` means the NE node isn't running (or the +/// op isn't handled NE-side yet) so the app can degrade gracefully. +public enum ProxyResponse: Codable, Sendable, Equatable { + case ok(Data?) + case error(String) + case unsupported + + private enum CodingKeys: String, CodingKey { + case kind, payload, error + } + + private enum Kind: String, Codable { + case ok, error, unsupported + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .ok(let payload): + try c.encode(Kind.ok, forKey: .kind) + try c.encodeIfPresent(payload, forKey: .payload) + case .error(let message): + try c.encode(Kind.error, forKey: .kind) + try c.encode(message, forKey: .error) + case .unsupported: + try c.encode(Kind.unsupported, forKey: .kind) + } + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + switch try c.decode(Kind.self, forKey: .kind) { + case .ok: + self = .ok(try c.decodeIfPresent(Data.self, forKey: .payload)) + case .error: + self = .error(try c.decode(String.self, forKey: .error)) + case .unsupported: + self = .unsupported + } + } +} + +// MARK: - Payload DTOs (mirror the RNSAPI seam types, Foundation-only) +// +// These mirror the RNSAPI DTOs (`LocalInfo`, `SendOutcome`) the proxy needs to +// reconstruct, but are declared HERE (Foundation-only, `Codable`) so the wire +// codec doesn't depend on RNSAPI. `ProxyRnsBackend` maps these back onto the +// real `LocalInfo` / `SendOutcome`; the NE encodes them without ever importing +// RNSAPI. `StatusSnapshot` is NOT mirrored — it's already `Decodable` with +// stable `snake_case` keys, and only the APP side decodes it, so the NE encodes +// an equivalent JSON object inline. + +/// `Codable` mirror of `RNSAPI.LocalInfo` for the `.start` response payload. +public struct ProxyLocalInfo: Codable, Sendable, Equatable { + public let identityHash: String + public let destinationHash: String + public init(identityHash: String, destinationHash: String) { + self.identityHash = identityHash + self.destinationHash = destinationHash + } +} + +/// `Codable` mirror of `RNSAPI.SendOutcome` for the `.lxmfSend` response payload. +/// Encodes the case as a discriminator plus an optional associated string so the +/// app can reconstruct the exact `SendOutcome` case (including +/// `.queued(messageHash:)` and `.other(_)`). +public struct ProxySendOutcome: Codable, Sendable, Equatable { + public enum Kind: String, Codable, Sendable { + case queued, requestingPath, badHash, notStarted, other + } + public let kind: Kind + /// `messageHash` for `.queued`; the reason string for `.other`; nil otherwise. + public let detail: String? + + public init(kind: Kind, detail: String? = nil) { + self.kind = kind + self.detail = detail + } +} + +/// `Codable` mirror of a heard announce — the fields of a `BackendEvent.announce` +/// — Foundation-only so it crosses the seam. The NE builds these from its +/// transport's PathTable (`heardAnnounces` response); `ProxyRnsBackend` polls and +/// re-emits each as a `.announce` event, so the app's existing announce handling +/// (`AppServices` `for await event in backend.events`) works unchanged in Model B. +public struct ProxyHeardAnnounce: Codable, Sendable, Equatable { + public let destHashHex: String + public let appDataHex: String + public let aspect: String + public let publicKeysHex: String + public let interfaceName: String + public let hops: Int + /// Last-heard time, epoch seconds (the proxy diffs on this to emit only + /// newly-seen / freshly re-announced destinations). + public let timestamp: Double + + public init(destHashHex: String, appDataHex: String, aspect: String, + publicKeysHex: String, interfaceName: String, hops: Int, timestamp: Double) { + self.destHashHex = destHashHex + self.appDataHex = appDataHex + self.aspect = aspect + self.publicKeysHex = publicKeysHex + self.interfaceName = interfaceName + self.hops = hops + self.timestamp = timestamp + } +} diff --git a/Sources/Shared/RNodeSeam.swift b/Sources/Shared/RNodeSeam.swift new file mode 100644 index 00000000..ea068147 --- /dev/null +++ b/Sources/Shared/RNodeSeam.swift @@ -0,0 +1,190 @@ +// +// RNodeSeam.swift +// Shared +// +// The NE↔app marshaling for Model B RNode-over-Bluetooth. reticulum-swift ships +// the RNode protocol stack (`RNodeInterface` + `KISSFramedTransport`) and a real +// CoreBluetooth NUS client (`BLETransport`). The ONLY missing piece for Model B is +// the cross-process seam: RNS runs in the Network Extension, but CoreBluetooth must +// run in the app. +// +// NE: RNodeInterface ──KISS──▶ AppGroupRNodeSeamTransport : ReticulumSwift.Transport +// │ (App-Group) +// app: BLETransport (real NUS) ◀── AppGroupRNodeServer ─────┘ +// +// Unlike the BLE *mesh* seam, RNode is a SINGLE raw serial stream with no +// multi-peer addressing, so this wire is tiny: the NE drives connect/send/disconnect +// on the app's real `BLETransport`, and the app feeds received bytes + radio +// state-changes back. The KISS framing lives in the NE (`KISSFramedTransport`), so +// the bytes that cross this seam are the raw serial stream — `send` carries already +// KISS-framed output, `dataReceived` carries raw input awaiting deframing. +// +// Reuses the `SeamWriter`/`SeamReader` binary codec from `BLEDriverSeam.swift` +// (same Shared module). Direction is by transport queue, not by type. +// + +import Foundation + +// MARK: - Link state + +/// Compact wire form of `ReticulumSwift.TransportState`, carried app→NE on +/// `stateChanged`. The NE-side seam transport maps this back to a `TransportState` +/// for `RNodeInterface`; the app-side server maps `BLETransport`'s state to this. +/// Kept here (Foundation-only) so the wire definition needs no ReticulumSwift import. +public enum RNodeLinkState: UInt8, Sendable, Equatable { + case disconnected = 0 + case connecting = 1 + case connected = 2 + case failed = 3 +} + +// MARK: - Message + +/// One message on the RNode serial seam. +public enum RNodeSeamMessage: Equatable, Sendable { + // ── Commands: NE → app (drive the app's real RNode `BLETransport`) ── + /// Begin scanning/connecting to the named RNode (the device name the NE is + /// configured with — `config.host`). Empty string = connect to the first RNode found. + case connect(deviceName: String) + /// Outbound serial bytes (already KISS-framed by the NE) to write to the radio. + /// `reqId` correlates with the matching `sendResult` so the NE's + /// `send(_:completion:)` completes only when the app's real write completes — + /// RNode flow control relies on write-completion (see + /// `RNodeInterface.sendViaTransport`, which awaits the completion before the next + /// queued packet is sent). + case send(reqId: UInt32, data: Data) + /// Tear the radio link down. + case disconnect + + // ── Events: app → NE (feed the NE's seam `Transport`) ── + /// Inbound serial bytes from the radio (raw, awaiting KISS deframing in the NE). + case dataReceived(data: Data) + /// The app radio's `TransportState` changed. + case stateChanged(state: RNodeLinkState) + /// Completion of a `send(reqId:)` — carries the app-side write error (nil = ok) + /// so the NE can resume the awaiting `send` continuation (flow control). + case sendResult(reqId: UInt32, error: String?) + + private enum Tag: UInt8 { + case connect = 1, send, disconnect + case dataReceived = 64, stateChanged, sendResult + } + + // MARK: Encode + + public func encode() -> Data { + var w = SeamWriter() + switch self { + case let .connect(deviceName): w.u8(Tag.connect.rawValue); w.str(deviceName) + case .disconnect: w.u8(Tag.disconnect.rawValue) + case let .send(reqId, data): w.u8(Tag.send.rawValue); w.u32(reqId); w.data(data) + case let .dataReceived(data): w.u8(Tag.dataReceived.rawValue); w.data(data) + case let .stateChanged(state): w.u8(Tag.stateChanged.rawValue); w.u8(state.rawValue) + case let .sendResult(reqId, error): w.u8(Tag.sendResult.rawValue); w.u32(reqId); w.optStr(error) + } + return w.out + } + + // MARK: Decode + + public init(decoding data: Data) throws { + var r = SeamReader(data) + let raw = try r.u8() + guard let tag = Tag(rawValue: raw) else { throw SeamError.unknownTag(raw) } + switch tag { + case .connect: self = .connect(deviceName: try r.str()) + case .disconnect: self = .disconnect + case .send: self = .send(reqId: try r.u32(), data: try r.data()) + case .dataReceived: self = .dataReceived(data: try r.data()) + case .stateChanged: + let s = try r.u8() + guard let state = RNodeLinkState(rawValue: s) else { throw SeamError.unknownTag(s) } + self = .stateChanged(state: state) + case .sendResult: self = .sendResult(reqId: try r.u32(), error: try r.optStr()) + } + try r.expectEnd() + } +} + +// MARK: - Wire abstraction + +/// Carries `RNodeSeamMessage`s across the App-Group. NE: `send` → the NE→app queue, +/// `inbound` ← the app→NE queue. App: reversed. Injected so both the NE-side +/// `AppGroupRNodeSeamTransport` and the app-side `AppGroupRNodeServer` are +/// unit-testable with an in-memory loopback. Mirrors `BLESeamTransport`. +public protocol RNodeSeamWire: AnyObject, Sendable { + func send(_ message: RNodeSeamMessage) + /// Decoded messages arriving from the other process. + var inbound: AsyncStream { get } +} + +// MARK: - Shared config snapshot + +/// Foundation-only snapshot of the RNode radio configuration, persisted by the app to +/// the App-Group `rnodeConfigKey` and read by the NE. Fields mirror the app's +/// `RNodeConfig` (in RNSAPI, which the NE does not link); the NE maps these to +/// reticulum-swift's `RadioConfig`. +public struct RNodeSeamConfig: Codable, Equatable, Sendable { + public var deviceName: String + public var frequency: UInt32 + public var bandwidth: UInt32 + public var txPower: UInt8 + public var spreadingFactor: UInt8 + public var codingRate: UInt8 + public var stAlock: Float? + public var ltAlock: Float? + + public init( + deviceName: String, + frequency: UInt32, + bandwidth: UInt32, + txPower: UInt8, + spreadingFactor: UInt8, + codingRate: UInt8, + stAlock: Float? = nil, + ltAlock: Float? = nil + ) { + self.deviceName = deviceName + self.frequency = frequency + self.bandwidth = bandwidth + self.txPower = txPower + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.stAlock = stAlock + self.ltAlock = ltAlock + } + + // MARK: App-Group persistence + + /// Read the persisted RNode config, or nil if no RNode is enabled. + public static func loadFromAppGroup(_ group: String = appGroupIdentifier) -> RNodeSeamConfig? { + guard let defaults = UserDefaults(suiteName: group), + let data = defaults.data(forKey: SharedDefaultsConstants.rnodeConfigKey), + let config = try? JSONDecoder().decode(RNodeSeamConfig.self, from: data) else { + return nil + } + return config + } + + /// Persist this config to the App-Group (app side) + post the change notification. + public func saveToAppGroup(_ group: String = appGroupIdentifier) { + guard let defaults = UserDefaults(suiteName: group), + let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: SharedDefaultsConstants.rnodeConfigKey) + Self.postChangedNotification() + } + + /// Clear the persisted config (RNode disabled) + post the change notification. + public static func clearFromAppGroup(_ group: String = appGroupIdentifier) { + UserDefaults(suiteName: group)?.removeObject(forKey: SharedDefaultsConstants.rnodeConfigKey) + postChangedNotification() + } + + private static func postChangedNotification() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(SharedDefaultsConstants.rnodeConfigChangedNotificationName as CFString), + nil, nil, true + ) + } +} diff --git a/Sources/Shared/SharedFrameQueue.swift b/Sources/Shared/SharedFrameQueue.swift index 9b209373..9cbf33a3 100644 --- a/Sources/Shared/SharedFrameQueue.swift +++ b/Sources/Shared/SharedFrameQueue.swift @@ -3,10 +3,30 @@ // Columba Shared // // Lock-free append-only queue backed by a shared file in the App Group container. -// The Network Extension appends frames; the main app reads and clears them. +// One process appends frames; the other reads and clears them. // // Frame format: [4-byte length (big-endian)][1-byte interface tag][N-byte frame data] -// Interface tags: 0x01 = TCP, 0x02 = Auto +// Interface tags: 0x01 = TCP, 0x02 = Auto, 0x10 = BLE mesh radio, 0x11 = RNode radio +// +// Two named directional queues live in the App Group container (Model B IPC bridge): +// +// • `e2a` (NE→app): frames the Network Extension produces. Historically this +// carried inbound TCP/Auto frames for the app to inject into its transport +// (#57); under Model B it also carries NE-originated frames that the app +// must transmit on a radio (tagged with the target radio selector). Backed +// by the original `frame_queue` file name for back-compat — `init` defaults +// to that name so existing call sites (`PacketTunnelProvider`, +// `ExtensionFrameReader`) keep working unchanged. +// +// • `a2e` (app→NE): radio-received frames the app forwards into the NE's RNS +// instance so the NE is the single RNS node reachable over both TCP and +// radio. Backed by the `frame_queue_a2e` file name. +// +// This file imports ONLY Foundation and is compiled into BOTH the ColumbaApp +// and ColumbaNetworkExtension targets. It must stay free of ReticulumSwift / +// RNSAPI so it can compile in the NE target (which does not link those). The +// `NetworkInterface`-conforming bridge lives in a separate file +// (`AppGroupBridgeInterface.swift`) that imports ReticulumSwift. // import Foundation @@ -26,22 +46,120 @@ public enum SharedDefaultsConstants { /// restart. public static let configChangedNotificationName = "network.columba.configChanged" - /// Darwin notification posted by the extension when inbound - /// frames have been written to `SharedFrameQueue`. The app's + /// Darwin notification posted by the extension when frames have + /// been written to the NE→app (`e2a`) `SharedFrameQueue`. The app's /// `ExtensionFrameReader` observes this to drain the queue. public static let packetReadyNotificationName = "network.columba.packetReady" + /// Darwin notification posted by the app when radio-received frames + /// have been written to the app→NE (`a2e`) `SharedFrameQueue`. The + /// extension observes this to drain the queue and inject the frames + /// into its RNS transport via `AppGroupBridgeInterface`. (Wiring of + /// the observer on the NE side is Track A5.) + public static let radioFrameReadyNotificationName = "network.columba.radioFrameReady" + + /// Darwin notification posted when a `BLEDriverSeamMessage` is written to the + /// NE→app BLE-seam queue (`bleSeamN2A`). The app's seam transport observes it. + public static let bleSeamN2ANotificationName = "network.columba.bleSeam.n2a" + /// Darwin notification posted when a `BLEDriverSeamMessage` is written to the + /// app→NE BLE-seam queue (`bleSeamA2N`). The NE's seam transport observes it. + public static let bleSeamA2NNotificationName = "network.columba.bleSeam.a2n" + + /// Darwin notification posted when an `RNodeSeamMessage` is written to the + /// NE→app RNode-seam queue (`rnodeSeamN2A`). The app's RNode server observes it. + public static let rnodeSeamN2ANotificationName = "network.columba.rnodeSeam.n2a" + /// Darwin notification posted when an `RNodeSeamMessage` is written to the + /// app→NE RNode-seam queue (`rnodeSeamA2N`). The NE's seam transport observes it. + public static let rnodeSeamA2NNotificationName = "network.columba.rnodeSeam.a2n" + /// Shared UserDefaults key holding the JSON-encoded interface /// configuration array (full `InterfaceEntity` objects). Both the /// app's `InterfaceRepository` and the extension's /// `loadInterfaceConfigs` read from this key. public static let interfacesKey = "com.columba.interfaces" + + /// Shared UserDefaults key holding the JSON-encoded `RNodeSeamConfig` for the + /// Model B RNode interface (device name + radio params), or absent when no RNode + /// is enabled. Written by the app (which owns the full `RNodeConfig`), read by the + /// NE (which is RNSAPI-free and maps it to reticulum-swift's `RadioConfig`). + public static let rnodeConfigKey = "com.columba.rnodeConfig" + + /// Darwin notification posted by the app when the RNode config (`rnodeConfigKey`) + /// changes (enabled / disabled / re-tuned). The NE observes it to (re)build or tear + /// down its `RNodeInterface`. + public static let rnodeConfigChangedNotificationName = "network.columba.rnodeConfigChanged" + + /// Shared UserDefaults key holding the JSON-encoded `PropagationSeamConfig` (the + /// selected propagation node hash + stamp cost + sync interval/enabled), or absent + /// when none is selected. Written by the app (`PropagationNodeManager`, Model B + /// only), read by the NE which wires it onto its in-NE `LXMRouter`. + public static let propagationConfigKey = "com.columba.propagationConfig" + + /// Darwin notification posted by the app when `propagationConfigKey` changes (PN + /// selected/cleared, interval/enabled edited). The NE observes it to re-apply the PN + /// on its router and restart its sync scheduler. + public static let propagationConfigChangedNotificationName = "network.columba.propagationConfigChanged" + + /// Darwin notification posted by the app to ask the NE to run one immediate + /// propagation sync (the "Sync Now" button — the app can't call the NE's router). + public static let propagationSyncNowNotificationName = "network.columba.propagationSyncNow" + + /// Shared UserDefaults key holding the JSON-encoded `PropagationSyncStateSnapshot` + /// the NE writes as a sync progresses (phase / progress / counts / error). The app + /// reads it to drive the in-app sync UI; Darwin carries no payload, hence this key. + public static let propagationSyncStateKey = "com.columba.propagationSyncState" + + /// Darwin notification posted by the NE when `propagationSyncStateKey` updates. The + /// app observes it to refresh `PropagationNodeManager.syncState` (the sync sheet). + public static let propagationSyncStateChangedNotificationName = "network.columba.propagationSyncStateChanged" } -/// Interface tag identifying which network interface a frame arrived on. +/// Interface tag identifying which network interface a frame is associated with. +/// +/// For `e2a` (NE→app) inbound frames this is the interface the frame arrived on +/// inside the NE (`tcp` / `auto`). For `e2a` NE-originated radio transmissions and +/// for `a2e` (app→NE) radio receptions, this selects which radio the frame came +/// from / should be transmitted on (`bleMesh` / `rnode`). public enum FrameInterfaceTag: UInt8 { case tcp = 0x01 case auto = 0x02 + /// BLE-mesh radio (Columba's GATT mesh transport). + case bleMesh = 0x10 + /// RNode radio (LoRa over BLE/serial RNode hardware). + case rnode = 0x11 + /// A codec'd `BLEDriverSeamMessage` on the Model B BLE driver seam (the + /// dedicated `bleSeam*` queues carry only these, so the tag is uniform). + case bleControl = 0x20 + /// A codec'd `RNodeSeamMessage` on the Model B RNode serial seam (the dedicated + /// `rnodeSeam*` queues carry only these, so the tag is uniform). + case rnodeControl = 0x21 +} + +/// File names for the two directional App-Group frame queues. +/// +/// `default_` (= `frame_queue`) preserves the original single-queue file name so +/// existing NE→app call sites keep working without migration (#57). `a2e` is the +/// new app→NE radio-ingest queue introduced for the Model B IPC bridge. +public enum SharedFrameQueueName { + /// NE→app direction. Original file name — keep for back-compat. + public static let e2a = "frame_queue" + /// app→NE direction (radio-received frames forwarded into the NE's RNS). + public static let a2e = "frame_queue_a2e" + + // Model B BLE driver seam (dedicated queues, separate from the radio-frame + // a2e/e2a above so the BLE control/data stream never intermixes with — or + // double-drains against — `AppGroupBridgeInterface`). + /// NE→app: `BLEDriver` commands + `sendFragment` (app drains). + public static let bleSeamN2A = "ble_seam_n2a" + /// app→NE: driver stream events + `receivedFragment` + reqId results (NE drains). + public static let bleSeamA2N = "ble_seam_a2n" + + // Model B RNode serial seam (dedicated queues, separate from the BLE seam above + // and the radio-frame a2e/e2a). + /// NE→app: RNode transport commands (connect / send / disconnect). App drains. + public static let rnodeSeamN2A = "rnode_seam_n2a" + /// app→NE: RNode transport events (dataReceived / stateChanged). NE drains. + public static let rnodeSeamA2N = "rnode_seam_a2n" } /// A frame read from the shared queue, tagged with its source interface. @@ -76,16 +194,23 @@ public final class SharedFrameQueue: @unchecked Sendable { /// Create a shared frame queue in the given App Group container. /// - /// - Parameter appGroupIdentifier: The App Group identifier (e.g., "group.network.columba.Columba") - public init(appGroupIdentifier: String) { + /// - Parameters: + /// - appGroupIdentifier: The App Group identifier (e.g., "group.network.columba.Columba") + /// - name: Queue file name within the container. Defaults to + /// `SharedFrameQueueName.e2a` (`"frame_queue"`) for back-compat with + /// the original single NE→app queue (#57). Pass + /// `SharedFrameQueueName.a2e` for the app→NE radio-ingest queue. Each + /// name gets its own backing file and its own `.lock` file, so the two + /// directions never contend on the same POSIX lock. + public init(appGroupIdentifier: String, name: String = SharedFrameQueueName.e2a) { guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupIdentifier ) else { // Fallback to tmp if app group not available (shouldn't happen in production) - self.fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("frame_queue") + self.fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(name) return } - self.fileURL = containerURL.appendingPathComponent("frame_queue") + self.fileURL = containerURL.appendingPathComponent(name) } deinit { diff --git a/Sources/SwiftBLEBridge/RNodeNativeBindings.swift b/Sources/SwiftBLEBridge/RNodeNativeBindings.swift deleted file mode 100644 index 757d66d0..00000000 --- a/Sources/SwiftBLEBridge/RNodeNativeBindings.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// RNodeNativeBindings.swift -// SwiftBLEBridge -// -// C-ABI shims exposing SwiftRNodeBridge.shared to Python's ctypes. The Python -// `IOSRNodeInterface` (app/rnode/IOSRNodeInterface.py) binds these via -// `ctypes.CDLL(None)` at module import time — symbol names + signatures MUST -// match the `_decl(...)` calls there. Mirror of `BleNativeBindings.swift`. -// -// Return codes: -// 0 = success -// -2 = argument error (null pointer / bad encoding) -// - -import Foundation - -// File-private decoders (Swift `private` is file-scoped, so these don't -// collide with the identically-purposed helpers in BleNativeBindings.swift). -private func rnodeDecodeCString(_ ptr: UnsafePointer?) -> String? { - guard let ptr else { return nil } - return String(cString: ptr) -} - -private func rnodeDecodeBytes(_ ptr: UnsafePointer?, length: Int32) -> Data? { - guard let ptr, length >= 0 else { return nil } - if length == 0 { return Data() } - return ptr.withMemoryRebound(to: UInt8.self, capacity: Int(length)) { p in - Data(bytes: p, count: Int(length)) - } -} - -@_cdecl("columba_rnode_start") -public func columba_rnode_start() -> Int32 { - SwiftRNodeBridge.shared.start() - return 0 -} - -@_cdecl("columba_rnode_stop") -public func columba_rnode_stop() -> Int32 { - SwiftRNodeBridge.shared.stop() - return 0 -} - -@_cdecl("columba_rnode_connect") -public func columba_rnode_connect(_ deviceName: UnsafePointer?) -> Int32 { - guard let name = rnodeDecodeCString(deviceName) else { return -2 } - SwiftRNodeBridge.shared.connect(deviceName: name) - return 0 -} - -@_cdecl("columba_rnode_disconnect") -public func columba_rnode_disconnect() -> Int32 { - SwiftRNodeBridge.shared.disconnect() - return 0 -} - -@_cdecl("columba_rnode_write") -public func columba_rnode_write( - _ bytes: UnsafePointer?, - _ length: Int32 -) -> Int32 { - guard let payload = rnodeDecodeBytes(bytes, length: length) else { return -2 } - SwiftRNodeBridge.shared.write(payload) - return 0 -} diff --git a/Sources/SwiftBLEBridge/SwiftBLEBridge.swift b/Sources/SwiftBLEBridge/SwiftBLEBridge.swift index ef88490d..b52003c7 100644 --- a/Sources/SwiftBLEBridge/SwiftBLEBridge.swift +++ b/Sources/SwiftBLEBridge/SwiftBLEBridge.swift @@ -97,9 +97,8 @@ public final class SwiftBLEBridge: NSObject, @unchecked Sendable { // Keyed by `CBCentral.identifier.uuidString`. private var gattServerPeers: [String: BleGattServerPeer] = [:] - // Cap on a peer's backpressure notify queue (mirrors - // SwiftRNodeBridge.maxPendingWrites) so a stuck or vanished subscriber - // can't grow pendingNotifies without bound. + // Cap on a peer's backpressure notify queue so a stuck or vanished + // subscriber can't grow pendingNotifies without bound. private let maxPendingNotifies = 128 // Same bound for the central-role per-client outbound write queue. @@ -120,6 +119,18 @@ public final class SwiftBLEBridge: NSObject, @unchecked Sendable { private var txCharCBUUID: CBUUID = BleConstants.txCharCBUUID private var identityCharCBUUID: CBUUID = BleConstants.identityCharCBUUID + // MARK: - State-restoration identifiers (Track C8 — background BLE wake) + + /// Stable restore identifiers handed to CoreBluetooth so iOS can RELAUNCH + /// the app (into the background) on a BLE event for a peer we were + /// connected/scanning/advertising to, then hand the SAME manager instances + /// back via `willRestoreState`. These strings MUST be stable across launches + /// — iOS keys its preserved manager state on them. Changing them orphans the + /// preserved state. Process-wide constants since there is exactly one + /// central + one peripheral manager per app (see `shared`). + public static let centralRestoreIdentifier = "network.columba.ble.central" + public static let peripheralRestoreIdentifier = "network.columba.ble.peripheral" + public override init() { super.init() } // MARK: - Public API @@ -166,11 +177,58 @@ public final class SwiftBLEBridge: NSObject, @unchecked Sendable { // calls stop() then start() in quick succession; stop() now // intentionally leaves the managers alive to avoid CB // teardown races). Only create on first start. + // + // Track C8 — state-restoration / background BLE wake: pass the + // stable restore identifiers so iOS preserves these managers' + // state across an app kill and RELAUNCHES us (into the background) + // on a BLE event for a preserved peer, handing the same managers + // back via `centralManager(_:willRestoreState:)` / + // `peripheralManager(_:willRestoreState:)`. Opting in here is what + // arms `UIApplication.LaunchOptionsKey.bluetoothCentrals` / + // `.bluetoothPeripherals` at relaunch. For iOS to actually hand the + // restored manager back, the app must RE-CREATE a manager with the + // SAME restore identifier EARLY in launch — see + // `SwiftBLEBridge.restoreAtLaunch()` and its call site in the app's + // launch path (ColumbaApp). + // + // ──────────────────────────────────────────────────────────────── + // DELIVERY CAVEAT (read before relying on background wake): + // The wake itself (relaunch + manager hand-back) is wired here in + // native Swift. But the BLE message DELIVERY path — turning inbound + // GATT bytes into a processed + notified LXMF message — is currently + // Python-coupled: SwiftBLEBridge routes `on_data_received` (and the + // rest of the BleCallbackSlot callbacks) through `callbackInvoker` + // into the embedded Python RNS stack (IOSBLEDriver.py / + // PythonBLECallbackBridge). On the SWIFT backend (Model B's target) + // there is NO native BLE delivery path yet, so a background BLE wake + // only results in a *delivered + notified* message when the PYTHON + // backend is the active one and is (re)started early enough in the + // relaunch to re-install the callbackInvoker. Native-Swift BLE + // delivery is a deliberate follow-on; until it lands, treat C8's wake + // as Python-backend-only for end-to-end delivery. Scope is BLE-direct; + // RNode-over-iOS now runs Model B (radio hosted in the app via + // reticulum-swift BLETransport with its own CBCentralManager, RNS in + // the Network Extension), outside this mesh restore-identifier scope. + // ──────────────────────────────────────────────────────────────── if self.centralManager == nil { - self.centralManager = CBCentralManager(delegate: self, queue: queue) + self.centralManager = CBCentralManager( + delegate: self, + queue: queue, + options: [ + CBCentralManagerOptionRestoreIdentifierKey: + Self.centralRestoreIdentifier + ] + ) } if self.peripheralManager == nil { - self.peripheralManager = CBPeripheralManager(delegate: self, queue: queue) + self.peripheralManager = CBPeripheralManager( + delegate: self, + queue: queue, + options: [ + CBPeripheralManagerOptionRestoreIdentifierKey: + Self.peripheralRestoreIdentifier + ] + ) } self.isStartedFlag = true // Surface any already-poweredOn managers so scan/advertise @@ -186,6 +244,63 @@ public final class SwiftBLEBridge: NSObject, @unchecked Sendable { startRssiPolling() } + /// Track C8 — at-launch re-instantiation for background BLE wake. + /// + /// Call this EARLY in the app's launch path (before the run loop settles) + /// when iOS has relaunched the app for a CoreBluetooth event — i.e. when + /// `UIApplication.LaunchOptionsKey.bluetoothCentrals` and/or + /// `.bluetoothPeripherals` are present in `launchOptions`, or simply + /// unconditionally at every launch (cheap, and the only reliable trigger + /// in a pure-SwiftUI app that has no `application(_:didFinishLaunching…)` + /// to read `launchOptions` from — see the call-site note in the app). + /// + /// Re-creating a `CBCentralManager` / `CBPeripheralManager` with the SAME + /// restore identifier is the documented contract that makes iOS replay the + /// preserved state through `willRestoreState`. If we don't re-create the + /// manager promptly at relaunch, iOS discards the preserved state and the + /// wake is wasted. + /// + /// This intentionally does NOT call `start(...)` (which needs the per- + /// session service/char UUIDs the Python driver injects, and flips + /// `isStartedFlag` / starts scanning+advertising). It only re-materialises + /// the managers so the restore handshake completes; the regular + /// `start(...)` path (driven by the active backend bringing BLE up) then + /// re-adopts the rest of the session. The manager UUIDs default to + /// `BleConstants` until `start(...)` overrides them, which is correct — the + /// service/char UUIDs are fixed wire constants (see BleConstants), so the + /// restored peripherals re-wire against the right service even before + /// `start(...)` runs. + /// + /// NOTE (delivery): re-creating the managers re-arms the wake and re-wires + /// CoreBluetooth state, but inbound bytes are only turned into a notified + /// message once the active backend's delivery path is live (Python-backend- + /// only today — see the DELIVERY CAVEAT in `start(...)`). The app's launch + /// path should kick the backend's BLE bring-up alongside calling this. + public func restoreAtLaunch() { + queue.sync { + if self.centralManager == nil { + self.centralManager = CBCentralManager( + delegate: self, + queue: queue, + options: [ + CBCentralManagerOptionRestoreIdentifierKey: + Self.centralRestoreIdentifier + ] + ) + } + if self.peripheralManager == nil { + self.peripheralManager = CBPeripheralManager( + delegate: self, + queue: queue, + options: [ + CBPeripheralManagerOptionRestoreIdentifierKey: + Self.peripheralRestoreIdentifier + ] + ) + } + } + } + /// Periodically request RSSI samples from connected centrals so the /// BLE Connections UI can show a current-ish dBm reading instead of /// the (often stale) scan-time RSSI. Results land asynchronously via @@ -556,6 +671,76 @@ public final class SwiftBLEBridge: NSObject, @unchecked Sendable { extension SwiftBLEBridge: CBCentralManagerDelegate { + /// Track C8 — central-side state restoration. CoreBluetooth invokes this + /// (on `queue`, BEFORE `centralManagerDidUpdateState`) when the system has + /// relaunched the app and is handing back a `CBCentralManager` we created + /// with `centralRestoreIdentifier`. Re-adopt the preserved central state so + /// the bridge's in-memory model matches what CoreBluetooth still holds: + /// + /// • `CBCentralManagerRestoredStatePeripheralsKey` — peripherals that + /// were connected (or pending connection) when we were suspended. iOS + /// hands back the live `CBPeripheral` objects; we MUST take a strong ref + /// (re-populate `gattClients` / `discoveredPeripherals`) or it + /// deallocates them and drops the connection. We re-set ourselves as + /// delegate and, for already-`.connected` peripherals, re-drive service + /// discovery so the GATT handshake (identity read → connected) re-runs + /// exactly as in the normal `didConnect` path. + /// • `CBCentralManagerRestoredStateScanServicesKey` / + /// `…ScanOptionsKey` — if we were scanning when killed, mark scan as + /// pending so `tryStartScanLocked()` (called from + /// `centralManagerDidUpdateState(.poweredOn)`, which fires right after + /// this) resumes it. + /// + /// Already on `queue` (CB delegate dispatch), so peer-state mutation here + /// follows the same locking discipline as the other delegate callbacks. + /// + /// DELIVERY CAVEAT: re-adopting here re-wires CoreBluetooth, but the + /// resulting `on_data_received` only becomes a notified message via the + /// Python delivery path (Python-backend-only today — see the block in + /// `start(...)`). Native-Swift BLE delivery is a follow-on. + public func centralManager( + _ central: CBCentralManager, + willRestoreState dict: [String: Any] + ) { + let restoredPeripherals = + (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] + emitInfo("centralManager willRestoreState peripherals=\(restoredPeripherals.count)") + + for peripheral in restoredPeripherals { + let address = peripheral.identifier.uuidString + // Strong ref so iOS doesn't deallocate the restored peripheral. + peripheral.delegate = self + discoveredPeripherals[address] = peripheral + let client = gattClients[address] ?? BleGattClient(peripheral: peripheral) + gattClients[address] = client + + // Re-drive the handshake for peripherals CB still has connected. + // Mirrors the normal didConnect adoption: stamp MTU, then discover + // our service so didDiscoverServices → … → identity read re-runs. + // Peripherals restored mid-connect (.connecting) are left for + // CoreBluetooth to finish; its didConnect will adopt them normally. + if peripheral.state == .connected { + let mtu = peripheral.maximumWriteValueLength(for: .withoutResponse) + client.mtu = mtu + client.state = .discoveringServices + peripheral.discoverServices([serviceCBUUID]) + emitInfo("restored connected peripheral addr=\(address) mtu=\(mtu)") + } else { + client.state = .connecting + emitInfo("restored pending peripheral addr=\(address) state=\(peripheral.state.rawValue)") + } + } + + // If a scan was in flight when we were killed, re-arm it. The actual + // scanForPeripherals call happens in centralManagerDidUpdateState once + // the manager reports .poweredOn (which lands right after this). + if let scanServices = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID], + !scanServices.isEmpty { + pendingScanRequested = true + emitInfo("restored scan request services=\(scanServices.map { $0.uuidString })") + } + } + public func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: @@ -849,6 +1034,74 @@ extension SwiftBLEBridge: CBPeripheralDelegate { extension SwiftBLEBridge: CBPeripheralManagerDelegate { + /// Track C8 — peripheral-side state restoration. CoreBluetooth invokes this + /// (on `queue`, BEFORE `peripheralManagerDidUpdateState`) when the system + /// has relaunched the app and is handing back a `CBPeripheralManager` we + /// created with `peripheralRestoreIdentifier`. Re-adopt the preserved + /// peripheral (GATT-server) state: + /// + /// • `CBPeripheralManagerRestoredStateServicesKey` — the published + /// `CBMutableService`(s) iOS preserved. We re-bind our + /// `serverRxChar` / `serverTxChar` / `serverIdentityChar` to the + /// restored characteristic objects and set `gattServiceAdded = true` so + /// the `setUpGattServiceIfNeeded()` call in + /// `peripheralManagerDidUpdateState(.poweredOn)` (which fires right + /// after this) does NOT re-`add(service:)`. Re-adding a service iOS + /// already holds makes it broadcast Service Changed, which breaks + /// subscribed centrals — the exact hazard `gattServiceAdded` guards + /// against in the normal path. + /// • `CBPeripheralManagerRestoredStateAdvertisementDataKey` — if we were + /// advertising when killed, mark advertise as pending so + /// `tryStartAdvertiseLocked()` resumes it once the manager reports + /// `.poweredOn`. + /// + /// Subscribed centrals are NOT in this dictionary — CoreBluetooth re-issues + /// `didSubscribeTo` for restored subscriptions, which the existing delegate + /// already adopts into `gattServerPeers`. Already on `queue` (CB delegate + /// dispatch), so the same locking discipline as the other callbacks holds. + /// + /// DELIVERY CAVEAT: same as the central side — re-adoption re-wires the + /// GATT server, but inbound writes only become notified messages via the + /// Python delivery path today (see `start(...)`). Native-Swift delivery is + /// a follow-on. + public func peripheralManager( + _ peripheral: CBPeripheralManager, + willRestoreState dict: [String: Any] + ) { + let restoredServices = + (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] + emitInfo("peripheralManager willRestoreState services=\(restoredServices.count)") + + // Re-bind our characteristic refs from the restored service so + // send()/drainPeerNotifiesLocked keep working against the same objects + // CoreBluetooth still has published. Match by UUID — the wire constants + // are fixed (see BleConstants). + if let service = restoredServices.first(where: { $0.uuid == serviceCBUUID }) { + for case let ch as CBMutableCharacteristic in (service.characteristics ?? []) { + switch ch.uuid { + case rxCharCBUUID: serverRxChar = ch + case txCharCBUUID: serverTxChar = ch + case identityCharCBUUID: serverIdentityChar = ch + default: break + } + } + // The service is already published — do NOT re-add it (would + // trigger Service Changed). Flag as added so setUpGattServiceIfNeeded + // becomes a no-op when poweredOn lands right after this. + gattServiceAdded = true + emitInfo("restored published service uuid=\(service.uuid.uuidString)") + } + + // Resume advertising if we were advertising when suspended. + if let adData = dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any] { + pendingAdvertiseRequested = true + if let name = adData[CBAdvertisementDataLocalNameKey] as? String, !name.isEmpty { + pendingAdvertiseDeviceName = name + } + emitInfo("restored advertise request name=\(pendingAdvertiseDeviceName ?? "")") + } + } + public func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { switch peripheral.state { case .poweredOn: diff --git a/Sources/SwiftBLEBridge/SwiftRNodeBridge.swift b/Sources/SwiftBLEBridge/SwiftRNodeBridge.swift deleted file mode 100644 index 29919844..00000000 --- a/Sources/SwiftBLEBridge/SwiftRNodeBridge.swift +++ /dev/null @@ -1,423 +0,0 @@ -// -// SwiftRNodeBridge.swift -// SwiftBLEBridge -// -// CoreBluetooth Nordic-UART-Service (NUS) client for RNode LoRa hardware. -// -// Deliberately SEPARATE from `SwiftBLEBridge`: the RNode interface and the BLE -// mesh are unrelated transports that can be enabled independently or at the -// same time, so each owns its own `CBCentralManager` + delegate (decision: -// Torlando, 2026-05-26 — no shared scanner, no coupling). They merely share -// this SwiftPM module because both are pure-CoreBluetooth code; there is no -// runtime coupling between the two centrals. -// -// This is the Swift (I/O) half of the iOS RNode interface. The KISS framing + -// RNode binary protocol live in Python (`app/rnode/IOSRNodeInterface.py`, -// ported from Android). Python → Swift goes through the `columba_rnode_*` -// C-ABI shims in `RNodeNativeBindings.swift`; Swift → Python goes through the -// injected `RNodeCallbackInvoker` (concrete impl `PythonRNodeCallbackBridge`, -// which lives in the pbxproj target because it needs Python.h). Mirrors the -// IOSBLEInterface / SwiftBLEBridge split exactly. -// -// BLE/NUS only — no USB-serial, no Bluetooth Classic (no iOS support). -// - -import Foundation -#if canImport(CoreBluetooth) -import CoreBluetooth -#endif - -/// Callback slots the Python RNode interface registers with the bridge via -/// `rns_bridge.set_rnode_callback(slot, callable)`. Scoped to the two events a -/// NUS client emits (cf. the richer `BleCallbackSlot` for the mesh). -public enum RNodeCallbackSlot: String, Sendable, CaseIterable { - /// `cb(data: bytes)` — a payload notified on the NUS TX characteristic - /// (RNode → phone). Raw serial bytes; the Python side runs KISS framing. - case onData = "data" - /// `cb(connected: bool, device_name: str)` — link came up (TX notify - /// subscribed) or went down (disconnect). - case onState = "state" -} - -/// Abstraction over "invoke the Python RNode callback registered under this -/// slot". Concrete impl (`PythonRNodeCallbackBridge`) is pbxproj-only (needs -/// Python.h); the SwiftPM target builds + unit-tests without the Python C-API -/// by injecting a stub. Mirror of `BleCallbackInvoker`, minus the bool-return -/// variant (RNode has no synchronous callbacks). -public protocol RNodeCallbackInvoker: AnyObject, Sendable { - func invoke(slot: RNodeCallbackSlot, args: [Any]) -} - -#if canImport(CoreBluetooth) - -/// CoreBluetooth NUS client. Scans for the RNode by advertised name, connects, -/// discovers the Nordic UART Service, subscribes to TX notifications, and -/// writes outbound serial to RX (write-without-response, MTU-chunked). One per -/// process — the `@_cdecl` shims route through `.shared`. -public final class SwiftRNodeBridge: NSObject, @unchecked Sendable { - - public static let shared = SwiftRNodeBridge() - - // Nordic UART Service — RNode firmware exposes its BLE serial here. - private static let nusService = CBUUID(string: "6e400001-b5a3-f393-e0a9-e50e24dcca9e") - private static let nusRxChar = CBUUID(string: "6e400002-b5a3-f393-e0a9-e50e24dcca9e") // write (phone → RNode) - private static let nusTxChar = CBUUID(string: "6e400003-b5a3-f393-e0a9-e50e24dcca9e") // notify (RNode → phone) - - /// Own serial queue — distinct from SwiftBLEBridge's. CB delegate callbacks - /// land here; callback invocations hop to the Python serial queue inside - /// PythonBridge. - private let queue = DispatchQueue(label: "network.columba.rnode", qos: .userInitiated) - private var callbackInvoker: RNodeCallbackInvoker? - - // Our own central — NOT shared with the mesh. Held strong for the bridge's - // lifetime; reused across stop()/start() to avoid CB teardown races. - private var central: CBCentralManager? - // Strong ref to the connected peripheral — iOS deallocates CBPeripheral - // without one, dropping the link. Also the handle for background reconnect. - private var peripheral: CBPeripheral? - private var rxChar: CBCharacteristic? - private var txChar: CBCharacteristic? - - private var targetName: String = "" // device name to match while scanning - private var wantConnected = false // user intends a live link → drives reconnect - private var isLinkUp = false // TX notify subscribed → link usable - private var startedFlag = false - - // Writes issued before the link is up (the protocol sends radio config - // right after connect(), but our connect is async). Queued here and flushed - // on link-up so radio config isn't silently lost into a not-yet-ready link. - private var pendingWrites: [Data] = [] - private let maxPendingWrites = 128 - - // Post-link MTU-sized chunks awaiting CoreBluetooth transmit-queue space. - // writeValue(.withoutResponse) silently drops once the queue is full - // (iOS 11+), so chunks are queued here and drained as the queue frees up - // (see drainChunksLocked / peripheralIsReady(toSendWriteWithoutResponse:)). - private var pendingChunks: [Data] = [] - private let maxPendingChunks = 4096 - - public override init() { super.init() } - - // MARK: - Public API (called from the C-ABI shims / app glue) - - public func setCallbackInvoker(_ invoker: RNodeCallbackInvoker?) { - queue.sync { self.callbackInvoker = invoker } - } - - /// Bring up the central. Idempotent. The central isn't created until here, - /// so merely installing the callback invoker at app launch costs nothing. - public func start() { - queue.sync { - guard !startedFlag else { return } - if central == nil { - central = CBCentralManager(delegate: self, queue: queue) - } - startedFlag = true - } - } - - /// Tear down the active link but keep the central alive (Apply & Restart - /// calls stop() then start() in quick succession). Clears reconnect intent. - public func stop() { - queue.sync { - wantConnected = false - if let c = central, c.isScanning { c.stopScan() } - if let p = peripheral { central?.cancelPeripheralConnection(p) } - teardownLinkLocked(notify: false) - peripheral = nil - // Clear the invoker inside the serialized block: didUpdateValueFor - // notifications CoreBluetooth already queued on `queue` are - // delivered after this block, and their guard only matches the - // static TX-char UUID (no isLinkUp check), so they would otherwise - // fire .onData into Python's mid-teardown IOSRNodeInterface and - // corrupt KISS decoder state. Mirrors SwiftBLEBridge.stop(); - // re-installed on the next start. - callbackInvoker = nil - } - } - - /// Connect to the RNode advertising `deviceName`. If we already hold the - /// peripheral (reconnect after a drop), issue a direct pending connect - /// (works in the background, no scan); otherwise start scanning. - public func connect(deviceName: String) { - queue.async { [weak self] in - guard let self else { return } - self.targetName = deviceName - self.wantConnected = true - if let p = self.peripheral { - self.central?.connect(p, options: nil) - self.log("reconnect-issued name=\(deviceName)") - } else { - self.tryStartScanLocked() - } - } - } - - public func disconnect() { - queue.async { [weak self] in - guard let self else { return } - self.wantConnected = false - if let c = self.central, c.isScanning { c.stopScan() } - if let p = self.peripheral { self.central?.cancelPeripheralConnection(p) } - self.teardownLinkLocked(notify: true) - self.peripheral = nil - } - } - - /// Write outbound serial to the RNode RX characteristic, chunked to the - /// negotiated write-without-response MTU. KISS frames routinely exceed one - /// BLE MTU; the RNode firmware reassembles a continuous serial stream, so - /// sequential chunking with no inter-chunk framing is correct. - public func write(_ data: Data) { - queue.async { [weak self] in - guard let self else { return } - guard self.isLinkUp, self.peripheral != nil, self.rxChar != nil else { - // Link not up yet — queue (bounded) and flush on link-up. - if self.pendingWrites.count < self.maxPendingWrites { - self.pendingWrites.append(data) - } else { - self.log("pending-write queue full — dropping \(data.count)B") - } - return - } - self.writeChunkedLocked(data) - } - } - - /// Chunk a frame to the negotiated MTU and queue the chunks for sending. - /// Caller guarantees the link is up. The chunks aren't written directly — - /// they're enqueued and drained under transmit-queue backpressure, so a - /// frame that exceeds the BLE queue mid-way isn't silently truncated. - private func writeChunkedLocked(_ data: Data) { - guard let p = peripheral, rxChar != nil else { return } - let mtu = max(20, p.maximumWriteValueLength(for: .withoutResponse)) - var offset = 0 - while offset < data.count { - let end = min(offset + mtu, data.count) - guard pendingChunks.count < maxPendingChunks else { - log("pending-chunk queue full — dropping \(data.count - offset)B of frame") - break - } - pendingChunks.append(data.subdata(in: offset.. Bool { - guard !targetName.isEmpty else { return true } // empty → first NUS device seen - let t = targetName.lowercased() - guard let n = adName?.lowercased() else { return false } - return n == t || n.contains(t) - } - - private func teardownLinkLocked(notify: Bool) { - rxChar = nil - txChar = nil - // Drop queued writes — on reconnect the protocol re-runs _configure_device - // and re-sends radio config, so stale queued frames must not replay. - pendingWrites.removeAll() - pendingChunks.removeAll() - let wasUp = isLinkUp - isLinkUp = false - if notify && wasUp { - callbackInvoker?.invoke(slot: .onState, args: [false, targetName]) - } - } - - fileprivate func log(_ message: String) { - print("SwiftRNodeBridge: \(message)") - } -} - -// MARK: - CBCentralManagerDelegate - -extension SwiftRNodeBridge: CBCentralManagerDelegate { - - public func centralManagerDidUpdateState(_ central: CBCentralManager) { - switch central.state { - case .poweredOn: - log("central poweredOn") - // Resume whatever the user asked for before the radio was ready. - if wantConnected { - if let p = peripheral { central.connect(p, options: nil) } - else { tryStartScanLocked() } - } - case .unauthorized: log("unauthorized — check Bluetooth permission") - case .poweredOff: log("poweredOff") - case .unsupported: log("unsupported on this device") - case .resetting: log("resetting") - case .unknown: log("state unknown") - @unknown default: log("state \(central.state.rawValue)") - } - } - - public func centralManager( - _ central: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi RSSI: NSNumber - ) { - let adName = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name - guard nameMatches(adName) else { return } - log("matched '\(adName ?? "")' rssi=\(RSSI.intValue) — connecting") - central.stopScan() - self.peripheral = peripheral // strong ref before connect - peripheral.delegate = self - central.connect(peripheral, options: nil) - } - - public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - log("didConnect — discovering NUS") - peripheral.discoverServices([Self.nusService]) - } - - public func centralManager( - _ central: CBCentralManager, - didFailToConnect peripheral: CBPeripheral, - error: Error? - ) { - log("didFailToConnect: \(String(describing: error))") - // Direct connect failed (e.g. out of range) — fall back to scanning. - if wantConnected { tryStartScanLocked() } - } - - public func centralManager( - _ central: CBCentralManager, - didDisconnectPeripheral peripheral: CBPeripheral, - error: Error? - ) { - log("didDisconnect: \(String(describing: error))") - teardownLinkLocked(notify: true) - // Auto-reconnect: RNode BLE links drop often. If the user still wants - // the link, issue a direct pending connect to the same peripheral; CB - // completes it (even backgrounded) when the RNode is back in range. - if wantConnected, let p = self.peripheral { - central.connect(p, options: nil) - } - } -} - -// MARK: - CBPeripheralDelegate - -extension SwiftRNodeBridge: CBPeripheralDelegate { - - public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - if let error { log("didDiscoverServices error: \(error)"); return } - guard let svc = peripheral.services?.first(where: { $0.uuid == Self.nusService }) else { - log("NUS service not found") - return - } - peripheral.discoverCharacteristics([Self.nusRxChar, Self.nusTxChar], for: svc) - } - - public func peripheral( - _ peripheral: CBPeripheral, - didDiscoverCharacteristicsFor service: CBService, - error: Error? - ) { - if let error { log("didDiscoverCharacteristics error: \(error)"); return } - for ch in (service.characteristics ?? []) { - switch ch.uuid { - case Self.nusRxChar: rxChar = ch - case Self.nusTxChar: txChar = ch - default: break - } - } - guard let tx = txChar, rxChar != nil else { - log("NUS RX/TX characteristic missing") - return - } - // Link comes up once the TX subscription is confirmed - // (didUpdateNotificationStateFor). - peripheral.setNotifyValue(true, for: tx) - } - - public func peripheral( - _ peripheral: CBPeripheral, - didUpdateNotificationStateFor characteristic: CBCharacteristic, - error: Error? - ) { - if let error { log("didUpdateNotificationState error: \(error)"); return } - guard characteristic.uuid == Self.nusTxChar, characteristic.isNotifying else { return } - isLinkUp = true - let mtu = peripheral.maximumWriteValueLength(for: .withoutResponse) - log("link up — TX notify subscribed, mtu=\(mtu)") - callbackInvoker?.invoke(slot: .onState, args: [true, targetName]) - flushPendingWritesLocked() - } - - public func peripheral( - _ peripheral: CBPeripheral, - didUpdateValueFor characteristic: CBCharacteristic, - error: Error? - ) { - if let error { log("didUpdateValueFor error: \(error)"); return } - guard characteristic.uuid == Self.nusTxChar, - let value = characteristic.value, !value.isEmpty else { return } - callbackInvoker?.invoke(slot: .onData, args: [value]) - } - - public func peripheral( - _ peripheral: CBPeripheral, - didWriteValueFor characteristic: CBCharacteristic, - error: Error? - ) { - if let error { log("didWriteValueFor error: \(error)") } - } - - public func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { - // Transmit queue freed up — resume draining queued chunks. Delivered on - // the central manager's `queue`, so call the locked helper directly. - drainChunksLocked() - } -} - -#else - -/// Non-CoreBluetooth stub so `swift build` typechecks the module on Linux/CI. -/// Mirrors the real bridge's surface (cf. the SwiftBLEBridge stub). -public final class SwiftRNodeBridge: @unchecked Sendable { - public static let shared = SwiftRNodeBridge() - public func setCallbackInvoker(_ invoker: RNodeCallbackInvoker?) {} - public func start() {} - public func stop() {} - public func connect(deviceName: String) {} - public func disconnect() {} - public func write(_ data: Data) {} -} - -#endif diff --git a/Tests/ColumbaAppTests/BLESeamDriverTests.swift b/Tests/ColumbaAppTests/BLESeamDriverTests.swift new file mode 100644 index 00000000..0e12cbca --- /dev/null +++ b/Tests/ColumbaAppTests/BLESeamDriverTests.swift @@ -0,0 +1,100 @@ +import XCTest +import ReticulumSwift +@testable import ColumbaApp + +/// Runtime tests for the NE-side BLE seam proxy (`AppGroupBLEDriver`): that it +/// encodes commands out, feeds the `BLEDriver` streams from decoded app events, +/// and correlates the value-returning calls (`connect`) by `reqId`. Uses an +/// in-memory mock `BLESeamTransport` (no SharedFrameQueue / Darwin), so it's +/// deterministic. +final class BLESeamDriverTests: XCTestCase { + + /// In-memory transport: records what the driver sends, lets the test inject + /// inbound messages as if they came from the app. + final class MockSeamTransport: BLESeamTransport, @unchecked Sendable { + private let lock = NSLock() + private var sent: [BLEDriverSeamMessage] = [] + let inbound: AsyncStream + private let inboundCont: AsyncStream.Continuation + init() { (inbound, inboundCont) = AsyncStream.makeStream(of: BLEDriverSeamMessage.self) } + func send(_ message: BLEDriverSeamMessage) { lock.lock(); sent.append(message); lock.unlock() } + func inject(_ message: BLEDriverSeamMessage) { inboundCont.yield(message) } + var sentMessages: [BLEDriverSeamMessage] { lock.lock(); defer { lock.unlock() }; return sent } + } + + private func waitUntil(_ timeout: TimeInterval = 2.0, _ cond: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while !cond() { + if Date() > deadline { XCTFail("timed out waiting for condition"); return } + try await Task.sleep(for: .milliseconds(5)) + } + } + + func testDiscoveredEventFeedsStream() async throws { + let mock = MockSeamTransport() + let driver = AppGroupBLEDriver(transport: mock) + var it = driver.discoveredPeers.makeAsyncIterator() + mock.inject(.discovered(address: "peer-A", rssi: -60, identity: nil)) + let peer = await it.next() + XCTAssertEqual(peer?.address, "peer-A") + XCTAssertEqual(peer?.rssi, -60) + } + + func testCommandIsEncodedOut() async throws { + let mock = MockSeamTransport() + let driver = AppGroupBLEDriver(transport: mock) + try await driver.startScanning() + XCTAssertEqual(mock.sentMessages, [.startScanning]) + } + + func testConnectReqIdRoundTrip() async throws { + let mock = MockSeamTransport() + let driver = AppGroupBLEDriver(transport: mock) + + // Start connect concurrently; it sends .connect then awaits the reply. + async let connection = driver.connect(address: "peerX") + + try await waitUntil { mock.sentMessages.contains { if case .connect = $0 { return true }; return false } } + guard case let .connect(reqId, address)? = mock.sentMessages.first(where: { + if case .connect = $0 { return true }; return false + }) else { return XCTFail("no .connect command sent") } + XCTAssertEqual(address, "peerX") + + // App replies with the result for that reqId → connect() resumes. + mock.inject(.connectResult(reqId: reqId, address: "peerX", mtu: 185, identity: nil, error: nil)) + + let conn = try await connection + XCTAssertEqual(conn.address, "peerX") + XCTAssertEqual(conn.mtu, 185) + } + + func testConnectErrorPropagates() async throws { + let mock = MockSeamTransport() + let driver = AppGroupBLEDriver(transport: mock) + async let connection = driver.connect(address: "peerY") + try await waitUntil { mock.sentMessages.contains { if case .connect = $0 { return true }; return false } } + guard case let .connect(reqId, _)? = mock.sentMessages.first(where: { + if case .connect = $0 { return true }; return false + }) else { return XCTFail("no .connect") } + mock.inject(.connectResult(reqId: reqId, address: "peerY", mtu: 0, identity: nil, error: "timeout")) + do { _ = try await connection; XCTFail("expected throw") } + catch let BLESeamError.driver(msg) { XCTAssertEqual(msg, "timeout") } + } + + func testReceivedFragmentRoutesToConnection() async throws { + let mock = MockSeamTransport() + let driver = AppGroupBLEDriver(transport: mock) + async let connection = driver.connect(address: "p") + try await waitUntil { mock.sentMessages.contains { if case .connect = $0 { return true }; return false } } + guard case let .connect(reqId, _)? = mock.sentMessages.first(where: { + if case .connect = $0 { return true }; return false + }) else { return XCTFail("no .connect") } + mock.inject(.connectResult(reqId: reqId, address: "p", mtu: 23, identity: nil, error: nil)) + let conn = try await connection + + var frags = conn.receivedFragments.makeAsyncIterator() + mock.inject(.receivedFragment(address: "p", data: Data([9, 8, 7]))) + let frag = await frags.next() + XCTAssertEqual(frag, Data([9, 8, 7])) + } +} diff --git a/Tests/ColumbaAppTests/MicronParserTests.swift b/Tests/ColumbaAppTests/MicronParserTests.swift index b0179d20..8706ccea 100644 --- a/Tests/ColumbaAppTests/MicronParserTests.swift +++ b/Tests/ColumbaAppTests/MicronParserTests.swift @@ -1,5 +1,7 @@ import XCTest @testable import ColumbaApp +import RNSAPI +import LXMFSwift final class MicronParserTests: XCTestCase { @@ -626,3 +628,340 @@ final class MicronParserTests: XCTestCase { XCTAssert(doc.elements.count >= 4) } } + +// MARK: - MessageRepository adapter (Track A0) + +/// Verifies the pure `static` mapping funcs in `MessageRepository` that adapt +/// the GRDB-backed `LXMFSwift` records to the RNSAPI Compat types the UI/ +/// ViewModels consume. Exercises the load-bearing conversions called out in +/// A0: Date<-Double, RNSAPI-enum<-LXMFSwift-UInt8, String<-String?. +final class MessageRepositoryAdapterTests: XCTestCase { + + // MARK: Conversation mapping (Date<-Double, String<-String?) + + func testMapConversationFullFields() { + var c = LXMFSwift.ConversationRecord( + destinationHash: Data([0x01, 0x02, 0x03]), + displayName: "Alice", + lastMessageTimestamp: 1_700_000_000.5, + lastMessagePreview: "hello", + unreadCount: 3, + isFavorite: true + ) + c.isPinned = 1 + c.iconName = "account" + c.iconFgColor = "ffffff" + c.iconBgColor = "1e88e5" + + let r = MessageRepository.mapConversation(c) + + XCTAssertEqual(r.hash, Data([0x01, 0x02, 0x03])) + XCTAssertEqual(r.displayName, "Alice") + XCTAssertEqual(r.isFavorite, 1) + XCTAssertEqual(r.isPinned, 1) + // Date <- Double (timeIntervalSince1970) + XCTAssertEqual(r.lastMessageAt, Date(timeIntervalSince1970: 1_700_000_000.5)) + XCTAssertEqual(r.lastMessage, "hello") + XCTAssertEqual(r.unreadCount, 3) + XCTAssertEqual(r.iconName, "account") + XCTAssertEqual(r.iconFgColor, "ffffff") + XCTAssertEqual(r.iconBgColor, "1e88e5") + } + + func testMapConversationNilDisplayNameBecomesEmptyString() { + // displayName is String? on the GRDB side, non-optional String on RNSAPI. + let c = LXMFSwift.ConversationRecord( + destinationHash: Data([0xAB]), + displayName: nil, + lastMessageTimestamp: 0, + lastMessagePreview: nil, + unreadCount: 0, + isFavorite: false + ) + let r = MessageRepository.mapConversation(c) + XCTAssertEqual(r.displayName, "") // String <- nil String? + XCTAssertNil(r.lastMessage) // String? passes through + XCTAssertEqual(r.isFavorite, 0) + XCTAssertEqual(r.isPinned, 0) + XCTAssertEqual(r.lastMessageAt, Date(timeIntervalSince1970: 0)) + } + + // MARK: State enum <- UInt8 + + func testMapStateSemantic() { + XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.generating), .draft) + XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.outbound), .outbound) + XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.sending), .sending) + XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.sent), .sent) + XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.delivered), .delivered) + XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.rejected), .failed) + XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.cancelled), .failed) + XCTAssertEqual(MessageRepository.mapState(LXMFSwift.LXMessageState.failed), .failed) + } + + func testMapStateFromRawByte() { + // 0x08 == delivered, 0xFF == failed, 0x01 == outbound + XCTAssertEqual(MessageRepository.mapState(UInt8(0x08)), .delivered) + XCTAssertEqual(MessageRepository.mapState(UInt8(0xFF)), .failed) + XCTAssertEqual(MessageRepository.mapState(UInt8(0x01)), .outbound) + // Unknown byte falls back to .sent (matches the chat UI default arm). + XCTAssertEqual(MessageRepository.mapState(UInt8(0x77)), .sent) + } + + func testMapStateToGRDBRoundTrip() { + // received is inbound-only on RNSAPI; GRDB has no peer → delivered. + XCTAssertEqual(MessageRepository.mapStateToGRDB(.received), .delivered) + XCTAssertEqual(MessageRepository.mapStateToGRDB(.draft), .generating) + for s: RNSAPI.LXMessageState in [.outbound, .sending, .sent, .delivered, .failed] { + XCTAssertEqual(MessageRepository.mapState(MessageRepository.mapStateToGRDB(s)), s, + "round-trip should be stable for \(s)") + } + } + + // MARK: Method enum <- UInt8 + + func testMapMethodSemantic() { + XCTAssertEqual(MessageRepository.mapMethod(LXMFSwift.LXDeliveryMethod.opportunistic), .opportunistic) + XCTAssertEqual(MessageRepository.mapMethod(LXMFSwift.LXDeliveryMethod.direct), .direct) + XCTAssertEqual(MessageRepository.mapMethod(LXMFSwift.LXDeliveryMethod.propagated), .propagated) + XCTAssertEqual(MessageRepository.mapMethod(LXMFSwift.LXDeliveryMethod.paper), .paper) + } + + func testMapMethodFromRawByte() { + XCTAssertEqual(MessageRepository.mapMethod(UInt8(0x01)), .opportunistic) + XCTAssertEqual(MessageRepository.mapMethod(UInt8(0x02)), .direct) + XCTAssertEqual(MessageRepository.mapMethod(UInt8(0x03)), .propagated) + XCTAssertEqual(MessageRepository.mapMethod(UInt8(0x05)), .paper) + // Unknown byte → .unknown + XCTAssertEqual(MessageRepository.mapMethod(UInt8(0x42)), .unknown) + } + + func testMapMethodToGRDBUnknownDefaultsOpportunistic() { + XCTAssertEqual(MessageRepository.mapMethodToGRDB(.unknown), .opportunistic) + for m: RNSAPI.LXDeliveryMethod in [.opportunistic, .direct, .propagated, .paper] { + XCTAssertEqual(MessageRepository.mapMethod(MessageRepository.mapMethodToGRDB(m)), m, + "round-trip should be stable for \(m)") + } + } + + // MARK: MessageRecord mapping (all fields, incl. enum<-Int and String<-String?) + + /// Build a known GRDB `MessageRecord`. The struct's memberwise init is + /// module-internal, and the only public init is `init(from: LXMessage)`, + /// so seed it from a no-identity LXMFSwift.LXMessage (with `packed` set + /// manually so the init's `guard packed != nil` passes), then set the + /// columns that `init(from:)` doesn't take from the message. + private func makeGRDBRecord() throws -> LXMFSwift.MessageRecord { + var msg = LXMFSwift.LXMessage( + destinationHash: Data([0xDE, 0xAD, 0x01]), // arbitrary + sourceHash: Data([0x50, 0x52, 0x43]), + content: Data("body".utf8), + title: Data("subj".utf8), + timestamp: 1_650_000_000.25, + state: .delivered, + incoming: true + ) + msg.hash = Data([0xAA, 0xBB, 0xCC]) + msg.method = .direct + msg.rssi = -42.0 + msg.snr = 7.5 + msg.q = 0.9 + msg.receivingInterface = "TCPClient" + // packed carries the MessagePack field map (A0 bridge convention). + msg.fields = [LXMFSwift.LXMessage.FIELD_IMAGE: ["png", Data([0x89, 0x50])] as [Any]] + msg.packed = LxmfFieldCodec.pack(msg.fields!) + + var rec = try LXMFSwift.MessageRecord(from: msg) + // Columns init(from:) doesn't carry from the message: + rec.replyToId = "deadbeef" + rec.reactionsJson = "{\"👍\":[\"abc\"]}" + return rec + } + + func testMapRecordAllFields() throws { + let rec = try makeGRDBRecord() + let r = MessageRepository.mapRecord(rec) + + XCTAssertEqual(r.id, Data([0xAA, 0xBB, 0xCC])) + XCTAssertEqual(r.messageId, Data([0xAA, 0xBB, 0xCC])) + XCTAssertEqual(r.conversationHash, Data([0x50, 0x52, 0x43])) // incoming → sourceHash + XCTAssertEqual(r.content, Data("body".utf8)) + XCTAssertEqual(r.timestamp, 1_650_000_000.25, accuracy: 0.0001) // Double passes through + XCTAssertEqual(r.direction, .inbound) // incoming==true + XCTAssertEqual(r.state, RNSAPI.LXMessageState.delivered.rawValue) // enum<-UInt8 0x08 + XCTAssertEqual(r.method, RNSAPI.LXDeliveryMethod.direct.rawValue) // enum<-UInt8 0x02 + XCTAssertEqual(r.sourceHash, Data([0x50, 0x52, 0x43])) + XCTAssertEqual(r.rssi, -42.0) + XCTAssertEqual(r.snr, 7.5) + XCTAssertEqual(r.receivingInterface, "TCPClient") + XCTAssertEqual(r.replyToId, "deadbeef") // String? passes through + XCTAssertEqual(r.reactionsJson, "{\"👍\":[\"abc\"]}") + // packed_lxmf passes through verbatim and is the field map the UI decodes. + let decoded = LxmfFieldCodec.unpack(r.packedLxmf) + XCTAssertNotNil(decoded?[LXMFSwift.LXMessage.FIELD_IMAGE], "field map should round-trip through packedLxmf") + } + + func testMapToLXMessageRebuildsFromRecord() throws { + let rec = try makeGRDBRecord() + let m = MessageRepository.mapToLXMessage(rec) + + XCTAssertEqual(m.hash, Data([0xAA, 0xBB, 0xCC])) + XCTAssertEqual(m.sourceHash, Data([0x50, 0x52, 0x43])) + XCTAssertEqual(m.content, Data("body".utf8)) + XCTAssertEqual(m.title, Data("subj".utf8)) + XCTAssertEqual(m.timestamp, 1_650_000_000.25, accuracy: 0.0001) + XCTAssertTrue(m.incoming) + XCTAssertEqual(m.state, .delivered) + XCTAssertEqual(m.method, .direct) + XCTAssertEqual(m.rssi, -42.0) + XCTAssertEqual(m.snr, 7.5) + // Fields recovered from packedLxmf for attachment rendering. + XCTAssertNotNil(m.fields?[LXMFSwift.LXMessage.FIELD_IMAGE]) + } + + // MARK: Icon mapping + + func testMapIcon() { + let i = LXMFSwift.IconAppearance(iconName: "star", foregroundColor: "abcdef", backgroundColor: "012345") + let r = MessageRepository.mapIcon(i) + XCTAssertEqual(r.iconName, "star") + XCTAssertEqual(r.foregroundColor, "abcdef") + XCTAssertEqual(r.backgroundColor, "012345") + } + + // MARK: packed_lxmf = field map vs LXMF wire (A0 follow-up #2) + // + // The chat UI recovers attachments/icons by running + // `LxmfFieldCodec.unpack(record.packedLxmf)` (MessageBubble / Message(from:)). + // App / Python-path rows store a MessagePack *field map* in `packed_lxmf`; + // Swift / Network-Extension rows store the signed LXMF *wire* (LXMRouter + // persists `LXMessage.packed`). The adapter must recover fields for BOTH so + // attachments render uniformly. These tests drive the real production + // adapter (`mapRecord` / `mapToLXMessage`) — no reimplementation. + + /// Common attachment/icon payload used by both the field-map and wire rows + /// so the assertions are identical regardless of storage form. + /// FIELD_IMAGE (0x06) = [format, bytes] + /// FIELD_FILE_ATTACHMENTS (0x05) = [[name, bytes], …] + /// FIELD_ICON_APPEARANCE (0x04) = [name, fgRGB(3), bgRGB(3)] + private static let imageBytes = Data([0x89, 0x50, 0x4E, 0x47]) + private static let fileBytes = Data([0x01, 0x02, 0x03, 0x04, 0x05]) + private func attachmentFields() -> [UInt8: Any] { + [ + LXMFSwift.LXMessage.FIELD_IMAGE: ["png", Self.imageBytes] as [Any], + LXMFSwift.LXMessage.FIELD_FILE_ATTACHMENTS: [["doc.txt", Self.fileBytes] as [Any]] as [Any], + LXMFSwift.LXMessage.FIELD_ICON_APPEARANCE: ["account", Data([0xAA, 0xBB, 0xCC]), Data([0x11, 0x22, 0x33])] as [Any], + ] + } + + /// Assert the three attachment/icon fields survived recovery, matching the + /// exact shape the chat UI (`MessageBubble`) extracts. + private func assertAttachmentsRecovered(_ fields: [UInt8: Any]?, _ label: String) { + guard let fields else { return XCTFail("\(label): no fields recovered") } + + // FIELD_IMAGE: [format, bytes] + let image = fields[LXMFSwift.LXMessage.FIELD_IMAGE] as? [Any] + XCTAssertEqual(image?.count, 2, "\(label): image field shape") + XCTAssertEqual(image?[0] as? String, "png", "\(label): image format") + XCTAssertEqual(image?[1] as? Data, Self.imageBytes, "\(label): image bytes") + + // FIELD_FILE_ATTACHMENTS: [[name, bytes]] + let files = fields[LXMFSwift.LXMessage.FIELD_FILE_ATTACHMENTS] as? [Any] + let firstFile = files?.first as? [Any] + XCTAssertEqual(firstFile?[0] as? String, "doc.txt", "\(label): file name") + XCTAssertEqual(firstFile?[1] as? Data, Self.fileBytes, "\(label): file bytes") + + // FIELD_ICON_APPEARANCE: [name, fgRGB, bgRGB] + let icon = fields[LXMFSwift.LXMessage.FIELD_ICON_APPEARANCE] as? [Any] + XCTAssertEqual(icon?.count, 3, "\(label): icon field shape") + XCTAssertEqual(icon?[0] as? String, "account", "\(label): icon name") + XCTAssertEqual(icon?[1] as? Data, Data([0xAA, 0xBB, 0xCC]), "\(label): icon fg") + XCTAssertEqual(icon?[2] as? Data, Data([0x11, 0x22, 0x33]), "\(label): icon bg") + } + + /// (a) A realistic FIELD-MAP row (app / Python path): `packed_lxmf` = + /// `LxmfFieldCodec.pack(fields)`. Seeded onto a no-identity GRDB LXMessage + /// exactly as `MessageRepository.mapToGRDBMessage` does. + private func makeFieldMapRecord() throws -> LXMFSwift.MessageRecord { + var msg = LXMFSwift.LXMessage( + destinationHash: Data([0xDE, 0xAD, 0x10]), + sourceHash: Data([0x50, 0x52, 0x43]), + content: Data("body".utf8), + title: Data("subj".utf8), + timestamp: 1_650_000_000.25, + state: .delivered, + incoming: true + ) + msg.hash = Data([0xAA, 0xBB, 0xCC]) + msg.method = .direct + msg.fields = attachmentFields() + msg.packed = LxmfFieldCodec.pack(msg.fields!) // FIELD MAP, not wire + return try LXMFSwift.MessageRecord(from: msg) + } + + /// (b) A realistic WIRE row (Swift / NE path): a genuine LXMessage signed + + /// packed to the on-wire format via the real pack path, then persisted — + /// `MessageRecord.init(from:)` copies `LXMessage.packed` (the wire) into + /// `packed_lxmf`, exactly like `LXMRouter` does on inbound delivery. + private func makeWireRecord() throws -> (rec: LXMFSwift.MessageRecord, wire: Data) { + // Real ReticulumSwift identity (re-exported via LXMFSwift) so `pack()` + // can sign. Qualified to avoid the RNSAPI.Identity Compat-stub collision. + // The destination hash value is irrelevant to field recovery — any + // 16-byte value packs to valid wire. + let sourceIdentity = ReticulumSwift.Identity() + var msg = LXMFSwift.LXMessage( + destinationHash: Data(repeating: 0xD7, count: 16), + sourceIdentity: sourceIdentity, + content: Data("hello".utf8), + title: Data("subj".utf8), + fields: attachmentFields(), + desiredMethod: .direct + ) + let wire = try msg.pack() // genuine signed LXMF wire bytes + // Sanity: this is wire (not a field map) — the field-map codec can't + // read it, which is precisely the live bug this change fixes. + XCTAssertNil(LxmfFieldCodec.unpack(wire), + "wire bytes must NOT decode as a field map (else no bug)") + XCTAssertGreaterThan(wire.count, 96, "wire carries dest+src+sig header") + let rec = try LXMFSwift.MessageRecord(from: msg) + XCTAssertEqual(rec.packedLxmf, wire, "record must store the wire verbatim") + return (rec, wire) + } + + /// FIELD-MAP row → attachments recovered through BOTH adapter entry points. + func testFieldMapRowRecoversAttachments() throws { + let rec = try makeFieldMapRecord() + + // mapRecord → the UI runs LxmfFieldCodec.unpack(packedLxmf). + let mr = MessageRepository.mapRecord(rec) + assertAttachmentsRecovered(LxmfFieldCodec.unpack(mr.packedLxmf), "fieldmap/mapRecord") + + // mapToLXMessage → fields populated directly. + let lx = MessageRepository.mapToLXMessage(rec) + assertAttachmentsRecovered(lx.fields, "fieldmap/mapToLXMessage") + } + + /// WIRE row → attachments recovered through BOTH adapter entry points. + /// This is the regression target: before normalization the UI's + /// `LxmfFieldCodec.unpack(packedLxmf)` returned nil on wire bytes, so + /// Swift/NE-delivered images/files/icons silently didn't render. + func testWireRowRecoversAttachments() throws { + let (rec, _) = try makeWireRecord() + + // mapRecord must normalize wire → field map so the UI's unpack works. + let mr = MessageRepository.mapRecord(rec) + XCTAssertNotNil(LxmfFieldCodec.unpack(mr.packedLxmf), + "mapRecord must hand the UI a field map for wire rows") + assertAttachmentsRecovered(LxmfFieldCodec.unpack(mr.packedLxmf), "wire/mapRecord") + + // mapToLXMessage must populate fields from the wire, and keep `packed` + // coherent as a field map. + let lx = MessageRepository.mapToLXMessage(rec) + assertAttachmentsRecovered(lx.fields, "wire/mapToLXMessage") + assertAttachmentsRecovered(LxmfFieldCodec.unpack(lx.packed ?? Data()), "wire/mapToLXMessage.packed") + } + + // Note: the empty/field-map/wire discriminator is covered through the public + // adapters by testFieldMapRowRecoversAttachments + testWireRowRecoversAttachments + // (which call mapRecord/mapToLXMessage -> the internal recoverFields/normalizedFieldMap). +} diff --git a/Tests/interop/conftest.py b/Tests/interop/conftest.py index 6968ba25..e307d0f4 100644 --- a/Tests/interop/conftest.py +++ b/Tests/interop/conftest.py @@ -496,6 +496,118 @@ def assert_bubble_visible( finally: flow_path.unlink(missing_ok=True) + def assert_bubble_visible_via_network( + self, + *, + peer_display_name: str = "Anonymous Peer", + content: Optional[str] = None, + has_image: bool = False, + has_file_name: Optional[str] = None, + screenshot: Optional[str] = None, + timeout: float = 30.0, + ) -> None: + """Open the peer's thread via the **Contacts → Network** nav path and + assert the bubble renders — the BUG #1 path. + + The Chats path (`assert_bubble_visible`) reaches `MessagingView` from + an existing `Conversation` DB row via a `NavigationLink`. This path + instead goes Contacts tab → segmented **Network** → tap the peer's + announce row → NodeDetails → **Start Chat**, which builds a *fresh* + `Conversation(destinationHash: contact.identityHash, …)` and pushes it + through the `.chat` `navigationDestination`. `contact.identityHash` is + the announce's *destination* hash (`Contact.init(from: PathEntry)` sets + `identityHash = entry.destinationHash`), which equals the inbound + message's `sourceHash` — so both paths key `loadMessages` on the same + conversation hash and should render identically. This pins that they + do (BUG #1 = thread empty via Network tab). + + Leaves the app at the Network-tab root (two trailing `back`s pop + MessagingView → NodeDetails → Network list) so a follow-on + `assert_bubble_visible` (Chats path) can still reach the tab bar. + Call this BEFORE the Chats-path assertion for that reason. + """ + lines = [ + "appId: " + BUNDLE_ID, + "---", + # First-launch permission alerts can block the first tab tap. + "- tapOn: { text: \"Allow\", optional: true }", + "- tapOn: { text: \"Don't Allow\", optional: true }", + "- waitForAnimationToEnd: { timeout: 1500 }", + # Defensive pop-to-root: if a prior step left the app inside a + # pushed view (a thread / NodeDetails), the tab bar is hidden and + # the tab taps below would miss. `back` is a harmless no-op swipe + # at a tab root, so this is safe when already there. + "- back", + "- waitForAnimationToEnd: { timeout: 800 }", + "- back", + "- waitForAnimationToEnd: { timeout: 800 }", + # Contacts tab → segmented "Network" (label renders "Network (N)"; + # Maestro substring-matches "Network"). + "- tapOn:", + " text: \"Contacts\"", + " optional: true", + "- waitForAnimationToEnd: { timeout: 2000 }", + # The segmented control renders "Network (N)". Maestro's text + # matcher is a FULL-string regex match (not substring), so a bare + # "Network" misses "Network (15)" — the trailing `.*` is required. + # Non-optional so a selector regression fails loudly here rather + # than silently scrolling the wrong (My Contacts) list below. + "- tapOn:", + " text: \"Network.*\"", + "- waitForAnimationToEnd: { timeout: 2000 }", + # The announce list can be long (every heard peer/relay/site); + # scroll the peer's row into view, then open it. + "- scrollUntilVisible:", + " element:", + f" text: \"{_yaml_escape(peer_display_name)}\"", + " direction: DOWN", + " timeout: 15000", + f"- tapOn: \"{_yaml_escape(peer_display_name)}\"", + "- waitForAnimationToEnd: { timeout: 2500 }", + # NodeDetails → Start Chat → MessagingView (.chat destination). + "- tapOn:", + " text: \"Start Chat\"", + "- waitForAnimationToEnd: { timeout: 2500 }", + ] + if content is not None: + lines += [ + "- assertVisible:", + f" text: \"{_yaml_escape(content)}\"", + ] + if has_image: + lines += [ + "- assertVisible:", + " id: \"bubble_image\"", + ] + if has_file_name is not None: + lines += [ + "- assertVisible:", + f" text: \"{_yaml_escape(has_file_name)}\"", + ] + if screenshot is not None: + lines += [f"- takeScreenshot: {screenshot}"] + # Pop back to the Network-tab root so the tab bar is reachable again. + lines += [ + "- back", + "- waitForAnimationToEnd: { timeout: 1500 }", + "- back", + "- waitForAnimationToEnd: { timeout: 1500 }", + ] + flow_path = Path(os.environ.get("TMPDIR", "/tmp")) / f"_interop_assert_net_{os.getpid()}.yaml" + flow_path.write_text("\n".join(lines) + "\n") + try: + _sh(["maestro", "--device", self.udid, "test", str(flow_path)], timeout=timeout + 30) + except subprocess.CalledProcessError as e: + pytest.fail( + f"assert_bubble_visible_via_network failed (peer={peer_display_name!r}, " + f"content={content!r}, has_image={has_image}, " + f"has_file_name={has_file_name!r}). This is the BUG #1 path " + f"(thread empty when opened via Contacts→Network). Maestro stderr:\n" + f"{e.stderr or e.stdout}" + ) + finally: + flow_path.unlink(missing_ok=True) + def assert_peer_pin_visible(self, *, timeout: float = 40.0) -> None: """Navigate to the Map tab and assert a peer location pin rendered. diff --git a/Tests/interop/test_bug1_network_render.py b/Tests/interop/test_bug1_network_render.py new file mode 100644 index 00000000..094246f8 --- /dev/null +++ b/Tests/interop/test_bug1_network_render.py @@ -0,0 +1,104 @@ +"""BUG #1 regression — inbound thread renders via the Contacts→Network nav path. + +BUG #1 was: a peer's chat thread showed **empty** when opened via the Contacts +→ Network tab path (Network announce row → NodeDetails → "Start Chat"), while +the same thread rendered correctly when opened via the Chats tab. It was first +seen on-device while Model B bring-up was degraded, and narrowed to a view/nav +issue (cross-process reads were proven fresh, so not a read-path bug). + +Why both paths *should* render identically — the static case: + * Inbound persistence keys the conversation on `message.sourceHash` + (IncomingMessageHandler / AppServices) = the sender's lxmf.delivery + destination hash. + * The Network path builds a *fresh* `Conversation` from the announce: + `ContactsView.startChat` → `Conversation(destinationHash: contact.identityHash …)` + and `Contact.init(from: PathEntry)` sets `identityHash = entry.destinationHash` + — i.e. the same lxmf.delivery destination hash. + * Both nav paths therefore construct `MessagingViewModel(conversationHash:)` + with the identical hash and `loadMessages` runs the same query. + +This pins it empirically against the simulator (the nav/view code is +backend-independent, so the Python-backend sim reproduces it): Sideband sends a +text; we open the thread the BUG #1 way (Network tab) and assert the inbound +bubble renders, then via Chats as the control. Each open's `assertVisible: ` +is the render gate — an empty Network-tab thread (the BUG #1 symptom) fails the +first assertion. + +Run with: + cd Tests/interop + ~/.reticulum-host/venv/bin/pytest -v test_bug1_network_render.py +""" + +from __future__ import annotations +import re +import time + +import pytest + + +def _wait_for_inbound(sim, *, content: str, timeout: float = 30.0) -> None: + """Block until the inbound message for `content` is recorded, so both the + Chats row and the Network→Start-Chat thread have it to load. Marker: + `[RNS] inbound source=… content="…"` (was `[PY] inbound`; accept both).""" + deadline = time.time() + timeout + while time.time() < deadline: + for line in reversed(sim._tail_diag(800)): + if ("[RNS] inbound source=" in line or "[PY] inbound source=" in line) and content in line: + return + time.sleep(0.4) + pytest.fail(f"iOS didn't record inbound for {content!r} within {timeout}s") + + +def _peer_display_name(sim, sideband, *, timeout: float = 20.0) -> str: + """The name iOS heard in Sideband's lxmf.delivery announce — the row label + to tap in both the Network tab and Chats. Falls back to the same + `Peer ` form Columba shows for a nameless announce + (`Contact.resolvedDisplayName`).""" + pat = re.compile( + rf'announce dest={sideband.identity_hex}\s+aspect=lxmf\.delivery\s+name="([^"]*)"' + ) + deadline = time.time() + timeout + while time.time() < deadline: + for line in reversed(sim._tail_diag(800)): + m = pat.search(line) + if m: + return m.group(1) or f"Peer {sideband.identity_hex[:8].upper()}" + time.sleep(0.4) + # No announce sighting cached this launch — fall back to the nameless form. + return f"Peer {sideband.identity_hex[:8].upper()}" + + +def test_bug1_network_tab_renders_inbound_text(sim, sideband): + """Sideband → iOS text; open the thread via Contacts→Network (the BUG #1 + path) and assert the inbound bubble renders, then via Chats as the control. + + The render assertion is `assertVisible: ` inside each Maestro flow — + if the Network-tab thread came up empty (the BUG #1 symptom) the first + assertion fails. We drive the Network path FIRST because its helper pops + back to the tab root on exit, leaving the tab bar reachable for the Chats + assertion.""" + body = f"bug1-net-{int(time.time()*1000)}" + assert sideband.send_text( + dest_hex=sim.lxmf_delivery_hex, + content=body, + ), "Sideband-side send_text returned False" + + _wait_for_inbound(sim, content=body) + peer = _peer_display_name(sim, sideband) + print(f"[BUG1] peer row label = {peer!r}", flush=True) + + # ── BUG #1 path: Contacts → Network → announce row → Start Chat. ── + sim.assert_bubble_visible_via_network( + peer_display_name=peer, + content=body, + screenshot="screenshots/bug1-network", + ) + + # ── Chats control path: Chats → conversation row → thread. ── + # Tap the row by its message *preview* (== body), not its display name. + # On the legacy Python/sim backend the conversation row's name is the + # "Peer " fallback (persistInboundFromPython hardcodes it and the + # announce-read stamp's isEmpty guard never corrects it), so it differs + # from the Network tab's announce name. The preview is name-independent + # and stays valid if that legacy-path naming is later fixed. + sim.assert_bubble_visible(peer_display_name=body, content=body) diff --git a/app/rnode/IOSRNodeInterface.py b/app/rnode/IOSRNodeInterface.py deleted file mode 100644 index fbfeee5a..00000000 --- a/app/rnode/IOSRNodeInterface.py +++ /dev/null @@ -1,1634 +0,0 @@ -"""IOSRNodeInterface — Reticulum custom interface for RNode LoRa hardware on iOS. - -Port of Columba Android's `IOSRNodeInterface`. The KISS framing, frame -parsing, and RNode binary protocol are reused verbatim (proven on Android, in -turn ported from upstream `RNS.Interfaces.RNodeInterface`). Only the I/O layer -differs: instead of the Kotlin BLE bridge (jnius), we bridge to the Swift -`SwiftRNodeBridge` CoreBluetooth Nordic-UART client via ctypes `columba_rnode_*` -C-ABI shims, with Swift→Python notifications delivered through the `rns_bridge` -callback registry — exactly the pattern `IOSBLEInterface` / `IOSBLEDriver` use -for the BLE mesh. - -**BLE (Nordic UART Service) only** — no USB-serial, no Bluetooth Classic (no -iOS support). RNode flashing is out of scope (assume a pre-flashed RNode). - -RNS external-interface loader contract: - • file: /interfaces/IOSRNodeInterface.py - • config: type = IOSRNodeInterface - • footer: interface_class = IOSRNodeInterface - • subclass of RNS.Interfaces.Interface.Interface -""" - -import collections -import ctypes -import os -import sys -import threading -import time -from typing import Any, Callable, Optional -import RNS -from RNS.Interfaces.Interface import Interface - -# Make sibling modules + rns_bridge importable when Reticulum exec()s this file -# from /interfaces/ (mirrors IOSBLEInterface). -_this_file = globals().get("__file__") -_interfaces_dir = os.path.dirname(os.path.abspath(_this_file)) if _this_file else None -if _interfaces_dir and _interfaces_dir not in sys.path: - sys.path.insert(0, _interfaces_dir) - -import rns_bridge # Swift→Python callback registry (set_rnode_callback) - -# ── ctypes binding to SwiftRNodeBridge's C-ABI shims (Python → Swift) ── -try: - _lib = ctypes.CDLL(None) # symbols are statically linked into the app binary -except OSError: - _lib = None - - -def _decl(name: str, argtypes: list, restype: Any) -> Optional[Callable]: - if _lib is None: - return None - try: - fn = getattr(_lib, name) - except AttributeError: - return None - fn.argtypes = argtypes - fn.restype = restype - return fn - - -_rnode_start = _decl("columba_rnode_start", [], ctypes.c_int32) -_rnode_stop = _decl("columba_rnode_stop", [], ctypes.c_int32) -_rnode_connect = _decl("columba_rnode_connect", [ctypes.c_char_p], ctypes.c_int32) -_rnode_disconnect = _decl("columba_rnode_disconnect", [], ctypes.c_int32) -_rnode_write = _decl("columba_rnode_write", [ctypes.c_char_p, ctypes.c_int32], ctypes.c_int32) - - -class _RNodeBLEBridge: - """Presents the `KotlinRNodeBridge` method surface the ported protocol code - expects (connect / isConnected / getConnectedDeviceName / setOnDataReceived - / setOnConnectionStateChanged / writeSync / disconnect) over the Swift - `SwiftRNodeBridge` C-ABI shims. One per process. - - - Python → Swift: ctypes `columba_rnode_*` (shims return 0 on success). - - Swift → Python: `rns_bridge.set_rnode_callback("data"|"state", cb)` — Swift - invokes the registered callbacks (via PythonRNodeCallbackBridge, Python.h) - on NUS TX notify / connection-state change. - """ - - def __init__(self) -> None: - self._on_data: Optional[Callable] = None - self._on_state: Optional[Callable] = None - self._connected = False - self._device_name: Optional[str] = None - # Inbound NUS TX bytes are PUSHED from Swift (didUpdateValueFor) but the - # ported protocol drains them by POLLING `read()` in `_read_loop`. Bridge - # the two models with a thread-safe buffer: `_raw_on_data` appends here, - # `read()` drains. This is exactly how Android's KotlinRNodeBridge behaves - # (its registered on_data callback is a no-op — see _on_data_received). - self._rx_buffer = bytearray() - self._rx_lock = threading.Lock() - rns_bridge.set_rnode_callback("data", self._raw_on_data) - rns_bridge.set_rnode_callback("state", self._raw_on_state) - if _rnode_start: - _rnode_start() - - def connect(self, device_name: str, mode: Any = None) -> bool: - if _rnode_connect is None or not device_name: - return False - self._device_name = device_name - return _rnode_connect(device_name.encode("utf-8")) == 0 - - def isConnected(self) -> bool: - return self._connected - - def getConnectedDeviceName(self) -> Optional[str]: - return self._device_name if self._connected else None - - def setOnDataReceived(self, cb: Callable) -> None: - self._on_data = cb - - def setOnConnectionStateChanged(self, cb: Callable) -> None: - self._on_state = cb - - def writeSync(self, data: bytes) -> int: - """Write to the RNode RX characteristic. Returns bytes accepted - (== len on success) to satisfy the protocol's `written == len(data)`.""" - if _rnode_write is None: - return 0 - b = bytes(data) - return len(b) if _rnode_write(b, len(b)) == 0 else 0 - - def disconnect(self) -> None: - if _rnode_disconnect: - _rnode_disconnect() - self._connected = False - - def read(self) -> bytes: - """Drain buffered inbound serial (the poll side of `_read_loop`). - Non-blocking — returns b"" when empty so the read loop sleeps 10ms - rather than spinning.""" - with self._rx_lock: - if not self._rx_buffer: - return b"" - data = bytes(self._rx_buffer) - self._rx_buffer.clear() - return data - - def notifyOnlineStatusChanged(self, is_online: bool, name: str) -> None: - """No-op on iOS. On Android this drives a system heads-up notification - ("RNode Disconnected") via a bridge listener; iOS surfaces interface - state through the in-app NetworkStatus UI instead. Present so the - ported `_set_online` path doesn't take its try/except fallback.""" - return None - - # ── Swift → Python callbacks (invoked from the rns_bridge registry) ── - def _raw_on_data(self, data: bytes) -> None: - # Buffer for `read()` — this IS the inbound data path (the protocol's - # own on_data handler is a no-op; bytes are consumed by polling). - with self._rx_lock: - self._rx_buffer.extend(bytes(data)) - - def _raw_on_state(self, connected: bool, device_name: Optional[str] = None) -> None: - self._connected = bool(connected) - if device_name: - self._device_name = device_name - if self._on_state is not None: - try: - self._on_state(bool(connected), self._device_name) - except Exception as e: # noqa: BLE001 - RNS.log(f"IOSRNodeInterface: on_state callback raised: {e}", RNS.LOG_ERROR) - - -class KISS: - """KISS protocol constants and helpers.""" - - # Frame delimiters - FEND = 0xC0 - FESC = 0xDB - TFEND = 0xDC - TFESC = 0xDD - - # Commands - CMD_UNKNOWN = 0xFE - CMD_DATA = 0x00 - CMD_FREQUENCY = 0x01 - CMD_BANDWIDTH = 0x02 - CMD_TXPOWER = 0x03 - CMD_SF = 0x04 - CMD_CR = 0x05 - CMD_RADIO_STATE = 0x06 - CMD_RADIO_LOCK = 0x07 - CMD_DETECT = 0x08 - CMD_LEAVE = 0x0A - CMD_ST_ALOCK = 0x0B - CMD_LT_ALOCK = 0x0C - CMD_READY = 0x0F - CMD_STAT_RX = 0x21 - CMD_STAT_TX = 0x22 - CMD_STAT_RSSI = 0x23 - CMD_STAT_SNR = 0x24 - CMD_STAT_CHTM = 0x25 - CMD_STAT_PHYPRM = 0x26 - CMD_STAT_BAT = 0x27 - CMD_BLINK = 0x30 - CMD_RANDOM = 0x40 - CMD_BT_CTRL = 0x46 - CMD_BT_PIN = 0x62 # Bluetooth PIN response (4-byte big-endian integer) - CMD_PLATFORM = 0x48 - CMD_MCU = 0x49 - CMD_FW_VERSION = 0x50 - CMD_RESET = 0x55 - CMD_ERROR = 0x90 - - # External framebuffer (display) - CMD_FB_EXT = 0x41 # Enable/disable external framebuffer - CMD_FB_WRITE = 0x43 # Write framebuffer data - - # Framebuffer constants - FB_BYTES_PER_LINE = 8 # 64 pixels / 8 bits per byte - - # Detection - DETECT_REQ = 0x73 - DETECT_RESP = 0x46 - - # Radio state - RADIO_STATE_OFF = 0x00 - RADIO_STATE_ON = 0x01 - RADIO_STATE_ASK = 0xFF - - # Bluetooth control commands - BT_CTRL_PAIRING_MODE = 0x02 # Enter Bluetooth pairing mode - - # Platforms - PLATFORM_AVR = 0x90 - PLATFORM_ESP32 = 0x80 - PLATFORM_NRF52 = 0x70 - - # Errors - ERROR_INITRADIO = 0x01 - ERROR_TXFAILED = 0x02 - ERROR_QUEUE_FULL = 0x04 - ERROR_INVALID_CONFIG = 0x40 - - # Human-readable error messages - ERROR_MESSAGES = { - 0x01: "Radio initialization failed", - 0x02: "Transmission failed", - 0x04: "Data queue overflowed", - 0x40: ( - "Invalid configuration - TX power may exceed device limits. " - "Try reducing TX power (common limits: SX1262=22dBm, SX1276=17dBm)" - ), - } - - @staticmethod - def get_error_message(error_code): - """Get human-readable error message for error code.""" - return KISS.ERROR_MESSAGES.get(error_code, f"Unknown error (0x{error_code:02X})") - - @staticmethod - def escape(data): - """Escape special bytes in KISS data.""" - data = data.replace(bytes([0xDB]), bytes([0xDB, 0xDD])) - data = data.replace(bytes([0xC0]), bytes([0xDB, 0xDC])) - return data - - @staticmethod - def unescape(data): - """ - Unescape KISS data. - - Handles escape sequences: - - 0xDB 0xDC -> 0xC0 (FEND) - - 0xDB 0xDD -> 0xDB (FESC) - - Invalid escape (0xDB followed by other) -> skipped entirely - - Trailing 0xDB -> skipped - """ - result = bytearray() - i = 0 - while i < len(data): - if data[i] == 0xDB: # FESC - escape character - if i + 1 >= len(data): - # Trailing FESC at end of data - skip it - break - next_byte = data[i + 1] - if next_byte == 0xDC: - result.append(0xC0) # TFEND -> FEND - i += 2 - elif next_byte == 0xDD: - result.append(0xDB) # TFESC -> FESC - i += 2 - else: - # Invalid escape sequence - skip both bytes - i += 2 - else: - result.append(data[i]) - i += 1 - return bytes(result) - - -class IOSRNodeInterface(Interface): - """ - Columba-authored RNS.Interface speaking KISS to RNode LoRa hardware - over Bluetooth Classic (SPP/RFCOMM), Bluetooth Low Energy (GATT), or - USB serial. Bridges to Kotlin-side hardware drivers - (`KotlinRNodeBridge`, `KotlinUSBBridge`) via `event_bridge` accessors — - pyjnius is non-functional under Chaquopy so we cannot use the upstream - Android BLE/USB paths. - """ - - # Validation limits - FREQ_MIN = 137000000 - FREQ_MAX = 3000000000 - - # Required firmware version - REQUIRED_FW_VER_MAJ = 1 - REQUIRED_FW_VER_MIN = 52 - - # Timeouts - DETECT_TIMEOUT = 5.0 - CONFIG_DELAY = 0.15 - - # Connection modes (string values mirror what RnsConfigFile.kt emits) - MODE_CLASSIC = "classic" # Bluetooth Classic (SPP/RFCOMM) - MODE_BLE = "ble" # Bluetooth Low Energy (GATT) - MODE_USB = "usb" # USB Serial - - # IMPORTANT: HW_MTU must NOT be None on the instance. - # When HW_MTU is None, RNS Transport truncates packet.data by 3 bytes - # before computing link_id in Link.validate_request(). 500 (LoRa typical - # MTU) matches the v0.10.x reference and prevents this truncation. - HW_MTU = 500 - - # Mirrors upstream RNS.Interfaces.RNodeInterface.DEFAULT_IFAC_SIZE. - # Reticulum.py:1050 falls back to `interface.DEFAULT_IFAC_SIZE` when no - # `ifac_size` is configured on the interface — it's a per-interface-type - # class attribute on the upstream RNode interface (not defined on the - # `Interface` base), so subclassing `Interface.Interface` alone doesn't - # inherit it. Without this, RNS panics with `AttributeError` during - # external-interface init and the whole interface bring-up fails. - DEFAULT_IFAC_SIZE = 8 - - def __init__(self, owner, configuration): - """ - Initialize the RNode interface from upstream RNS's loader contract. - - Args: - owner: The Reticulum.Transport (passed by the external-interface - loader at Reticulum.py:936). - configuration: ConfigObj section or dict for this interface block - from the on-disk `config` file. Parsed via - `Interface.get_config_obj()` to normalise either shape. - """ - super().__init__() - - # Parse the configuration block. `RnsConfigFile.kt` writes the keys - # without underscores to match upstream RNS convention (txpower, - # spreadingfactor, codingrate). Optional fields are guarded with - # `in c` to preserve None for "not set". - c = Interface.get_config_obj(configuration) - - self.owner = owner - self.name = c["name"] - self.online = False - self.detached = False - self.detected = False - self.firmware_ok = False - self.interface_ready = False - - # Standard RNS interface attributes. IN/OUT set explicitly here even - # though the loader at Reticulum.py:959 force-sets OUT=True post-init; - # mirror the pattern from android_ble_interface.py so the class is - # consistent on its own. - self.IN = True - self.OUT = True - self.bitrate = 10000 # Approximate LoRa bitrate (varies with SF/BW) - self.rxb = 0 - self.txb = 0 - self.held_announces = [] - self.announce_allowed_at = 0 - self.announce_cap = RNS.Reticulum.ANNOUNCE_CAP - self.oa_freq_deque = collections.deque(maxlen=16) - self.ia_freq_deque = collections.deque(maxlen=16) - self.announce_rate_target = None - self.announce_rate_grace = 0 - self.announce_rate_penalty = 0 - self.ifac_size = 16 - self.ifac_netname = c["network_name"] if "network_name" in c else None - # Raw passphrase; RNS.Transport derives the key. - self.ifac_netkey = c["passphrase"] if "passphrase" in c else None - self.AUTOCONFIGURE_MTU = False - self.FIXED_MTU = True - # Force HW_MTU back onto the instance because the base - # Interface.__init__ above set it to None. Matches the BLEInterface - # workaround for the same RNS bug — see BLEInterface.py:284-302. - self.HW_MTU = IOSRNodeInterface.HW_MTU - self.mtu = RNS.Reticulum.MTU - - # Interface mode (RNS Transport behaviour selector). - mode_str = c["mode"] if "mode" in c else "full" - if mode_str == "full": - self.mode = Interface.MODE_FULL - elif mode_str == "gateway": - self.mode = Interface.MODE_GATEWAY - elif mode_str == "access_point": - self.mode = Interface.MODE_ACCESS_POINT - elif mode_str == "point_to_point": - self.mode = Interface.MODE_POINT_TO_POINT - elif mode_str == "roaming": - self.mode = Interface.MODE_ROAMING - elif mode_str == "boundary": - self.mode = Interface.MODE_BOUNDARY - else: - RNS.log(f"IOSRNodeInterface '{self.name}': unknown mode '{mode_str}', defaulting to full", RNS.LOG_WARNING) - self.mode = Interface.MODE_FULL - - # Connection target + mode. iOS supports BLE (Nordic UART Service) - # ONLY — no USB-serial, no Bluetooth Classic. Force BLE regardless of - # what the config says so a stale/other mode can't reach the USB/Classic - # code paths (dead on iOS). - self.connection_mode = self.MODE_BLE - self.target_device_name = c["target_device_name"] if "target_device_name" in c else None - # USB-specific fields. usb_device_id may be stale (Android reassigns - # IDs across plug cycles); usb_vendor_id + usb_product_id are stable - # and used by KotlinUSBBridge.findDeviceByVidPid() at start time. - self.usb_device_id = int(c["usb_device_id"]) if "usb_device_id" in c else None - self.usb_vendor_id = int(c["usb_vendor_id"]) if "usb_vendor_id" in c else None - self.usb_product_id = int(c["usb_product_id"]) if "usb_product_id" in c else None - - # Radio config — RnsConfigFile.kt emits the no-underscore key names - # to match upstream RNS convention (RNodeInterface.py:151-155). - self.frequency = int(c["frequency"]) if "frequency" in c else 915000000 - self.bandwidth = int(c["bandwidth"]) if "bandwidth" in c else 125000 - self.txpower = int(c["txpower"]) if "txpower" in c else 7 - self.sf = int(c["spreadingfactor"]) if "spreadingfactor" in c else 7 - self.cr = int(c["codingrate"]) if "codingrate" in c else 5 - self.st_alock = float(c["st_alock"]) if "st_alock" in c else None - self.lt_alock = float(c["lt_alock"]) if "lt_alock" in c else None - - # External framebuffer (Columba logo on RNode display). - self.enable_framebuffer = c.as_bool("enable_framebuffer") if "enable_framebuffer" in c else False - self.framebuffer_enabled = False - - # Reject TCP mode early — RnsConfigFile.kt splits TCP RNodes to - # upstream `RNS.RNodeInterface`, so a TCP request reaching this file - # is a misconfiguration. - if self.connection_mode == "tcp": - RNS.log( - f"IOSRNodeInterface '{self.name}': connection_mode='tcp' " - "is not supported by this interface — TCP RNodes use the " - "upstream RNS.RNodeInterface. Marking offline.", - RNS.LOG_ERROR, - ) - return - - # Resolve the bridges via event_bridge accessors. Both may be None if - # the Kotlin runtime didn't set them yet; we log + mark offline rather - # than crash so RNS Transport can keep other interfaces alive. - self.kotlin_bridge = None - self.usb_bridge = None - self._get_kotlin_bridge() - - # State tracking - self.state = KISS.RADIO_STATE_OFF - self.platform = None - self.mcu = None - self.maj_version = 0 - self.min_version = 0 - - # Radio state readback - self.r_frequency = None - self.r_bandwidth = None - self.r_txpower = None - self.r_sf = None - self.r_cr = None - self.r_state = None - self.r_stat_rssi = None - self.r_stat_snr = None - - # Read thread - self._read_thread = None - self._running = threading.Event() # Thread-safe flag for read loop control - self._read_lock = threading.Lock() - - # Auto-reconnection - self._reconnect_thread = None - self._reconnecting = False - self._max_reconnect_attempts = 30 # Try for ~5 minutes (30 * 10s) - self._reconnect_interval = 10.0 # Seconds between reconnection attempts - - # Error / status callbacks (Kotlin sets these via setOnErrorReceived / - # setOnOnlineStatusChanged on the constructed interface — optional). - self._on_error_callback = None - self._on_online_status_changed = None - - # Validate configuration. If invalid, log and bail without raising so - # other interfaces stay up. - try: - self._validate_config() - except ValueError as e: - RNS.log( - f"IOSRNodeInterface '{self.name}': invalid config — {e}", - RNS.LOG_ERROR, - ) - return - - RNS.log(f"IOSRNodeInterface '{self.name}' initialized", RNS.LOG_DEBUG) - - # Trigger the actual hardware connection in a daemon thread. In - # v0.10.x, this was driven by `reticulum_wrapper.initialize()` - # post-Reticulum-construct (the wrapper would call start() on each - # custom interface). Slim-python doesn't have a wrapper, and RNS's - # external-interface loader (Reticulum.py:1020) only calls __init__ - # + final_init — it does NOT call start() on non-RNodeMulti - # interfaces. So if we don't kick off start() here, the interface - # stays registered-but-offline forever. - # - # Daemon thread (not synchronous) because start() can take seconds - # (BLE scan + GATT connect) and RNS iterates interfaces in a single - # for-loop — blocking here would delay every subsequent interface's - # init and trip the apply-changes timeout. start() itself spawns - # the read thread + configure_device and returns when the device - # is ready (or False on failure); the daemon wrapper just keeps - # that error surface from killing the process. - threading.Thread( - target=self._safe_start, name=f"ColumbaRNode-start-{self.name}", daemon=True, - ).start() - - def _safe_start(self): - """Wraps start() so a connection failure in the daemon thread doesn't - leak an uncaught exception. Errors are already logged by start(); - this just catches anything start() let through and keeps the - interface in a sane (offline) state.""" - try: - self.start() - except Exception as e: # noqa: BLE001 - RNS.log( - f"IOSRNodeInterface[{self.name}] start() raised: {e} — interface staying offline", - RNS.LOG_ERROR, - ) - import traceback - traceback.print_exc() - self.online = False - - def _get_kotlin_bridge(self): - """Instantiate the iOS BLE bridge (Swift SwiftRNodeBridge over ctypes). - - Replaces Android's jnius/event_bridge resolution. `_RNodeBLEBridge` - presents the same method surface the protocol code expects, so the rest - of this file is unchanged from the Android port. - """ - try: - self.kotlin_bridge = _RNodeBLEBridge() - RNS.log("IOSRNodeInterface: SwiftRNodeBridge (ctypes) initialised", RNS.LOG_DEBUG) - except Exception as e: # noqa: BLE001 - self.kotlin_bridge = None - RNS.log(f"IOSRNodeInterface: failed to init SwiftRNodeBridge: {e}", RNS.LOG_ERROR) - - def _get_usb_bridge(self): - """Resolve the Kotlin USB-serial bridge via the usb_bridge slim-Python module.""" - try: - import usb_bridge - self.usb_bridge = usb_bridge.get_usb_bridge() - if self.usb_bridge is not None: - RNS.log("IOSRNodeInterface: KotlinUSBBridge resolved via usb_bridge module", RNS.LOG_DEBUG) - else: - RNS.log( - "IOSRNodeInterface: KotlinUSBBridge not available " - "(usb_bridge.get_usb_bridge() returned None) — USB mode will not function", - RNS.LOG_ERROR, - ) - except Exception as e: # noqa: BLE001 - RNS.log(f"IOSRNodeInterface: failed to get KotlinUSBBridge: {e}", RNS.LOG_ERROR) - - def _validate_config(self): - """Validate configuration parameters.""" - if self.frequency < self.FREQ_MIN or self.frequency > self.FREQ_MAX: - raise ValueError(f"Invalid frequency: {self.frequency}") - - # Max TX power varies by region (up to 36 dBm for NZ 865) - # The RNode firmware will validate against actual hardware limits - # and return error 0x40 if TX power exceeds device capability - if self.txpower < 0 or self.txpower > 36: - raise ValueError(f"Invalid TX power: {self.txpower}") - - if self.bandwidth < 7800 or self.bandwidth > 1625000: - raise ValueError(f"Invalid bandwidth: {self.bandwidth}") - - if self.sf < 5 or self.sf > 12: - raise ValueError(f"Invalid spreading factor: {self.sf}") - - if self.cr < 5 or self.cr > 8: - raise ValueError(f"Invalid coding rate: {self.cr}") - - if self.st_alock is not None and (self.st_alock < 0.0 or self.st_alock > 100.0): - raise ValueError(f"Invalid short-term airtime limit: {self.st_alock}") - - if self.lt_alock is not None and (self.lt_alock < 0.0 or self.lt_alock > 100.0): - raise ValueError(f"Invalid long-term airtime limit: {self.lt_alock}") - - def start(self): - """Start the interface - connect to RNode and configure radio.""" - # Handle USB mode separately - if self.connection_mode == self.MODE_USB: - return self._start_usb() - - if self.kotlin_bridge is None: - RNS.log("Cannot start - KotlinRNodeBridge not available", RNS.LOG_ERROR) - return False - - if not self.target_device_name: - RNS.log("Cannot start - no target device name configured", RNS.LOG_ERROR) - return False - - mode_str = "BLE" if self.connection_mode == self.MODE_BLE else "Bluetooth Classic" - - # The KotlinRNodeBridge is a process-wide singleton with one - # connectedDeviceName / one GATT client / one shared read buffer at a - # time. If a sibling IOSRNodeInterface has already won the - # connect-race (two interfaces' start() threads can fire in the same - # millisecond because RNS spawns them in the interface-init for-loop), - # calling bridge.connect() again clobbers the first connection's state - # AND has both python interfaces reading from the same byte stream, - # which corrupts both. Bail out cleanly so the first one keeps - # working and this one stays offline. (Architectural constraint - # inherited from v0.10.x — same single-bridge design.) - try: - if ( - hasattr(self.kotlin_bridge, "isConnected") - and self.kotlin_bridge.isConnected() - and hasattr(self.kotlin_bridge, "getConnectedDeviceName") - ): - already = self.kotlin_bridge.getConnectedDeviceName() - if already and already != self.target_device_name: - RNS.log( - f"Cannot start - KotlinRNodeBridge already serving '{already}'; " - f"only one BLE/Classic RNode at a time. '{self.target_device_name}' " - f"staying offline. To use this RNode, disable the other one " - f"and Apply & Restart.", - RNS.LOG_ERROR, - ) - return False - except Exception as e: # noqa: BLE001 - RNS.log(f"Bridge contention check failed (continuing): {e}", RNS.LOG_DEBUG) - - RNS.log(f"Connecting to RNode '{self.target_device_name}' via {mode_str}...", RNS.LOG_INFO) - - # Connect via Kotlin bridge with specified mode - if not self.kotlin_bridge.connect(self.target_device_name, self.connection_mode): - RNS.log(f"Failed to connect to {self.target_device_name}", RNS.LOG_ERROR) - return False - - # Set up data + connection-state callbacks. KotlinRNodeBridge in this - # codebase exposes listener-based registration via add*Listener - # methods rather than setOn*; wrap in try/except so a refactor doesn't - # block interface start. Polling-based reads in _read_loop are what - # actually drives data flow, so missing callbacks are non-fatal. - try: - if hasattr(self.kotlin_bridge, "setOnDataReceived"): - self.kotlin_bridge.setOnDataReceived(self._on_data_received) - if hasattr(self.kotlin_bridge, "setOnConnectionStateChanged"): - self.kotlin_bridge.setOnConnectionStateChanged(self._on_connection_state_changed) - except Exception as e: # noqa: BLE001 - RNS.log(f"IOSRNodeInterface: optional callback registration failed (non-fatal): {e}", RNS.LOG_DEBUG) - - # Stop any stale read thread before starting a new one. - # _on_connection_state_changed(False) does NOT clear _running, so an - # existing thread from the previous connection is still looping. Without - # this guard, both the old and the new thread poll the same - # KotlinRNodeBridge.readBuffer concurrently, stealing bytes from each - # other and corrupting every KISS frame. _start_usb() already applies - # this pattern (lines ~594-600) — mirror it here. - if self._read_thread is not None and self._read_thread.is_alive(): - RNS.log( - f"IOSRNodeInterface[{self.name}]: stopping stale BLE/Classic " - "read thread before reconnect start", - RNS.LOG_INFO, - ) - self._running.clear() - self._read_thread.join(timeout=2.0) - if self._read_thread.is_alive(): - RNS.log( - f"IOSRNodeInterface[{self.name}]: stale read thread did not stop " - "within timeout — aborting start to prevent race", - RNS.LOG_ERROR, - ) - return False - - # Start read thread - self._running.set() - self._read_thread = threading.Thread(target=self._read_loop, daemon=True) - self._read_thread.start() - - # Configure device - try: - time.sleep(1.5) # Allow BLE connection to fully stabilize - self._configure_device() - return True - except Exception as e: # noqa: BLE001 - RNS.log(f"Failed to configure RNode: {e}", RNS.LOG_ERROR) - self.stop() - return False - - def _start_usb(self): - """Start the interface in USB mode.""" - self._get_usb_bridge() - - if self.usb_bridge is None: - RNS.log("Cannot start USB mode - KotlinUSBBridge not available", RNS.LOG_ERROR) - return False - - # Try to find device by VID/PID first (stable identifiers) - # Device ID can change between plug/unplug cycles, so VID/PID is preferred - if self.usb_vendor_id is not None and self.usb_product_id is not None: - current_device_id = self.usb_bridge.findDeviceByVidPid(self.usb_vendor_id, self.usb_product_id) - if current_device_id >= 0: - RNS.log(f"Found USB device by VID/PID: VID={hex(self.usb_vendor_id)}, PID={hex(self.usb_product_id)} -> device ID {current_device_id}", RNS.LOG_INFO) - self.usb_device_id = current_device_id - else: - RNS.log(f"USB device not found by VID/PID: VID={hex(self.usb_vendor_id)}, PID={hex(self.usb_product_id)}", RNS.LOG_WARNING) - return False - - if self.usb_device_id is None: - RNS.log("Cannot start USB mode - no USB device ID configured and no VID/PID to look up", RNS.LOG_ERROR) - return False - - # If we're reconnecting (interface offline but bridge thinks it's connected), - # disconnect first to clear any stale state from previous USB connection - if not self.online and self.usb_bridge.isConnected(): - RNS.log("Clearing stale USB connection before reconnecting...", RNS.LOG_INFO) - self.usb_bridge.disconnect() - - RNS.log(f"Connecting to RNode via USB (device ID {self.usb_device_id})...", RNS.LOG_INFO) - - # Connect via USB bridge (baud rate 115200 is standard for RNode) - if not self.usb_bridge.connect(self.usb_device_id, 115200): - RNS.log(f"Failed to connect to USB device {self.usb_device_id}", RNS.LOG_ERROR) - return False - - # Optional callbacks — see start() above for rationale. - try: - if hasattr(self.usb_bridge, "setOnDataReceived"): - self.usb_bridge.setOnDataReceived(self._on_data_received) - if hasattr(self.usb_bridge, "setOnConnectionStateChanged"): - self.usb_bridge.setOnConnectionStateChanged(self._on_usb_connection_state_changed) - except Exception as e: # noqa: BLE001 - RNS.log(f"IOSRNodeInterface: optional USB callback registration failed (non-fatal): {e}", RNS.LOG_DEBUG) - - # Stop any existing read thread before starting a new one - # This prevents thread leaks if the disconnect callback didn't fire properly - # (e.g., if callback was overwritten by another interface on shared USB bridge) - if self._read_thread is not None and self._read_thread.is_alive(): - RNS.log(f"Stopping existing read loop thread before starting new one...", RNS.LOG_INFO) - self._running.clear() - self._read_thread.join(timeout=2.0) - if self._read_thread.is_alive(): - RNS.log(f"Old read thread did not stop within timeout - aborting start to prevent race", RNS.LOG_ERROR) - return False - - # Reset detection state for fresh configuration - self.detected = False - self.firmware_ok = False - self.interface_ready = False - - # Start read thread - self._running.set() - self._read_thread = threading.Thread(target=self._read_loop_usb, daemon=True) - self._read_thread.start() - - # Configure device - try: - self._configure_device() - return True - except Exception as e: # noqa: BLE001 - RNS.log(f"Failed to configure RNode: {e}", RNS.LOG_ERROR) - self.stop() - return False - - def _on_usb_connection_state_changed(self, connected, device_id): - """Callback when USB connection state changes.""" - RNS.log(f"[{self.name}] _on_usb_connection_state_changed called: connected={connected}, device_id={device_id}, my_device_id={self.usb_device_id}", RNS.LOG_INFO) - if connected: - RNS.log(f"[{self.name}] USB device connected: {device_id}", RNS.LOG_INFO) - else: - RNS.log(f"[{self.name}] USB device disconnected: {device_id}, setting online=False", RNS.LOG_WARNING) - self._set_online(False) - self.detected = False - # Stop the read loop to prevent thread leak and data races - # When the device is re-plugged, start() will create a fresh read loop - self._running.clear() - RNS.log(f"[{self.name}] After disconnect: online={self.online}, read loop stopped", RNS.LOG_INFO) - # Note: USB doesn't auto-reconnect - user must re-plug or re-select device - - def stop(self): - """Stop the interface and disconnect.""" - self._running.clear() - self._reconnecting = False # Stop any reconnection attempts - self._set_online(False) - - # Disconnect based on connection mode - if self.connection_mode == self.MODE_USB: - if self.usb_bridge: - self.usb_bridge.disconnect() - else: - if self.kotlin_bridge: - self.kotlin_bridge.disconnect() - - if self._read_thread: - self._read_thread.join(timeout=2.0) - - if self._reconnect_thread: - self._reconnect_thread.join(timeout=2.0) - - RNS.log(f"RNode interface '{self.name}' stopped", RNS.LOG_INFO) - - def _configure_device(self): - """Detect and configure the RNode.""" - # Send detect command - self._detect() - - # Wait for detection response - start_time = time.time() - while not self.detected and (time.time() - start_time) < self.DETECT_TIMEOUT: - time.sleep(0.1) - - if not self.detected: - raise IOError("Could not detect RNode device") - - # Race-safety wait for firmware_ok. The _detect() request bundles - # CMD_DETECT + CMD_FW_VERSION + CMD_PLATFORM + CMD_MCU into one - # 4-frame KISS payload. Under BLE GATT, the RNode responds with all - # 4 frames in a single notification (observed: 17-byte burst - # `c00846c0 c0500155c0 c04870c0 c04971c0`). _read_loop parses the - # bytes sequentially, setting self.detected = True on the first - # frame (DETECT) and self.firmware_ok = True on the second frame - # (FW_VERSION). The DETECT-watch loop above polls every 100ms — once - # detected flips, it exits immediately. Python's GIL can preempt - # _read_loop between those two writes (any bytecode boundary), so - # the main thread can grab the GIL, see detected=True, and proceed - # to the firmware_ok check below before _read_loop has finished - # parsing the FW_VERSION frame. v0.10.x reference has the same race - # but only on BLE — over Classic SPP / USB serial, frames arrive in - # separate reads with kernel delays between them, hiding the race. - # Short bounded wait closes it without inventing an Event mechanism. - fw_wait_start = time.time() - while not self.firmware_ok and (time.time() - fw_wait_start) < 1.0: - time.sleep(0.02) - - if not self.firmware_ok: - raise IOError(f"Invalid firmware version: {self.maj_version}.{self.min_version}") - - RNS.log(f"RNode detected: platform={hex(self.platform or 0)}, " - f"firmware={self.maj_version}.{self.min_version}", RNS.LOG_INFO) - - # Configure radio parameters - RNS.log("Configuring RNode radio...", RNS.LOG_VERBOSE) - self._init_radio() - - # Validate configuration - if self._validate_radio_state(): - self.interface_ready = True - self._set_online(True) - RNS.log(f"RNode '{self.name}' is online", RNS.LOG_INFO) - - # Display Columba logo on RNode if enabled - self._display_logo() - else: - raise IOError("Radio configuration validation failed") - - def _detect(self): - """Send detect command to RNode.""" - # Send detect command - each KISS frame needs FEND at start and end - kiss_command = bytes([ - KISS.FEND, KISS.CMD_DETECT, KISS.DETECT_REQ, KISS.FEND, - KISS.FEND, KISS.CMD_FW_VERSION, 0x00, KISS.FEND, - KISS.FEND, KISS.CMD_PLATFORM, 0x00, KISS.FEND, - KISS.FEND, KISS.CMD_MCU, 0x00, KISS.FEND - ]) - RNS.log(f"Sending detect command: {kiss_command.hex()}", RNS.LOG_DEBUG) - self._write(kiss_command) - - def _init_radio(self): - """Initialize radio with configured parameters.""" - self._set_frequency() - time.sleep(self.CONFIG_DELAY) - - self._set_bandwidth() - time.sleep(self.CONFIG_DELAY) - - self._set_tx_power() - time.sleep(self.CONFIG_DELAY) - - self._set_spreading_factor() - time.sleep(self.CONFIG_DELAY) - - self._set_coding_rate() - time.sleep(self.CONFIG_DELAY) - - if self.st_alock is not None: - self._set_st_alock() - time.sleep(self.CONFIG_DELAY) - - if self.lt_alock is not None: - self._set_lt_alock() - time.sleep(self.CONFIG_DELAY) - - self._set_radio_state(KISS.RADIO_STATE_ON) - time.sleep(self.CONFIG_DELAY) - - def _set_frequency(self): - """Set radio frequency.""" - c1 = (self.frequency >> 24) & 0xFF - c2 = (self.frequency >> 16) & 0xFF - c3 = (self.frequency >> 8) & 0xFF - c4 = self.frequency & 0xFF - data = KISS.escape(bytes([c1, c2, c3, c4])) - kiss_command = bytes([KISS.FEND, KISS.CMD_FREQUENCY]) + data + bytes([KISS.FEND]) - self._write(kiss_command) - - def _set_bandwidth(self): - """Set radio bandwidth.""" - c1 = (self.bandwidth >> 24) & 0xFF - c2 = (self.bandwidth >> 16) & 0xFF - c3 = (self.bandwidth >> 8) & 0xFF - c4 = self.bandwidth & 0xFF - data = KISS.escape(bytes([c1, c2, c3, c4])) - kiss_command = bytes([KISS.FEND, KISS.CMD_BANDWIDTH]) + data + bytes([KISS.FEND]) - self._write(kiss_command) - - def _set_tx_power(self): - """Set TX power.""" - kiss_command = bytes([KISS.FEND, KISS.CMD_TXPOWER, self.txpower, KISS.FEND]) - self._write(kiss_command) - - def _set_spreading_factor(self): - """Set spreading factor.""" - kiss_command = bytes([KISS.FEND, KISS.CMD_SF, self.sf, KISS.FEND]) - self._write(kiss_command) - - def _set_coding_rate(self): - """Set coding rate.""" - kiss_command = bytes([KISS.FEND, KISS.CMD_CR, self.cr, KISS.FEND]) - self._write(kiss_command) - - def _set_st_alock(self): - """Set short-term airtime lock.""" - at = int(self.st_alock * 100) - c1 = (at >> 8) & 0xFF - c2 = at & 0xFF - data = KISS.escape(bytes([c1, c2])) - kiss_command = bytes([KISS.FEND, KISS.CMD_ST_ALOCK]) + data + bytes([KISS.FEND]) - self._write(kiss_command) - - def _set_lt_alock(self): - """Set long-term airtime lock.""" - at = int(self.lt_alock * 100) - c1 = (at >> 8) & 0xFF - c2 = at & 0xFF - data = KISS.escape(bytes([c1, c2])) - kiss_command = bytes([KISS.FEND, KISS.CMD_LT_ALOCK]) + data + bytes([KISS.FEND]) - self._write(kiss_command) - - def _set_radio_state(self, state): - """Set radio state (on/off).""" - self.state = state - kiss_command = bytes([KISS.FEND, KISS.CMD_RADIO_STATE, state, KISS.FEND]) - self._write(kiss_command) - - def _validate_radio_state(self): - """Validate that radio state matches configuration.""" - # Poll for radio state with timeout — different RNode hardware reports - # state at different speeds. T-Beam Supreme answers within ~300ms; - # Heltec V3 (ESP32-S3) and T114 (nRF52) can take 1-2 seconds to - # transition to RADIO_STATE_ON and emit the CMD_RADIO_STATE frame. - # Original v0.10.x code did a single sleep(0.3) which is too short - # for the slower variants — observed "Radio state not ON: None" on - # Fold tonight with the Heltec E517. Poll for up to 5s, checking - # every 100ms, then proceed with whatever state we have for the - # final validation pass below. - validation_deadline = time.time() + 5.0 - while time.time() < validation_deadline: - with self._read_lock: - r_state_poll = self.r_state - if r_state_poll == KISS.RADIO_STATE_ON: - break - time.sleep(0.1) - - # Read all reported radio state under lock for thread safety. - # The read loop updates these from a background thread. - with self._read_lock: - r_frequency = self.r_frequency - r_bandwidth = self.r_bandwidth - r_sf = self.r_sf - r_cr = self.r_cr - r_state = self.r_state - - # Check if we got the expected values back - if r_frequency is not None and r_frequency != self.frequency: - RNS.log(f"Frequency mismatch: configured={self.frequency}, reported={r_frequency}", RNS.LOG_ERROR) - return False - - if r_bandwidth is not None and r_bandwidth != self.bandwidth: - RNS.log(f"Bandwidth mismatch: configured={self.bandwidth}, reported={r_bandwidth}", RNS.LOG_ERROR) - return False - - if r_sf is not None and r_sf != self.sf: - RNS.log(f"SF mismatch: configured={self.sf}, reported={r_sf}", RNS.LOG_ERROR) - return False - - if r_cr is not None and r_cr != self.cr: - RNS.log(f"CR mismatch: configured={self.cr}, reported={r_cr}", RNS.LOG_ERROR) - return False - - if r_state != KISS.RADIO_STATE_ON: - RNS.log(f"Radio state not ON: {r_state}", RNS.LOG_ERROR) - return False - - return True - - # Exponential backoff delays for write retries (in seconds) - WRITE_BACKOFF_DELAYS = [0.3, 1.0, 3.0] - - def _write(self, data, max_retries=3): - """Write data to the RNode via Kotlin bridge with exponential backoff retry.""" - # Select bridge based on connection mode - if self.connection_mode == self.MODE_USB: - if self.usb_bridge is None: - raise IOError("USB bridge not available") - bridge = self.usb_bridge - else: - if self.kotlin_bridge is None: - raise IOError("Kotlin bridge not available") - bridge = self.kotlin_bridge - - last_error = None - for attempt in range(max_retries): - # USB bridge uses write(), Bluetooth bridge uses writeSync() - if self.connection_mode == self.MODE_USB: - written = bridge.write(data) - else: - written = bridge.writeSync(data) - - if written == len(data): - return # Success - - last_error = f"expected {len(data)}, wrote {written}" - if attempt < max_retries - 1: - # Use exponential backoff delay (0.3s, 1.0s, 3.0s, ...) - delay = self.WRITE_BACKOFF_DELAYS[min(attempt, len(self.WRITE_BACKOFF_DELAYS) - 1)] - RNS.log(f"Write attempt {attempt + 1} failed ({last_error}), retrying in {delay}s...", RNS.LOG_WARNING) - time.sleep(delay) - - raise IOError(f"Write failed after {max_retries} attempts: {last_error}") - - # ------------------------------------------------------------------------- - # External Framebuffer (Display) Methods - # ------------------------------------------------------------------------- - - def enable_external_framebuffer(self): - """Enable external framebuffer mode on RNode display.""" - kiss_command = bytes([KISS.FEND, KISS.CMD_FB_EXT, 0x01, KISS.FEND]) - self._write(kiss_command) - self.framebuffer_enabled = True - RNS.log(f"{self} External framebuffer enabled", RNS.LOG_DEBUG) - - def disable_external_framebuffer(self): - """Disable external framebuffer, return to normal RNode UI.""" - kiss_command = bytes([KISS.FEND, KISS.CMD_FB_EXT, 0x00, KISS.FEND]) - self._write(kiss_command) - self.framebuffer_enabled = False - RNS.log(f"{self} External framebuffer disabled", RNS.LOG_DEBUG) - - def write_framebuffer(self, line, line_data): - """Write 8 bytes of pixel data to a specific line (0-63). - - Args: - line: Line number (0-63) - line_data: 8 bytes of pixel data (64 pixels, 1 bit per pixel) - """ - if line < 0 or line > 63: - raise ValueError(f"Line must be 0-63, got {line}") - if len(line_data) != KISS.FB_BYTES_PER_LINE: - raise ValueError(f"Line data must be {KISS.FB_BYTES_PER_LINE} bytes") - - data = bytes([line]) + line_data - escaped = KISS.escape(data) - kiss_command = bytes([KISS.FEND, KISS.CMD_FB_WRITE]) + escaped + bytes([KISS.FEND]) - self._write(kiss_command) - - def display_image(self, imagedata): - """Send a 64x64 monochrome image to RNode display. - - Args: - imagedata: List or bytes of 512 bytes (64 lines x 8 bytes per line) - """ - if len(imagedata) != 512: - raise ValueError(f"Image data must be 512 bytes, got {len(imagedata)}") - - for line in range(64): - line_start = line * KISS.FB_BYTES_PER_LINE - line_end = line_start + KISS.FB_BYTES_PER_LINE - line_data = bytes(imagedata[line_start:line_end]) - self.write_framebuffer(line, line_data) - # Small delay to prevent BLE write throttling - time.sleep(0.015) - - RNS.log(f"{self} Sent 64x64 image to RNode framebuffer", RNS.LOG_DEBUG) - - def _display_logo(self): - """Display or disable the Columba logo on RNode based on settings.""" - if self.enable_framebuffer: - try: - from columba_logo import columba_fb_data - self.display_image(columba_fb_data) - # Delay before enable command to ensure framebuffer data is processed - time.sleep(0.05) - self.enable_external_framebuffer() - RNS.log(f"{self} Displayed Columba logo on RNode", RNS.LOG_DEBUG) - except ImportError: - RNS.log(f"{self} columba_logo module not found, skipping logo display", RNS.LOG_WARNING) - except Exception as e: # noqa: BLE001 - RNS.log(f"{self} Failed to display logo: {e}", RNS.LOG_WARNING) - else: - # Explicitly disable external framebuffer to restore normal RNode UI - try: - self.disable_external_framebuffer() - RNS.log(f"{self} Disabled external framebuffer on RNode", RNS.LOG_DEBUG) - except Exception as e: # noqa: BLE001 - RNS.log(f"{self} Failed to disable framebuffer: {e}", RNS.LOG_WARNING) - - def _read_loop(self): - """Background thread for reading and parsing KISS frames.""" - in_frame = False - escape = False - command = KISS.CMD_UNKNOWN - data_buffer = b"" - - RNS.log("RNode read loop started", RNS.LOG_DEBUG) - - while self._running.is_set(): - try: - # Read available data - raw_data = self.kotlin_bridge.read() - # Convert to bytes if needed (Chaquopy may return jarray) - if hasattr(raw_data, '__len__'): - data = bytes(raw_data) - else: - data = bytes(raw_data) if raw_data else b"" - - if len(data) == 0: - time.sleep(0.01) - continue - - # Parse KISS frames - RNS.log(f"RNode parsing {len(data)} bytes: {data.hex()}", RNS.LOG_DEBUG) - for byte in data: - if in_frame and byte == KISS.FEND and command == KISS.CMD_DATA: - # End of data frame - in_frame = False - self._process_incoming(data_buffer) - data_buffer = b"" - elif byte == KISS.FEND: - # Start of frame - in_frame = True - command = KISS.CMD_UNKNOWN - data_buffer = b"" - elif in_frame and len(data_buffer) < 512: - if escape: - if byte == KISS.TFEND: - data_buffer += bytes([KISS.FEND]) - elif byte == KISS.TFESC: - data_buffer += bytes([KISS.FESC]) - else: - # Invalid escape sequence - FESC should only be followed by TFEND or TFESC - RNS.log(f"Invalid KISS escape sequence: FESC followed by 0x{byte:02X}", RNS.LOG_WARNING) - data_buffer += bytes([byte]) - escape = False - elif byte == KISS.FESC: - escape = True - elif command == KISS.CMD_UNKNOWN: - command = byte - elif command == KISS.CMD_DATA: - data_buffer += bytes([byte]) - elif command == KISS.CMD_FREQUENCY: - if len(data_buffer) < 4: - data_buffer += bytes([byte]) - if len(data_buffer) == 4: - freq = (data_buffer[0] << 24) | (data_buffer[1] << 16) | (data_buffer[2] << 8) | data_buffer[3] - with self._read_lock: - self.r_frequency = freq - RNS.log(f"RNode frequency: {freq}", RNS.LOG_DEBUG) - elif command == KISS.CMD_BANDWIDTH: - if len(data_buffer) < 4: - data_buffer += bytes([byte]) - if len(data_buffer) == 4: - bw = (data_buffer[0] << 24) | (data_buffer[1] << 16) | (data_buffer[2] << 8) | data_buffer[3] - with self._read_lock: - self.r_bandwidth = bw - RNS.log(f"RNode bandwidth: {bw}", RNS.LOG_DEBUG) - elif command == KISS.CMD_TXPOWER: - with self._read_lock: - self.r_txpower = byte - RNS.log(f"RNode TX power: {byte}", RNS.LOG_DEBUG) - elif command == KISS.CMD_SF: - with self._read_lock: - self.r_sf = byte - RNS.log(f"RNode SF: {byte}", RNS.LOG_DEBUG) - elif command == KISS.CMD_CR: - with self._read_lock: - self.r_cr = byte - RNS.log(f"RNode CR: {byte}", RNS.LOG_DEBUG) - elif command == KISS.CMD_RADIO_STATE: - with self._read_lock: - self.r_state = byte - RNS.log(f"RNode radio state: {byte}", RNS.LOG_DEBUG) - elif command == KISS.CMD_STAT_RSSI: - with self._read_lock: - self.r_stat_rssi = byte - 157 # RSSI offset - elif command == KISS.CMD_STAT_SNR: - with self._read_lock: - self.r_stat_snr = int.from_bytes([byte], "big", signed=True) / 4.0 - elif command == KISS.CMD_FW_VERSION: - if len(data_buffer) < 2: - data_buffer += bytes([byte]) - if len(data_buffer) == 2: - self.maj_version = data_buffer[0] - self.min_version = data_buffer[1] - self._validate_firmware() - elif command == KISS.CMD_PLATFORM: - self.platform = byte - elif command == KISS.CMD_MCU: - self.mcu = byte - elif command == KISS.CMD_DETECT: - if byte == KISS.DETECT_RESP: - self.detected = True - RNS.log("RNode detected!", RNS.LOG_DEBUG) - elif command == KISS.CMD_ERROR: - error_message = KISS.get_error_message(byte) - RNS.log(f"RNode error (0x{byte:02X}): {error_message}", RNS.LOG_ERROR) - # Surface error to UI via callback - if self._on_error_callback: - try: - self._on_error_callback(byte, error_message) - except Exception as cb_err: # noqa: BLE001 - RNS.log(f"Error callback failed: {cb_err}", RNS.LOG_ERROR) - elif command == KISS.CMD_READY: - pass # Device ready - - except Exception as e: # noqa: BLE001 - if self._running.is_set(): - RNS.log(f"Read loop error: {e}", RNS.LOG_ERROR) - time.sleep(0.1) - - RNS.log("RNode read loop stopped", RNS.LOG_DEBUG) - - def _read_loop_usb(self): - """Background thread for reading and parsing KISS frames from USB. - - Similar to _read_loop but uses USB bridge instead of Bluetooth bridge, - and includes handling for CMD_BT_PIN during Bluetooth pairing mode. - """ - in_frame = False - escape = False - command = KISS.CMD_UNKNOWN - data_buffer = b"" - - RNS.log("RNode USB read loop started", RNS.LOG_DEBUG) - - while self._running.is_set(): - try: - # Read available data from USB bridge - raw_data = self.usb_bridge.read() - # Convert to bytes if needed (Chaquopy may return jarray) - if hasattr(raw_data, '__len__'): - data = bytes(raw_data) - else: - data = bytes(raw_data) if raw_data else b"" - - if len(data) == 0: - time.sleep(0.01) - continue - - # Parse KISS frames - RNS.log(f"RNode USB parsing {len(data)} bytes: {data.hex()}", RNS.LOG_DEBUG) - for byte in data: - if in_frame and byte == KISS.FEND and command == KISS.CMD_DATA: - # End of data frame - in_frame = False - self._process_incoming(data_buffer) - data_buffer = b"" - elif byte == KISS.FEND: - # Start of frame - in_frame = True - command = KISS.CMD_UNKNOWN - data_buffer = b"" - elif in_frame and len(data_buffer) < 512: - if escape: - if byte == KISS.TFEND: - data_buffer += bytes([KISS.FEND]) - elif byte == KISS.TFESC: - data_buffer += bytes([KISS.FESC]) - else: - # Invalid escape sequence - RNS.log(f"Invalid KISS escape sequence: FESC followed by 0x{byte:02X}", RNS.LOG_WARNING) - data_buffer += bytes([byte]) - escape = False - elif byte == KISS.FESC: - escape = True - elif command == KISS.CMD_UNKNOWN: - command = byte - elif command == KISS.CMD_DATA: - data_buffer += bytes([byte]) - elif command == KISS.CMD_FREQUENCY: - if len(data_buffer) < 4: - data_buffer += bytes([byte]) - if len(data_buffer) == 4: - freq = (data_buffer[0] << 24) | (data_buffer[1] << 16) | (data_buffer[2] << 8) | data_buffer[3] - with self._read_lock: - self.r_frequency = freq - RNS.log(f"RNode frequency: {freq}", RNS.LOG_DEBUG) - elif command == KISS.CMD_BANDWIDTH: - if len(data_buffer) < 4: - data_buffer += bytes([byte]) - if len(data_buffer) == 4: - bw = (data_buffer[0] << 24) | (data_buffer[1] << 16) | (data_buffer[2] << 8) | data_buffer[3] - with self._read_lock: - self.r_bandwidth = bw - RNS.log(f"RNode bandwidth: {bw}", RNS.LOG_DEBUG) - elif command == KISS.CMD_TXPOWER: - with self._read_lock: - self.r_txpower = byte - RNS.log(f"RNode TX power: {byte}", RNS.LOG_DEBUG) - elif command == KISS.CMD_SF: - with self._read_lock: - self.r_sf = byte - RNS.log(f"RNode SF: {byte}", RNS.LOG_DEBUG) - elif command == KISS.CMD_CR: - with self._read_lock: - self.r_cr = byte - RNS.log(f"RNode CR: {byte}", RNS.LOG_DEBUG) - elif command == KISS.CMD_RADIO_STATE: - with self._read_lock: - self.r_state = byte - RNS.log(f"RNode radio state: {byte}", RNS.LOG_DEBUG) - elif command == KISS.CMD_STAT_RSSI: - with self._read_lock: - self.r_stat_rssi = byte - 157 # RSSI offset - elif command == KISS.CMD_STAT_SNR: - with self._read_lock: - self.r_stat_snr = int.from_bytes([byte], "big", signed=True) / 4.0 - elif command == KISS.CMD_FW_VERSION: - if len(data_buffer) < 2: - data_buffer += bytes([byte]) - if len(data_buffer) == 2: - self.maj_version = data_buffer[0] - self.min_version = data_buffer[1] - self._validate_firmware() - elif command == KISS.CMD_PLATFORM: - self.platform = byte - elif command == KISS.CMD_MCU: - self.mcu = byte - elif command == KISS.CMD_DETECT: - if byte == KISS.DETECT_RESP: - self.detected = True - RNS.log("RNode detected!", RNS.LOG_DEBUG) - elif command == KISS.CMD_BT_PIN: - # Bluetooth PIN response during pairing mode - # PIN is sent as 4-byte big-endian integer by RNode firmware - if len(data_buffer) < 4: - data_buffer += bytes([byte]) - if len(data_buffer) == 4: - pin_value = int.from_bytes(data_buffer, byteorder='big') - pin = f"{pin_value:06d}" - RNS.log(f"RNode Bluetooth PIN: {pin}", RNS.LOG_INFO) - # Note: Kotlin USB bridge also parses PIN and notifies UI - # This is a backup notification in case Kotlin missed it - if self.usb_bridge: - try: - self.usb_bridge.notifyBluetoothPin(pin) - except Exception as e: # noqa: BLE001 - RNS.log(f"Failed to notify BT PIN: {e}", RNS.LOG_ERROR) - elif command == KISS.CMD_ERROR: - error_message = KISS.get_error_message(byte) - RNS.log(f"RNode error (0x{byte:02X}): {error_message}", RNS.LOG_ERROR) - # Surface error to UI via callback - if self._on_error_callback: - try: - self._on_error_callback(byte, error_message) - except Exception as cb_err: # noqa: BLE001 - RNS.log(f"Error callback failed: {cb_err}", RNS.LOG_ERROR) - elif command == KISS.CMD_READY: - pass # Device ready - - except Exception as e: # noqa: BLE001 - if self._running.is_set(): - RNS.log(f"USB read loop error: {e}", RNS.LOG_ERROR) - time.sleep(0.1) - - RNS.log("RNode USB read loop stopped", RNS.LOG_DEBUG) - - def _validate_firmware(self): - """Check if firmware version is acceptable.""" - if self.maj_version > self.REQUIRED_FW_VER_MAJ: - self.firmware_ok = True - elif self.maj_version == self.REQUIRED_FW_VER_MAJ and self.min_version >= self.REQUIRED_FW_VER_MIN: - self.firmware_ok = True - else: - self.firmware_ok = False - RNS.log(f"Firmware version {self.maj_version}.{self.min_version} is below required " - f"{self.REQUIRED_FW_VER_MAJ}.{self.REQUIRED_FW_VER_MIN}", RNS.LOG_WARNING) - - def _process_incoming(self, data): - """Process incoming data frame from RNode.""" - if len(data) > 0 and self.online: - # Update receive counter - self.rxb += len(data) - # Pass to Reticulum Transport for processing - RNS.Transport.inbound(data, self) - RNS.log(f"RNode received {len(data)} bytes", RNS.LOG_DEBUG) - - def _on_data_received(self, data): - """Callback from Kotlin bridge when data is received.""" - # Data is already being processed in _read_loop via polling - # This callback is for future async implementation - pass - - def _on_connection_state_changed(self, connected, device_name): - """Callback when Bluetooth connection state changes.""" - if connected: - RNS.log(f"RNode connected: {device_name}", RNS.LOG_INFO) - # Stop any reconnection attempts if we're now connected - self._reconnecting = False - else: - RNS.log(f"RNode disconnected: {device_name}", RNS.LOG_WARNING) - self._set_online(False) - self.detected = False - # Start auto-reconnection if not already reconnecting - self._start_reconnection_loop() - - def setOnErrorReceived(self, callback): - """ - Set callback for RNode error events. - - The callback will be called when the RNode reports an error, - with signature: callback(error_code: int, error_message: str) - - @param callback: Callable that receives (error_code, error_message) - """ - self._on_error_callback = callback - - def setOnOnlineStatusChanged(self, callback): - """ - Set callback for online status change events. - - The callback will be called when the interface's online status changes, - with signature: callback(is_online: bool) - - This enables event-driven UI updates when the RNode connects/disconnects. - - @param callback: Callable that receives (is_online) - """ - self._on_online_status_changed = callback - - def _set_online(self, is_online): - """ - Set online status and notify callback if status changed. - - Thread-safe: Uses _read_lock to synchronize with process_outgoing(). - - @param is_online: New online status - """ - with self._read_lock: - old_status = self.online - self.online = is_online - if old_status != is_online: - # Existing in-Python observer chain (callbacks registered by other - # python-side code that wants the live online state). - if self._on_online_status_changed: - try: - self._on_online_status_changed(is_online) - except Exception as e: # noqa: BLE001 - RNS.log(f"Error in online status callback: {e}", RNS.LOG_ERROR) - # Notify the Kotlin RNodeBridge so ServiceNotificationManager can - # raise / dismiss its "RNode Disconnected" heads-up notification. - # ReticulumService.onCreate registers an RNodeOnlineStatusListener - # against the bridge singleton. - # - # USB-mode interfaces don't share the BLE/Classic kotlin_bridge — - # for those, KotlinUSBBridge fires its own UsbConnectionListener - # on ACTION_USB_DEVICE_DETACHED system broadcast, which converges - # in the same notification path. Filter here to avoid invoking a - # bridge method that doesn't exist on KotlinUSBBridge. - if self.connection_mode != self.MODE_USB and self.kotlin_bridge is not None: - try: - self.kotlin_bridge.notifyOnlineStatusChanged(is_online, self.name) - except Exception as e: # noqa: BLE001 - RNS.log( - f"Failed to notify kotlin bridge of online status change: {e}", - RNS.LOG_DEBUG, - ) - - def _start_reconnection_loop(self): - """Start a background thread to attempt reconnection.""" - if self._reconnecting: - RNS.log("Reconnection already in progress", RNS.LOG_DEBUG) - return - - self._reconnecting = True - self._reconnect_thread = threading.Thread(target=self._reconnection_loop, daemon=True) - self._reconnect_thread.start() - RNS.log(f"Started auto-reconnection loop for {self.target_device_name}", RNS.LOG_INFO) - - def _reconnection_loop(self): - """Background thread that attempts to reconnect to the RNode.""" - attempt = 0 - while self._reconnecting and attempt < self._max_reconnect_attempts: - attempt += 1 - RNS.log(f"Reconnection attempt {attempt}/{self._max_reconnect_attempts} for {self.target_device_name}...", RNS.LOG_INFO) - - try: - if self.start(): - RNS.log(f"Successfully reconnected to {self.target_device_name}", RNS.LOG_INFO) - self._reconnecting = False - return - else: - RNS.log(f"Reconnection attempt {attempt} failed, will retry in {self._reconnect_interval}s", RNS.LOG_WARNING) - except Exception as e: # noqa: BLE001 - RNS.log(f"Reconnection attempt {attempt} error: {e}", RNS.LOG_ERROR) - - # Wait before next attempt (but check if we should stop) - for _ in range(int(self._reconnect_interval * 10)): - if not self._reconnecting: - return - time.sleep(0.1) - - if self._reconnecting: - RNS.log(f"Failed to reconnect to {self.target_device_name} after {attempt} attempts", RNS.LOG_ERROR) - self._reconnecting = False - - def process_held_announces(self): - """Process any held announces. Required by RNS Transport. - - Overrides the base Interface implementation because we store held - announces in a list (the legacy v0.10.x shape) rather than the base - class's dict-keyed-by-destination-hash structure. The base behaviour - is "release the lowest-hop announce when ingress freq drops below - the threshold"; this simpler version just releases everything in - order. Same overall correctness for low-volume mesh announces. - """ - # Process and clear held announces - for announce in self.held_announces: - try: - RNS.Transport.inbound(announce, self) - except Exception as e: # noqa: BLE001 - RNS.log(f"Error processing held announce: {e}", RNS.LOG_ERROR) - self.held_announces = [] - - def sent_announce(self, from_spawned=False): - """Called when an announce is sent on this interface. Tracks announce frequency.""" - self.oa_freq_deque.append(time.time()) - - def received_announce(self): - """Called when an announce is received on this interface. Tracks announce frequency.""" - self.ia_freq_deque.append(time.time()) - - def should_ingress_limit(self): - """Check if ingress limiting should be applied. Required by RNS Transport.""" - return False - - def process_outgoing(self, data): - """Send data through the RNode interface.""" - # Thread-safe check of online status (synchronized with _set_online) - with self._read_lock: - is_online = self.online - if not is_online: - RNS.log("Cannot send - interface is offline", RNS.LOG_WARNING) - return - - # KISS-frame the data - escaped_data = KISS.escape(data) - kiss_frame = bytes([KISS.FEND, KISS.CMD_DATA]) + escaped_data + bytes([KISS.FEND]) - - try: - self._write(kiss_frame) - # Update transmit counter - self.txb += len(data) - RNS.log(f"RNode sent {len(data)} bytes", RNS.LOG_DEBUG) - except Exception as e: # noqa: BLE001 - RNS.log(f"Failed to send data: {e}", RNS.LOG_ERROR) - - def get_rssi(self): - """Get last received signal strength.""" - with self._read_lock: - return self.r_stat_rssi - - def get_snr(self): - """Get last received signal-to-noise ratio.""" - with self._read_lock: - return self.r_stat_snr - - def enter_bluetooth_pairing_mode(self): - """ - Send command to enter Bluetooth pairing mode (USB mode only). - - When connected via USB, this sends the CMD_BT_CTRL command with - BT_CTRL_PAIRING_MODE parameter to put the RNode into Bluetooth - pairing mode. The RNode will respond with CMD_BT_PIN containing - the 6-digit PIN that must be entered on the Android device's - Bluetooth settings to complete pairing. - - This is primarily useful for T114 devices and RNodes without - a user button for entering pairing mode manually. - - Returns: - True if command was sent successfully, False otherwise - """ - if self.connection_mode != self.MODE_USB: - RNS.log("Bluetooth pairing mode is only available via USB connection", RNS.LOG_WARNING) - return False - - if self.usb_bridge is None or not self.usb_bridge.isConnected(): - RNS.log("Cannot enter pairing mode - not connected via USB", RNS.LOG_ERROR) - return False - - RNS.log("Sending Bluetooth pairing mode command...", RNS.LOG_INFO) - - try: - # KISS frame: FEND CMD_BT_CTRL BT_CTRL_PAIRING_MODE FEND - kiss_cmd = bytes([KISS.FEND, KISS.CMD_BT_CTRL, KISS.BT_CTRL_PAIRING_MODE, KISS.FEND]) - self._write(kiss_cmd) - RNS.log("Bluetooth pairing mode command sent", RNS.LOG_INFO) - return True - except Exception as e: # noqa: BLE001 - RNS.log(f"Failed to send pairing mode command: {e}", RNS.LOG_ERROR) - return False - - def __str__(self): - return f"IOSRNodeInterface[{self.name}]" - - -# RNS external-interface loader contract: the module must expose -# `interface_class` pointing to the class to instantiate. See -# Reticulum.py:933 — `interface_class = interface_globals["interface_class"]`. -interface_class = IOSRNodeInterface diff --git a/app/rns_bridge.py b/app/rns_bridge.py index c8266c5d..18e0806b 100644 --- a/app/rns_bridge.py +++ b/app/rns_bridge.py @@ -908,10 +908,9 @@ def stop() -> None: _links.clear() _telephony_destination = None - # Drop registered BLE + RNode callbacks so a subsequent start() doesn't + # Drop registered BLE callbacks so a subsequent start() doesn't # invoke closures bound to the previous driver / Swift bridge. clear_ble_callbacks() - clear_rnode_callbacks() global _ble_bridge_handle, _announce_generation _ble_bridge_handle = None # Supersede any in-flight delayed re-announce thread (see start()). @@ -1426,11 +1425,10 @@ def reset_identity(identity_path: str) -> None: links_to_teardown = list(_links.values()) _links.clear() _telephony_destination = None - # Drop registered BLE + RNode callbacks + the BLE bridge handle so the + # Drop registered BLE callbacks + the BLE bridge handle so the # start() this function's docstring requires doesn't invoke closures # bound to the torn-down driver / Swift bridge (mirrors stop()). clear_ble_callbacks() - clear_rnode_callbacks() global _ble_bridge_handle, _announce_generation _ble_bridge_handle = None # Supersede any in-flight delayed re-announce thread (see start()). @@ -1588,34 +1586,6 @@ def clear_ble_callbacks() -> None: _ble_callbacks.clear() -# ── RNode bridge callback registry (mirrors the BLE one above) ── -# Swift's SwiftRNodeBridge pushes Nordic-UART TX bytes + connection-state -# changes into the Python IOSRNodeInterface through these. Slots: -# "data" → cb(data: bytes) — decrypted NUS TX payload -# "state" → cb(connected: bool, name) — link up/down + device name -_rnode_callbacks: dict[str, Any] = {} - - -def set_rnode_callback(slot: str, callable_: Any) -> None: - """Register a Python callable for an RNode bridge event slot ("data" / - "state"). Used by IOSRNodeInterface's _RNodeBLEBridge. Pass None to clear.""" - if callable_ is None: - _rnode_callbacks.pop(slot, None) - else: - _rnode_callbacks[slot] = callable_ - - -def _rnode_get_callback(slot: str) -> Any: - """Swift-called lookup (PythonRNodeCallbackBridge) for the RNode "data" / - "state" handler. Returns the stored callable, or None.""" - return _rnode_callbacks.get(slot) - - -def clear_rnode_callbacks() -> None: - """Drop every registered RNode callback (stop()/restart).""" - _rnode_callbacks.clear() - - # Smoke-test entry point: register a callable that doubles its arg. The # Swift side calls `invokeBLECallbackBoolSync(slot="_test_roundtrip", args=[5])` # and asserts the bool return is True. Used by `lxma-test://test-ble-callback-roundtrip` diff --git a/docs/MODEL_B_BACKGROUND_DELIVERY.md b/docs/MODEL_B_BACKGROUND_DELIVERY.md new file mode 100644 index 00000000..7d1f0efc --- /dev/null +++ b/docs/MODEL_B_BACKGROUND_DELIVERY.md @@ -0,0 +1,240 @@ +# Model B — Background LXMF Delivery + +How Columba-iOS delivers an LXMF message (and a notification) while the app is +backgrounded, suspended, or **locked**, without APNS — by running the real +Reticulum + LXMF stack inside a Network Extension that *completes* delivery +(proves, decrypts, persists, notifies), rather than just sniffing. + +> Status: inbound delivery + announce propagation **verified on-device** +> (2026-06-02, iPhone 14). See "Verified on-device" below. +> +> Companion docs: the per-phase implementation spec lives in the Obsidian vault +> (`80 Assistant/Memory/Columba-iOS/track_a_model_b_implementation_spec.md`, +> A0–A5, cited to `file:line`); NE security threat model in +> `track_c5_ne_security_threat_model.md`; App-Store framing in +> `app_store_review_packet_tunnel_ne.md`. + +--- + +## Why this shape ("Model B") + +A suspended iOS app cannot be woken to finish network work (Apple DTS 769398), so +the only way to deliver an LXMF message while the phone is locked is to have a +**separately-scheduled process** that owns the messaging endpoint and completes +delivery itself. The `NEPacketTunnelProvider` Network Extension (NE) is that +process: iOS keeps it running for the active VPN/tunnel, independent of the app's +lifecycle. + +**Model B = the NE is the *canonical* node.** It owns the single +`lxmf.delivery` destination and terminates every transfer. The app is a +transport/UI satellite. The alternative (Model A: app owns the node, NE sniffs +and hands off) was rejected — two processes contending for one destination causes +path-flap, link double-response, and cross-process receive-dedup races. Model B +has exactly one node, one writer, one place for dedup. + +--- + +## Runtime topology (two processes) + +``` + ┌──────────────────────── iPhone ────────────────────────┐ + TCP relay ─────┼─▶ NE process (NEReticulumNode) — THE node │ + (internet/LAN) │ • shared identity (keychain access group) │ + │ • ReticulumSwift transport + LXMFSwift LXMRouter │ + │ • lxmf.delivery destination (the one true endpoint) │ + │ • owns the TCP relay interface (its own NWConnection) │ + │ • AppGroupBridgeInterface ◀──────────────────────┐ │ + │ • prove / link / resource / decrypt — ALL here │ │ + │ • writes plaintext ─▶ shared App-Group GRDB store │ │ + │ • posts UNUserNotification + dbChanged Darwin notif │ │ + │ │ │ + │ App process (ColumbaApp) │ │ + BLE / RNode ───┼─▶ • radio drivers (Auto / BLE / RNode) │ │ + (radio peers) │ • pumps radio frames ─────────────────────────────┘ │ + │ • ProxyRnsBackend: send/announce/status → NE via IPC │ + │ • UI reads the shared GRDB (read-only) │ + └──────────────────────────────────────────────────────────┘ + ONE destination on the NE, reachable via two transport paths: direct over the + NE's TCP relay, or peer→radio→app→App-Group bridge→NE. Standard multi-path + routing — no duplicate-destination anomaly. +``` + +### Who owns what + +| Concern | Owner | Notes | +|---|---|---| +| `lxmf.delivery` destination + identity | **NE** | identity loaded from the shared keychain access group | +| ReticulumSwift transport + `LXMRouter` | **NE** | the only RNS/LXMF node in the system | +| TCP relay interface (`ne-tcp-relay`) | **NE** | its own `NWConnection`; see "TCP egress" note | +| Inbound prove/decrypt/persist/notify | **NE** | `NEDeliveryDelegate` | +| Self-announce of the delivery dest | **NE** | app can't announce while suspended | +| Radio interfaces (Auto / BLE / RNode) | **App** | frames bridged to the NE | +| Send / announce / status requests | **App → NE** | via `ProxyRnsBackend` over IPC | +| UI, shared-store reads | **App** | read-only reader of the NE's GRDB store | + +--- + +## Inbound delivery flow (the headline path) + +1. A sender resolves a path to `lxmf.delivery` (learned from the NE's announce) + and opens an RNS **link**, or sends opportunistically. +2. Frames arrive at the NE either directly over the **TCP relay**, or via a + radio peer → app radio driver → **AppGroupBridge** → NE transport. +3. The NE's `ReticulumSwift` transport handles the link / resource; the + `LXMFSwift` `LXMRouter` validates (signature / duplicate / stamp), decrypts, + and **persists** the plaintext to the shared App-Group GRDB store. +4. `NEDeliveryDelegate.router(_:didReceiveMessage:)` fires: + - posts a local `UNUserNotification` (sender + short preview), and + - posts a `dbChanged` Darwin notification so a foregrounded app refreshes. +5. The sender receives a **delivery proof** (RNS link delivery). +6. On next open, the app reads the message from the shared GRDB — no re-fetch. + +This whole path runs in the NE while the app is suspended/locked. + +## Outbound / send flow + +1. The app composes a message and calls `ProxyRnsBackend.sendLxmfMessage(...)`. +2. That marshals a `composeOutbound` envelope (dest, content, `LxmfFieldCodec`- + packed fields, method) over the IPC seam to the NE. +3. The NE's `sendLxmfForIPC(...)` builds the `LXMessage` with the **shared** + identity, persists it, and `transport.send` selects the path automatically + (path-table lookup → TCP direct, or via the AppGroupBridge → app → radio). +4. Outbound state flows back via the GRDB `messages.state` column + `dbChanged`. +5. **Durable outbox:** if the NE isn't up when the app sends, the request is + persisted to an App-Group outbox and drained on the next NE start + (`NEReticulumNode.drainOutbox`). + +## Announce flow + +The NE announces its own `lxmf.delivery` destination — the app can't drive +announces while suspended: + +- **on node start**, once the relay reports connected (`startAnnounceScheduler` + → `waitForRelayConnected` → `selfAnnounce`); +- **on relay (re)connect** (`onRelayReconnected`, wired via + `transport.setOnInterfaceConnected`) — so a relay that restarted (and lost its + path table) promptly relearns us, instead of waiting for the interval; +- **periodically**, on the user's configured interval (mirrors the app's + `AutoAnnounceManager`). + +The app's "announce now" button routes through `ProxyRnsBackend.announce` → IPC +→ `NEReticulumNode.announceForIPC`. + +--- + +## IPC + bridge mechanics + +- **Control IPC** (`Sources/Shared/ProxyIPC.swift`): a Foundation-only + `ProxyRequest`/`ProxyResponse` envelope (magic + version framed). The app's + `ProxyRnsBackend` (`Sources/RNSBackendProxy/`) sends it over + `NETunnelProviderSession.sendProviderMessage`; the NE decodes it in + `PacketTunnelProvider.handleAppMessage` and dispatches to `NEReticulumNode`'s + `…ForIPC` methods. The seam is Foundation-only so the NE never imports RNSAPI + (see the collision rule). +- **Frame bridge** (`Sources/Shared/AppGroupBridgeInterface.swift`): a real + `ReticulumSwift.NetworkInterface` (`mode = .full`, so announces propagate both + ways) registered on the NE's transport. It carries radio frames app↔NE over an + App-Group `SharedFrameQueue` (POSIX-flock'd file, restart-idempotent — survives + NE jetsam) split into two named unidirectional queues (`a2e` / `e2a`), with a + `radioFrameReady` Darwin notification app→NE and `packetReady` NE→app. + +## Shared state + +- **Identity:** shared keychain access group + (`$(AppIdentifierPrefix)network.columba.Columba.shared`). The app creates the + identity and writes it to the shared group; the NE reads it + (`NEReticulumNode.loadSharedIdentity`). Accessibility + `…AfterFirstUnlockThisDeviceOnly` (NE-readable while locked, post-first-unlock). + The app also publishes the *resolved* group name to App-Group UserDefaults so + the NE can resolve it even when the bundle-seed probe can't run (locked). +- **Message store:** `LXMFSwift.LXMFDatabase` (GRDB, WAL) in the App-Group + container. **Single writer = the NE**; the app opens it **read-only**. Cross- + process refresh is the `dbChanged` Darwin notification (GRDB observation does + not cross processes). File protection + `completeUntilFirstUserAuthentication` so it's writable while locked. + +--- + +## Load-bearing invariants + +1. **Single node = the NE.** The app owns no `lxmf.delivery` destination and no + `LXMRouter`. On launch the app must NOT start a competing destination-owning + backend (gated for Model B in `AppServices` / the startup interface loop — + e.g. it skips the app-side TCP interface). +2. **Single writer = the NE.** The app is read-only on the GRDB store; outbound + composed in-app is *handed to the NE to send + persist*. +3. **Durable dedup.** Dedup keys on the path-independent + `LXMessage.hash`; it lives in the GRDB store (`messageExists`) so it survives + an NE restart and spans transport paths (TCP copy == BLE copy). +4. **Always-the-node.** The node identity is a pure function of (shared identity + + shared store), so NE jetsam/restart is transparent — it comes back as the + same node. On-demand connect (`NEOnDemandRuleConnect`) relaunches it. +5. **The RNSAPI / ReticulumSwift collision rule.** RNSAPI's `Compat` layer + re-declares the same type names as `ReticulumSwift`. Files that conform to + `ReticulumSwift` protocols or use its types import **ReticulumSwift (+ + Foundation) ONLY, never RNSAPI**. The NE target is entirely RNSAPI-free; the + proxy seam (`ProxyIPC`) is Foundation-only so no RNSAPI/ReticulumSwift type + ever crosses it. `AppServices` stays RNSAPI-typed; LXMFSwift is confined to + `MessageRepository`. + +--- + +## Build / packaging gotchas + +- **Build the NE via the `ColumbaNetworkExtension` scheme**, not the + `Columba-Swift` app scheme — the app scheme does NOT compile the NE (a false- + green trap). See `reference_ne_build_scheme.md`. +- Model B code is on the **`Debug-Swift` / `Release-Swift`** configs + (`COLUMBA_BACKEND_SWIFT` + `ENABLE_NETWORK_EXTENSION`). +- The NE is embedded + signed via `support/embed-ne.rb`; its deps + (ReticulumSwift + LXMFSwift) via `support/add-ne-backend-deps.rb`. +- **Runtime gate:** `BackendPreference.modelB` (app) and + `NEReticulumNode.modelBNodeEnabled` (NE) read the shared App-Group flag + `modelBBackgroundNE`. Model B only works on the Swift backend. + +### TCP egress from inside the tunnel +`reticulum-swift`'s `TCPTransport` sets `bypassTunnelEgress` +(`prohibitedInterfaceTypes = [.other]`) when the NE host enables it, so the +relay socket uses a physical interface rather than the provider's own utun. +(Note: as of the 2026-06-02 bring-up this turned out *not* to be the actual +egress fix — the NE socket was already egressing fine; it's retained as a +defensive measure. See `track_modelb_tcp_egress_announce_2026-06-02.md`.) + +--- + +## Verified on-device (2026-06-02, iPhone 14) + +- **Announce-out:** the NE's announce for `lxmf.delivery` is cryptographically + valid (verified the on-wire bytes against RNS's own `validate_announce` — + signature + destination hash) and the relay installs a path to it. +- **Inbound LXMF delivery (headline):** a real LXMF message sent from a desktop + peer to the delivery dest reached `state = DELIVERED` (delivery proof) on the + sender, and the NE logged `inbound message persisted` — LXMF-swift validated + + stored to the shared GRDB and `NEDeliveryDelegate` posted the notification. + +**How to re-test (desktop peer with RNS/LXMF):** +1. Confirm reachability: `rnpath ` resolves on the relay + host. (If not, suspect a wedged relay daemon — see + `reference_mac_relay_wedge_diagnostic.md`.) +2. Send an LXMF message to the dest (DIRECT). It should reach `DELIVERED`. +3. Pull the NE's `ext-diag.log` (host copies it to the app's Documents on + launch; retrieve via `devicectl … copy from --domain-type appDataContainer`) + and confirm `inbound message persisted`. + +--- + +## Known gaps / TODO (as of 2026-06-02) + +- **UI status reflects the app's interfaces, not the NE's.** In Model B the app + owns no TCP interface, so its interface card shows the TCP relay as + "disconnected" even though the NE's relay is connected. The card (and the + "announce" button) should read NE state via `ProxyRnsBackend.statusSnapshot` / + route through the proxy. +- **Temporary bring-up state to revert before ship:** `BackendPreference.modelB` + and `NEReticulumNode.modelBNodeEnabled` are defaulted ON for testing; the NE + diagnostic logging (announce EMITTED / self-announce / relay-reconnect) and the + reticulum-swift `TCPTransport` egress diagnostic are temporary. Add a real UI + toggle for Model B. +- **Not yet exercised:** delivery while the device is locked (mechanism is in + place — file protection + NE-runs-while-locked); the app-side radio relay + (Auto/BLE/RNode → AppGroupBridge → NE). diff --git a/docs/MODEL_B_TESTING_TODO.md b/docs/MODEL_B_TESTING_TODO.md new file mode 100644 index 00000000..3d8a6daf --- /dev/null +++ b/docs/MODEL_B_TESTING_TODO.md @@ -0,0 +1,154 @@ +# Model B — on-device test plan (next steps) + +Status: Model B (NE-canonical LXMF node) is **working on-device** — inbound LXMF +delivery (decrypt / persist / notify / delivery-proof), announce in + out, and the +relay TCP path are all verified on the iPhone 14. What remains is the harder +end-to-end surface: **radios**, **delivery while locked**, and the **memory GATE**. + +> **Model B now ships OFF by default.** Before any test below: Settings → Network +> Backend → enable **"Background delivery (Model B)"**, then relaunch the app (the +> backend is built once per launch). Confirm via Settings → Network Status = +> Connected and the interface card showing the NE relay online. +> +> Peer for all tests = the **Android Columba** client ("Torlando - Columba", +> lxmf.delivery dest `…`). Relay = the LAN Reticulum host (`lxmd`/`rnsd`). + +--- + +## 1. Radio bridging (app bridges BLE / RNode → NE-owned node) + +Model B's app process owns **no destination**; it pumps radio frames to the +NE over the App-Group bridge, and the NE-owned node terminates delivery. These +tests prove that bridge in both directions. + +- [ ] **BLE inbound:** pair the device with a BLE Reticulum peer (RNode/again the + Android client over BLE, **TCP relay disabled** so the only path is BLE). + Peer announces → appears in Contacts → Network tab. Peer sends an LXMF + message → message renders + a delivery proof goes back. Confirms + app→bridge→NE inbound + NE→bridge→app→radio proof egress. +- [ ] **BLE announce egress:** tap the announce button → confirm the NE's + self-announce reaches the BLE peer (peer sees us). Check `ext-diag.log` + for `announce EMITTED … ifaces=[…ble…]`. +- [ ] **RNode (best-effort):** same two checks over an RNode interface. RNode-on- + iOS is immature — treat failures as "scope note", not a Model B regression. +- [ ] **Dual-path dedup:** peer reachable over **both** TCP relay and BLE at once; + send one message → exactly **one** row + **one** banner (NE-side dedup). +- [ ] **MTU sanity:** send a multi-part message over BLE (small attachment) → + confirm the NE never forms a link the radio MTU can't carry (no stalled + transfer). Watch for resource-cancel in `ext-diag.log`. + +> Note (known scope gap): the native **Swift** BLE/RNode path is the Model B +> target; the legacy driver path was Python-coupled. If BLE delivery doesn't +> work on the Swift backend yet, that's the documented C8 follow-on, not this bug. + +--- + +## 2. Delivery while locked / backgrounded (the headline feature) + +The NE must complete delivery and post the notification **itself** while the host +app is suspended and the phone is locked (a suspended app can't be woken — Apple +DTS 769398). + +- [ ] **Locked opportunistic (headline):** unlock once since boot, then **lock** + the device, leave it ~30s (app suspended, NE alive). Peer sends an + opportunistic LXMF message → a **rich** banner (sender name + preview) on + the lock screen. Unlock → message already in the thread, no re-fetch. +- [ ] **Locked direct + attachment:** locked, peer sends a direct message with a + small image → NE completes the Link + reassembly while locked, persists, + notifies. Then a **large** attachment (exercises Track L disk-streaming). +- [ ] **Backgrounded (not locked):** app backgrounded (home screen), peer sends → + banner + thread updates on next foreground. +- [ ] **Jetsam recovery:** background the app, force-kill the NE under memory + pressure (or `devicectl … process terminate` the NE pid) → the on-demand + rule relaunches the tunnel → next message still delivers. +- [ ] **Restart identity invariance:** note the delivery dest (`…`), kill + + relaunch the NE, confirm the **same** dest + intact history (shared identity + + shared store ⇒ transparent restart). +- [ ] **Proof timing:** confirm the sender (Android) shows "delivered" (our proof + arrived) for each locked delivery — no send-side timeout. + +Capture lock-screen behavior with a photo/video (no unified-log over WiFi). Pull +the NE log after each run: `devicectl … appDataContainer … Documents/ext-diag.log`. + +--- + +## 3. Under-pressure / NE memory GATE (Phase 1b) + +**Goal:** prove the NE survives the realistic delivery **peak** without a +memory-reason jetsam kill — the single assumption the whole epic rests on. The +old PoC only measured *idle* (~13.8 MB). This measures *under load*. + +**Why it felt hard:** there's no Xcode memory gauge over WiFi and the NE is a +separate, hard-to-attach process. The trick is to make the **NE log its own +memory** and drive load from a desktop peer — no debugger needed. + +### 3a. Instrument (one-time, ~30 min) +- [ ] Add a lightweight sampler to the NE: every 250 ms during a receive/reassembly + window, log `os_proc_available_memory()` and `mach_task_basic_info().resident_size` + to `ext-diag.log` (reuse the old `measureRNSFootprint()` sampler from the PoC; + it already exists in git history). 250 ms — the existing 5 s cadence misses + the transient peak. +- [ ] Log `stopTunnel(with:)` `reason`; treat `NEProviderStopReason.memoryLow` (or a + silent mid-reassembly log truncation) as a **kill**. + +### 3b. Drive load (desktop peer over the relay) +- [ ] **Small/opportunistic burst:** desktop peer sends N opportunistic messages + back-to-back to our dest → sample peak resident across the prove+persist window. +- [ ] **Small Link/Resource:** peer sends a small direct (Link) message → sample + across link setup + reassembly. +- [ ] **Large attachment:** peer sends a 24–32 MB **incompressible** attachment + (`autoCompress:false`) → this is the real stressor; it exercises Track L + disk-streaming. Sample the whole transfer. +- [ ] Run each scenario **5×** to catch variance. + +### 3c. Verdict (iPhone 14) +- [ ] **PASS:** peak resident stays **≥ 8 MB below** the `os_proc_available_memory()=0` + point across all 5 runs, **zero** memory-reason stops → Model B holds for that + payload class. +- [ ] **CONDITIONAL:** small fits but the large attachment breaches → ship small- + payload delivery now; large-payload delivery gated on finishing Track L + streaming (L2/L3 landed; verify the peak is window-bounded, not payload-bounded). +- [ ] **FAIL:** even a small Resource gets jetsam-killed → escalate; fall back to + sniff-only + complete-on-open and re-scope. + +> Shortcut now that delivery actually works: you can get a first read **without** +> the harness — drive real traffic from the Android peer, then pull `ext-diag.log` +> and read the sampler line just before/after the transfer. The controlled +> desktop-peer harness is only needed for the clean 5-run numbers in the verdict. + +--- + +## 4. Known-open / deferred (track, not blockers for the above) + +- [ ] **Incoming-message render bug (Network-tab path)** — under diagnosis. This + build adds `[DIAG-STORE]` (app's read view at launch) + `[MSG] … records=/loaded=` + logs. Repro: open the convo via Contacts→Network tab (empty) and via Chats + (works), then pull `diag.log` and compare the two `[MSG]` lines' counts vs the + `[DIAG-STORE]` count. **Remove these temp DIAG logs once root-caused.** +- [ ] **Conversation display name** — fixed this build: an announce carrying a name + now stamps it onto an existing nil-name conversation. Verify the convo title + flips from the "Peer " fallback to the announced name after the peer re-announces + (≈ every few min) + re-opening the thread. +- [ ] **A5 read-path follow-up** — the app still opens the shared store + `readonly: false` (it should be `readonly: true`, NE = sole writer; see + `MessageRepository.swift` A5 note). Decide after the render-bug diagnosis, + since it interacts with the read path. +- [ ] **`bypassTunnelEgress` revert-candidate** — reticulum-swift PR #18 added it as + defensive insurance; the real egress fix was bouncing the wedged relay daemon. + Revisit / consider reverting once egress has been stable for a while. + +--- + +## Tooling cheatsheet + +- Device: iPhone 14, devicectl id ``. Get the + **current LAN IP** from the NE socket / `lsof`, not memory (DHCP moves it). +- Pull NE log: `xcrun devicectl device copy from --device --domain-type appDataContainer --domain-identifier network.columba.Columba --source Documents/ext-diag.log --destination /tmp/ext-diag.log` +- Pull app log: same, `--source Documents/diag.log`. +- The shared **GRDB DB** can't be pulled live (devicectl `..` bug + the NE holds it + open). Use the in-app `[DIAG-STORE]` log instead, or copy the DB to Documents + from app code if raw rows are needed. +- Relay bounce (clears a wedged daemon): `launchctl kickstart -k gui/$(id -u)/network.reticulum.rnsd` and `…/network.lxmf.lxmd`. +- Build: NE = `ColumbaNetworkExtension` scheme, app = `Columba-Swift` scheme, + config `Debug-Swift`. **Build the NE scheme to validate NE changes** — the app + scheme alone won't surface NE compile errors (false-green trap). diff --git a/flows/announce-now.yml b/flows/announce-now.yml new file mode 100644 index 00000000..4e360fbd --- /dev/null +++ b/flows/announce-now.yml @@ -0,0 +1,60 @@ +appId: network.columba.Columba +name: announce-now +tags: + - smoke + - connectivity +--- +# Drives a MANUAL announce to test outbound connectivity end-to-end (app -> the +# active interface -> [if background transport is on] the extension -> relay). +# Does NOT clearState/clearKeychain — relies on the already-configured identity, +# TCP relay interface, and background-transport setting on the device. +- launchApp +- waitForAnimationToEnd: + timeout: 6000 +# Dismiss onboarding if it somehow appears (configured device: shouldn't). +- runFlow: + when: + visible: "Skip" + commands: + - tapOn: "Skip" + - waitForAnimationToEnd: + timeout: 2000 +- runFlow: + when: + visible: "Get Started" + commands: + - tapOn: "Get Started" + - waitForAnimationToEnd: + timeout: 2000 +# Go to the Settings tab. +- tapOn: + text: "Settings" + optional: true +- waitForAnimationToEnd: + timeout: 3000 +# Expand the Auto Announce card (it's an ExpandableSettingsCard; collapsed by default). +- scrollUntilVisible: + element: + text: "Auto Announce" + direction: DOWN + timeout: 20000 +- tapOn: + text: "Auto Announce" +- waitForAnimationToEnd: + timeout: 2000 +# Tap the manual "Announce Now" button inside the expanded card. +- scrollUntilVisible: + element: + text: "Announce Now" + direction: DOWN + timeout: 20000 +- tapOn: + text: "Announce Now" +- waitForAnimationToEnd: + timeout: 5000 +# Confirmation toast (optional — log capture is the real verification). +- assertVisible: + text: "Announce sent!" + optional: true +- waitForAnimationToEnd: + timeout: 3000 diff --git a/flows/bug1-network-tab-repro.yml b/flows/bug1-network-tab-repro.yml new file mode 100644 index 00000000..1d69a13a --- /dev/null +++ b/flows/bug1-network-tab-repro.yml @@ -0,0 +1,69 @@ +appId: network.columba.Columba +name: bug1-network-tab-repro +tags: + - debug +--- +# Reproduces BUG #1: open the "ReadProbe" (a65eb058) conversation via the +# Contacts → Network tab path (which showed empty), then via the Chats path +# (which worked). Each open fires `[MSG] loadMessages records=/loaded=` in +# diag.log; screenshots capture whether the message bubble actually renders. +# Relies on the probe's recent announce + persisted message (msgs=1). +- launchApp +- waitForAnimationToEnd: + timeout: 6000 + +# ---- Path A: Contacts → Network → peer → Start Chat ---- +- tapOn: + text: "Contacts" + optional: true +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + text: "Network" + optional: true +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: + text: "ReadProbe" + direction: DOWN + timeout: 15000 +- tapOn: + text: "ReadProbe" +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + text: "Start Chat" + optional: true +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: bug1_via_network_tab +- assertVisible: + text: "read-count probe" + optional: true + +# ---- back to the tab root, then Path B: Chats → conversation ---- +- back +- waitForAnimationToEnd: + timeout: 2000 +- back +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: + text: "Chats" + optional: true +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: + text: "ReadProbe" + direction: DOWN + timeout: 15000 +- tapOn: + text: "ReadProbe" +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: bug1_via_chats +- assertVisible: + text: "read-count probe" + optional: true diff --git a/support/add-ne-backend-deps.rb b/support/add-ne-backend-deps.rb new file mode 100644 index 00000000..c365d256 --- /dev/null +++ b/support/add-ne-backend-deps.rb @@ -0,0 +1,50 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# add-ne-backend-deps.rb — Track C2(e): link the native Swift RNS + LXMF stack +# into the ColumbaNetworkExtension target so the NE can run the backend itself +# (Model B / Track A5). Idempotent + additive: adds ReticulumSwift + LXMFSwift as +# package product dependencies + Frameworks-phase entries on the NE target only, +# attaching to the SAME XCRemoteSwiftPackageReference the app target already uses. +# +# Usage: ruby support/add-ne-backend-deps.rb + +require 'xcodeproj' + +PROJECT_PATH = File.expand_path('../Columba.xcodeproj', __dir__) +NE_TARGET = 'ColumbaNetworkExtension' +PRODUCTS = %w[ReticulumSwift LXMFSwift].freeze + +project = Xcodeproj::Project.open(PROJECT_PATH) +ne = project.targets.find { |t| t.name == NE_TARGET } +raise "no #{NE_TARGET} target" unless ne + +existing = ne.package_product_dependencies.map(&:product_name) + +PRODUCTS.each do |product| + if existing.include?(product) + puts " = #{product} already linked on #{NE_TARGET}" + next + end + + # Reuse the XCRemoteSwiftPackageReference another target already resolves for + # this product (the app target links both), so the NE attaches to the same + # pinned package rather than introducing a second resolution. + ref_dep = project.targets.flat_map(&:package_product_dependencies) + .find { |d| d.product_name == product } + raise "no existing package product dependency for #{product} to source the package ref" unless ref_dep + + dep = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) + dep.package = ref_dep.package + dep.product_name = product + ne.package_product_dependencies << dep + + build_file = project.new(Xcodeproj::Project::Object::PBXBuildFile) + build_file.product_ref = dep + ne.frameworks_build_phase.files << build_file + + puts " + #{product} linked on #{NE_TARGET}" +end + +project.save +puts "Saved #{File.basename(PROJECT_PATH)}" diff --git a/support/add-swift-backend-config.rb b/support/add-swift-backend-config.rb index 4307b385..0b023d5e 100644 --- a/support/add-swift-backend-config.rb +++ b/support/add-swift-backend-config.rb @@ -4,10 +4,11 @@ # add-swift-backend-config.rb — Phase 2 build-time backend toggle. # # Adds `Debug-Swift` / `Release-Swift` build configurations (clones of Debug / -# Release) that define `COLUMBA_BACKEND_SWIFT` on the ColumbaApp target, plus a -# shared `Columba-Swift` scheme that builds them. Selecting that scheme (or -# `xcodebuild -scheme Columba-Swift`) builds the native reticulum-swift/LXMF-swift -# backend instead of the embedded-Python default; the rest of the app is backend- +# Release) that define `COLUMBA_BACKEND_SWIFT` + `ENABLE_NETWORK_EXTENSION` on the +# ColumbaApp target, plus a shared `Columba-Swift` scheme that builds them. +# Selecting that scheme (or `xcodebuild -scheme Columba-Swift`) builds the native +# reticulum-swift/LXMF-swift backend instead of the embedded-Python default and +# enables the background Network-Extension wiring; the rest of the app is backend- # agnostic (BackendFactory's `#if COLUMBA_BACKEND_SWIFT`). # # Additive + idempotent — only adds the new configs/scheme, never strips packages @@ -19,42 +20,59 @@ PROJECT_PATH = File.expand_path('../Columba.xcodeproj', __dir__) APP_TARGET = 'ColumbaApp' -BACKEND_CONDITION = 'COLUMBA_BACKEND_SWIFT' +# Swift compilation conditions injected on the app target's -Swift configs. +# COLUMBA_BACKEND_SWIFT selects the native backend; ENABLE_NETWORK_EXTENSION +# compiles the background-NE wiring (TunnelManager/ExtensionFrameReader/etc.). +APP_CONDITIONS = %w[COLUMBA_BACKEND_SWIFT ENABLE_NETWORK_EXTENSION].freeze project = Xcodeproj::Project.open(PROJECT_PATH) # (base config name => Swift variant name) VARIANTS = { 'Debug' => 'Debug-Swift', 'Release' => 'Release-Swift' }.freeze -def clone_config(owner, base_name, swift_name, project, inject_condition: false) +def ensure_swift_config(owner, base_name, swift_name, project, conditions: []) list = owner.build_configuration_list - return if list.build_configurations.any? { |c| c.name == swift_name } + cfg = list.build_configurations.find { |c| c.name == swift_name } - base = list.build_configurations.find { |c| c.name == base_name } - raise "no '#{base_name}' config on #{owner}" unless base + if cfg.nil? + base = list.build_configurations.find { |c| c.name == base_name } + raise "no '#{base_name}' config on #{owner}" unless base - cfg = project.new(Xcodeproj::Project::Object::XCBuildConfiguration) - cfg.name = swift_name - cfg.build_settings = base.build_settings.dup - cfg.base_configuration_reference = base.base_configuration_reference - - if inject_condition - existing = cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] || '$(inherited)' - unless existing.include?(BACKEND_CONDITION) - cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = "#{existing} #{BACKEND_CONDITION}" - end + cfg = project.new(Xcodeproj::Project::Object::XCBuildConfiguration) + cfg.name = swift_name + cfg.build_settings = base.build_settings.dup + cfg.base_configuration_reference = base.base_configuration_reference + list.build_configurations << cfg + puts " + #{swift_name} on #{owner.respond_to?(:name) ? owner.name : 'project'}" end - list.build_configurations << cfg - puts " + #{swift_name} on #{owner.respond_to?(:name) ? owner.name : 'project'}#{inject_condition ? " (#{BACKEND_CONDITION})" : ''}" + # Idempotently ensure each Swift compilation condition — runs on first create + # AND on every re-run, so adding a new condition (e.g. ENABLE_NETWORK_EXTENSION + # alongside COLUMBA_BACKEND_SWIFT) only needs a re-run of this script. Token- + # exact match (split), not substring, so conditions can't false-positive. + return if conditions.empty? + + existing = cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] || '$(inherited)' + tokens = existing.split + added = [] + conditions.each do |cond| + next if tokens.include?(cond) + tokens << cond + added << cond + end + unless added.empty? + cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = tokens.join(' ') + puts " ~ #{swift_name} on #{owner.respond_to?(:name) ? owner.name : 'project'}: += #{added.join(' ')}" + end end VARIANTS.each do |base_name, swift_name| # Project-level config (Xcode requires the config to exist at project + target). - clone_config(project, base_name, swift_name, project) - # Per-target — inject the backend condition only on the app target. + ensure_swift_config(project, base_name, swift_name, project) + # Per-target — inject the app conditions only on the app target. project.targets.each do |target| - clone_config(target, base_name, swift_name, project, inject_condition: target.name == APP_TARGET) + conds = target.name == APP_TARGET ? APP_CONDITIONS : [] + ensure_swift_config(target, base_name, swift_name, project, conditions: conds) end end diff --git a/support/embed-ne.rb b/support/embed-ne.rb new file mode 100644 index 00000000..c4213f71 --- /dev/null +++ b/support/embed-ne.rb @@ -0,0 +1,59 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# embed-ne.rb — wire ColumbaNetworkExtension into the app so a device build embeds +# the signed .appex into ColumbaApp.app/PlugIns (C2 packaging completion). Adds the +# app->NE target dependency + an "Embed App Extensions" copy-files phase (PlugIns, +# CodeSignOnCopy), and ensures the NE target carries the signing team. Idempotent. +# +# Usage: ruby support/embed-ne.rb + +require 'xcodeproj' + +PROJECT_PATH = File.expand_path('../Columba.xcodeproj', __dir__) +APP_NAME = 'ColumbaApp' +NE_NAME = 'ColumbaNetworkExtension' +TEAM = 'M2977H5PM5' + +project = Xcodeproj::Project.open(PROJECT_PATH) +app = project.targets.find { |t| t.name == APP_NAME } or raise "no #{APP_NAME} target" +ne = project.targets.find { |t| t.name == NE_NAME } or raise "no #{NE_NAME} target" + +# 1. Target dependency: app depends on the NE (so the NE builds before the embed). +if app.dependencies.any? { |d| d.target&.uuid == ne.uuid } + puts " = #{APP_NAME} already depends on #{NE_NAME}" +else + app.add_dependency(ne) + puts " + #{APP_NAME} -> #{NE_NAME} target dependency" +end + +# 2. Embed App Extensions copy-files phase (PlugIns), embedding the .appex with +# code-sign-on-copy. +embed = app.copy_files_build_phases.find do |p| + p.symbol_dst_subfolder_spec == :plug_ins || p.name == 'Embed App Extensions' +end +if embed.nil? + embed = app.new_copy_files_build_phase('Embed App Extensions') + embed.symbol_dst_subfolder_spec = :plug_ins + puts " + Embed App Extensions phase" +else + puts " = Embed App Extensions phase present" +end + +if embed.files.any? { |bf| bf.file_ref&.uuid == ne.product_reference.uuid } + puts " = #{NE_NAME}.appex already embedded" +else + bf = embed.add_file_reference(ne.product_reference) + bf.settings = { 'ATTRIBUTES' => %w[RemoveHeadersOnCopy CodeSignOnCopy] } + puts " + embed #{NE_NAME}.appex (CodeSignOnCopy)" +end + +# 3. Ensure the NE target signs with the same team + automatic style. +ne.build_configurations.each do |c| + c.build_settings['DEVELOPMENT_TEAM'] = TEAM if (c.build_settings['DEVELOPMENT_TEAM'] || '').empty? + c.build_settings['CODE_SIGN_STYLE'] = 'Automatic' if (c.build_settings['CODE_SIGN_STYLE'] || '').empty? +end +puts " ~ #{NE_NAME} DEVELOPMENT_TEAM/CODE_SIGN_STYLE ensured" + +project.save +puts "Saved #{File.basename(PROJECT_PATH)}"