diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 348371de..63203c61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,6 +22,14 @@ jobs: git clone https://github.com/torlando-tech/LXMF-swift.git ../LXMF-swift git clone https://github.com/torlando-tech/LXST-swift.git ../LXST-swift + # The Python-backend (`Columba`) scheme links Python.xcframework + the + # bundled wheels; without these the build phase that copies them fails. + # Fetch them before any xcodebuild step (pinned deps → reproducible). + - name: Fetch Python framework + wheels + run: | + support/fetch-python.sh + support/fetch-wheels.sh + - name: Select Xcode run: | XCODE=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1) @@ -37,7 +45,7 @@ jobs: echo "device_id=$DEVICE_ID" >> "$GITHUB_OUTPUT" echo "Using simulator: $DEVICE_ID" - - name: Build + - name: Build (Python backend — default) run: | xcodebuild build \ -project Columba.xcodeproj \ @@ -50,6 +58,22 @@ jobs: DEVELOPMENT_TEAM="" \ PROVISIONING_PROFILE_SPECIFIER="" + # Verify the native reticulum-swift/LXMF-swift backend variant also builds + # (COLUMBA_BACKEND_SWIFT). The UI/AppServices are backend-agnostic, so this + # guards against the Swift backend or its seam drifting out of compile. + - name: Build (Swift-native backend) + run: | + xcodebuild build \ + -project Columba.xcodeproj \ + -scheme Columba-Swift \ + -sdk iphonesimulator \ + -destination "id=${{ steps.sim.outputs.device_id }}" \ + CODE_SIGN_IDENTITY=- \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=YES \ + DEVELOPMENT_TEAM="" \ + PROVISIONING_PROFILE_SPECIFIER="" + - name: Build and run tests run: | xcodebuild test \ diff --git a/.gitignore b/.gitignore index 01091ca1..479d91f4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ xcuserdata/ *.dSYM timeline.xctimeline playground.xcworkspace +build/ # Swift Package Manager .build/ @@ -27,3 +28,16 @@ package-lock.json # Local Xcode signing overrides Config/LocalSigning.xcconfig + +# Embedded Python (fetched by support/fetch-python.sh — too large for git) +Frameworks/Python.xcframework/ +Frameworks/Python-3.13-iOS-support.*.tar.gz +Frameworks/VERSIONS + +# iOS Python wheels (fetched by support/fetch-wheels.sh) +wheels-iphoneos/ +wheels-iphonesimulator/ + +# Python build artifacts +__pycache__/ +*.pyc diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..ce7e4f5f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,48 @@ +# Columba-iOS Architecture + +Target / module dependency graph for the iOS app. Mirrors the role of `docs/architecture.md` in the sibling Android repo (`columba/`). + +Regenerate this file from the current `Package.swift` + `Columba.xcodeproj/project.pbxproj`: + +```sh +ruby support/generate-module-graph.rb +``` + +The script reads: +- pbxproj targets (e.g. `ColumbaApp`, `ColumbaNetworkExtension`, `PythonBridge`, `RNSBackendPy`) via the `xcodeproj` Ruby gem (already a dependency for `support/configure-xcodeproj.rb`). +- SPM targets (e.g. `RNSAPI`, `LXSTSwift`, `COpus`, `CCodec2`, `SwiftBLEBridge`) via `swift package dump-package`. + +It overwrites the Mermaid block between the start/end marker comments below the `## Target Graph` heading. Do not edit between the markers by hand — changes will be lost on next regen. + +## Target Graph + + +```mermaid +flowchart TD + CCodec2["CCodec2"] + COpus["COpus"] + ColumbaApp["ColumbaApp"] + ColumbaNetworkExtension["ColumbaNetworkExtension"] + LXSTSwift["LXSTSwift"] + MapLibre["MapLibre"] + RNSAPI["RNSAPI"] + SwiftBLEBridge["SwiftBLEBridge"] + ColumbaApp --> LXSTSwift + ColumbaApp --> MapLibre + ColumbaApp --> RNSAPI + ColumbaApp --> SwiftBLEBridge + LXSTSwift --> CCodec2 + LXSTSwift --> COpus + LXSTSwift --> RNSAPI + SwiftBLEBridge --> RNSAPI + classDef app fill:#1f6feb,stroke:#0d419d,color:#fff + classDef extension fill:#8957e5,stroke:#553098,color:#fff + classDef bridge fill:#f0883e,stroke:#9e4c0f,color:#fff + classDef spm_lib fill:#3fb950,stroke:#0f7a2e,color:#fff + classDef c_lib fill:#6e7681,stroke:#30363d,color:#fff + class ColumbaApp app + class LXSTSwift,MapLibre,RNSAPI,SwiftBLEBridge spm_lib + class ColumbaNetworkExtension extension + class CCodec2,COpus c_lib +``` + diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index c1688388..f42dafee 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -3,14 +3,10 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ - T001 /* AudioRingBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT01 /* AudioRingBufferTests.swift */; }; - T002 /* AudioManagerConfigChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT02 /* AudioManagerConfigChangeTests.swift */; }; - T004 /* CallManagerCallKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT04 /* CallManagerCallKitTests.swift */; }; - T005 /* MapStyleURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT05 /* MapStyleURLTests.swift */; }; 001 /* ColumbaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F001 /* ColumbaApp.swift */; }; 002 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = F002 /* Theme.swift */; }; 003 /* ChatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F003 /* ChatsViewModel.swift */; }; @@ -33,7 +29,7 @@ 020 /* IdenticonGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F020 /* IdenticonGenerator.swift */; }; 021 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F021 /* MapView.swift */; platformFilter = ios; }; 022 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F022 /* Assets.xcassets */; }; - 023 /* LXMFSwift in Frameworks */ = {isa = PBXBuildFile; productRef = P001 /* LXMFSwift */; }; + 0229B2C848210EE825D13B8E /* PythonBLECallbackBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF4DCA18506B96230721ACC /* PythonBLECallbackBridge.swift */; }; 024 /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = F024 /* AppServices.swift */; }; 025 /* MessageRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F025 /* MessageRepository.swift */; }; 026 /* SettingsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F026 /* SettingsRepository.swift */; }; @@ -44,18 +40,15 @@ 031 /* InterfaceManagementScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F031 /* InterfaceManagementScreen.swift */; }; 032 /* NodeDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F032 /* NodeDetailsView.swift */; }; 033 /* PropagationNodeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F033 /* PropagationNodeManager.swift */; }; + 03328BB5F0687E3918A9387D /* CodecSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7529BF99835005DE07E1B65F /* CodecSelectionSheet.swift */; }; 034 /* NetworkStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F034 /* NetworkStatusViewModel.swift */; }; 035 /* NetworkStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F035 /* NetworkStatusView.swift */; }; 036 /* MaterialDesignIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = F036 /* MaterialDesignIcons.swift */; }; 037 /* ProfileIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F037 /* ProfileIcon.swift */; }; 038 /* IconPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F038 /* IconPickerView.swift */; }; 039 /* materialdesignicons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F039 /* materialdesignicons.ttf */; }; - FNT1 /* JetBrainsMono-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FNT1F /* JetBrainsMono-Regular.ttf */; }; - FNT2 /* JetBrainsMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FNT2F /* JetBrainsMono-Bold.ttf */; }; 040 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F040 /* NotificationService.swift */; }; 041 /* AutoAnnounceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F041 /* AutoAnnounceManager.swift */; }; - 041P /* AutoAnnouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F041P /* AutoAnnouncePolicy.swift */; }; - 041R /* PeerChildInterfaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F041R /* PeerChildInterfaceRegistry.swift */; }; 042 /* LocalIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F042 /* LocalIdentity.swift */; }; 043 /* IdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F043 /* IdentityManager.swift */; }; 044 /* IdentityManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F044 /* IdentityManagerView.swift */; }; @@ -95,16 +88,6 @@ 065B /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F065 /* ThemeManager.swift */; }; 066B /* AppearanceCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F066 /* AppearanceCard.swift */; }; 067B /* CustomThemeEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F067 /* CustomThemeEditorView.swift */; }; - 068B /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F068 /* CallManager.swift */; platformFilter = ios; }; - 069B /* CodecProfileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F069 /* CodecProfileInfo.swift */; platformFilter = ios; }; - 06AB /* CallControlButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F06A /* CallControlButton.swift */; platformFilter = ios; }; - 06BB /* CodecSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F06B /* CodecSelectionSheet.swift */; platformFilter = ios; }; - 06CB /* IncomingCallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F06C /* IncomingCallScreen.swift */; platformFilter = ios; }; - 06DB /* PttButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F06D /* PttButton.swift */; platformFilter = ios; }; - 06EB /* VoiceCallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F06E /* VoiceCallScreen.swift */; platformFilter = ios; }; - 06F0 /* CallKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F06F /* CallKitManager.swift */; platformFilter = ios; }; - 06FB /* LXSTSwift in Frameworks */ = {isa = PBXBuildFile; platformFilter = ios; productRef = P003 /* LXSTSwift */; }; - 0700 /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F070 /* AudioManager.swift */; platformFilter = ios; }; 071B /* BLEConnectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F071 /* BLEConnectionsView.swift */; }; 072B /* RNodeProbeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F072 /* RNodeProbeScanner.swift */; }; 073 /* MessageDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F073 /* MessageDetailView.swift */; }; @@ -123,15 +106,46 @@ 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 */; }; - 086B /* TCPClientWizardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F086 /* TCPClientWizardViewModel.swift */; }; - 087B /* TCPClientWizard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F087 /* TCPClientWizard.swift */; }; - T006 /* TCPClientWizardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT06 /* TCPClientWizardViewModelTests.swift */; }; - T003 /* MicronParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT03 /* MicronParserTests.swift */; }; - TAA0 /* AutoAnnouncePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FTAA /* AutoAnnouncePolicyTests.swift */; }; - TPCR /* PeerChildInterfaceRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FTPC /* PeerChildInterfaceRegistryTests.swift */; }; - 2F3D64B12F7227E100049252 /* ReticulumSwift in Frameworks */ = {isa = PBXBuildFile; productRef = P004 /* ReticulumSwift */; }; + 13F299C3C99F5D86B797FA11 /* SwiftBLEBridge in Frameworks */ = {isa = PBXBuildFile; productRef = 5BE4B79EB452909EE61ACE6C /* SwiftBLEBridge */; }; + 238336F8024494A8B57F9419 /* CeaseTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF48C97880B30682DC35613C /* CeaseTelemetry.swift */; }; + 2F8EC8212FC732AB00235991 /* ReticulumSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2F8EC8202FC732AB00235991 /* ReticulumSwift */; }; + 2F8EC8232FC732AF00235991 /* LXMFSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2F8EC8222FC732AF00235991 /* LXMFSwift */; }; + 3A790961A270CBDE9A30BC04 /* VoiceCallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691F2029BDC2C1024AA9B01 /* VoiceCallScreen.swift */; }; + 4758210ABE17DE6E3BE0B3F6 /* AutoAnnouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F87296BB580791A95C977B6 /* AutoAnnouncePolicy.swift */; }; + 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 */; }; + 55F3BF7D600C32E07B7B8C26 /* TCPClientWizard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96EBCA636D502CF4367E32C7 /* TCPClientWizard.swift */; }; + 59CE6F635354725F7D445625 /* PyLocalIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34E1ECAE91B0A2C56D0FC8AA /* PyLocalIdentity.swift */; }; + 5F1FF2954475331208BAAD47 /* JetBrainsMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B1AB71D8977FE792BA9B176D /* JetBrainsMono-Bold.ttf */; }; + 6176037A389D1F97BB47992B /* Python.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3BEE6E339CC852C9BA8C5D75 /* Python.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 668C0A33D82AB3D07CC52E83 /* PythonRNSBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAFC5BF3DDB882B1864F1C0 /* PythonRNSBackend.swift */; }; + 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 */; }; + 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 */; }; + AC4014BF281AD8BE6FF9E852 /* PeerChildInterfaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8A2F8952F6F036C94824552 /* PeerChildInterfaceRegistry.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 */; }; + D33B15C781E3C98A5CBD06F3 /* CallKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7375908681A4DF99F125C7 /* CallKitManager.swift */; }; + D85E5BBB51FACA138622FFFA /* IncomingCallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4C54CECCAF0B117FB6C197 /* IncomingCallScreen.swift */; }; + DE9220E1D4ABF9A4C2CBA761 /* JetBrainsMono-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E4AB432DD1264CAE1F35B22A /* JetBrainsMono-Regular.ttf */; }; E001 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01 /* PacketTunnelProvider.swift */; }; E002 /* SharedFrameQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = F076 /* SharedFrameQueue.swift */; }; + EA609BF7A35298B1651160C3 /* PttButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68384C48BFF8F5294340EDB /* PttButton.swift */; }; + EDF2F6B945FAA23366617FD6 /* TCPClientWizardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD87870C760723D7E77C87E9 /* TCPClientWizardViewModel.swift */; }; + F4CA7E2F745F05E21D45E11A /* RNSAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 0FD6A68A52A54D21FDB70324 /* RNSAPI */; }; + F80B09722B3A7CCA7C99DE0C /* DiscoveredDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9630845DA60F57A34819CC4B /* DiscoveredDevice.swift */; }; + PNT001 /* PythonNetworkTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = PNT002 /* PythonNetworkTransport.swift */; }; + PRC001 /* PythonRNodeCallbackBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = PRC002 /* PythonRNodeCallbackBridge.swift */; }; + SRB001 /* SwiftRNSBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = SRB002 /* SwiftRNSBackend.swift */; }; + T003 /* MicronParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT03 /* MicronParserTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -144,15 +158,51 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 9796AB46906D510ACD8F0AB1 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 6176037A389D1F97BB47992B /* Python.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ - FT01 /* AudioRingBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRingBufferTests.swift; sourceTree = ""; }; - FT02 /* AudioManagerConfigChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioManagerConfigChangeTests.swift; sourceTree = ""; }; - FT04 /* CallManagerCallKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManagerCallKitTests.swift; sourceTree = ""; }; - FT03 /* MicronParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronParserTests.swift; sourceTree = ""; }; - FTAA /* AutoAnnouncePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoAnnouncePolicyTests.swift; sourceTree = ""; }; - FTPC /* PeerChildInterfaceRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChildInterfaceRegistryTests.swift; sourceTree = ""; }; - FT05 /* MapStyleURLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapStyleURLTests.swift; sourceTree = ""; }; - TPROD /* ColumbaAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ColumbaAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 00A8D344945F752C18EF2D9E /* AudioManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioManager.swift; sourceTree = ""; }; + 11D4DB375C0C7BB62E8A8B23 /* ColumbaPython-Bridging-Header.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "ColumbaPython-Bridging-Header.h"; sourceTree = ""; }; + 1F7375908681A4DF99F125C7 /* CallKitManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CallKitManager.swift; sourceTree = ""; }; + 34E1ECAE91B0A2C56D0FC8AA /* PyLocalIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PyLocalIdentity.swift; path = Python/Models/PyLocalIdentity.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 = ""; }; + 51D8CBCF7E546028A043E1C7 /* PyConversation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PyConversation.swift; path = Python/Models/PyConversation.swift; sourceTree = ""; }; + 710EBCE55DCC9E71A9AB0E86 /* PythonBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonBridge.swift; sourceTree = ""; }; + 71922EC204982A357F814F23 /* CallControlButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CallControlButton.swift; sourceTree = ""; }; + 7529BF99835005DE07E1B65F /* CodecSelectionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CodecSelectionSheet.swift; sourceTree = ""; }; + 86E428836698BA8A8973A92F /* CallManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; + 8CAFC5BF3DDB882B1864F1C0 /* PythonRNSBackend.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PythonRNSBackend.swift; sourceTree = ""; }; + 8CBD293157E715F490613984 /* PyMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PyMessage.swift; path = Python/Models/PyMessage.swift; sourceTree = ""; }; + 9630845DA60F57A34819CC4B /* DiscoveredDevice.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiscoveredDevice.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + BF48C97880B30682DC35613C /* CeaseTelemetry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CeaseTelemetry.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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -193,12 +243,8 @@ F037 /* ProfileIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileIcon.swift; sourceTree = ""; }; F038 /* IconPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconPickerView.swift; sourceTree = ""; }; F039 /* materialdesignicons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = materialdesignicons.ttf; sourceTree = ""; }; - FNT1F /* JetBrainsMono-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "JetBrainsMono-Regular.ttf"; sourceTree = ""; }; - FNT2F /* JetBrainsMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "JetBrainsMono-Bold.ttf"; sourceTree = ""; }; F040 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; F041 /* AutoAnnounceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoAnnounceManager.swift; sourceTree = ""; }; - F041P /* AutoAnnouncePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoAnnouncePolicy.swift; sourceTree = ""; }; - F041R /* PeerChildInterfaceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerChildInterfaceRegistry.swift; sourceTree = ""; }; F042 /* LocalIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalIdentity.swift; sourceTree = ""; }; F043 /* IdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManager.swift; sourceTree = ""; }; F044 /* IdentityManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManagerView.swift; sourceTree = ""; }; @@ -237,15 +283,6 @@ F065 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; F066 /* AppearanceCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceCard.swift; sourceTree = ""; }; F067 /* CustomThemeEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomThemeEditorView.swift; sourceTree = ""; }; - F068 /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; - F069 /* CodecProfileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodecProfileInfo.swift; sourceTree = ""; }; - F06A /* CallControlButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallControlButton.swift; sourceTree = ""; }; - F06B /* CodecSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodecSelectionSheet.swift; sourceTree = ""; }; - F06C /* IncomingCallScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallScreen.swift; sourceTree = ""; }; - F06D /* PttButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PttButton.swift; sourceTree = ""; }; - F06E /* VoiceCallScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCallScreen.swift; sourceTree = ""; }; - F06F /* CallKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallKitManager.swift; sourceTree = ""; }; - F070 /* AudioManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioManager.swift; sourceTree = ""; }; F071 /* BLEConnectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEConnectionsView.swift; sourceTree = ""; }; F072 /* RNodeProbeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNodeProbeScanner.swift; sourceTree = ""; }; F073 /* MessageDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDetailView.swift; sourceTree = ""; }; @@ -256,6 +293,8 @@ F078 /* ExtensionFrameReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFrameReader.swift; sourceTree = ""; }; F079 /* OnboardingRestoreSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingRestoreSheet.swift; sourceTree = ""; }; F07A /* PlatformCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformCompat.swift; sourceTree = ""; }; + F07B /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/Signing.xcconfig; sourceTree = SOURCE_ROOT; }; + F07C /* LocalSigning.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/LocalSigning.xcconfig.example; sourceTree = SOURCE_ROOT; }; F07D /* MicronDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronDocument.swift; sourceTree = ""; }; F07E /* MicronParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronParser.swift; sourceTree = ""; }; F07F /* NomadNetBrowserService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NomadNetBrowserService.swift; sourceTree = ""; }; @@ -265,15 +304,16 @@ F083 /* MicronRenderContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronRenderContainer.swift; sourceTree = ""; }; F084 /* MonospaceLineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonospaceLineView.swift; sourceTree = ""; }; F085 /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = ""; }; - F086 /* TCPClientWizardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModel.swift; sourceTree = ""; }; - F087 /* TCPClientWizard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizard.swift; sourceTree = ""; }; - FT06 /* TCPClientWizardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModelTests.swift; sourceTree = ""; }; - F07B /* Config/Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/Signing.xcconfig; sourceTree = SOURCE_ROOT; }; - F07C /* Config/LocalSigning.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/LocalSigning.xcconfig.example; sourceTree = SOURCE_ROOT; }; + FB2569A3C1BFBFDD77F7E639 /* BackendPreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackendPreference.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 = ""; }; + FT03 /* MicronParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronParserTests.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 = ""; }; PROD /* ColumbaApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ColumbaApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 */ /* Begin PBXFrameworksBuildPhase section */ @@ -288,10 +328,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2F3D64B12F7227E100049252 /* ReticulumSwift in Frameworks */, - 023 /* LXMFSwift in Frameworks */, - 06FB /* LXSTSwift in Frameworks */, 049 /* MapLibre in Frameworks */, + 8A321B0938566F0D62D64562 /* Python.xcframework in Frameworks */, + 5254FC2433ED759989FB1094 /* LXSTSwift in Frameworks */, + F4CA7E2F745F05E21D45E11A /* RNSAPI in Frameworks */, + 13F299C3C99F5D86B797FA11 /* SwiftBLEBridge in Frameworks */, + 2F8EC8212FC732AB00235991 /* ReticulumSwift in Frameworks */, + 2F8EC8232FC732AF00235991 /* LXMFSwift in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -305,6 +348,96 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 07385E9DF47719A55B436C1B /* Util */ = { + isa = PBXGroup; + children = ( + ); + name = Util; + sourceTree = ""; + }; + 4AD6205A86259FBAD00F37F1 /* RNSAPI */ = { + isa = PBXGroup; + children = ( + 5C59D74E72B7FB0C3B971D47 /* Compat.swift */, + 56E3FE7875CE802628A630EF /* Models */, + 5B34D5BDB12DC34A4AD14EA6 /* Protocols */, + 07385E9DF47719A55B436C1B /* Util */, + ); + name = RNSAPI; + path = Sources/RNSAPI; + sourceTree = ""; + }; + 56E3FE7875CE802628A630EF /* Models */ = { + isa = PBXGroup; + children = ( + ); + name = Models; + sourceTree = ""; + }; + 5B34D5BDB12DC34A4AD14EA6 /* Protocols */ = { + isa = PBXGroup; + children = ( + ); + name = Protocols; + sourceTree = ""; + }; + 5C59D74E72B7FB0C3B971D47 /* Compat.swift */ = { + isa = PBXGroup; + children = ( + ); + name = Compat.swift; + sourceTree = ""; + }; + 91614E532B3F0134673ED735 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3BEE6E339CC852C9BA8C5D75 /* Python.xcframework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9A4EC6E91C9E0CAAD134372D /* Python */ = { + isa = PBXGroup; + children = ( + D293DF12C132854354B20FFF /* Models */, + ); + name = Python; + sourceTree = ""; + }; + 9FCA0C1149D62160BB052B3A /* RNSBackendPy */ = { + isa = PBXGroup; + children = ( + 8CAFC5BF3DDB882B1864F1C0 /* PythonRNSBackend.swift */, + SRB002 /* SwiftRNSBackend.swift */, + ); + name = RNSBackendPy; + path = Sources/RNSBackendPy; + sourceTree = ""; + }; + C6877E3C038C0DC36597F1D5 /* PythonBridge */ = { + isa = PBXGroup; + children = ( + 710EBCE55DCC9E71A9AB0E86 /* PythonBridge.swift */, + A0C41F9E953D0445F3C34AE7 /* PythonRuntime.swift */, + 11D4DB375C0C7BB62E8A8B23 /* ColumbaPython-Bridging-Header.h */, + CCF4DCA18506B96230721ACC /* PythonBLECallbackBridge.swift */, + PRC002 /* PythonRNodeCallbackBridge.swift */, + ); + name = PythonBridge; + path = Sources/PythonBridge; + sourceTree = ""; + }; + D293DF12C132854354B20FFF /* Models */ = { + isa = PBXGroup; + children = ( + 3C562D79BB3B6559A1B1EFDA /* PyAnnounce.swift */, + 8CBD293157E715F490613984 /* PyMessage.swift */, + 51D8CBCF7E546028A043E1C7 /* PyConversation.swift */, + 34E1ECAE91B0A2C56D0FC8AA /* PyLocalIdentity.swift */, + ); + name = Models; + sourceTree = ""; + }; GAPP /* App */ = { isa = PBXGroup; children = ( @@ -316,11 +449,11 @@ GCALL /* Call */ = { isa = PBXGroup; children = ( - F06A /* CallControlButton.swift */, - F06B /* CodecSelectionSheet.swift */, - F06C /* IncomingCallScreen.swift */, - F06D /* PttButton.swift */, - F06E /* VoiceCallScreen.swift */, + 71922EC204982A357F814F23 /* CallControlButton.swift */, + 7529BF99835005DE07E1B65F /* CodecSelectionSheet.swift */, + 3D4C54CECCAF0B117FB6C197 /* IncomingCallScreen.swift */, + B68384C48BFF8F5294340EDB /* PttButton.swift */, + D691F2029BDC2C1024AA9B01 /* VoiceCallScreen.swift */, ); path = Call; sourceTree = ""; @@ -358,7 +491,7 @@ path = Contacts; sourceTree = ""; }; - GEXT /* Sources/ColumbaNetworkExtension */ = { + GEXT /* ColumbaNetworkExtension */ = { isa = PBXGroup; children = ( FE01 /* PacketTunnelProvider.swift */, @@ -385,9 +518,10 @@ F045 /* LoRaPresets.swift */, F053 /* TcpCommunityServer.swift */, F05B /* MigrationData.swift */, - F069 /* CodecProfileInfo.swift */, F07D /* MicronDocument.swift */, F07E /* MicronParser.swift */, + ACA6D4C7151A87D862FF9B8A /* CodecProfileInfo.swift */, + 9630845DA60F57A34819CC4B /* DiscoveredDevice.swift */, ); path = Models; sourceTree = ""; @@ -403,6 +537,18 @@ path = Messaging; sourceTree = ""; }; + GNOMAD /* NomadNet */ = { + isa = PBXGroup; + children = ( + F081 /* NomadNetBrowserView.swift */, + F082 /* MicronDocumentView.swift */, + F083 /* MicronRenderContainer.swift */, + F084 /* MonospaceLineView.swift */, + F085 /* ZoomableScrollView.swift */, + ); + path = NomadNet; + sourceTree = ""; + }; GONB /* Onboarding */ = { isa = PBXGroup; children = ( @@ -417,27 +563,15 @@ path = Onboarding; sourceTree = ""; }; - GNOMAD /* NomadNet */ = { - isa = PBXGroup; - children = ( - F081 /* NomadNetBrowserView.swift */, - F082 /* MicronDocumentView.swift */, - F083 /* MicronRenderContainer.swift */, - F084 /* MonospaceLineView.swift */, - F085 /* ZoomableScrollView.swift */, - ); - path = NomadNet; - sourceTree = ""; - }; GRES /* Resources */ = { isa = PBXGroup; children = ( F022 /* Assets.xcassets */, F023 /* Info.plist */, F039 /* materialdesignicons.ttf */, - FNT1F /* JetBrainsMono-Regular.ttf */, - FNT2F /* JetBrainsMono-Bold.ttf */, F075 /* ColumbaApp.entitlements */, + E4AB432DD1264CAE1F35B22A /* JetBrainsMono-Regular.ttf */, + B1AB71D8977FE792BA9B176D /* JetBrainsMono-Bold.ttf */, ); path = Resources; sourceTree = ""; @@ -472,13 +606,13 @@ F066 /* AppearanceCard.swift */, F067 /* CustomThemeEditorView.swift */, F071 /* BLEConnectionsView.swift */, - F087 /* TCPClientWizard.swift */, GRNW /* RNodeWizard */, + 96EBCA636D502CF4367E32C7 /* TCPClientWizard.swift */, ); path = Settings; sourceTree = ""; }; - GSHARED /* Sources/Shared */ = { + GSHARED /* Shared */ = { isa = PBXGroup; children = ( F076 /* SharedFrameQueue.swift */, @@ -490,6 +624,7 @@ isa = PBXGroup; children = ( F024 /* AppServices.swift */, + BKF002 /* BackendFactory.swift */, F025 /* MessageRepository.swift */, F026 /* SettingsRepository.swift */, F027 /* NotificationObserver.swift */, @@ -498,8 +633,6 @@ F033 /* PropagationNodeManager.swift */, F040 /* NotificationService.swift */, F041 /* AutoAnnounceManager.swift */, - F041P /* AutoAnnouncePolicy.swift */, - F041R /* PeerChildInterfaceRegistry.swift */, F042 /* LocalIdentity.swift */, F043 /* IdentityManager.swift */, F04B /* LocationSharingManager.swift */, @@ -507,17 +640,31 @@ F05D /* MigrationExporter.swift */, F05E /* MigrationImporter.swift */, F061 /* OfflineMapManager.swift */, - F068 /* CallManager.swift */, - F06F /* CallKitManager.swift */, - F070 /* AudioManager.swift */, F074 /* SharedDefaults.swift */, F077 /* TunnelManager.swift */, F078 /* ExtensionFrameReader.swift */, F07F /* NomadNetBrowserService.swift */, + EBA4F8C4C9008A06DFF5AC8A /* PythonConfigWriter.swift */, + 00A8D344945F752C18EF2D9E /* AudioManager.swift */, + 1F7375908681A4DF99F125C7 /* CallKitManager.swift */, + 86E428836698BA8A8973A92F /* CallManager.swift */, + PNT002 /* PythonNetworkTransport.swift */, + FB2569A3C1BFBFDD77F7E639 /* BackendPreference.swift */, + BF48C97880B30682DC35613C /* CeaseTelemetry.swift */, + 9F87296BB580791A95C977B6 /* AutoAnnouncePolicy.swift */, + A8A2F8952F6F036C94824552 /* PeerChildInterfaceRegistry.swift */, ); path = Services; sourceTree = ""; }; + GTESTS /* ColumbaAppTests */ = { + isa = PBXGroup; + children = ( + FT03 /* MicronParserTests.swift */, + ); + path = Tests/ColumbaAppTests; + sourceTree = ""; + }; GTHM /* Theme */ = { isa = PBXGroup; children = ( @@ -559,7 +706,7 @@ F05A /* RNodeWizardViewModel.swift */, F05F /* MigrationViewModel.swift */, F080 /* NomadNetBrowserViewModel.swift */, - F086 /* TCPClientWizardViewModel.swift */, + AD87870C760723D7E77C87E9 /* TCPClientWizardViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -574,35 +721,25 @@ name = Products; sourceTree = ""; }; - GTESTS /* Tests/ColumbaAppTests */ = { - isa = PBXGroup; - children = ( - FT01 /* AudioRingBufferTests.swift */, - FT02 /* AudioManagerConfigChangeTests.swift */, - FT03 /* MicronParserTests.swift */, - FT04 /* CallManagerCallKitTests.swift */, - FTAA /* AutoAnnouncePolicyTests.swift */, - FTPC /* PeerChildInterfaceRegistryTests.swift */, - FT05 /* MapStyleURLTests.swift */, - FT06 /* TCPClientWizardViewModelTests.swift */, - ); - path = Tests/ColumbaAppTests; - sourceTree = ""; - }; ROOT = { isa = PBXGroup; children = ( - SRCS /* Sources/ColumbaApp */, - GSHARED /* Sources/Shared */, - GEXT /* Sources/ColumbaNetworkExtension */, - GTESTS /* Tests/ColumbaAppTests */, - F07B /* Config/Signing.xcconfig */, - F07C /* Config/LocalSigning.xcconfig.example */, + SRCS /* ColumbaApp */, + GSHARED /* Shared */, + GEXT /* ColumbaNetworkExtension */, + GTESTS /* ColumbaAppTests */, + F07B /* Signing.xcconfig */, + F07C /* LocalSigning.xcconfig.example */, PRODS /* Products */, + 91614E532B3F0134673ED735 /* Frameworks */, + D001472BC7DFD3CD7BF27F0C /* app */, + C6877E3C038C0DC36597F1D5 /* PythonBridge */, + 4AD6205A86259FBAD00F37F1 /* RNSAPI */, + 9FCA0C1149D62160BB052B3A /* RNSBackendPy */, ); sourceTree = ""; }; - SRCS /* Sources/ColumbaApp */ = { + SRCS /* ColumbaApp */ = { isa = PBXGroup; children = ( GAPP /* App */, @@ -612,6 +749,7 @@ GVIEWS /* Views */, GSVC /* Services */, GRES /* Resources */, + 9A4EC6E91C9E0CAAD134372D /* Python */, ); path = Sources/ColumbaApp; sourceTree = ""; @@ -642,6 +780,8 @@ SRCBP /* Sources */, FWBP /* Frameworks */, RESBP /* Resources */, + 9796AB46906D510ACD8F0AB1 /* Embed Frameworks */, + CF4F87412D5E39178E82799E /* Install Python stdlib & process dylibs */, ); buildRules = ( ); @@ -649,10 +789,12 @@ ); name = ColumbaApp; packageProductDependencies = ( - P001 /* LXMFSwift */, - P003 /* LXSTSwift */, P002 /* MapLibre */, - P004 /* ReticulumSwift */, + A49337EFC55C10979AEB702B /* LXSTSwift */, + 0FD6A68A52A54D21FDB70324 /* RNSAPI */, + 5BE4B79EB452909EE61ACE6C /* SwiftBLEBridge */, + 2F8EC8202FC732AB00235991 /* ReticulumSwift */, + 2F8EC8222FC732AF00235991 /* LXMFSwift */, ); productName = ColumbaApp; productReference = PROD /* ColumbaApp.app */; @@ -697,7 +839,7 @@ }; }; }; - buildConfigurationList = PBCLST /* Build configuration list for PBXProject "ColumbaApp" */; + buildConfigurationList = PBCLST /* Build configuration list for PBXProject "Columba" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; @@ -707,10 +849,11 @@ ); mainGroup = ROOT; packageReferences = ( - PKGREF /* XCRemoteSwiftPackageReference "LXMF-swift" */, - PKGREF3 /* XCRemoteSwiftPackageReference "LXST-swift" */, PKGREF2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */, - PKGREF4 /* XCRemoteSwiftPackageReference "reticulum-swift" */, + PKGREF3 /* XCRemoteSwiftPackageReference "LXST-swift" */, + ACEF9566B3811B841D6E5672 /* XCLocalSwiftPackageReference "." */, + 2F8EC81E2FC7324D00235991 /* XCRemoteSwiftPackageReference "reticulum-swift" */, + 2F8EC81F2FC7326600235991 /* XCRemoteSwiftPackageReference "LXMF-swift" */, ); productRefGroup = PRODS /* Products */; projectDirPath = ""; @@ -730,37 +873,39 @@ files = ( 022 /* Assets.xcassets in Resources */, 039 /* materialdesignicons.ttf in Resources */, - FNT1 /* JetBrainsMono-Regular.ttf in Resources */, - FNT2 /* JetBrainsMono-Bold.ttf in Resources */, + 67079BBC2E8309A43DF576E5 /* app in Resources */, + DE9220E1D4ABF9A4C2CBA761 /* JetBrainsMono-Regular.ttf in Resources */, + 5F1FF2954475331208BAAD47 /* JetBrainsMono-Bold.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - TTDEP /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = TARG /* ColumbaApp */; - targetProxy = TTPROXY /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXSourcesBuildPhase section */ - TSRCBP /* Sources */ = { - isa = PBXSourcesBuildPhase; +/* Begin PBXShellScriptBuildPhase section */ + CF4F87412D5E39178E82799E /* Install Python stdlib & process dylibs */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( - T001 /* AudioRingBufferTests.swift in Sources */, - T002 /* AudioManagerConfigChangeTests.swift in Sources */, - T003 /* MicronParserTests.swift in Sources */, - T004 /* CallManagerCallKitTests.swift in Sources */, - TAA0 /* AutoAnnouncePolicyTests.swift in Sources */, - TPCR /* PeerChildInterfaceRegistryTests.swift in Sources */, - T005 /* MapStyleURLTests.swift in Sources */, - T006 /* TCPClientWizardViewModelTests.swift in Sources */, + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(PROJECT_DIR)/Frameworks/Python.xcframework/build/utils.sh", + ); + name = "Install Python stdlib & process dylibs"; + outputFileListPaths = ( + ); + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\n# Swift-native backend build (COLUMBA_BACKEND_SWIFT): no embedded Python,\n# so skip the wheels copy + install_python entirely (leaner build, no\n# multi-hundred-MB app_packages copy). The Python.xcframework is still\n# linked (build phases are not config-scoped) — full unlink is Phase 4 (two\n# targets / gen-time).\ncase \" $SWIFT_ACTIVE_COMPILATION_CONDITIONS \" in\n *\" COLUMBA_BACKEND_SWIFT \"*) echo \"note: Swift backend — skipping Python wheels/install\"; exit 0 ;;\nesac\n\n# Copy platform-appropriate wheels into /app_packages/ before\n# install_python processes the .so extensions inside them.\ncase \"$EFFECTIVE_PLATFORM_NAME\" in\n -iphoneos) WHEELS_SRC=\"$PROJECT_DIR/wheels-iphoneos\" ;;\n -iphonesimulator) WHEELS_SRC=\"$PROJECT_DIR/wheels-iphonesimulator\" ;;\n *) echo \"error: unsupported platform $EFFECTIVE_PLATFORM_NAME\" >&2; exit 1 ;;\nesac\n[ -d \"$WHEELS_SRC\" ] || {\n echo \"error: $WHEELS_SRC missing — run support/fetch-wheels.sh\" >&2\n exit 1\n}\nmkdir -p \"$CODESIGNING_FOLDER_PATH/app_packages\"\nrsync -au --delete \"$WHEELS_SRC/\" \"$CODESIGNING_FOLDER_PATH/app_packages/\"\n\nsource \"$PROJECT_DIR/Frameworks/Python.xcframework/build/utils.sh\"\ninstall_python Frameworks/Python.xcframework app_packages"; + showEnvVarsInLog = 0; }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ ESRCBP /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -798,6 +943,7 @@ 021 /* MapView.swift in Sources */, 048 /* MapLibreMapView.swift in Sources */, 024 /* AppServices.swift in Sources */, + BKF001 /* BackendFactory.swift in Sources */, 025 /* MessageRepository.swift in Sources */, 026 /* SettingsRepository.swift in Sources */, 027 /* NotificationObserver.swift in Sources */, @@ -814,8 +960,6 @@ 038 /* IconPickerView.swift in Sources */, 040 /* NotificationService.swift in Sources */, 041 /* AutoAnnounceManager.swift in Sources */, - 041P /* AutoAnnouncePolicy.swift in Sources */, - 041R /* PeerChildInterfaceRegistry.swift in Sources */, 042 /* LocalIdentity.swift in Sources */, 043 /* IdentityManager.swift in Sources */, 044 /* IdentityManagerView.swift in Sources */, @@ -854,15 +998,6 @@ 065B /* ThemeManager.swift in Sources */, 066B /* AppearanceCard.swift in Sources */, 067B /* CustomThemeEditorView.swift in Sources */, - 068B /* CallManager.swift in Sources */, - 069B /* CodecProfileInfo.swift in Sources */, - 06AB /* CallControlButton.swift in Sources */, - 06BB /* CodecSelectionSheet.swift in Sources */, - 06CB /* IncomingCallScreen.swift in Sources */, - 06DB /* PttButton.swift in Sources */, - 06EB /* VoiceCallScreen.swift in Sources */, - 06F0 /* CallKitManager.swift in Sources */, - 0700 /* AudioManager.swift in Sources */, 071B /* BLEConnectionsView.swift in Sources */, 074B /* SharedDefaults.swift in Sources */, 076B /* SharedFrameQueue.swift in Sources */, @@ -879,14 +1014,361 @@ 082B /* MicronRenderContainer.swift in Sources */, 083B /* MonospaceLineView.swift in Sources */, 084B /* ZoomableScrollView.swift in Sources */, - 086B /* TCPClientWizardViewModel.swift in Sources */, - 087B /* TCPClientWizard.swift in Sources */, + 779118E89F4D38BF960DB3D0 /* PyAnnounce.swift in Sources */, + C91321B8E0BA9F487D5E1AC8 /* PyMessage.swift in Sources */, + 557D530BBEDAEBE9A6A0BE41 /* PyConversation.swift in Sources */, + 59CE6F635354725F7D445625 /* PyLocalIdentity.swift in Sources */, + 4CC7FE5D6B0D6557B8868210 /* PythonBridge.swift in Sources */, + C2487934101CA21C68F1F458 /* PythonRuntime.swift in Sources */, + 668C0A33D82AB3D07CC52E83 /* PythonRNSBackend.swift in Sources */, + SRB001 /* SwiftRNSBackend.swift in Sources */, + 69B724BD147A18C5EAFD080C /* PythonConfigWriter.swift in Sources */, + 71FAB7848D244A6D2FC1628A /* AudioManager.swift in Sources */, + D33B15C781E3C98A5CBD06F3 /* CallKitManager.swift in Sources */, + 886AB689C7699471510BAF9A /* CallManager.swift in Sources */, + PNT001 /* PythonNetworkTransport.swift in Sources */, + 8768F2E6CD7941D82997A1BB /* CallControlButton.swift in Sources */, + 03328BB5F0687E3918A9387D /* CodecSelectionSheet.swift in Sources */, + D85E5BBB51FACA138622FFFA /* IncomingCallScreen.swift in Sources */, + EA609BF7A35298B1651160C3 /* PttButton.swift in Sources */, + 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 */, + 4758210ABE17DE6E3BE0B3F6 /* AutoAnnouncePolicy.swift in Sources */, + AC4014BF281AD8BE6FF9E852 /* PeerChildInterfaceRegistry.swift in Sources */, + EDF2F6B945FAA23366617FD6 /* TCPClientWizardViewModel.swift in Sources */, + 55F3BF7D600C32E07B7B8C26 /* TCPClientWizard.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + TSRCBP /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + T003 /* MicronParserTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + TTDEP /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = TTARG /* ColumbaAppTests */; + targetProxy = TTPROXY /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 19741F31C64D608B53A0BD61 /* Debug-Swift */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG COLUMBA_NOMADNET_ENABLED COLUMBA_RNODE_ENABLED $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = "Debug-Swift"; + }; + 1CE9BF08C76D04B9FB6ED9C9 /* Release-Swift */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F07B /* Signing.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Sources/ColumbaApp/Resources/ColumbaApp.entitlements; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M2977H5PM5; + ENABLE_PREVIEWS = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sources/ColumbaApp/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Columba; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Columba uses Bluetooth for peer-to-peer mesh networking and connecting to RNode radio devices."; + INFOPLIST_KEY_NSCameraUsageDescription = "Columba uses the camera to scan QR codes for adding contacts."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Columba uses local network access to discover nearby Reticulum peers and connect to TCP servers on your network."; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Columba uses background location to share your position with peers on the mesh network."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Columba uses your location to show your position on the map and share it with peers."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Columba uses the microphone for voice calls over the mesh network."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = armv7; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.0.2; + PRODUCT_BUNDLE_IDENTIFIER = network.columba.Columba; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/PythonBridge/ColumbaPython-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Release-Swift"; + }; + 22F92FA927670E44C5E34001 /* Debug-Swift */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F07B /* Signing.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Sources/ColumbaApp/Resources/ColumbaApp.entitlements; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M2977H5PM5; + ENABLE_PREVIEWS = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sources/ColumbaApp/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Columba; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Columba uses Bluetooth for peer-to-peer mesh networking and connecting to RNode radio devices."; + INFOPLIST_KEY_NSCameraUsageDescription = "Columba uses the camera to scan QR codes for adding contacts."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Columba uses local network access to discover nearby Reticulum peers and connect to TCP servers on your network."; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Columba uses background location to share your position with peers on the mesh network."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Columba uses your location to show your position on the map and share it with peers."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Columba uses the microphone for voice calls over the mesh network."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = armv7; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.0.2; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = network.columba.Columba; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) COLUMBA_BACKEND_SWIFT"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/PythonBridge/ColumbaPython-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug-Swift"; + }; + 40664B29B91154746B7404C5 /* Release-Swift */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = network.columba.Columba.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ColumbaApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ColumbaApp"; + }; + name = "Release-Swift"; + }; + 4DDB99DC9C9364354F194D97 /* Debug-Swift */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F07B /* Signing.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sources/ColumbaNetworkExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Columba Transport"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.0.2; + PRODUCT_BUNDLE_IDENTIFIER = network.columba.Columba.tunnel; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug-Swift"; + }; + 834F37C59F9C28697BFC834F /* Release-Swift */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "COLUMBA_NOMADNET_ENABLED $(inherited)"; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = "Release-Swift"; + }; + 937AA4F4090BA98BCC8888B0 /* Debug-Swift */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = network.columba.Columba.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ColumbaApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ColumbaApp"; + }; + name = "Debug-Swift"; + }; + BB9D1E96F186BDF9EBE59FE3 /* Release-Swift */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F07B /* Signing.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sources/ColumbaNetworkExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Columba Transport"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.0.2; + PRODUCT_BUNDLE_IDENTIFIER = network.columba.Columba.tunnel; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Release-Swift"; + }; DBG /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -924,7 +1406,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -946,14 +1428,14 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG COLUMBA_NOMADNET_ENABLED COLUMBA_RNODE_ENABLED $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; EDBG /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F07B /* Config/Signing.xcconfig */; + baseConfigurationReference = F07B /* Signing.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements; CODE_SIGN_STYLE = Automatic; @@ -979,7 +1461,7 @@ }; EREL /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F07B /* Config/Signing.xcconfig */; + baseConfigurationReference = F07B /* Signing.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = Sources/ColumbaNetworkExtension/ColumbaNetworkExtension.entitlements; CODE_SIGN_STYLE = Automatic; @@ -1040,7 +1522,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -1055,6 +1537,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "COLUMBA_NOMADNET_ENABLED $(inherited)"; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -1062,13 +1545,18 @@ }; TDBG /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F07B /* Config/Signing.xcconfig */; + baseConfigurationReference = F07B /* Signing.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Sources/ColumbaApp/Resources/ColumbaApp.entitlements; CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M2977H5PM5; ENABLE_PREVIEWS = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sources/ColumbaApp/Resources/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Columba; @@ -1088,11 +1576,13 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 0.0.2; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = network.columba.Columba; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/PythonBridge/ColumbaPython-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1100,13 +1590,18 @@ }; TREL /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F07B /* Config/Signing.xcconfig */; + baseConfigurationReference = F07B /* Signing.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Sources/ColumbaApp/Resources/ColumbaApp.entitlements; CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M2977H5PM5; ENABLE_PREVIEWS = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sources/ColumbaApp/Resources/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Columba; @@ -1131,6 +1626,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/PythonBridge/ColumbaPython-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1184,6 +1680,8 @@ buildConfigurations = ( TDBG /* Debug */, TREL /* Release */, + 22F92FA927670E44C5E34001 /* Debug-Swift */, + 1CE9BF08C76D04B9FB6ED9C9 /* Release-Swift */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -1193,15 +1691,19 @@ buildConfigurations = ( EDBG /* Debug */, EREL /* Release */, + 4DDB99DC9C9364354F194D97 /* Debug-Swift */, + BB9D1E96F186BDF9EBE59FE3 /* Release-Swift */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - PBCLST /* Build configuration list for PBXProject "ColumbaApp" */ = { + PBCLST /* Build configuration list for PBXProject "Columba" */ = { isa = XCConfigurationList; buildConfigurations = ( DBG /* Debug */, REL /* Release */, + 19741F31C64D608B53A0BD61 /* Debug-Swift */, + 834F37C59F9C28697BFC834F /* Release-Swift */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -1211,19 +1713,36 @@ buildConfigurations = ( TTDBG /* Debug */, TTREL /* Release */, + 937AA4F4090BA98BCC8888B0 /* Debug-Swift */, + 40664B29B91154746B7404C5 /* Release-Swift */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + ACEF9566B3811B841D6E5672 /* XCLocalSwiftPackageReference "." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = .; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ - PKGREF /* XCRemoteSwiftPackageReference "LXMF-swift" */ = { + 2F8EC81E2FC7324D00235991 /* XCRemoteSwiftPackageReference "reticulum-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/torlando-tech/reticulum-swift.git"; + requirement = { + kind = exactVersion; + version = 0.2.3; + }; + }; + 2F8EC81F2FC7326600235991 /* XCRemoteSwiftPackageReference "LXMF-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/torlando-tech/LXMF-swift.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.4.0; + kind = exactVersion; + version = 0.3.4; }; }; PKGREF2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = { @@ -1238,40 +1757,40 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/torlando-tech/LXST-swift.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.2.0; - }; - }; - PKGREF4 /* XCRemoteSwiftPackageReference "reticulum-swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/torlando-tech/reticulum-swift.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.3.0; + branch = "feat/transport-agnostic"; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - P001 /* LXMFSwift */ = { + 0FD6A68A52A54D21FDB70324 /* RNSAPI */ = { + isa = XCSwiftPackageProductDependency; + productName = RNSAPI; + }; + 2F8EC8202FC732AB00235991 /* ReticulumSwift */ = { isa = XCSwiftPackageProductDependency; - package = PKGREF /* XCRemoteSwiftPackageReference "LXMF-swift" */; + package = 2F8EC81E2FC7324D00235991 /* XCRemoteSwiftPackageReference "reticulum-swift" */; + productName = ReticulumSwift; + }; + 2F8EC8222FC732AF00235991 /* LXMFSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 2F8EC81F2FC7326600235991 /* XCRemoteSwiftPackageReference "LXMF-swift" */; productName = LXMFSwift; }; - P002 /* MapLibre */ = { + 5BE4B79EB452909EE61ACE6C /* SwiftBLEBridge */ = { isa = XCSwiftPackageProductDependency; - package = PKGREF2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */; - productName = MapLibre; + productName = SwiftBLEBridge; }; - P003 /* LXSTSwift */ = { + A49337EFC55C10979AEB702B /* LXSTSwift */ = { isa = XCSwiftPackageProductDependency; package = PKGREF3 /* XCRemoteSwiftPackageReference "LXST-swift" */; productName = LXSTSwift; }; - P004 /* ReticulumSwift */ = { + P002 /* MapLibre */ = { isa = XCSwiftPackageProductDependency; - package = PKGREF4 /* XCRemoteSwiftPackageReference "reticulum-swift" */; - productName = ReticulumSwift; + package = PKGREF2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */; + productName = MapLibre; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c732770d..7f549b7e 100644 --- a/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Columba.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ab8e26f753ec08ec6924cbf6fd931cf46454ee4b8b8fe509d5eb37eec3ff45e0", + "originHash" : "0dc60df1ece66a9258a79fc22638d7b173a5b15313dae679b5e8e6f01b9f15a7", "pins" : [ { "identity" : "bitbytedata", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/torlando-tech/LXMF-swift.git", "state" : { - "revision" : "21f877614181800116013771dcab163b08c113fc", - "version" : "0.4.0" + "revision" : "b31a14a8fe9ab9626ce9b333d5978098910b54ea", + "version" : "0.3.4" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/torlando-tech/LXST-swift.git", "state" : { - "revision" : "f84bd720ea602ed5a1983a0f058d90b6a4174c35", - "version" : "0.2.0" + "branch" : "feat/transport-agnostic", + "revision" : "b192964e14af193814685e43fddc233525262615" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/torlando-tech/reticulum-swift.git", "state" : { - "revision" : "034d9c7570c7428ebe5daab1ee1b8d17fc1e9c87", - "version" : "0.3.0" + "revision" : "b8ae6491d5ca62db30b097382cdf8553edda9b92", + "version" : "0.2.3" } }, { diff --git a/Columba.xcodeproj/xcshareddata/xcschemes/Columba-Swift.xcscheme b/Columba.xcodeproj/xcshareddata/xcschemes/Columba-Swift.xcscheme new file mode 100644 index 00000000..e8a0f086 --- /dev/null +++ b/Columba.xcodeproj/xcshareddata/xcschemes/Columba-Swift.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Frameworks/.gitkeep b/Frameworks/.gitkeep new file mode 100644 index 00000000..c0082ece --- /dev/null +++ b/Frameworks/.gitkeep @@ -0,0 +1,4 @@ +Python.xcframework lives in this directory. Fetch it with `support/fetch-python.sh`. +The xcframework, the VERSIONS marker, and the upstream tarball are all gitignored +because the xcframework is ~110 MB. The fetch script pins a specific release tag +for reproducibility — see support/fetch-python.sh. diff --git a/Package.swift b/Package.swift index ef7ac2ce..8dc49e2c 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,23 @@ // swift-tools-version: 5.9 import PackageDescription +// Module layout — the iOS app is Xcode-built (Columba.xcodeproj), with +// configure-xcodeproj.rb pulling files in by path. This SwiftPM manifest +// exists for two reasons: +// +// 1. The Xcode project references this manifest as a LOCAL package +// (XCLocalSwiftPackageReference) so RNSAPI / SwiftBLEBridge get built by +// SwiftPM rather than hand-written pbxproj entries. +// 2. `swift build` (used by tooling + CI) can still typecheck the pure-Swift +// libraries without the Python.xcframework bridging header. +// +// The LXST voice stack (LXSTSwift + the Opus/Codec2 codec C trees) is no longer +// vendored here — it lives in the standalone, transport-agnostic LXST-swift +// package (consumed via SwiftPM, wired to RNS through Columba's +// PythonNetworkTransport). See `dependencies` below. +// +// Targets that DO require the bridging header (PythonBridge, RNSBackendPy, +// ColumbaApp) live ONLY in the pbxproj — they're not declared here. let package = Package( name: "ColumbaApp", platforms: [ @@ -8,38 +25,44 @@ let package = Package( .macOS(.v14) ], products: [ - .executable( - name: "ColumbaApp", - targets: ["ColumbaApp"] - ) + .library(name: "RNSAPI", targets: ["RNSAPI"]), + .library(name: "SwiftBLEBridge", targets: ["SwiftBLEBridge"]), ], dependencies: [ - // SPM resolves these from GitHub on every fresh checkout. To work - // against an in-progress local clone of any of these libraries - // without committing a path-override, drop a per-machine - // `.swiftpm/configuration/mirrors.json` mapping the URL to a local - // directory — see README "Local development against unreleased - // library changes" for the exact recipe. - .package(url: "https://github.com/torlando-tech/LXMF-swift.git", from: "0.4.0"), - .package(url: "https://github.com/torlando-tech/LXST-swift.git", from: "0.2.0"), - .package(url: "https://github.com/torlando-tech/reticulum-swift.git", from: "0.3.0"), - .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution", from: "6.9.0"), + // Transport-agnostic LXST voice library (owns the Opus/Codec2 codecs + // and the NetworkTransport seam; no Reticulum dependency). Columba + // provides the implementation via PythonNetworkTransport. Tracking the + // branch until a release is tagged — same model as the RNS fork. + .package(url: "https://github.com/torlando-tech/LXST-swift.git", branch: "feat/transport-agnostic"), ], targets: [ - .executableTarget( - name: "ColumbaApp", - dependencies: [ - .product(name: "LXMFSwift", package: "LXMF-swift"), - .product(name: "LXSTSwift", package: "LXST-swift"), - // ReticulumSwift is imported directly by several view - // models (e.g. NomadNetBrowserViewModel, - // MessagingViewModel) — listed here as a direct product - // dep so the version constraint on the package above is - // actually exercised by SPM at resolution time. - .product(name: "ReticulumSwift", package: "reticulum-swift"), - .product(name: "MapLibre", package: "maplibre-gl-native-distribution"), - ], - path: "Sources/ColumbaApp" - ) + // ──────── RNSAPI: pure-interface protocol surface ──────── + .target( + name: "RNSAPI", + path: "Sources/RNSAPI", + // libsqlite3 (system) backs LXMFDatabase's on-disk persistence. + linkerSettings: [.linkedLibrary("sqlite3")] + ), + + // ──────── SwiftBLEBridge: CoreBluetooth wrapper for ble-reticulum ── + // Mirror of Columba Android's reticulum/ble module. Holds CBCentralManager + // + CBPeripheralManager state and exposes a Swift API that the iOS BLE + // driver (app/ble/ios_ble_driver.py) calls into. The Python ↔ Swift + // callback invocation path lives separately in the pbxproj-only + // `PythonBLECallbackBridge.swift` (which needs Python.h); SwiftBLEBridge + // itself is pure CoreBluetooth so `swift build` compiles it cleanly. + .target( + name: "SwiftBLEBridge", + dependencies: ["RNSAPI"], + path: "Sources/SwiftBLEBridge" + ), + // Pure-Swift unit tests for RNSAPI (msgpack, AppDataParser, + // PropagationNodeInfo). Runs natively via `swift test` on macOS — no + // simulator / Xcode test target needed (RNSAPI has no UIKit/Python deps). + .testTarget( + name: "RNSAPITests", + dependencies: ["RNSAPI"], + path: "Tests/RNSAPITests" + ), ] ) diff --git a/Sources/PythonBridge/ColumbaPython-Bridging-Header.h b/Sources/PythonBridge/ColumbaPython-Bridging-Header.h new file mode 100644 index 00000000..5ae9618a --- /dev/null +++ b/Sources/PythonBridge/ColumbaPython-Bridging-Header.h @@ -0,0 +1,35 @@ +#ifndef ColumbaPython_Bridging_Header_h +#define ColumbaPython_Bridging_Header_h + +#import + +// Swift cannot express `PyConfig_SetString(&config, &config.home, ...)` because of +// overlapping-access exclusivity rules — both arguments alias into `config`. +// These tiny inline shims pull the dual-mutation pattern down into C, which +// has no such restriction. One shim per `PyConfig` field we touch. + +static inline PyStatus ColumbaPy_PyConfig_SetHome(PyConfig *config, const wchar_t *home) { + return PyConfig_SetString(config, &config->home, home); +} + +// Py_None is a macro in CPython's headers, which Swift cannot import. This +// shim returns a fresh reference so the caller can pass it where a "new" +// reference is expected (e.g. PyTuple_SetItem, which steals refs). +static inline PyObject *ColumbaPy_None(void) { + Py_INCREF(Py_None); + return Py_None; +} + +// Py_True / Py_False are likewise macros. New refs so they can be packed into +// tuples + lists where slot setters steal the ref. +static inline PyObject *ColumbaPy_True(void) { + Py_INCREF(Py_True); + return Py_True; +} + +static inline PyObject *ColumbaPy_False(void) { + Py_INCREF(Py_False); + return Py_False; +} + +#endif diff --git a/Sources/PythonBridge/PythonBLECallbackBridge.swift b/Sources/PythonBridge/PythonBLECallbackBridge.swift new file mode 100644 index 00000000..815a7262 --- /dev/null +++ b/Sources/PythonBridge/PythonBLECallbackBridge.swift @@ -0,0 +1,69 @@ +// +// PythonBLECallbackBridge.swift +// Columba (ColumbaApp target — auto-picked up by configure-xcodeproj.rb) +// +// Glue between SwiftBLEBridge's `BleCallbackInvoker` protocol and PythonBridge's +// `invokeBLECallback` / `invokeBLECallbackBoolSync`. Conforming class lives in +// the pbxproj target (NOT the SwiftBLEBridge SwiftPM target) because invocation +// routes through PythonBridge which depends on Python.h. +// + +import Foundation +import SwiftBLEBridge + +/// Bridges SwiftBLEBridge's BLE event slots to PythonBridge's GIL-aware +/// invocation primitives. Pass an instance of this to +/// `SwiftBLEBridge.setCallbackInvoker(_:)` after the Python runtime is up. +public final class PythonBLECallbackBridge: BleCallbackInvoker, @unchecked Sendable { + + private let pythonBridge: PythonBridge + + public init(pythonBridge: PythonBridge) { + self.pythonBridge = pythonBridge + } + + public func invoke(slot: BleCallbackSlot, args: [Any]) { + pythonBridge.invokeBLECallback(slot: slot.rawValue, args: convert(args)) + } + + public func invokeBool(slot: BleCallbackSlot, args: [Any]) -> Bool { + pythonBridge.invokeBLECallbackBoolSync( + slot: slot.rawValue, + args: convert(args) + ) + } + + // MARK: - Arg conversion + + /// Bridge boundary: the Swift CB delegates can pass any-typed args; we + /// match each one to a known BLEArg variant. Unknown types fall through + /// as `.string("")` which is logged on the Python side via + /// PyErr_Print — the goal is to catch type misuse loudly in dev rather + /// than silently dropping the call. + private func convert(_ args: [Any]) -> [BLEArg] { + args.map { value in + switch value { + case let s as String: + return .string(s) + case let i as Int: + return .int(i) + case let i as Int32: + return .int(Int(i)) + case let i as Int64: + return .int(Int(i)) + case let u as UInt: + return .int(Int(u)) + case let b as Bool: + return .bool(b) + case let d as Data: + return .bytes(d) + case let arr as [String]: + return .stringList(arr) + case is NSNull: + return .none + default: + return .string("") + } + } + } +} diff --git a/Sources/PythonBridge/PythonBridge.swift b/Sources/PythonBridge/PythonBridge.swift new file mode 100644 index 00000000..f3992b88 --- /dev/null +++ b/Sources/PythonBridge/PythonBridge.swift @@ -0,0 +1,1084 @@ +import Foundation + +// PythonBridge can't import ColumbaApp's DiagLog (lives in the app target); +// duplicate the writer here for status-side diagnostics. Same file path +// (Documents/diag.log) so output interleaves with the rest. +private func DiagLog_status(_ message: String) { + let ts = ISO8601DateFormatter().string(from: Date()) + let line = "[\(ts)] [PY-STATUS] \(message)\n" + NSLog("%@", "[PY-STATUS] \(message)") + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + guard let url = docs?.appendingPathComponent("diag.log"), + let data = line.data(using: .utf8) else { return } + if FileManager.default.fileExists(atPath: url.path) { + if let fh = try? FileHandle(forWritingTo: url) { + fh.seekToEndOfFile(); fh.write(data); fh.closeFile() + } + } else { + try? data.write(to: url) + } +} + +/// Wraps the Python `rns_bridge` module. Every call hops onto a dedicated serial +/// queue (so all Python work is serialized — RNS internally still runs its own +/// background threads, but our Swift-initiated calls don't race) and uses +/// `PythonRuntime.shared.withGIL` to hold the GIL for the duration. +/// +/// Events from RNS / LXMF callbacks land on a thread-safe Python queue.Queue; +/// Swift drains it via a Combine `Timer.publish` at ~5 Hz. +public final class PythonBridge: @unchecked Sendable { + // Connection / send results bubble up as plain enums; the SwiftUI layer + // turns them into user-facing strings. + public struct LocalInfo: Equatable, Sendable { + public let identityHash: String + public let destinationHash: String + public init(identityHash: String, destinationHash: String) { + self.identityHash = identityHash + self.destinationHash = destinationHash + } + } + + public enum BridgeError: LocalizedError { + case notStarted + case pythonException(String) + case marshallingFailure(String) + + public var errorDescription: String? { + switch self { + case .notStarted: return "Python bridge not started" + case .pythonException(let m): return "Python error: \(m)" + case .marshallingFailure(let m): return "Marshalling: \(m)" + } + } + } + + public enum SendOutcome: Equatable, Sendable { + /// Successfully queued. `messageHash` is the real LXMF message hash hex + /// (populated once Python packs the message); empty if unavailable. + /// Callers persist the outbound message under this so a later + /// `.delivery` event can match it. + case queued(messageHash: String) + case requestingPath + case badHash + case notStarted + case other(String) + } + + public enum Event: Equatable, Sendable { + case announce(destHash: String, appDataHex: String, aspect: String, publicKeysHex: String, interfaceName: String, hops: Int, t: Date) + case inbound(sourceHash: String, content: String, title: String, fieldsHex: String, t: Date) + case state(String, t: Date) + + /// Delivery / failure proof for an outbound message, keyed by its LXMF + /// message hash hex. `state` is "delivered" or "failed". Drives the + /// chat UI's double-check / failed indicator. + case delivery(messageHash: String, state: String, t: Date) + + // RNS.Link events — used by lxst-swift for voice calls. The + // Swift LXST state machine consumes these to drive its own + // call lifecycle; Python is just the underlying Link pipe. + case linkState(linkId: Int, state: String, reason: String, inbound: Bool, t: Date) + case linkPacket(linkId: Int, data: Data, t: Date) + case linkIdentified(linkId: Int, identityHashHex: String, t: Date) + } + + private let queue = DispatchQueue(label: "network.columba.python", qos: .userInitiated) + /// Dedicated queue for long-blocking poll-style Python calls (e.g. + /// `propagationSync`, which blocks up to its timeout). Running these on the + /// main `queue` would starve every other bridge call for the whole timeout, + /// since `queue` is serial. The blocking Python poll releases the GIL + /// between iterations (`time.sleep`), so short calls on `queue` acquire the + /// GIL and run promptly while a sync is in flight. Two queues into CPython + /// is safe: `withGIL` (PyGILState_Ensure/Release) serializes all Python + /// access regardless of thread — the same pattern the BLE callback path + /// already relies on. + private let blockingQueue = DispatchQueue(label: "network.columba.python.blocking", qos: .userInitiated) + private var module: UnsafeMutablePointer? + + public init() {} + + // MARK: - Public API + + /// Initialize Reticulum + LXMRouter inside Python. Returns local hashes on success. + /// + /// `identityBytes` is the preferred path for production iOS — Swift reads the + /// 64-byte identity blob from Keychain and hands it over here; Python loads it + /// via `RNS.Identity.from_bytes()`, never touching the filesystem for keys. + /// Pass `nil` to fall back to `identityPath` (file-on-disk; mainly for CLI / PoC). + public func start( + configDir: String, + identityPath: String, + displayName: String, + identityBytes: Data? = nil + ) async throws -> LocalInfo { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + try ensureModuleLoaded() + guard let module = self.module else { throw BridgeError.notStarted } + guard let fn = PyObject_GetAttrString(module, "start") else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + + // Positional args 0..2, then identity_bytes (None or bytes) at slot 3. + // Swift wrote the RNS config file at /config before this + // call (see PythonConfigWriter); rns_bridge.py just reads it. + let args = PyTuple_New(4) + guard args != nil else { throw BridgeError.marshallingFailure("PyTuple_New") } + defer { Py_DecRef(args) } + + let strings = [configDir, identityPath, displayName] + for (idx, value) in strings.enumerated() { + guard let u = PyUnicode_FromString(value) else { + throw BridgeError.marshallingFailure("PyUnicode_FromString[\(idx)]") + } + PyTuple_SetItem(args, idx, u) // steals ref + } + + // identity_bytes at slot 3: either Py_None or a bytes object. + if let data = identityBytes, !data.isEmpty { + let bytesObj: UnsafeMutablePointer? = data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return nil } + return PyBytes_FromStringAndSize(base.assumingMemoryBound(to: CChar.self), raw.count) + } + guard let bytesObj else { throw BridgeError.marshallingFailure("PyBytes_FromStringAndSize") } + PyTuple_SetItem(args, 3, bytesObj) // steals ref + } else { + PyTuple_SetItem(args, 3, ColumbaPy_None()) // steals ref + } + + guard let result = PyObject_CallObject(fn, args) else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(result) } + return try extractLocalInfo(result) + } + } + } + + public func stop() async throws { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { return } + guard let fn = PyObject_GetAttrString(module, "stop") else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(0) else { return } + defer { Py_DecRef(args) } + if let r = PyObject_CallObject(fn, args) { Py_DecRef(r) } + } + } + } + + public func resetIdentity(identityPath: String) async throws { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + try ensureModuleLoaded() + guard let module = self.module else { return } + guard let fn = PyObject_GetAttrString(module, "reset_identity") else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + guard let path = PyUnicode_FromString(identityPath) else { + throw BridgeError.marshallingFailure("identityPath") + } + guard let r = PyObject_CallOneArg(fn, path) else { + Py_DecRef(path) + throw BridgeError.pythonException(currentPythonException()) + } + Py_DecRef(path) + Py_DecRef(r) + } + } + } + + /// Outcome of a one-shot NomadNet page fetch over RNS Link. + /// Mirrors what `rns_bridge.fetch_nomadnet_page` returns. + public struct NomadNetFetchResult: Sendable, Equatable { + public enum Status: String, Sendable { + case ok + case noPath = "no-path" + case linkFailed = "link-failed" + case requestFailed = "request-failed" + case timeout + case badHash = "bad-hash" + case notStarted = "not-started" + case unknown + } + public let ok: Bool + public let status: Status + public let data: Data + public let contentType: String + + public init(ok: Bool, status: Status, data: Data, contentType: String) { + self.ok = ok + self.status = status + self.data = data + self.contentType = contentType + } + } + + /// Establish an RNS Link to the destination, request `path`, wait up to + /// `timeout` seconds for the response, tear down the link, and return + /// the bytes. `formFields` is `nil` for a plain GET-style fetch or a + /// dictionary for form submission (msgpack-packed on the Python side). + public func fetchNomadNetPage( + destHashHex: String, + path: String, + timeout: TimeInterval = 30.0, + formFields: [String: String]? = nil + ) async throws -> NomadNetFetchResult { + // Runs on `blockingQueue`, not the main serial `queue`: the Python + // fetch blocks up to `timeout` inside link_ready/response_ready waits, + // and on the shared queue that would stall drainEvents + every other + // bridge call for the duration — the same starvation propagationSync + // avoids. + try await runOnQueue(on: blockingQueue) { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { + return NomadNetFetchResult(ok: false, status: .notStarted, data: Data(), contentType: "") + } + guard let fn = PyObject_GetAttrString(module, "fetch_nomadnet_page") else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(4) else { throw BridgeError.marshallingFailure("PyTuple_New") } + defer { Py_DecRef(args) } + guard let nnDest = PyUnicode_FromString(destHashHex), + let nnPath = PyUnicode_FromString(path) else { + throw BridgeError.marshallingFailure("PyUnicode_FromString") + } + PyTuple_SetItem(args, 0, nnDest) + PyTuple_SetItem(args, 1, nnPath) + PyTuple_SetItem(args, 2, PyFloat_FromDouble(timeout)) + + if let fields = formFields, !fields.isEmpty { + guard let dict = PyDict_New() else { throw BridgeError.marshallingFailure("PyDict_New") } + for (k, v) in fields { + let pyk = PyUnicode_FromString(k) + let pyv = PyUnicode_FromString(v) + PyDict_SetItem(dict, pyk, pyv) + if let pyk { Py_DecRef(pyk) } + if let pyv { Py_DecRef(pyv) } + } + PyTuple_SetItem(args, 3, dict) + } else { + PyTuple_SetItem(args, 3, ColumbaPy_None()) + } + + guard let result = PyObject_CallObject(fn, args) else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(result) } + + let ok = pyBoolFromDict(result, key: "ok") ?? false + let statusStr = pyStringFromDict(result, key: "status") ?? "" + let contentType = pyStringFromDict(result, key: "content_type") ?? "" + + // Extract bytes from the `data` key — Python returns a bytes object. + var data = Data() + if let dataObj = PyDict_GetItemString(result, "data") { + var buf: UnsafeMutablePointer? = nil + var len: Py_ssize_t = 0 + if PyBytes_AsStringAndSize(dataObj, &buf, &len) == 0, let buf, len > 0 { + data = Data(bytes: buf, count: Int(len)) + } + } + let status = NomadNetFetchResult.Status(rawValue: statusStr) ?? .unknown + return NomadNetFetchResult(ok: ok, status: status, data: data, contentType: contentType) + } + } + } + + /// Outcome of an LXMF propagation-node sync. Mirrors what + /// `rns_bridge.propagation_sync` returns. + public struct PropagationSyncResult: Sendable, Equatable { + public enum State: String, Sendable { + case idle + case pathRequested = "path_requested" + case linkEstablishing = "link_establishing" + case linkEstablished = "link_established" + case requestSent = "request_sent" + case receiving + case responseReceived = "response_received" + case complete + case noPath = "no_path" + case transferFailed = "transfer_failed" + case noRouter = "no-router" + case notStarted = "not-started" + case noNode = "no-node" + case unknown + } + public let ok: Bool + public let state: State + public let receivedMessages: Int + public let reason: String + } + + /// Set / clear the outbound LXMF propagation node. Pass empty string + /// to clear. `stampCost` is the per-message stamp cost the node + /// advertises (in its announce app_data); 0 if unknown. + @discardableResult + public func setPropagationNode(destHashHex: String, stampCost: Int = 0) async throws -> Bool { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { return false } + guard let fn = PyObject_GetAttrString(module, "set_propagation_node") else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(2) else { throw BridgeError.marshallingFailure("PyTuple_New") } + defer { Py_DecRef(args) } + guard let pnDest = PyUnicode_FromString(destHashHex), + let pnCost = PyLong_FromLongLong(Int64(stampCost)) else { + throw BridgeError.marshallingFailure("PyUnicode/PyLong") + } + PyTuple_SetItem(args, 0, pnDest) + PyTuple_SetItem(args, 1, pnCost) + guard let result = PyObject_CallObject(fn, args) else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(result) } + return pyBoolFromDict(result, key: "ok") ?? false + } + } + } + + /// Block until LXMF propagation-node sync completes or times out. + /// + /// Runs on `blockingQueue`, not the main serial `queue`: the underlying + /// Python poll blocks up to `timeout`, and on the shared queue that would + /// stall every other bridge call (announce, send, …) for the duration. + public func propagationSync(timeout: TimeInterval = 60.0) async throws -> PropagationSyncResult { + try await runOnQueue(on: blockingQueue) { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { + return PropagationSyncResult(ok: false, state: .notStarted, receivedMessages: 0, reason: "not-started") + } + guard let fn = PyObject_GetAttrString(module, "propagation_sync") else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + guard let arg = PyFloat_FromDouble(timeout) else { + throw BridgeError.marshallingFailure("timeout") + } + guard let result = PyObject_CallOneArg(fn, arg) else { + Py_DecRef(arg) + throw BridgeError.pythonException(currentPythonException()) + } + Py_DecRef(arg) + defer { Py_DecRef(result) } + let ok = pyBoolFromDict(result, key: "ok") ?? false + let stateStr = pyStringFromDict(result, key: "state") ?? "" + let reason = pyStringFromDict(result, key: "reason") ?? "" + let count: Int = { + guard let v = PyDict_GetItemString(result, "received_messages") else { return 0 } + return Int(PyLong_AsLongLong(v)) + }() + let state = PropagationSyncResult.State(rawValue: stateStr) ?? .unknown + return PropagationSyncResult(ok: ok, state: state, receivedMessages: count, reason: reason) + } + } + } + + /// Re-broadcast the LXMF delivery destination's announce with the + /// given display name. Returns true on success. + public func announce(displayName: String) async throws -> Bool { + try await callAnnounce(functionName: "announce", displayName: displayName) + } + + /// Re-broadcast the LXST telephony destination's announce so peers can + /// discover us for voice calls. Returns true on success. + public func announceTelephony(displayName: String) async throws -> Bool { + try await callAnnounce(functionName: "announce_telephony", displayName: displayName) + } + + /// Shared CPython call shape for the two announce variants. + private func callAnnounce(functionName: String, displayName: String) async throws -> Bool { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { return false } + guard let fn = PyObject_GetAttrString(module, functionName) else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + guard let arg = PyUnicode_FromString(displayName) else { + throw BridgeError.marshallingFailure("displayName") + } + guard let result = PyObject_CallOneArg(fn, arg) else { + Py_DecRef(arg) + throw BridgeError.pythonException(currentPythonException()) + } + Py_DecRef(arg) + defer { Py_DecRef(result) } + return pyBoolFromDict(result, key: "ok") ?? false + } + } + } + + /// Send an LXMF message via the Python bridge. `method` selects LXMF's + /// desired-method (opportunistic / direct / propagated); the function name + /// stays `sendOpportunistic` historically because that was the only mode + /// when the Swift surface was first carved. + public func sendOpportunistic(destHashHex: String, content: String, fieldsHex: String = "", + method: String = "opportunistic") async throws -> SendOutcome { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { return .notStarted } + guard let fn = PyObject_GetAttrString(module, "send_opportunistic") else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(4) else { throw BridgeError.marshallingFailure("PyTuple_New") } + defer { Py_DecRef(args) } + guard let soDest = PyUnicode_FromString(destHashHex), + let soContent = PyUnicode_FromString(content), + let soFields = PyUnicode_FromString(fieldsHex), + let soMethod = PyUnicode_FromString(method) else { + throw BridgeError.marshallingFailure("PyUnicode_FromString") + } + PyTuple_SetItem(args, 0, soDest) + PyTuple_SetItem(args, 1, soContent) + PyTuple_SetItem(args, 2, soFields) + PyTuple_SetItem(args, 3, soMethod) + guard let result = PyObject_CallObject(fn, args) else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(result) } + let reason = pyStringFromDict(result, key: "reason") ?? "unknown" + let ok = pyBoolFromDict(result, key: "ok") ?? false + if ok { + let hash = pyStringFromDict(result, key: "message_hash") ?? "" + return .queued(messageHash: hash) + } + switch reason { + case "requesting-path": return .requestingPath + case "bad-hash": return .badHash + case "not-started": return .notStarted + default: return .other(reason) + } + } + } + } + + /// Hot-add or hot-remove a single interface on the *running* Reticulum + /// stack — no restart. Calls `rns_bridge.add_interface(name)` / + /// `remove_interface(name)`, which attach/detach against the live + /// `RNS.Transport`. `name` is the config section name (see + /// `PythonConfigWriter.sectionName(for:)`); the caller must have written + /// the full config file first so `add` can read the new section. + /// + /// Returns the Python `{"ok", "reason"}` outcome. Throws only on a hard + /// bridge/marshalling error — a failed add (bad config, unreachable + /// endpoint) comes back as `(ok: false, reason: ...)`, never a crash. + public func applyInterface(name: String, add: Bool) async throws -> (ok: Bool, reason: String) { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { return (false, "not-started") } + let fnName = add ? "add_interface" : "remove_interface" + guard let fn = PyObject_GetAttrString(module, fnName) else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(1) else { throw BridgeError.marshallingFailure("PyTuple_New") } + defer { Py_DecRef(args) } + guard let aiName = PyUnicode_FromString(name) else { + throw BridgeError.marshallingFailure("PyUnicode_FromString") + } + PyTuple_SetItem(args, 0, aiName) // steals ref + guard let result = PyObject_CallObject(fn, args) else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(result) } + let ok = pyBoolFromDict(result, key: "ok") ?? false + let reason = pyStringFromDict(result, key: "reason") ?? "unknown" + return (ok, reason) + } + } + } + + /// Decoded view of the Python `status()` snapshot. Mirrors the JSON + /// rns_bridge.status_json returns. The Swift UI uses this to drive the + /// "interface online / offline" badges in Network Status + Manage + /// Interfaces without re-querying the C-level RNS Transport state. + public struct StatusSnapshot: Decodable, Sendable { + public let started: Bool + public let interfaces: [InterfaceStatus] + public let destinationTableSize: Int? + public let pathTableSize: Int? + + public struct InterfaceStatus: Decodable, Sendable { + /// Config section name (matches what PythonConfigWriter wrote). + /// Dynamically-spawned interfaces (AutoInterfacePeer, etc.) can + /// have `name=None` upstream, which serializes as JSON null; + /// tolerate it by decoding optional and substituting "". + public let sectionName: String + /// Friendly `"TCPInterface[section/host:port]"` representation. + public let name: String + public let online: Bool + public let rxBytes: Int + public let txBytes: Int + + enum CodingKeys: String, CodingKey { + case sectionName = "section_name" + case name + case online + case rxBytes = "rx_bytes" + case txBytes = "tx_bytes" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.sectionName = (try? c.decode(String?.self, forKey: .sectionName)) ?? "" + self.name = (try? c.decode(String?.self, forKey: .name)) ?? "" + 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 + } + } + + enum CodingKeys: String, CodingKey { + case started + case interfaces + case destinationTableSize = "destination_table_size" + case pathTableSize = "path_table_size" + } + } + + /// Read RNS Transport diagnostic info — interfaces, online state, table sizes. + public func status() async -> StatusSnapshot? { + let raw: String? = await withCheckedContinuation { cont in + queue.async { + let out = PythonRuntime.shared.withGIL { () -> String? in + guard let module = self.module else { return nil } + guard let fn = PyObject_GetAttrString(module, "status_json") else { + let exc = self.currentPythonException() + DiagLog_status("status_json attr lookup failed: \(exc)") + return nil + } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(0) else { return nil } + defer { Py_DecRef(args) } + guard let result = PyObject_CallObject(fn, args) else { + let exc = self.currentPythonException() + DiagLog_status("status_json call raised: \(exc)") + return nil + } + defer { Py_DecRef(result) } + guard let c = PyUnicode_AsUTF8(result) else { + DiagLog_status("status_json returned non-str") + return nil + } + return String(cString: c) + } + cont.resume(returning: out) + } + } + guard let raw, let data = raw.data(using: .utf8) else { return nil } + do { + return try JSONDecoder().decode(StatusSnapshot.self, from: data) + } catch { + DiagLog_status("decode failed: \(error) raw=\(raw.prefix(200))") + return nil + } + } + + /// Invoke a no-arg module-level function in `rns_bridge` that returns + /// a Python string. Used for diagnostic hooks that want to surface + /// a string back to Swift (e.g., interface-load error messages). + /// Returns nil on Python error or if the function doesn't exist. + public func callModuleFunctionReturningString(name: String) async -> String? { + await withCheckedContinuation { cont in + queue.async { [self] in + let out = PythonRuntime.shared.withGIL { () -> String? in + guard let module = self.module else { return nil } + guard let fn = PyObject_GetAttrString(module, name) else { + PyErr_Clear() + return nil + } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(0) else { return nil } + defer { Py_DecRef(args) } + guard let r = PyObject_CallObject(fn, args) else { + PyErr_Print() + return nil + } + defer { Py_DecRef(r) } + guard let c = PyUnicode_AsUTF8(r) else { return nil } + return String(cString: c) + } + cont.resume(returning: out) + } + } + } + + /// Invoke a no-arg module-level function in `rns_bridge`. Returns true if + /// the call completes without raising; the function's return value is + /// discarded. Convenience for test hooks and one-shot helpers. + public func callModuleFunctionNoArgs(name: String) async -> Bool { + await withCheckedContinuation { cont in + queue.async { [self] in + let ok = PythonRuntime.shared.withGIL { () -> Bool in + guard let module = self.module else { return false } + guard let fn = PyObject_GetAttrString(module, name) else { + PyErr_Clear() + return false + } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(0) else { return false } + defer { Py_DecRef(args) } + guard let r = PyObject_CallObject(fn, args) else { + PyErr_Print() + return false + } + Py_DecRef(r) + return true + } + cont.resume(returning: ok) + } + } + } + + /// Drain pending events from the Python-side queue.Queue. Returns empty list + /// if the bridge isn't started yet. + public func drainEvents() async -> [Event] { + await withCheckedContinuation { cont in + queue.async { + let events = PythonRuntime.shared.withGIL { () -> [Event] in + guard let module = self.module else { return [] } + guard let fn = PyObject_GetAttrString(module, "drain_events") else { return [] } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(0) else { return [] } + defer { Py_DecRef(args) } + guard let result = PyObject_CallObject(fn, args) else { return [] } + defer { Py_DecRef(result) } + return self.parseEventList(result) + } + cont.resume(returning: events) + } + } + } + + // MARK: - Private + + private func runOnQueue( + on targetQueue: DispatchQueue? = nil, + _ body: @escaping @Sendable () throws -> T + ) async throws -> T { + try await withCheckedThrowingContinuation { cont in + (targetQueue ?? queue).async { + do { cont.resume(returning: try body()) } + catch { cont.resume(throwing: error) } + } + } + } + + private func ensureModuleLoaded() throws { + if module != nil { return } + guard let m = PyImport_ImportModule("rns_bridge") else { + throw BridgeError.pythonException(currentPythonException()) + } + module = m + } + + private func currentPythonException() -> String { + // Drain the error indicator into a string. Mirrors how PyErr_Print would format. + guard PyErr_Occurred() != nil else { return "(no error set)" } + var ptype: UnsafeMutablePointer? + var pvalue: UnsafeMutablePointer? + var ptraceback: UnsafeMutablePointer? + PyErr_Fetch(&ptype, &pvalue, &ptraceback) + PyErr_NormalizeException(&ptype, &pvalue, &ptraceback) + defer { + if let p = ptype { Py_DecRef(p) } + if let p = pvalue { Py_DecRef(p) } + if let p = ptraceback { Py_DecRef(p) } + } + var description = "Python error" + if let value = pvalue, let str = PyObject_Str(value) { + defer { Py_DecRef(str) } + if let c = PyUnicode_AsUTF8(str) { description = String(cString: c) } + } + if let type = ptype, let tname = PyObject_GetAttrString(type, "__name__") { + defer { Py_DecRef(tname) } + if let c = PyUnicode_AsUTF8(tname) { + description = "\(String(cString: c)): \(description)" + } + } + return description + } + + private func extractLocalInfo(_ d: UnsafeMutablePointer) throws -> LocalInfo { + guard let identityHash = pyStringFromDict(d, key: "identity_hash"), + let destinationHash = pyStringFromDict(d, key: "destination_hash") else { + throw BridgeError.marshallingFailure("LocalInfo dict missing fields") + } + return LocalInfo(identityHash: identityHash, destinationHash: destinationHash) + } + + private func pyStringFromDict(_ d: UnsafeMutablePointer, key: String) -> String? { + guard let item = PyDict_GetItemString(d, key) else { return nil } + guard let c = PyUnicode_AsUTF8(item) else { return nil } + return String(cString: c) + } + + private func pyBoolFromDict(_ d: UnsafeMutablePointer, key: String) -> Bool? { + guard let item = PyDict_GetItemString(d, key) else { return nil } + return PyObject_IsTrue(item) == 1 + } + + private func pyDoubleFromDict(_ d: UnsafeMutablePointer, key: String) -> Double? { + guard let item = PyDict_GetItemString(d, key) else { return nil } + let v = PyFloat_AsDouble(item) + if v == -1.0 && PyErr_Occurred() != nil { PyErr_Clear(); return nil } + return v + } + + private func parseEventList(_ list: UnsafeMutablePointer) -> [Event] { + let count = PyObject_Length(list) + guard count > 0 else { return [] } + var out: [Event] = [] + for i in 0.., key: String) -> Int? { + guard let item = PyDict_GetItemString(d, key) else { return nil } + let v = PyLong_AsLongLong(item) + if v == -1 && PyErr_Occurred() != nil { PyErr_Clear(); return nil } + return Int(v) + } + + // MARK: - RNS.Link ops (voice calls) + + /// Open an outbound RNS.Link to a destination. Default aspect is + /// `lxst.telephony` (voice); pass another aspect string for other + /// Link-based protocols (e.g. NomadNet page browsing already uses + /// its own one-shot path, so this is currently voice-only). + /// + /// Returns the Python-side `link_id` on success. A subsequent + /// `.linkState(linkId:, state: "established")` event fires once + /// the link is up. + public func openLink(destHashHex: String, aspect: String = "lxst.telephony") async throws -> (ok: Bool, linkId: Int, reason: String) { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { + return (false, 0, "not-started") + } + guard let fn = PyObject_GetAttrString(module, "open_link") else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(2) else { throw BridgeError.marshallingFailure("PyTuple_New") } + defer { Py_DecRef(args) } + guard let olDest = PyUnicode_FromString(destHashHex), + let olAspect = PyUnicode_FromString(aspect) else { + throw BridgeError.marshallingFailure("PyUnicode_FromString") + } + PyTuple_SetItem(args, 0, olDest) + PyTuple_SetItem(args, 1, olAspect) + guard let result = PyObject_CallObject(fn, args) else { + throw BridgeError.pythonException(currentPythonException()) + } + defer { Py_DecRef(result) } + let ok = pyBoolFromDict(result, key: "ok") ?? false + let linkId = pyIntFromDict(result, key: "link_id") ?? 0 + let reason = pyStringFromDict(result, key: "reason") ?? "" + return (ok, linkId, reason) + } + } + } + + /// Send opaque bytes over an open Link. Returns true on success. + @discardableResult + public func linkSend(linkId: Int, data: Data) async throws -> Bool { + let hex = data.map { String(format: "%02x", $0) }.joined() + return try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { return false } + guard let fn = PyObject_GetAttrString(module, "link_send") else { return false } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(2) else { return false } + defer { Py_DecRef(args) } + guard let lsHex = PyUnicode_FromString(hex) else { return false } + PyTuple_SetItem(args, 0, PyLong_FromLongLong(Int64(linkId))) + PyTuple_SetItem(args, 1, lsHex) + guard let result = PyObject_CallObject(fn, args) else { return false } + defer { Py_DecRef(result) } + return pyBoolFromDict(result, key: "ok") ?? false + } + } + } + + /// Identify our local identity on the Link to the remote. + @discardableResult + public func linkIdentify(linkId: Int) async throws -> Bool { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { return false } + guard let fn = PyObject_GetAttrString(module, "link_identify") else { return false } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(1) else { return false } + defer { Py_DecRef(args) } + PyTuple_SetItem(args, 0, PyLong_FromLongLong(Int64(linkId))) + guard let result = PyObject_CallObject(fn, args) else { return false } + defer { Py_DecRef(result) } + return pyBoolFromDict(result, key: "ok") ?? false + } + } + } + + /// Tear down a Link from our side. + @discardableResult + public func linkTeardown(linkId: Int) async throws -> Bool { + try await runOnQueue { [self] in + try PythonRuntime.shared.withGIL { [self] in + guard let module = self.module else { return false } + guard let fn = PyObject_GetAttrString(module, "link_teardown") else { return false } + defer { Py_DecRef(fn) } + guard let args = PyTuple_New(1) else { return false } + defer { Py_DecRef(args) } + PyTuple_SetItem(args, 0, PyLong_FromLongLong(Int64(linkId))) + guard let result = PyObject_CallObject(fn, args) else { return false } + defer { Py_DecRef(result) } + return pyBoolFromDict(result, key: "ok") ?? false + } + } + } +} + +private extension Data { + /// Decode a hex-encoded string into raw bytes. Tolerates uppercase + /// + mixed case. Returns nil if length is odd or any character is + /// out of range. + init?(hexEncoded hex: String) { + guard hex.count % 2 == 0 else { return nil } + var out = Data(capacity: hex.count / 2) + var idx = hex.startIndex + while idx < hex.endIndex { + let next = hex.index(idx, offsetBy: 2) + guard let byte = UInt8(hex[idx.. Int in + self.invokeBLECallbackLocked(slot: slot, args: args) + return 0 + } + } + } + + /// Synchronous bool-return invocation. Backs upstream's + /// `on_duplicate_identity_detected(addr, identity_bytes) -> bool`. The + /// CoreBluetooth delegate calling this is expected to block on the result; + /// we sync to the Python queue, acquire the GIL, invoke, and return the bool. + /// + /// MUST NOT be called from the Python queue itself (re-entrancy would + /// deadlock). The BLE queue is a different DispatchQueue, so calling from + /// CB delegate methods is safe. + func invokeBLECallbackBoolSync(slot: String, args: [BLEArg]) -> Bool { + queue.sync { [self] in + PythonRuntime.shared.withGIL { () -> Bool in + let result = self.invokeBLECallbackLocked(slot: slot, args: args, wantResult: true) + if let pyResult = result { + defer { Py_DecRef(pyResult) } + return PyObject_IsTrue(pyResult) == 1 + } + return false + } + } + } + + /// 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). + @discardableResult + private func invokeBLECallbackLocked( + slot: String, + args: [BLEArg], + wantResult: Bool = false, + getterName: String = "_ble_get_callback" + ) -> UnsafeMutablePointer? { + guard let module = self.module else { return nil } + guard let getterFn = PyObject_GetAttrString(module, getterName) else { + PyErr_Clear() + return nil + } + defer { Py_DecRef(getterFn) } + guard let slotStr = PyUnicode_FromString(slot) else { return nil } + guard let callable = PyObject_CallOneArg(getterFn, slotStr) else { + Py_DecRef(slotStr) + PyErr_Clear() + return nil + } + Py_DecRef(slotStr) + defer { Py_DecRef(callable) } + // None means "no callback registered" — silent no-op. + if isNone(callable) { return nil } + guard let argsTuple = bleArgsToPyTuple(args) else { return nil } + defer { Py_DecRef(argsTuple) } + guard let result = PyObject_CallObject(callable, argsTuple) else { + // Print the traceback to stderr so RNS log captures it; clear + // the error indicator so subsequent Python calls don't see it. + PyErr_Print() + return nil + } + if wantResult { return result } + Py_DecRef(result) + return nil + } + + private func bleArgsToPyTuple(_ args: [BLEArg]) -> UnsafeMutablePointer? { + guard let tuple = PyTuple_New(args.count) else { return nil } + for (idx, arg) in args.enumerated() { + guard let pyobj = bleArgToPy(arg) else { + Py_DecRef(tuple) + return nil + } + PyTuple_SetItem(tuple, idx, pyobj) // steals ref + } + return tuple + } + + private func bleArgToPy(_ arg: BLEArg) -> UnsafeMutablePointer? { + switch arg { + case .string(let s): + return PyUnicode_FromString(s) + case .int(let i): + return PyLong_FromLongLong(Int64(i)) + case .bool(let b): + return b ? ColumbaPy_True() : ColumbaPy_False() + case .bytes(let data): + return data.withUnsafeBytes { raw -> UnsafeMutablePointer? in + guard let base = raw.baseAddress else { + return PyBytes_FromStringAndSize(nil, 0) + } + return PyBytes_FromStringAndSize( + base.assumingMemoryBound(to: CChar.self), + raw.count + ) + } + case .stringList(let strs): + guard let list = PyList_New(strs.count) else { return nil } + for (idx, s) in strs.enumerated() { + guard let u = PyUnicode_FromString(s) else { + Py_DecRef(list) + return nil + } + PyList_SetItem(list, idx, u) // steals ref + } + return list + case .none: + return ColumbaPy_None() + } + } + + private func isNone(_ obj: UnsafeMutablePointer) -> Bool { + guard let none = ColumbaPy_None() else { return false } + defer { Py_DecRef(none) } + return obj == none + } +} diff --git a/Sources/PythonBridge/PythonRNodeCallbackBridge.swift b/Sources/PythonBridge/PythonRNodeCallbackBridge.swift new file mode 100644 index 00000000..a7569432 --- /dev/null +++ b/Sources/PythonBridge/PythonRNodeCallbackBridge.swift @@ -0,0 +1,56 @@ +// +// 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/PythonBridge/PythonRuntime.swift b/Sources/PythonBridge/PythonRuntime.swift new file mode 100644 index 00000000..343ed97a --- /dev/null +++ b/Sources/PythonBridge/PythonRuntime.swift @@ -0,0 +1,175 @@ +import Foundation + +/// Owns the embedded Python interpreter lifecycle. +/// +/// BeeWare's `install_python` build script lays out the app bundle like: +/// /python/lib/python3.13/ — full stdlib (PYTHONHOME) +/// /app/ — our Python application code (sys.path[0]) +/// /app_packages/ — installed wheels (site-packages) +/// /Frameworks/*.framework — per-`.so` frameworks created at build time +/// +/// Init sequence mirrors BeeWare's testbed (isolated config, PYTHONHOME, signal +/// handlers, site.addsitedir for app_packages, sys.path[0] = app). Must be called +/// exactly once before any other Python operation. +/// +/// After init, the calling thread releases the GIL via `PyEval_SaveThread()` so +/// RNS / LXMF background threads can run. All subsequent Python work must be +/// wrapped in `withGIL { ... }` which acquires and releases the GIL for the +/// duration of the closure — works from any thread. +final class PythonRuntime { + static let shared = PythonRuntime() + + enum State { case uninitialized, running, failed(String), finalized } + private(set) var state: State = .uninitialized + + /// Returned by `PyEval_SaveThread()` after init; the embed thread's saved state. + /// We don't actually re-use this — `PyGILState_Ensure/Release` is reentrant + /// and works from any thread — but we keep it alive so `Py_Finalize` can find + /// it if we ever shut down cleanly. + private var savedThreadState: OpaquePointer? + + private init() {} + + /// Initialize Python. Returns `sys.version` on success. + @discardableResult + func start() -> Result { + guard case .uninitialized = state else { + return .failure(RuntimeError.alreadyStarted) + } + + let bundlePath = Bundle.main.resourcePath ?? Bundle.main.bundlePath + let pythonHome = "\(bundlePath)/python" + let appPath = "\(bundlePath)/app" + let appPackagesPath = "\(bundlePath)/app_packages" + + setenv("NO_COLOR", "1", 1) + setenv("PYTHON_COLORS", "0", 1) + + var preconfig = PyPreConfig() + PyPreConfig_InitIsolatedConfig(&preconfig) + preconfig.utf8_mode = 1 + + var pyStatus = Py_PreInitialize(&preconfig) + if PyStatus_Exception(pyStatus) != 0 { + return failed("Py_PreInitialize: \(message(pyStatus))") + } + + var config = PyConfig() + PyConfig_InitIsolatedConfig(&config) + config.buffered_stdio = 0 + config.write_bytecode = 0 + config.install_signal_handlers = 1 + + if let homeWide = Py_DecodeLocale(pythonHome, nil) { + pyStatus = ColumbaPy_PyConfig_SetHome(&config, homeWide) + PyMem_RawFree(homeWide) + if PyStatus_Exception(pyStatus) != 0 { + PyConfig_Clear(&config) + return failed("PyConfig_SetString(home): \(message(pyStatus))") + } + } + + pyStatus = PyConfig_Read(&config) + if PyStatus_Exception(pyStatus) != 0 { + PyConfig_Clear(&config) + return failed("PyConfig_Read: \(message(pyStatus))") + } + + pyStatus = Py_InitializeFromConfig(&config) + PyConfig_Clear(&config) + if PyStatus_Exception(pyStatus) != 0 { + return failed("Py_InitializeFromConfig: \(message(pyStatus))") + } + + if !addSiteDir(appPackagesPath) { + return failed("Failed to add app_packages site dir at \(appPackagesPath)") + } + if !prependSysPath(appPath) { + return failed("Failed to prepend \(appPath) to sys.path") + } + FileManager.default.changeCurrentDirectoryPath(appPath) + + guard let version = readSysVersion() else { + return failed("Could not read sys.version after init") + } + + // Release the GIL so RNS / LXMF threads can run when started later. + // PyGILState_Ensure/Release will re-acquire it from any thread for each call. + savedThreadState = OpaquePointer(PyEval_SaveThread()) + + state = .running + return .success(version) + } + + /// Run a block while holding the Python GIL. Safe to call from any thread. + /// Nested calls are fine — PyGILState_Ensure/Release is reentrant. + func withGIL(_ body: () throws -> T) rethrows -> T { + let gilState = PyGILState_Ensure() + defer { PyGILState_Release(gilState) } + return try body() + } + + private func addSiteDir(_ path: String) -> Bool { + guard let siteModule = PyImport_ImportModule("site") else { return false } + defer { Py_DecRef(siteModule) } + guard let addsitedir = PyObject_GetAttrString(siteModule, "addsitedir") else { return false } + defer { Py_DecRef(addsitedir) } + guard PyCallable_Check(addsitedir) != 0 else { return false } + guard let pathObj = PyUnicode_FromString(path) else { return false } + guard let result = PyObject_CallOneArg(addsitedir, pathObj) else { + Py_DecRef(pathObj) + return false + } + Py_DecRef(pathObj) + Py_DecRef(result) + return true + } + + private func prependSysPath(_ path: String) -> Bool { + guard let sysModule = PyImport_ImportModule("sys") else { return false } + defer { Py_DecRef(sysModule) } + guard let sysPath = PyObject_GetAttrString(sysModule, "path") else { return false } + defer { Py_DecRef(sysPath) } + guard let pathObj = PyUnicode_FromString(path) else { return false } + let result = PyList_Insert(sysPath, 0, pathObj) + Py_DecRef(pathObj) + return result == 0 + } + + private func readSysVersion() -> String? { + guard let sysModule = PyImport_ImportModule("sys") else { + PyErr_Print() + return nil + } + defer { Py_DecRef(sysModule) } + guard let versionObj = PyObject_GetAttrString(sysModule, "version") else { + PyErr_Print() + return nil + } + defer { Py_DecRef(versionObj) } + guard let cstr = PyUnicode_AsUTF8(versionObj) else { return nil } + return String(cString: cstr) + } + + private func message(_ status: PyStatus) -> String { + if let cstr = status.err_msg { return String(cString: cstr) } + return "(no message)" + } + + private func failed(_ reason: String) -> Result { + state = .failed(reason) + return .failure(RuntimeError.initFailed(reason)) + } + + enum RuntimeError: LocalizedError { + case alreadyStarted + case initFailed(String) + + var errorDescription: String? { + switch self { + case .alreadyStarted: return "Python runtime already started" + case .initFailed(let reason): return "Python init failed: \(reason)" + } + } + } +} diff --git a/Sources/RNSAPI/Compat.swift b/Sources/RNSAPI/Compat.swift new file mode 100644 index 00000000..86187bf0 --- /dev/null +++ b/Sources/RNSAPI/Compat.swift @@ -0,0 +1,2288 @@ +import Foundation +import CryptoKit +import SQLite3 + +// ───────────────────────────────────────────────────────────────────────────── +// RNSAPI v1 compatibility layer — types that mirror the public API surface +// of the deleted AI-Swift libraries (reticulum-swift / LXMF-swift / LXSTSwift) +// so the existing Columba iOS UI compiles unchanged on top of the new +// Python-backed protocol layer. +// +// Real behavior wired in: +// * Identity (separate file): Keychain + CryptoKit key derivation +// * Destination.hash(...) helpers (SHA-256 truncation, matches RNS wire) +// +// Stubbed (compile-only, body returns nil/empty/false or no-ops): +// * Most interface lifecycle methods (connect/disconnect/send/etc.) +// * RatchetManager +// * Callback registration (callback manager not wired) +// * Crypto methods (sign/verify/encrypt/decrypt — Python does the work) +// * Database persistence (Python sqlite3 store is the truth) +// +// Each stub is small enough that a runtime call no-ops rather than crashes. +// Buttons that hit stubbed paths will appear to do nothing — by design for +// v1; the bridge wiring for each lands in RNSBackendPy as feature areas +// come back online (BLE, AutoInterface, etc.). +// ───────────────────────────────────────────────────────────────────────────── + +// MARK: - Hash helpers + +public enum Hashing { + /// 10-byte truncated SHA-256 of the dotted destination name. + public static func destinationNameHash(appName: String, aspects: [String]) -> Data { + var name = appName + for aspect in aspects { name += "." + aspect } + return Data(SHA256.hash(data: name.data(using: .utf8) ?? Data())).prefix(10) + } + + /// 16-byte truncated SHA-256 (canonical RNS truncation). + public static func truncatedHash(_ data: Data) -> Data { + Data(SHA256.hash(data: data)).prefix(16) + } + + /// 16-byte identity hash from concatenated 32+32 public keys. + public static func identityHash(encryptionPublicKey: Data, signingPublicKey: Data) -> Data { + Data(SHA256.hash(data: encryptionPublicKey + signingPublicKey)).prefix(16) + } +} + +// MARK: - Enums + +public enum DestType: UInt8, Sendable { + case single = 0x00 + case group = 0x01 + case plain = 0x02 + case link = 0x03 +} + +public enum DestinationType: String, Equatable, Sendable { + case single, plain, link, group +} + +public enum DestinationDirection: Sendable { + case `in` + case out +} + +// Note: Columba defines its own `InterfaceType` in +// Sources/ColumbaApp/Services/InterfaceRepository.swift with UI-layer cases +// (tcpClient, tcpServer, multipeer, ...). The protocol-layer +// `InterfaceConfig.type` below uses a separate enum (`WireInterfaceType`) +// keyed to the wire-side names AppServices already passes (`.tcp`, `.ble`, +// `.autoInterface`, `.rnode`, `.multipeerConnectivity`). +public enum WireInterfaceType: String, Sendable, Equatable { + case tcp + case udp + case i2p + case autoInterface + case rnode + case ble + case multipeerConnectivity +} + +public enum InterfaceState: Sendable, Equatable { + case disconnected + case connecting + case connected + case reconnecting(attempt: Int = 0) + case connectionFailed(underlying: String) + case sendFailed(underlying: String) + case notConnected + case invalidConfig(reason: String) +} + +public enum LXDeliveryMethod: String, Equatable, Sendable { + case opportunistic, direct, propagated, paper, unknown +} + +public enum LXMessageState: String, Equatable, Sendable, Codable { + case draft, outbound, sending, sent, delivered, failed, received +} + +public enum LXMessageRepresentation: String, Equatable, Sendable { + case unknown, opportunistic, direct, propagated +} + +public enum LXUnverifiedReason: String, Equatable, Sendable { + case signatureMismatch, missingIdentity, malformed +} + +public enum LXMFError: Error, LocalizedError, Sendable { + case routerNotInitialized + case destinationNotFound + case sendFailed(String) + case deliveryTimeout + case other(String) + + public var errorDescription: String? { + switch self { + case .routerNotInitialized: return "LXMRouter not initialized" + case .destinationNotFound: return "Destination not found" + case .sendFailed(let s): return "Send failed: \(s)" + case .deliveryTimeout: return "Delivery timeout" + case .other(let s): return s + } + } +} + +// MARK: - Destination + +/// Reticulum destination. Mirrors the AI-Swift `Destination` class so the +/// existing Columba UI compiles unchanged. Hash computation uses real SHA-256 +/// truncation (matches RNS wire format). Callbacks / ratchets / streams are +/// stub no-ops — those wire through `RNSBackendPy` in later commits. +public final class Destination: @unchecked Sendable { + public let identity: Identity? + public let appName: String + public let aspects: [String] + public let destinationType: DestType + public var direction: DestinationDirection + public var appData: Data? + public var ratchetManager: RatchetManager? + public private(set) var ratchetsEnabled: Bool = false + public private(set) var ratchetsEnforced: Bool = false + + public var hash: Data { + switch destinationType { + case .single, .link: + guard let identity else { return Destination.plainHash(appName: appName, aspects: aspects) } + return Destination.hash(identity: identity, appName: appName, aspects: aspects) + case .plain, .group: + return Destination.plainHash(appName: appName, aspects: aspects) + } + } + + public var publicKeys: Data? { identity?.publicKeys } + public var nameHash: Data { Hashing.destinationNameHash(appName: appName, aspects: aspects) } + public var fullName: String { aspects.isEmpty ? appName : appName + "." + aspects.joined(separator: ".") } + public var announceNameHash: Data { nameHash } + public var hexHash: String { hash.toHex() } + + public init( + identity: Identity, + appName: String, + aspects: [String] = [], + type: DestType = .single, + direction: DestinationDirection = .in + ) { + self.identity = identity + self.appName = appName + self.aspects = aspects + self.destinationType = type + self.direction = direction + } + + public init( + plainAppName appName: String, + aspects: [String] = [], + direction: DestinationDirection = .in + ) { + self.identity = nil + self.appName = appName + self.aspects = aspects + self.destinationType = .plain + self.direction = direction + } + + public func enableRatchets(storagePath: String) async throws { + ratchetsEnabled = true + ratchetManager = RatchetManager() + } + + public func enforceRatchets() { ratchetsEnforced = ratchetsEnabled } + + public static func hash(identity: Identity, appName: String, aspects: [String] = []) -> Data { + var combined = Hashing.destinationNameHash(appName: appName, aspects: aspects) + combined.append(identity.hash) + return Hashing.truncatedHash(combined) + } + + public static func plainHash(appName: String, aspects: [String] = []) -> Data { + Hashing.truncatedHash(Hashing.destinationNameHash(appName: appName, aspects: aspects)) + } + + public static func hash( + encryptionPublicKey: Data, + signingPublicKey: Data, + appName: String, + aspects: [String] = [] + ) -> Data { + let idHash = Hashing.identityHash(encryptionPublicKey: encryptionPublicKey, signingPublicKey: signingPublicKey) + var combined = Hashing.destinationNameHash(appName: appName, aspects: aspects) + combined.append(idHash) + return Hashing.truncatedHash(combined) + } +} + +extension Destination: CustomStringConvertible { + public var description: String { "Destination<\(fullName):\(hexHash.prefix(8))...>" } +} + +public enum DestinationError: Error, Sendable { + case identityRequired + case plainCannotAnnounce + case invalidAppName + case callbackManagerNotSet +} + +public final class RatchetManager: @unchecked Sendable { + public init() {} + public init(storagePath: String, identity: Identity) {} + public func loadOrCreate() async throws {} + public func rotateIfNeeded() async {} + /// Called both as a property and as a method in different sites — provide both. + public func currentRatchetPublicBytes() async -> Data? { nil } +} + +// MARK: - Packet / Announce / Link + +public struct Packet: Equatable, Sendable { + public let payload: Data + public init(payload: Data = Data()) { self.payload = payload } + public func encode() -> Data { payload } +} + +public struct Announce: Identifiable, Equatable, Sendable { + public let destinationHash: Data + public var displayName: String + public var firstSeen: Date + public var lastSeen: Date + public var hopCount: Int + public var signalStrength: Int? + public var isRelay: Bool + public var badgeType: BadgeType + public var icon: IconAppearance? + + public var id: Data { destinationHash } + + public enum BadgeType: String, Equatable, Sendable { + case lxmfDelivery, lxmfPropagation, nomadnetNode, lxstTelephony, unknown + } + + public init( + destinationHash: Data, + displayName: String = "", + firstSeen: Date = Date(), + lastSeen: Date = Date(), + hopCount: Int = 0, + signalStrength: Int? = nil, + isRelay: Bool = false, + badgeType: BadgeType = .lxmfDelivery, + icon: IconAppearance? = nil + ) { + self.destinationHash = destinationHash + self.displayName = displayName + self.firstSeen = firstSeen + self.lastSeen = lastSeen + self.hopCount = hopCount + self.signalStrength = signalStrength + self.isRelay = isRelay + self.badgeType = badgeType + self.icon = icon + } + + public init(destination: Destination, ratchet: Data? = nil) { + self.destinationHash = destination.hash + self.displayName = "" + self.firstSeen = Date() + self.lastSeen = Date() + self.hopCount = 0 + self.signalStrength = nil + self.isRelay = false + self.badgeType = .lxmfDelivery + self.icon = nil + } + + public func buildPacket() throws -> Packet { Packet() } +} + +public protocol AnnounceHandler: AnyObject { + var aspectFilter: String? { get } + func receivedAnnounce(destinationHash: Data, identity: Identity, appData: Data?) +} + +/// Reason a Link was torn down. Mirrors the reticulum-swift teardown reasons +/// and the Python `RNS.Link.closed_callback` reason argument +/// (0 = link timeout, 1 = initiator closed, 2 = destination closed, 3 = network failure). +public enum TeardownReason: String, Equatable, Sendable { + case timeout + case initiatorClosed + case destinationClosed + case networkFailure +} + +/// Protocol for Link identification callbacks — fires when the remote peer +/// sends a `LINK_IDENTIFY` packet revealing their identity. Mirrors the +/// reticulum-swift `IdentifyCallbacks` surface lxst-swift's Telephone uses. +public protocol IdentifyCallbacks: AnyObject, Sendable { + func remoteIdentified(_ identity: Identity) async +} + +public final class Link: @unchecked Sendable { + public enum State: Equatable, Sendable { + case pending, active, closed, stale, established + public var isEstablished: Bool { self == .active || self == .established } + } + + public let identityHash: Data + public var state: State = .pending + public var label: String = "" + public var url: String? + public var fieldNames: [String] = [] + public var endIndex: Int { 0 } + + /// Bridge-allocated Link ID. Set by AppServices when wrapping a Python + /// link_state event into a Compat Link instance; zero on Links that + /// never crossed the bridge (e.g., the stub from `initiateLink`). + public var linkId: UInt64 = 0 + + /// Hook installed by AppServices that forwards bytes to the Python + /// RNS.Link via `PythonRNSBackend.linkSend(linkId:data:)`. Set once + /// the Link is wired; nil until then. + public var sendBytesHook: (@Sendable (Data) async throws -> Void)? + + /// Hook installed by AppServices that forwards a LINKIDENTIFY request + /// to the Python RNS.Link via `PythonRNSBackend.linkIdentify(linkId:)`. + /// Telephone calls Link.identify(identity:) after receiving AVAILABLE + /// on an outbound call; that has to translate into the Python-side + /// `link.identify(identity)` call so the remote peer learns our + /// identity and can apply its caller-allowed filter. + public var identifyHook: (@Sendable () async throws -> Void)? + + /// Hooks installed by lxst-swift via `setCloseCallback` / + /// `setIdentifyCallbacks` — invoked by AppServices when the corresponding + /// Python events fire (link_state(closed) → closeCallback; + /// link_identified → identifyCallbacks.remoteIdentified). + public var closeCallback: (@Sendable (TeardownReason) async -> Void)? + /// Strong reference to the identify handler. lxst-swift's Telephone + /// creates a `TelephoneIdentifyHandler` inline and hands it off here + /// with no caller-held reference; if this was weak the handler would + /// deallocate before Python's link_identified event arrived and the + /// inbound-call flow would silently stall at AVAILABLE. + public var identifyCallbacks: (any IdentifyCallbacks)? + + /// Fired when the link finishes establishing. AppServices invokes this + /// when Python emits link_state(state=established) for this linkId. + public var establishedCallback: (@Sendable (Link) async -> Void)? + + /// Packet callback for inbound data on the Link. Installed by lxst-swift + /// (LinkSource); fired by AppServices when a Python link_packet event + /// arrives for this linkId. Carries the decrypted bytes from the remote. + public var packetCallback: (@Sendable (Data, Packet) async -> Void)? + + public init(identityHash: Data) { self.identityHash = identityHash } + + public func identify(_ identity: Identity) { + // Synchronous form — fire-and-forget through the async hook. + if let hook = identifyHook { + Task { try? await hook() } + } + } + public func identify(identity: Identity) async throws { + if let hook = identifyHook { + try await hook() + } + } + public func close() { state = .closed } + public func request(_ path: String) {} + public func request(_ path: String, data: Any? = nil, responseTimeout: TimeInterval? = nil) async throws -> RequestReceipt { + RequestReceipt(linkIdentityHash: identityHash, path: path) + } + public var stateUpdates: AsyncStream { AsyncStream { _ in } } + + /// Pass-through for Compat-layer Links — the Python `RNS.Link` + /// transparently encrypts every Packet payload when the Packet is built, + /// so callers like lxst-swift's Packetizer that historically called + /// `link.encrypt(_:)` before handing bytes to the transport just get the + /// data back unchanged on iOS. + public func encrypt(_ data: Data) async throws -> Data { data } + + /// Pass-through to mirror `encrypt(_:)` for symmetry. The Python side + /// also decrypts inbound link Packet payloads transparently before the + /// `link_packet` event is emitted, so the bytes lxst-swift receives are + /// already plaintext. + public func decrypt(_ data: Data) async throws -> Data { data } + + /// Send bytes over the link. Forwards to `sendBytesHook` when wired; + /// silently drops on unwired Links (e.g., the stub from `initiateLink`). + public func sendBytes(_ data: Data) async throws { + if let hook = sendBytesHook { try await hook(data) } + } + + /// Install the close-reason callback (lxst-swift Telephone uses this). + /// Accepts nil so the caller can clear the callback before closing the + /// link to suppress a spurious "remote closed" delivery during local + /// hangup. + public func setCloseCallback(_ callback: (@Sendable (TeardownReason) async -> Void)?) async { + self.closeCallback = callback + } + + /// Install the identify-callbacks bridge. Accepts nil to clear. + public func setIdentifyCallbacks(_ callbacks: (any IdentifyCallbacks)?) async { + self.identifyCallbacks = callbacks + } + + /// Install the packet callback. lxst-swift's LinkSource calls this after + /// constructing itself; AppServices forwards Python link_packet events + /// here. + public func setPacketCallback(_ callback: @escaping @Sendable (Data, Packet) async -> Void) async { + self.packetCallback = callback + } + + /// Install the established callback — fires once the Python side reports + /// link_state(state=established) for this linkId. CallManager uses this + /// to defer "send AVAILABLE" until after the LRRTT handshake completes + /// (sending earlier would race the encryption-key setup). + public func setLinkEstablishedCallback(_ callback: @escaping @Sendable (Link) async -> Void) async { + self.establishedCallback = callback + } +} + +public struct RequestReceipt: Equatable, Sendable { + public let linkIdentityHash: Data + public let path: String + public init(linkIdentityHash: Data, path: String) { + self.linkIdentityHash = linkIdentityHash + self.path = path + } + public var statusUpdates: AsyncStream { AsyncStream { _ in } } + public func awaitResponse(timeout: TimeInterval) async throws -> Data? { nil } +} + +public enum MessagePackValue: Hashable, Sendable { + case `nil` + case bool(Bool) + case int(Int64) + case uint(UInt64) + case float(Float) + case double(Double) + case string(String) + case binary(Data) + indirect case array([MessagePackValue]) + indirect case map([MessagePackValue: MessagePackValue]) +} + +// `packMsgPack` / `unpackMsgPack` live in RNSAPI/Util/MsgPack.swift — real +// implementation supporting the wire-format subset LXST uses. + +// MARK: - IconAppearance + +public struct IconAppearance: Codable, Equatable, Sendable { + public static let fieldKey: UInt8 = 0x04 + + public let iconName: String + public let fgColor: String + public let bgColor: String + + public var foregroundColor: String { fgColor } + public var backgroundColor: String { bgColor } + + public init(iconName: String, fgColor: String, bgColor: String) { + self.iconName = iconName; self.fgColor = fgColor; self.bgColor = bgColor + } + + public init(iconName: String, foregroundColor: String, backgroundColor: String) { + self.iconName = iconName; self.fgColor = foregroundColor; self.bgColor = backgroundColor + } + + /// Encode for FIELD_ICON_APPEARANCE (0x04) — canonical Sideband / Android + /// wire form `[icon_name, fg_rgb_bytes(3), bg_rgb_bytes(3)]`. iOS stores + /// colors as 6-char lowercase RGB hex (matching `ProfileIcon.swift`'s + /// stored form, no `#` prefix); we decode them to 3 raw bytes so the wire + /// shape matches what Sideband/Android send. Returns `[Any]` so the LXMF + /// MessagePack encoders (both `LxmfFieldCodec` and `LXMFSwift`'s + /// `convertArrayToMsgpack`) emit a proper msgpack array of `[string, + /// binary, binary]` rather than wrapping the whole thing as binary. + /// + /// On malformed local hex the channel is filled with three zero bytes + /// rather than dropped — keeps the array shape valid so the receiver + /// renders the default identicon rather than failing to parse. + public func toLXMFFieldValue() -> [Any] { + let fgBytes = Self.hexRgbToData(fgColor) ?? Data([0, 0, 0]) + let bgBytes = Self.hexRgbToData(bgColor) ?? Data([0, 0, 0]) + return [iconName, fgBytes, bgBytes] as [Any] + } + + /// Decode FIELD_ICON_APPEARANCE (0x04) into an `IconAppearance`. + /// + /// Accepts either `String` or UTF-8 `Data` for the icon name — peers + /// vary: Swift `String` survives msgpack as `.string` while LXMF-kt's + /// canonical form is `extension.toByteArray(Charsets.UTF_8)` which + /// arrives as `.binary`. Returns nil if the value isn't a 3-element + /// array, the name is empty, or either colour channel isn't a + /// 3-byte blob. + public static func fromLXMFFieldValue(_ value: Any) -> IconAppearance? { + guard let arr = value as? [Any], arr.count >= 3 else { return nil } + let name: String? + if let s = arr[0] as? String { name = s } + else if let d = arr[0] as? Data { name = String(data: d, encoding: .utf8) } + else { name = nil } + guard let iconName = name, !iconName.isEmpty else { return nil } + guard let fg = (arr[1] as? Data).flatMap(Self.dataToHexRgb), + let bg = (arr[2] as? Data).flatMap(Self.dataToHexRgb) else { return nil } + return IconAppearance(iconName: iconName, fgColor: fg, bgColor: bg) + } + + /// Decode `"RRGGBB"` / `"#RRGGBB"` to 3 raw RGB bytes. Returns nil for any + /// other length so we don't silently truncate longer hex (e.g. `#RRGGBBAA`). + private static func hexRgbToData(_ hex: String) -> Data? { + var s = Substring(hex) + if s.hasPrefix("#") { s = s.dropFirst() } + guard s.count == 6 else { return nil } + var bytes = Data(); bytes.reserveCapacity(3) + for i in stride(from: 0, to: 6, by: 2) { + let start = s.index(s.startIndex, offsetBy: i) + let end = s.index(start, offsetBy: 2) + guard let b = UInt8(s[start.. String? { + guard data.count == 3 else { return nil } + return data.map { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - LXMessage + +public final class LXMessage: @unchecked Sendable { + // Field constants — canonical source is `LxmfFields` (upstream LXMF / + // Sideband-compatible). Aliased here for existing call sites. + public static let FIELD_ICON_APPEARANCE = LxmfFields.FIELD_ICON_APPEARANCE // 0x04 + public static let FIELD_FILE_ATTACHMENTS = LxmfFields.FIELD_FILE_ATTACHMENTS // 0x05 + public static let FIELD_IMAGE = LxmfFields.FIELD_IMAGE // 0x06 + public static let FIELD_AUDIO = LxmfFields.FIELD_AUDIO // 0x07 + public static let FIELD_TELEMETRY = LxmfFields.FIELD_TELEMETRY // 0x02 — was 0x08 (FIELD_THREAD upstream): interop bug, fixed + public static let FIELD_APP_DATA: UInt8 = 0x10 // iOS-local; upstream 0x10 is reaction-legacy — audit (task #32) + public static let FIELD_COLUMBA_META = LxmfFields.FIELD_CUSTOM_META // 0xFD — was invented 0x70; matches Android's migration + + public let destinationHash: Data + public var sourceHash: Data + public var sourceIdentity: Identity? + public var signature: Data + public var timestamp: Double + public var title: Data + public var content: Data + public var fields: [UInt8: Any]? + public var stamp: Data? + public var hash: Data + public var state: LXMessageState + public var method: LXDeliveryMethod + public var representation: LXMessageRepresentation + public var incoming: Bool + public var packed: Data? + public var signatureValidated: Bool + public var unverifiedReason: LXUnverifiedReason? + public var deliveryAttempts: Int = 0 + public var nextDeliveryAttempt: Date? + public var progress: Double = 0.0 + public var fallbackMethod: LXDeliveryMethod? + public var rssi: Double? + public var snr: Double? + public var q: Double? + public var receivingInterface: String? + public var desiredMethod: LXDeliveryMethod + + public init( + destinationHash: Data, + sourceIdentity: Identity?, + content: Data, + title: Data = Data(), + fields: [UInt8: Any]? = nil, + desiredMethod: LXDeliveryMethod = .opportunistic + ) { + self.destinationHash = destinationHash + self.sourceIdentity = sourceIdentity + self.sourceHash = sourceIdentity?.hash ?? Data() + self.signature = Data() + self.timestamp = Date().timeIntervalSince1970 + self.title = title + self.content = content + self.fields = fields + self.stamp = nil + self.hash = Data() + self.state = .draft + self.method = .unknown + self.representation = .unknown + self.incoming = false + self.packed = nil + self.signatureValidated = false + self.unverifiedReason = nil + self.fallbackMethod = nil + self.desiredMethod = desiredMethod + } + + /// Compatibility init mirroring AI-Swift's most common ctor with named `method:`. + public convenience init( + destinationHash: Data, + sourceIdentity: Identity?, + content: Data, + title: Data = Data(), + fields: [UInt8: Any]? = nil, + method: LXDeliveryMethod + ) { + self.init( + destinationHash: destinationHash, + sourceIdentity: sourceIdentity, + content: content, + title: title, + fields: fields, + desiredMethod: method + ) + } + + public func pack() throws -> Data { Data() } + + public static func unpackFromBytes(_ data: Data, sourceIdentity: Identity? = nil) throws -> LXMessage { + LXMessage(destinationHash: Data(), sourceIdentity: sourceIdentity, content: Data()) + } + + public var contentAsString: String { String(data: content, encoding: .utf8) ?? "" } + public var titleAsString: String { String(data: title, encoding: .utf8) ?? "" } +} + +// MARK: - LXMRouter + +public final class LXMRouter: @unchecked Sendable { + public weak var delegate: LXMRouterDelegate? + + /// Set by AppServices once RNSBackendPy is ready. Returns the real LXMF + /// message hash hex once Python packs the message (or nil if unavailable), + /// so the inout `handleOutbound` can stamp it onto the message — the chat + /// then persists the outbound row under the same key the delivery proof + /// event carries. + public var sendHook: ((LXMessage) async throws -> String?)? + + public init() {} + public init(identity: Identity, databasePath: String) async throws {} + + public var outboundPropagationNode: Data? + public var propagationStampCost: Int = 0 + + public func setDelegate(_ delegate: LXMRouterDelegate) { self.delegate = delegate } + public func setTransport(_ transport: ReticulumTransport) {} + public func setRatchetManager(_ manager: RatchetManager?) {} + public func setPropagationStampCost(_ cost: Int) async { self.propagationStampCost = cost } + public func registerDeliveryDestination(_ destination: Destination) {} + + @discardableResult + public func handleOutbound(_ message: LXMessage) async throws -> Bool { + if let hook = sendHook { _ = try await hook(message); return true } + return false + } + + /// Inout variant used by ViewModels that need the router to populate + /// `hash` / `state` / `timestamp` on the message after pack. Stamps the + /// real LXMF message hash (returned by the send hook) onto `message.hash`, + /// replacing the caller's optimistic placeholder, so the persisted row is + /// keyed by the hash that delivery-proof events reference. + @discardableResult + public func handleOutbound(_ message: inout LXMessage) async throws -> Bool { + if let hook = sendHook { + if let hashHex = try await hook(message), !hashHex.isEmpty, + let hashData = try? hashHex.hexToData() { + message.hash = hashData + } + return true + } + return false + } + + public func restart() async {} + /// Latest sync state — observed by PropagationNodeManager after a sync. + public var syncState: PropagationTransferState { + get async { PropagationTransferState() } + } + public func syncFromPropagationNode() async throws {} + public func shutdown() async {} +} + +/// PropagationTransferState — the LXMF propagation-node sync state observed +/// by PropagationNodeManager after each sync attempt. Canonical home now +/// lives in RNSAPI so LXSTSwift (and any future SwiftPM consumer) can also +/// see it; the ColumbaApp duplicate was removed. +public struct PropagationTransferState: Equatable, Sendable { + public enum State: Equatable, Sendable { + case idle, linking, linked, linkFailed, transferring, transferFailed, noPath, complete + } + public var state: State + public var receivedMessages: Int + public var errorDescription: String? + public var lastSync: Date? + public var progress: Double + + public init(state: State = .idle, receivedMessages: Int = 0, + errorDescription: String? = nil, lastSync: Date? = nil, progress: Double = 0) { + self.state = state + self.receivedMessages = receivedMessages + self.errorDescription = errorDescription + self.lastSync = lastSync + self.progress = progress + } + + public var isSyncing: Bool { + switch state { + case .linking, .linked, .transferring: return true + default: return false + } + } +} + +/// InterfaceMode — controls Reticulum announce propagation per interface. +/// Identical rawValues to the upstream Python config so JSON encoding +/// round-trips. The duplicate in ColumbaApp/Services/InterfaceRepository.swift +/// was removed in favor of this canonical RNSAPI home. +public enum InterfaceMode: String, Codable, Sendable, Equatable, CaseIterable { + case full = "full" + case gateway = "gateway" + case accessPoint = "access_point" + case roaming = "roaming" + case boundary = "boundary" + + public var displayName: String { + switch self { + case .full: return "Full" + case .gateway: return "Gateway" + case .accessPoint: return "Access Point" + case .roaming: return "Roaming" + case .boundary: return "Boundary" + } + } + + public var description: String { + switch self { + case .full: return "All features enabled" + case .gateway: return "Path discovery for others" + case .accessPoint: return "Quiet unless active" + case .roaming: return "Mobile relative to others" + case .boundary: return "Links dissimilar segments" + } + } +} + +/// RNodeConfig — full BLE-device-name + radio-parameter set. The duplicate +/// in ColumbaApp/Services/InterfaceRepository.swift was removed. +public struct RNodeConfig: 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 func toRadioConfig() -> RadioConfig { + RadioConfig( + frequency: frequency, + bandwidth: bandwidth, + txPower: txPower, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + stAlock: stAlock, + ltAlock: ltAlock + ) + } + + public static var defaultUS915: RNodeConfig { + RNodeConfig( + deviceName: "", + frequency: 915_000_000, + bandwidth: 125_000, + txPower: 17, + spreadingFactor: 7, + codingRate: 5, + stAlock: nil, + ltAlock: nil + ) + } + + public init( + deviceName: String = "", + frequency: UInt32 = 0, + bandwidth: UInt32 = 0, + txPower: UInt8 = 0, + spreadingFactor: UInt8 = 0, + codingRate: UInt8 = 0, + 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 + } +} + +/// PeerLocation — shared location data received from a peer (or shared by +/// the local user). Canonical home is now RNSAPI; the duplicate in +/// ColumbaApp/Views/PlatformCompat.swift was removed. +public struct PeerLocation: Identifiable, Equatable, Sendable { + public let id: Data + public var displayName: String? + public var latitude: Double + public var longitude: Double + public var altitude: Double + public var speed: Double + public var bearing: Double + public var accuracy: Double + public var lastUpdate: Date + public var iconAppearance: IconAppearance? + + public init( + id: Data, + displayName: String? = nil, + latitude: Double, + longitude: Double, + altitude: Double = 0, + speed: Double = 0, + bearing: Double = 0, + accuracy: Double = 0, + lastUpdate: Date = Date(), + iconAppearance: IconAppearance? = nil + ) { + self.id = id + self.displayName = displayName + self.latitude = latitude + self.longitude = longitude + self.altitude = altitude + self.speed = speed + self.bearing = bearing + self.accuracy = accuracy + self.lastUpdate = lastUpdate + self.iconAppearance = iconAppearance + } + + public var isStale: Bool { Date().timeIntervalSince(lastUpdate) > 300 } + public var shortHash: String { id.prefix(4).map { String(format: "%02x", $0) }.joined() } +} + +/// SharingDuration — duration choices for location-sharing sessions. The +/// duplicate in ColumbaApp/Views/PlatformCompat.swift was removed. +public enum SharingDuration: String, CaseIterable, Identifiable, Sendable { + case fifteenMinutes = "15 min" + case oneHour = "1 hour" + case fourHours = "4 hours" + case untilMidnight = "Until midnight" + case indefinite = "Until I stop" + + public var id: String { rawValue } + + public func calculateEndDate(from start: Date = Date()) -> Date? { + switch self { + case .fifteenMinutes: return start.addingTimeInterval(15 * 60) + case .oneHour: return start.addingTimeInterval(60 * 60) + case .fourHours: return start.addingTimeInterval(4 * 60 * 60) + case .untilMidnight: + var cal = Calendar.current + cal.timeZone = .current + return cal.nextDate(after: start, matching: DateComponents(hour: 0, minute: 0, second: 0), matchingPolicy: .nextTime) + case .indefinite: return nil + } + } +} + +public protocol LXMRouterDelegate: AnyObject { + func router(_ router: LXMRouter, didReceiveMessage message: LXMessage) +} + +// MARK: - LXMFDatabase (SQLite-backed; in-memory dicts are a write-through cache) + +/// SQLite-backed persistence for LXMF conversations and messages. +/// +/// Architecture: the in-memory dicts below are a **write-through cache** over a +/// SQLite database at `init(path:)`. On launch the whole store is loaded from +/// disk into the dicts (`loadAll`); all reads serve from the dicts (preserving +/// the exact ordering/paging semantics the UI relies on); every mutation writes +/// the affected row back to SQLite so it survives app restarts. Records are +/// stored as Codable-JSON blobs with a few indexed columns for querying, so the +/// schema doesn't churn when record fields change. +/// +/// (Replaces the previous pure in-memory stub — the "Phase 2: real SQLite" TODO. +/// Loads everything into RAM on launch, which is fine at current volumes; a +/// future optimisation is lazy/paged queries straight off SQLite.) +public final class LXMFDatabase: @unchecked Sendable { + private let lock = NSLock() + private var conversations: [Data: ConversationRecord] = [:] + private var messagesByConversation: [Data: [MessageRecord]] = [:] + private var messagesById: [Data: MessageRecord] = [:] + private var peerIcons: [Data: IconAppearance] = [:] + + /// Open SQLite handle, or nil if the database couldn't be opened (in which + /// case the store degrades to in-memory-only for the session rather than + /// crashing). + private var db: OpaquePointer? + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + // SQLite wants to know whether a bound buffer is transient (copy now) or + // static (kept). We always pass transient so it copies during bind. + private static let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + public init(path: String) { + var handle: OpaquePointer? + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX + if sqlite3_open_v2(path, &handle, flags, nil) == SQLITE_OK { + self.db = handle + createSchema() + loadAll() + } else { + if let handle { sqlite3_close(handle) } + self.db = nil + // Degrade gracefully: dicts stay empty, store works in-memory only. + } + } + + deinit { + if let db { sqlite3_close(db) } + } + + // MARK: SQLite plumbing + + private func createSchema() { + let ddl = """ + CREATE TABLE IF NOT EXISTS conversations ( + hash BLOB PRIMARY KEY, + last_message_at REAL, + data BLOB NOT NULL + ); + CREATE TABLE IF NOT EXISTS messages ( + id BLOB PRIMARY KEY, + conversation_hash BLOB NOT NULL, + timestamp REAL NOT NULL, + data BLOB NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_hash); + CREATE TABLE IF NOT EXISTS peer_icons ( + hash BLOB PRIMARY KEY, + data BLOB NOT NULL + ); + """ + sqlite3_exec(db, ddl, nil, nil, nil) + } + + /// Hydrate the in-memory caches from disk. Called once at init. + private func loadAll() { + // Conversations + forEachRow("SELECT data FROM conversations") { stmt in + if let conv = try? self.decoder.decode(ConversationRecord.self, from: self.columnBlob(stmt, 0)) { + self.conversations[conv.hash] = conv + } + } + // Messages + forEachRow("SELECT data FROM messages") { stmt in + if let rec = try? self.decoder.decode(MessageRecord.self, from: self.columnBlob(stmt, 0)) { + self.messagesById[rec.id] = rec + self.messagesByConversation[rec.conversationHash, default: []].append(rec) + } + } + // Peer icons + forEachRow("SELECT hash, data FROM peer_icons") { stmt in + let hash = self.columnBlob(stmt, 0) + if let icon = try? self.decoder.decode(IconAppearance.self, from: self.columnBlob(stmt, 1)) { + self.peerIcons[hash] = icon + } + } + } + + /// Prepare `sql`, step every row, invoking `body` per row. Caller binds + /// nothing (used for parameterless SELECTs at load). + private func forEachRow(_ sql: String, _ body: (OpaquePointer?) -> Void) { + guard let db else { return } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + while sqlite3_step(stmt) == SQLITE_ROW { body(stmt) } + } + + private func bindBlob(_ stmt: OpaquePointer?, _ idx: Int32, _ data: Data) { + if data.isEmpty { + sqlite3_bind_zeroblob(stmt, idx, 0) + } else { + data.withUnsafeBytes { raw in + _ = sqlite3_bind_blob(stmt, idx, raw.baseAddress, Int32(data.count), Self.SQLITE_TRANSIENT) + } + } + } + + private func columnBlob(_ stmt: OpaquePointer?, _ idx: Int32) -> Data { + guard let p = sqlite3_column_blob(stmt, idx) else { return Data() } + let n = sqlite3_column_bytes(stmt, idx) + return Data(bytes: p, count: Int(n)) + } + + // MARK: Persistence helpers (assume `lock` is held by the caller) + + private func persistConversation(_ conv: ConversationRecord) { + guard let db, let data = try? encoder.encode(conv) else { return } + var stmt: OpaquePointer? + let sql = "INSERT OR REPLACE INTO conversations (hash, last_message_at, data) VALUES (?, ?, ?)" + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + bindBlob(stmt, 1, conv.hash) + if let at = conv.lastMessageAt { sqlite3_bind_double(stmt, 2, at.timeIntervalSince1970) } + else { sqlite3_bind_null(stmt, 2) } + bindBlob(stmt, 3, data) + sqlite3_step(stmt) + } + + private func persistMessage(_ rec: MessageRecord) { + guard let db, let data = try? encoder.encode(rec) else { return } + var stmt: OpaquePointer? + let sql = "INSERT OR REPLACE INTO messages (id, conversation_hash, timestamp, data) VALUES (?, ?, ?, ?)" + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + bindBlob(stmt, 1, rec.id) + bindBlob(stmt, 2, rec.conversationHash) + sqlite3_bind_double(stmt, 3, rec.timestamp) + bindBlob(stmt, 4, data) + sqlite3_step(stmt) + } + + private func persistPeerIcon(_ hash: Data, _ icon: IconAppearance) { + guard let db, let data = try? encoder.encode(icon) else { return } + var stmt: OpaquePointer? + let sql = "INSERT OR REPLACE INTO peer_icons (hash, data) VALUES (?, ?)" + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + bindBlob(stmt, 1, hash) + bindBlob(stmt, 2, data) + sqlite3_step(stmt) + } + + /// Run a parameterless DELETE-style statement with a single blob param. + private func execWithBlob(_ sql: String, _ blob: Data) { + guard let db else { return } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + bindBlob(stmt, 1, blob) + sqlite3_step(stmt) + } + + private func keyFor(_ message: LXMessage) -> Data { + // Inbound: source is the peer. Outbound: destination is the peer. + message.incoming ? message.sourceHash : message.destinationHash + } + + public func saveMessage(_ message: LXMessage) throws { + lock.lock(); defer { lock.unlock() } + let convHash = keyFor(message) + + // Ensure conversation row exists. + if conversations[convHash] == nil { + conversations[convHash] = ConversationRecord( + hash: convHash, + displayName: "", + lastMessageAt: Date(timeIntervalSince1970: message.timestamp), + lastMessage: String(data: message.content, encoding: .utf8), + unreadCount: message.incoming ? 1 : 0 + ) + } else { + var conv = conversations[convHash]! + conv.lastMessageAt = Date(timeIntervalSince1970: message.timestamp) + conv.lastMessage = String(data: message.content, encoding: .utf8) + if message.incoming { conv.unreadCount += 1 } + conversations[convHash] = conv + } + + // Persist `message.fields` so attachment payloads (FIELD_IMAGE 0x06, + // FIELD_FILE_ATTACHMENTS 0x05, FIELD_ICON_APPEARANCE 0x04, replies, …) + // survive a DB reload. `packedLxmf` is named for the full LXMF wire + // format historically, but `LXMessage.pack()`/`unpackFromBytes` are + // Compat stubs (they predate the field-path migration), so we now use + // it to carry just the MessagePack-encoded field map — the only piece + // the UI needs to reconstruct image/attachment bubbles. Empty Data when + // there are no fields, matching `LxmfFieldCodec.pack`'s empty-map + // convention so `unpack` returns nil cleanly on load. + let fieldsPacked: Data = (message.fields?.isEmpty == false) + ? LxmfFieldCodec.pack(message.fields!) + : Data() + let record = MessageRecord( + id: message.hash, + conversationHash: convHash, + content: message.content, + timestamp: message.timestamp, + direction: message.incoming ? .inbound : .outbound, + state: message.state.rawValue, + messageId: message.hash, + sourceHash: message.sourceHash, + method: message.method.rawValue, + rssi: message.rssi, + snr: message.snr, + receivingInterface: message.receivingInterface, + packedLxmf: fieldsPacked + ) + messagesById[message.hash] = record + // Replace in-place if this hash was already cached (re-save), else + // append — mirrors the DB's INSERT OR REPLACE so the cache can't + // accumulate duplicate rows for the same message. + if let idx = messagesByConversation[convHash]?.firstIndex(where: { $0.id == message.hash }) { + messagesByConversation[convHash]?[idx] = record + } else { + messagesByConversation[convHash, default: []].append(record) + } + + if let conv = conversations[convHash] { persistConversation(conv) } + persistMessage(record) + } + + public func getMessage(id: Data) throws -> LXMessage? { nil } + public func hasMessage(id: Data) throws -> Bool { + lock.lock(); defer { lock.unlock() } + return messagesById[id] != nil + } + public func getMessages(forConversation hash: Data, limit: Int = 50, offset: Int = 0) throws -> [LXMessage] { [] } + public func updateMessageState(id: Data, state: LXMessageState) throws { + lock.lock(); defer { lock.unlock() } + if var rec = messagesById[id] { + rec.state = state.rawValue + messagesById[id] = rec + if let idx = messagesByConversation[rec.conversationHash]?.firstIndex(where: { $0.id == id }) { + messagesByConversation[rec.conversationHash]?[idx] = rec + } + persistMessage(rec) + } + } + public func deleteMessage(id messageId: Data) throws { + lock.lock(); defer { lock.unlock() } + if let rec = messagesById.removeValue(forKey: messageId) { + messagesByConversation[rec.conversationHash]?.removeAll { $0.id == messageId } + execWithBlob("DELETE FROM messages WHERE id = ?", messageId) + } + } + public func getMessageRecord(id: Data) throws -> MessageRecord? { + lock.lock(); defer { lock.unlock() } + return messagesById[id] + } + public func getMessageRecords(forConversation hash: Data, limit: Int = 200, offset: Int = 0) throws -> [MessageRecord] { + lock.lock(); defer { lock.unlock() } + let all = messagesByConversation[hash] ?? [] + // DESC by timestamp — offset 0 returns the newest `limit` messages, + // subsequent pages walk backward in time. Callers + // (MessagingViewModel.loadMessages / loadMoreMessages) `.reversed()` + // each page so the in-memory `messages` array ends up + // [oldest .. newest] for top-down chat display, and + // `loadMoreMessages` can insert older pages at the front. + // + // Before this was ASC, which combined with the caller's reverse + // meant offset 0 fetched the OLDEST `limit` messages and put the + // newest of THAT page at index 0 — so once a conversation passed + // `limit` total messages, freshly-sent ones disappeared on reload + // and ordering looked inverted. + let sorted = all.sorted { $0.timestamp > $1.timestamp } + let end = min(offset + limit, sorted.count) + guard offset < end else { return [] } + return Array(sorted[offset.. [ConversationRecord] { + lock.lock(); defer { lock.unlock() } + let all = Array(conversations.values).sorted { $0.lastMessageTimestamp > $1.lastMessageTimestamp } + let end = min(offset + limit, all.count) + guard offset < end else { return [] } + return Array(all[offset.. ConversationRecord? { + lock.lock(); defer { lock.unlock() } + return conversations[hash] + } + public func ensureConversation(hash: Data, displayName: String?) throws { + lock.lock(); defer { lock.unlock() } + if var conv = conversations[hash] { + if let displayName, !displayName.isEmpty { conv.displayName = displayName } + conversations[hash] = conv + } else { + conversations[hash] = ConversationRecord( + hash: hash, + displayName: displayName ?? "", + lastMessageAt: nil, + lastMessage: nil, + unreadCount: 0 + ) + } + if let conv = conversations[hash] { persistConversation(conv) } + } + public func updateDisplayName(hash: Data, displayName: String?) throws { + lock.lock(); defer { lock.unlock() } + if var conv = conversations[hash] { + conv.displayName = displayName ?? "" + conversations[hash] = conv + persistConversation(conv) + } + } + public func setFavorite(hash: Data, isFavorite: Bool) throws { + lock.lock(); defer { lock.unlock() } + if var conv = conversations[hash] { + conv.isFavorite = isFavorite ? 1 : 0 + conversations[hash] = conv + persistConversation(conv) + } + } + public func setPinned(hash: Data, isPinned: Bool) throws { + lock.lock(); defer { lock.unlock() } + if var conv = conversations[hash] { + conv.isPinned = isPinned ? 1 : 0 + conversations[hash] = conv + persistConversation(conv) + } + } + public func setUnreadCount(hash: Data, count: Int) throws { + lock.lock(); defer { lock.unlock() } + if var conv = conversations[hash] { + conv.unreadCount = count + conversations[hash] = conv + persistConversation(conv) + } + } + public func markConversationRead(hash: Data) throws { try setUnreadCount(hash: hash, count: 0) } + public func deleteConversation(hash: Data) throws { + lock.lock(); defer { lock.unlock() } + conversations.removeValue(forKey: hash) + messagesByConversation.removeValue(forKey: hash) + messagesById = messagesById.filter { $0.value.conversationHash != hash } + execWithBlob("DELETE FROM conversations WHERE hash = ?", hash) + execWithBlob("DELETE FROM messages WHERE conversation_hash = ?", hash) + } + public func updateConversation(for message: LXMessage) throws { + // saveMessage already updates the conversation row; this is a no-op for now. + } + + public func updatePeerIcon(_ hash: Data, iconName: String, fgColor: String, bgColor: String) throws { + lock.lock(); defer { lock.unlock() } + let icon = IconAppearance(iconName: iconName, fgColor: fgColor, bgColor: bgColor) + peerIcons[hash] = icon + persistPeerIcon(hash, icon) + } + public func getPeerIcon(_ hash: Data) throws -> IconAppearance? { + lock.lock(); defer { lock.unlock() } + return peerIcons[hash] + } + + public func loadPendingOutbound() throws -> [LXMessage] { [] } + public func loadFailedOutbound() throws -> [LXMessage] { [] } + + public func updateReplyToId(messageId: Data, replyToId: String) throws { + lock.lock(); defer { lock.unlock() } + if var rec = messagesById[messageId] { + rec.replyToId = replyToId + messagesById[messageId] = rec + if let idx = messagesByConversation[rec.conversationHash]?.firstIndex(where: { $0.id == messageId }) { + messagesByConversation[rec.conversationHash]?[idx] = rec + } + persistMessage(rec) + } + } + public func updateReactions(messageId: Data, reactionsJson: String) throws { + lock.lock(); defer { lock.unlock() } + if var rec = messagesById[messageId] { + rec.reactionsJson = reactionsJson + messagesById[messageId] = rec + if let idx = messagesByConversation[rec.conversationHash]?.firstIndex(where: { $0.id == messageId }) { + messagesByConversation[rec.conversationHash]?[idx] = rec + } + persistMessage(rec) + } + } + public func getReactionsJson(messageId: Data) throws -> String? { + lock.lock(); defer { lock.unlock() } + return messagesById[messageId]?.reactionsJson + } +} + +public struct ConversationRecord: Identifiable, Equatable, Sendable, Codable { + public let hash: Data + public var displayName: String + public var isFavorite: Int + public var isPinned: Int + public var lastMessageAt: Date? + public var lastMessage: String? + public var unreadCount: Int + public var iconName: String? + public var iconFgColor: String? + public var iconBgColor: String? + + /// Alias for `hash` used by older call sites that mirror Android's + /// `Conversation.destinationHash`. + public var destinationHash: Data { hash } + + /// Alias for `lastMessageAt`. Defaults to `.distantPast` if no message + /// has been seen yet, so call sites can sort without unwrapping. + public var lastMessageTimestamp: Date { lastMessageAt ?? .distantPast } + + /// Alias for `lastMessage`. Defaults to empty string for previews. + public var lastMessagePreview: String { lastMessage ?? "" } + + public var id: Data { hash } + + public init( + hash: Data, + displayName: String = "", + isFavorite: Int = 0, + isPinned: Int = 0, + lastMessageAt: Date? = nil, + lastMessage: String? = nil, + unreadCount: Int = 0, + iconName: String? = nil, + iconFgColor: String? = nil, + iconBgColor: String? = nil + ) { + self.hash = hash + self.displayName = displayName + self.isFavorite = isFavorite + self.isPinned = isPinned + self.lastMessageAt = lastMessageAt + self.lastMessage = lastMessage + self.unreadCount = unreadCount + self.iconName = iconName + self.iconFgColor = iconFgColor + self.iconBgColor = iconBgColor + } +} + +public struct MessageRecord: Identifiable, Equatable, Sendable, Codable { + public let id: Data + public let conversationHash: Data + public var content: Data + public var timestamp: Double + public var direction: Direction + /// Stored as raw string so it can be persisted directly to SQLite; matches + /// AI-Swift's `LXMessageState.rawValue` semantics on the call sites. + public var state: String + public var messageId: Data + public var sourceHash: Data + /// Stored as raw string matching `LXDeliveryMethod.rawValue`. + public var method: String + public var rssi: Double? + public var snr: Double? + public var receivingInterface: String? + public var replyToId: String? + public var reactionsJson: String? + /// MessagePack-encoded LXMF field map (the `[UInt8: Any]` produced by + /// `LxmfFieldCodec.pack`). Carries attachment payloads — FIELD_IMAGE + /// (0x06) `[format, bytes]`, FIELD_FILE_ATTACHMENTS (0x05), the icon + /// appearance (0x04), reply hash (0x30), etc. — so they survive a DB + /// reload. Empty `Data()` when the message has no fields. Named + /// `packedLxmf` historically (the intent was the full LXMF wire) but the + /// Compat `LXMessage.pack()` / `unpackFromBytes` are stubs, so we carry + /// just the field map. Decode with `LxmfFieldCodec.unpack(...)`. + public var packedLxmf: Data + + public enum Direction: String, Equatable, Sendable, Codable { case inbound, outbound } + + public init( + id: Data, + conversationHash: Data, + content: Data, + timestamp: Double, + direction: Direction, + state: String, + messageId: Data = Data(), + sourceHash: Data = Data(), + method: String = "", + rssi: Double? = nil, + snr: Double? = nil, + receivingInterface: String? = nil, + replyToId: String? = nil, + reactionsJson: String? = nil, + packedLxmf: Data = Data() + ) { + self.id = id + self.conversationHash = conversationHash + self.content = content + self.timestamp = timestamp + self.direction = direction + self.state = state + self.messageId = messageId + self.sourceHash = sourceHash + self.method = method + self.rssi = rssi + self.snr = snr + self.receivingInterface = receivingInterface + self.replyToId = replyToId + self.reactionsJson = reactionsJson + self.packedLxmf = packedLxmf + } +} + +// MARK: - Transport + +public final class ReticulumTransport: @unchecked Sendable { + public var hwMtu: Int { 500 } + public var radioRssi: Double? { nil } + public var radioSnr: Double? { nil } + public var radioQuality: Double? { nil } + public var transportEnabled: Bool = false + public var transportIdentityHash: Data? + public var onDiagnostic: (@Sendable (String) -> Void)? + + public init() {} + public init(identity: Identity? = nil, storagePath: String? = nil) {} + public init(pathTable: PathTable) {} + + public func registerPathRequestHandler() async {} + + public func setOnInterfacePeerSpawned(_ callback: (@Sendable (String) async -> Void)?) {} + public func setOnInterfaceConnected(_ callback: (@Sendable (String) async -> Void)?) {} + public func setOnInterfaceAdded(_ callback: (@Sendable (String) async -> Void)?) {} + public func setOnDiagnostic(_ callback: @escaping @Sendable (String) -> Void) { + onDiagnostic = callback + } + + // Registered interfaces — the Compat layer is a stub for ReticulumSwift's + // transport, but Network Status UI still needs to know what's wired so + // it can render the per-interface rows. AppServices calls add*Interface + // for each enabled entity at startup and after Apply & Restart, the + // transport mirrors them into `registeredInterfaces` keyed by id, and + // `getInterfaceSnapshots()` reflects current state (state field is + // mutated on the interface stub by `applyPythonInterfaceStatus`). + private let _interfaceLock = NSLock() + private var registeredInterfaces: [String: any NetworkInterface] = [:] + private var registeredInterfaceTypes: [String: WireInterfaceType] = [:] + + // Python-discovered auxiliary interfaces — AutoInterfacePeer / + // BLEPeer / etc. that RNS spawns dynamically and registers with + // `RNS.Transport.interfaces` but Swift never explicitly adds. Pushed + // here by `AppServices.applyPythonInterfaceStatus` each poll tick + // (~2s) so getInterfaceSnapshots can include them. + private var pythonAuxiliarySnapshots: [InterfaceSnapshot] = [] + + public func setPythonAuxiliarySnapshots(_ snapshots: [InterfaceSnapshot]) { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + pythonAuxiliarySnapshots = snapshots + } + + public func addInterface(_ interface: any NetworkInterface) async throws { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + registeredInterfaces[interface.id] = interface + registeredInterfaceTypes[interface.id] = .tcp + } + public func removeInterface(id: String) async { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + registeredInterfaces.removeValue(forKey: id) + registeredInterfaceTypes.removeValue(forKey: id) + } + public func addAutoInterface(_ autoInterface: AutoInterface) async throws { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + registeredInterfaces[autoInterface.id] = autoInterface + registeredInterfaceTypes[autoInterface.id] = .autoInterface + } + public func addBLEInterface(_ bleInterface: BLEInterface) async throws { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + registeredInterfaces[bleInterface.id] = bleInterface + registeredInterfaceTypes[bleInterface.id] = .ble + } + public func addMPCInterface(_ mpcInterface: MPCInterface) async throws { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + registeredInterfaces[mpcInterface.id] = mpcInterface + registeredInterfaceTypes[mpcInterface.id] = .multipeerConnectivity + } + public func getInterface(id: String) -> (any NetworkInterface)? { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + return registeredInterfaces[id] + } + public var interfaceCount: Int { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + return registeredInterfaces.count + } + public var interfaceIds: [String] { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + return Array(registeredInterfaces.keys) + } + public func listInterfaceIds() async -> [String] { interfaceIds } + public func getInterfaceName(for interfaceId: String) async -> String? { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + // Exact match on Swift-side entity ID — that's what + // user-defined interfaces register under. + if let direct = registeredInterfaces[interfaceId]?.name { return direct } + // The Python side may pass us the RNS config section name + // (e.g. "Hub-FFB1F1"), which PythonConfigWriter formats as + // `-<6 char entity id prefix>`. Strip that + // suffix and try matching by the sanitized name. Avoids the + // user seeing "Hub-FFB1F1" or "python-rns" in the UI. + if let dash = interfaceId.lastIndex(of: "-") { + let suffix = interfaceId[interfaceId.index(after: dash)...] + let suffixIsHexId = suffix.count == 6 + && suffix.allSatisfy { $0.isHexDigit } + if suffixIsHexId { + let candidateName = String(interfaceId[.. [InterfaceSnapshot] { + _interfaceLock.lock(); defer { _interfaceLock.unlock() } + var out: [InterfaceSnapshot] = registeredInterfaces.values.map { iface in + let wireType = registeredInterfaceTypes[iface.id] ?? .tcp + let label: String + switch wireType { + case .tcp: label = "TCPClient" + case .udp: label = "UDP" + case .i2p: label = "I2P" + case .autoInterface: label = "AutoInterface" + case .rnode: label = "RNode" + case .ble: label = "BLE" + case .multipeerConnectivity: label = "Multipeer" + } + // The `NetworkInterface` protocol only carries `online`. Derive + // a coarse state from that — `applyPythonInterfaceStatus` updates + // the concrete iface's `online` flag from Python's view, so + // `connected/disconnected` here lines up with the real picture. + let state: InterfaceState = iface.online ? .connected : .disconnected + return InterfaceSnapshot( + id: iface.id, + name: iface.name, + online: iface.online, + typeLabel: label, + type: wireType, + state: state, + isAutoInterfacePeer: false, + isBLEPeerInterface: false, + peerAddress: nil, + lastErrorDescription: nil + ) + } + // Append python-discovered auxiliary interfaces (AutoInterfacePeer, + // BLEPeer, etc.) — these aren't user-configured rows so they don't + // get added via add*Interface, but they should still render so the + // user can see when LAN/BLE peer discovery is working. + out.append(contentsOf: pythonAuxiliarySnapshots) + return out + } + + // ──────── Backend bridge hooks ──────── + // + // AppServices installs these closures at startup. They translate the + // Compat-layer transport API that callers like CallManager and the + // lxst-swift Telephone speak into the Python-backed RNS world. Each is + // optional: until installed, the methods below are no-ops (matches the + // pre-bridge Phase 1b behavior) so the build can stay green while + // wiring proceeds. + + public var registerDestinationHook: (@Sendable (Destination) async -> Void)? + public var unregisterDestinationHook: (@Sendable (Data) -> Void)? + public var registerDestinationLinkCallbackHook: + (@Sendable (Data, @escaping @Sendable (Link) async -> Void) -> Void)? + 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) + } + public func registerDestinationLinkCallback(for destHash: Data, callback: @escaping @Sendable (Link) async -> Void) { + registerDestinationLinkCallbackHook?(destHash, callback) + } + public func unregisterDestination(hash: Data) { + unregisterDestinationHook?(hash) + } + public var destinationCount: Int { 0 } + + public func initiateLink(to destination: Destination, identity: Identity) async throws -> Link { + if let hook = initiateLinkHook { + return try await hook(destination, identity) + } + return Link(identityHash: destination.identity?.hash ?? Data()) + } + public var activeLinkCount: Int { 0 } + public var pendingLinkCount: Int { 0 } + + + + public func handleReceivedData(data: Data, from interfaceId: String) async {} + + public func setTransportEnabled(_ enabled: Bool, identity: Identity? = nil) { + self.transportEnabled = enabled + } + + public func requestPath(for destinationHash: Data) async {} + + /// Wait up to `timeout` seconds for a path to `destinationHash`. Stub + /// returns `true` immediately so lxst-swift's outbound-call code path can + /// flow through to the real `openLink` bridge call, where the Python + /// RNS.Transport handles real path discovery + timeout. + public func awaitPath(for destinationHash: Data, timeout: TimeInterval) async -> Bool { + true + } + + public func applyIFAC(raw: Data, interfaceId: String) -> Data { raw } +} + +public struct InterfaceSnapshot: Identifiable, Equatable, Sendable { + public let id: String + public let name: String + public let online: Bool + public let typeLabel: String + public let type: WireInterfaceType + public let state: InterfaceState + public let isAutoInterfacePeer: Bool + public let isBLEPeerInterface: Bool + public let peerAddress: String? + public let lastErrorDescription: String? + + public init( + id: String, + name: String, + online: Bool, + typeLabel: String, + type: WireInterfaceType = .tcp, + state: InterfaceState = .disconnected, + isAutoInterfacePeer: Bool = false, + isBLEPeerInterface: Bool = false, + peerAddress: String? = nil, + lastErrorDescription: String? = nil + ) { + self.id = id + self.name = name + self.online = online + self.typeLabel = typeLabel + self.type = type + self.state = state + self.isAutoInterfacePeer = isAutoInterfacePeer + self.isBLEPeerInterface = isBLEPeerInterface + self.peerAddress = peerAddress + self.lastErrorDescription = lastErrorDescription + } +} + +// MARK: - PathTable + +/// In-memory path table. Python's RNS.Transport.path_table is the source of +/// truth for the network state; AppServices.handlePythonEvent mirrors each +/// `announce` event into this Compat-layer table via `insert(_:)`. The +/// ContactsViewModel subscribes to `pathUpdates` (one AsyncStream per +/// subscriber) to render the Network tab. +public final class PathTable: @unchecked Sendable { + private let lock = NSLock() + private var entries: [Data: PathEntry] = [:] + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + // SQLite backing (write-through cache, same pattern as LXMFDatabase). The + // dict above stays the live read/notify layer; the DB persists path entries + // (heard announces) so the Network tab survives an app restart instead of + // coming up empty. `init()` (no path) stays pure in-memory. + private var db: OpaquePointer? + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private static let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + public init() {} + + public init(databasePath: String) throws { + var handle: OpaquePointer? + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX + if sqlite3_open_v2(databasePath, &handle, flags, nil) == SQLITE_OK { + self.db = handle + sqlite3_exec(handle, "CREATE TABLE IF NOT EXISTS path_entries (hash BLOB PRIMARY KEY, data BLOB NOT NULL);", nil, nil, nil) + loadAll() + } else { + if let handle { sqlite3_close(handle) } + self.db = nil + } + } + + deinit { if let db { sqlite3_close(db) } } + + private func loadAll() { + guard let db else { return } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT data FROM path_entries", -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + while sqlite3_step(stmt) == SQLITE_ROW { + guard let p = sqlite3_column_blob(stmt, 0) else { continue } + let n = sqlite3_column_bytes(stmt, 0) + let data = Data(bytes: p, count: Int(n)) + if let entry = try? decoder.decode(PathEntry.self, from: data) { + entries[entry.destinationHash] = entry + } + } + } + + /// Persist one entry. Caller must hold `lock`. + private func persist(_ entry: PathEntry) { + guard let db, let data = try? encoder.encode(entry) else { return } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "INSERT OR REPLACE INTO path_entries (hash, data) VALUES (?, ?)", -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + bindBlob(stmt, 1, entry.destinationHash) + bindBlob(stmt, 2, data) + sqlite3_step(stmt) + } + + private func bindBlob(_ stmt: OpaquePointer?, _ idx: Int32, _ data: Data) { + if data.isEmpty { + sqlite3_bind_zeroblob(stmt, idx, 0) + } else { + data.withUnsafeBytes { raw in + _ = sqlite3_bind_blob(stmt, idx, raw.baseAddress, Int32(data.count), Self.SQLITE_TRANSIENT) + } + } + } + + private func execWithBlob(_ sql: String, _ blob: Data) { + guard let db else { return } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + bindBlob(stmt, 1, blob) + sqlite3_step(stmt) + } + + public func lookup(destinationHash: Data) async -> PathEntry? { + lock.lock(); defer { lock.unlock() } + return entries[destinationHash] + } + public func size() async -> Int { + lock.lock(); defer { lock.unlock() } + return entries.count + } + public func allEntries() async -> [PathEntry] { + lock.lock(); defer { lock.unlock() } + return Array(entries.values) + } + public func remove(_ destinationHash: Data) async { + lock.lock(); defer { lock.unlock() } + entries.removeValue(forKey: destinationHash) + execWithBlob("DELETE FROM path_entries WHERE hash = ?", destinationHash) + } + public func removeAll() async { + lock.lock(); defer { lock.unlock() } + entries.removeAll() + sqlite3_exec(db, "DELETE FROM path_entries", nil, nil, nil) + } + + /// Insert or update a path entry. Keyed by `destinationHash`; replacing + /// an existing entry yields the new copy to all live `pathUpdates` + /// subscribers so the UI re-renders. + public func insert(_ entry: PathEntry) async { + lock.lock() + entries[entry.destinationHash] = entry + persist(entry) + let continuationsCopy = continuations.values + lock.unlock() + for continuation in continuationsCopy { + continuation.yield(entry) + } + } + + /// Stream of path-table updates. Each subscriber gets its own continuation; + /// new inserts (and updates of existing entries) are broadcast to every + /// active subscriber. Cancellation of the consumer task tears down the + /// continuation. + public var pathUpdates: AsyncStream { + AsyncStream { continuation in + let id = UUID() + lock.lock() + continuations[id] = continuation + // Replay current entries so a late subscriber doesn't miss + // announces that arrived before it started listening. + let snapshot = Array(entries.values) + lock.unlock() + for entry in snapshot { continuation.yield(entry) } + continuation.onTermination = { [weak self] _ in + guard let self else { return } + self.lock.lock() + self.continuations.removeValue(forKey: id) + self.lock.unlock() + } + } + } +} + +public struct PathEntry: Identifiable, Equatable, Sendable, Codable { + public let destinationHash: Data + public var displayName: String + public var nextHop: Data + public var hopCount: Int + public var lastSeen: Date + public var publicKeys: Data + public var interfaceId: String + public var appData: Data? + public var expires: Date + public var timestamp: Date + public var detectedAspect: String? + public var isLXMFPropagationNode: Bool + public var isLXSTTelephony: Bool + public var isKnownDestination: Bool + + public var id: Data { destinationHash } + + public init( + destinationHash: Data, + displayName: String = "", + nextHop: Data = Data(), + hopCount: Int = 0, + lastSeen: Date = Date(), + publicKeys: Data = Data(), + interfaceId: String = "", + appData: Data? = nil, + expires: Date = Date.distantPast, + timestamp: Date = Date.distantPast, + detectedAspect: String? = nil, + isLXMFPropagationNode: Bool = false, + isLXSTTelephony: Bool = false, + isKnownDestination: Bool = false + ) { + self.destinationHash = destinationHash + self.displayName = displayName + self.nextHop = nextHop + self.hopCount = hopCount + self.lastSeen = lastSeen + self.publicKeys = publicKeys + self.interfaceId = interfaceId + self.appData = appData + self.expires = expires + self.timestamp = timestamp + self.detectedAspect = detectedAspect + self.isLXMFPropagationNode = isLXMFPropagationNode + self.isLXSTTelephony = isLXSTTelephony + self.isKnownDestination = isKnownDestination + } +} + +// MARK: - Interfaces + +public protocol NetworkInterface: AnyObject, Sendable { + var id: String { get } + var name: String { get } + var online: Bool { get } +} + +public protocol InterfaceDelegate: AnyObject, Sendable { + func interface(_ interface: any NetworkInterface, didChangeState state: InterfaceState) async + func interface(_ interface: any NetworkInterface, didReceiveData data: Data) async +} + +public final class TCPInterface: NetworkInterface, @unchecked Sendable { + public let id: String + public let name: String + public var online: Bool = false + public var state: InterfaceState = .disconnected + public var lastErrorDescription: String? + public var hwMtu: Int { 262144 } + public var delegate: InterfaceDelegate? + + public init(config: InterfaceConfig) throws { + self.id = config.id + self.name = config.name + } + + public func setDelegate(_ delegate: InterfaceDelegate) async { self.delegate = delegate } + public func connect() async throws {} + public func disconnect() async {} + public func send(_ data: Data) async throws {} + public func beginTunnelMode(send hook: @escaping @Sendable (Data) async -> Void) async {} + public func endTunnelMode() async {} +} + +public final class AutoInterface: NetworkInterface, @unchecked Sendable { + public let id: String + public let name: String + public var online: Bool = false + public var state: InterfaceState = .disconnected + public var peerCount: Int = 0 + + public init(config: InterfaceConfig) { + self.id = config.id + self.name = config.name + } + + public func connect() async throws {} + public func disconnect() async {} +} + +/// Stub BLE driver — full implementation lands when BLE comes back online. +public final class CoreBluetoothBLEDriver: @unchecked Sendable { + public let identityHash: Data + public init(identityHash: Data) { self.identityHash = identityHash } +} + +public final class BLEInterface: NetworkInterface, @unchecked Sendable { + public let id: String + public let name: String + public var online: Bool = false + public var state: InterfaceState = .disconnected + public var peerCount: Int = 0 + + public init(config: InterfaceConfig) { + self.id = config.id + self.name = config.name + } + + public init(driver: Any, config: InterfaceConfig) { + self.id = config.id + self.name = config.name + } + + public init(config: InterfaceConfig, driver: CoreBluetoothBLEDriver, transportIdentity: Data) { + self.id = config.id + self.name = config.name + } + + public func connect() async throws {} + public func disconnect() async {} + public func getConnectionInfos() async -> [BLEConnectionInfo] { [] } + public func disconnectPeer(identityHex: String) async {} +} + +public final class RNodeInterface: NetworkInterface, @unchecked Sendable { + public let id: String + public let name: String + public var online: Bool = false + public var state: InterfaceState = .disconnected + + public init(config: RNodeConfig, name: String) { + self.id = "rnode-\(name)" + self.name = name + } + + /// Compatibility init that mirrors the AppServices call site — + /// constructs from a generic InterfaceConfig + uses host/port as + /// the BLE device name. + public init(config: InterfaceConfig) throws { + self.id = config.id + self.name = config.name + } + + public func connect() async throws {} + public func disconnect() async {} + public func configureRadio(_ config: RadioConfig) async throws {} +} + +public final class MPCInterface: NetworkInterface, @unchecked Sendable { + public let id: String + public let name: String + public var online: Bool = false + public var state: InterfaceState = .disconnected + public var peerCount: Int = 0 + + public init(serviceType: String) { + self.id = "mpc-\(serviceType)" + self.name = serviceType + } + + public init(config: InterfaceConfig, displayName: String) { + self.id = config.id + self.name = displayName + } + + public func connect() async throws {} + public func disconnect() async {} +} + +public struct InterfaceConfig: Sendable, Equatable { + public let id: String + public let name: String + public let type: WireInterfaceType + public let enabled: Bool + public let mode: InterfaceMode + public let host: String + public let port: UInt16 + public let ifac: Data? + public let announceRateTarget: TimeInterval? + public let announceRateGrace: Int + public let announceRatePenalty: TimeInterval? + public let networkName: String? + public let passphrase: String? + + public init( + id: String, + name: String, + type: WireInterfaceType, + enabled: Bool, + mode: InterfaceMode = .full, + host: String = "", + port: UInt16 = 0, + ifac: Data? = nil, + announceRateTarget: TimeInterval? = nil, + announceRateGrace: Int = 0, + announceRatePenalty: TimeInterval? = nil, + networkName: String? = nil, + passphrase: String? = nil + ) { + self.id = id + self.name = name + self.type = type + self.enabled = enabled + self.mode = mode + self.host = host + self.port = port + self.ifac = ifac + self.announceRateTarget = announceRateTarget + self.announceRateGrace = announceRateGrace + self.announceRatePenalty = announceRatePenalty + self.networkName = networkName + self.passphrase = passphrase + } +} + +public struct BLEConnectionInfo: Identifiable, Equatable, Sendable { + public let identityHex: String + public var identityHash: String + public var displayName: String? + public var rssi: Int? + public var connected: Bool + public var lastSeen: Date + public var lastActivity: Date + public var connectionType: String + public var connectionDuration: TimeInterval + public var isOutgoing: Bool + public var mtu: Int + public var bytesSent: Int + public var bytesReceived: Int + public var packetsSent: Int + public var packetsReceived: Int + public var signalQuality: SignalQuality + + public var id: String { identityHex } + + public init( + identityHex: String, + identityHash: String = "", + displayName: String? = nil, + rssi: Int? = nil, + connected: Bool = false, + lastSeen: Date = Date(), + lastActivity: Date = Date(), + connectionType: String = "peripheral", + connectionDuration: TimeInterval = 0, + isOutgoing: Bool = false, + mtu: Int = 23, + bytesSent: Int = 0, + bytesReceived: Int = 0, + packetsSent: Int = 0, + packetsReceived: Int = 0, + signalQuality: SignalQuality = .unknown + ) { + self.identityHex = identityHex + self.identityHash = identityHash.isEmpty ? identityHex : identityHash + self.displayName = displayName + self.rssi = rssi + self.connected = connected + self.lastSeen = lastSeen + self.lastActivity = lastActivity + self.connectionType = connectionType + self.connectionDuration = connectionDuration + self.isOutgoing = isOutgoing + self.mtu = mtu + self.bytesSent = bytesSent + self.bytesReceived = bytesReceived + self.packetsSent = packetsSent + self.packetsReceived = packetsReceived + self.signalQuality = signalQuality + } +} + +public enum SignalQuality: String, Equatable, Sendable { + case excellent, good, fair, poor, unknown +} + +// MARK: - Location / Telemetry stubs (non-iOS only) +// +// The real `LocationSharingManager` lives in +// `ColumbaApp/Services/LocationSharingManager.swift` and only compiles on +// iOS (it needs CoreLocation + UIApplication.applicationState). This stub +// exists so the few cross-platform call sites that hold an +// `appServices.locationSharingManager?` reference still link on macOS / +// tests-on-macOS. When iOS, the real class takes over by being declared +// in the same module name space. + +#if !os(iOS) +public final class LocationSharingManager: @unchecked Sendable { + public var peerLocations: [Data: PeerLocation] = [:] + public var activePeers: Set = [] + public var isSharingWithAnyone: Bool { false } + public init() {} + public func isSharing(with destinationHash: Data) -> Bool { false } + public func startSharing(with destinationHash: Data) async {} + public func startSharing(with destinationHash: Data, duration: SharingDuration) {} + public func stopSharing(with destinationHash: Data) {} + public func sharedTelemetry(for destinationHash: Data) -> TelemetryPacket? { nil } + public func handleIncomingCease(from sourceHash: Data) async {} + public func handleIncomingTelemetry( + from peerHash: Data, + packet: TelemetryPacket, + displayName: String?, + iconAppearance: IconAppearance? = nil + ) {} + public func stopAllSharing() async {} + public func setBackgroundState(_ isBackground: Bool) {} +} +#endif + +public struct TelemetryPacket: Equatable, Sendable { + public let timestamp: Date + public let payload: Data + public init(timestamp: Date = Date(), payload: Data = Data()) { + self.timestamp = timestamp + self.payload = payload + } + + public static func decode(from data: Data) -> TelemetryPacket? { + TelemetryPacket(timestamp: Date(), payload: data) + } +} + +public struct LocationTelemetry: Equatable, Sendable { + public let latitude: Double + public let longitude: Double + public let altitude: Double? + public let timestamp: Date + public init(latitude: Double, longitude: Double, altitude: Double? = nil, timestamp: Date = Date()) { + self.latitude = latitude + self.longitude = longitude + self.altitude = altitude + self.timestamp = timestamp + } +} + +public struct RadioConfig: Equatable, Sendable { + public let frequency: UInt32 + public let bandwidth: UInt32 + public let spreadingFactor: UInt8 + public let codingRate: UInt8 + public let txPower: UInt8 + public let stAlock: Float? + public let ltAlock: Float? + public init( + frequency: UInt32 = 0, + bandwidth: UInt32 = 0, + txPower: UInt8 = 0, + spreadingFactor: UInt8 = 0, + codingRate: UInt8 = 0, + stAlock: Float? = nil, + ltAlock: Float? = nil + ) { + self.frequency = frequency + self.bandwidth = bandwidth + self.spreadingFactor = spreadingFactor + self.codingRate = codingRate + self.txPower = txPower + self.stAlock = stAlock + self.ltAlock = ltAlock + } +} + +// MARK: - Propagation + +public struct PropagationNodeInfo: Identifiable, Equatable, Sendable, Codable { + public let destinationHash: Data + public var displayName: String? + public var lastSeen: Date + public var hopCount: Int + public var perTransferLimit: Int + public var perSyncLimit: Int + public var stampCost: Int + public var enabled: Bool + + public var id: Data { destinationHash } + + public init( + destinationHash: Data = Data(), + displayName: String? = nil, + lastSeen: Date = Date(), + hopCount: Int = 0, + perTransferLimit: Int = 0, + perSyncLimit: Int = 0, + stampCost: Int = 0, + enabled: Bool = true + ) { + self.destinationHash = destinationHash + self.displayName = displayName + self.lastSeen = lastSeen + self.hopCount = hopCount + self.perTransferLimit = perTransferLimit + self.perSyncLimit = perSyncLimit + self.stampCost = stampCost + self.enabled = enabled + } + + public static func parse(_ data: Data) -> PropagationNodeInfo? { parse(from: data) } + + /// Parse an `lxmf.propagation` announce's app_data. Mirrors canonical + /// Python `LXMF.LXMRouter.get_propagation_node_app_data`: + /// msgpack [legacy_bool, timebase, node_state, per_transfer_limit, + /// per_sync_limit, stamp_cost, metadata_map] + /// `node_state` (index 2) is the enabled flag; the optional display name is + /// in the metadata map (index 6) at key PN_META_NAME. `destinationHash` / + /// `lastSeen` / `hopCount` aren't in app_data — the caller fills those from + /// the PathEntry. Returns nil if the data isn't a propagation announce. + public static func parse(from data: Data) -> PropagationNodeInfo? { + guard !data.isEmpty, + let value = try? unpackMsgPack(data), + case .array(let arr) = value, + arr.count >= 3 else { return nil } + + let enabled = arr[2].boolValue ?? false + let perTransfer = arr.count > 3 ? (arr[3].intValue ?? 0) : 0 + let perSync = arr.count > 4 ? (arr[4].intValue ?? 0) : 0 + // stamp_cost (index 5) is itself a list [cost, flexibility, peering] in + // current LXMF; take the first element. Tolerate a bare int too. + var stampCost = 0 + if arr.count > 5 { + if case .array(let sc) = arr[5], let first = sc.first { stampCost = first.intValue ?? 0 } + else { stampCost = arr[5].intValue ?? 0 } + } + var name: String? + if arr.count > 6, case .map(let metadata) = arr[6] { + let v = metadata[.uint(AppDataParser.pnMetaName)] ?? metadata[.int(Int64(AppDataParser.pnMetaName))] + switch v { + case .string(let s): name = s + case .binary(let d): name = String(data: d, encoding: .utf8) + default: break + } + } + + return PropagationNodeInfo( + displayName: name, + perTransferLimit: perTransfer, + perSyncLimit: perSync, + stampCost: stampCost, + enabled: enabled + ) + } +} + +private extension MessagePackValue { + var boolValue: Bool? { + if case .bool(let b) = self { return b } + return nil + } + /// Coerce an int-ish msgpack value (fixint can decode as .uint or .int). + var intValue: Int? { + switch self { + case .uint(let u): return Int(u) + case .int(let i): return Int(i) + default: return nil + } + } +} + +public enum PropagationState: String, Equatable, Sendable { + case idle, syncing, error +} diff --git a/Sources/RNSAPI/Models/Identity.swift b/Sources/RNSAPI/Models/Identity.swift new file mode 100644 index 00000000..822d536a --- /dev/null +++ b/Sources/RNSAPI/Models/Identity.swift @@ -0,0 +1,195 @@ +import Foundation +import Security +import CryptoKit + +/// A Reticulum identity. +/// +/// **v1 compatibility shape.** Long-term this becomes the pure-data Android +/// `rns-api/model/Identity` (just `hash`/`publicKey`/`privateKey: Data`) with +/// all crypto operations exposed via `RNSCore.sign/verify/encrypt/decrypt`. +/// For now we preserve the AI-Swift surface so the existing 28 call sites +/// in Columba iOS don't all need rewriting in one go. +/// +/// Wire format is the canonical RNS 64-byte raw private-key blob (32 bytes +/// X25519 encryption + 32 bytes Ed25519 signing). On disk we additionally +/// support a 128-byte format that includes the corresponding public keys +/// for forward-compat with the AI-Swift exporter — public keys are derived +/// when only the 64-byte form is present. +public struct Identity: Equatable, Sendable { + /// Identity hash — `SHA-256(publicKeys)` truncated to 16 bytes. + public let hash: Data + + /// 64-byte concatenated public key blob (32 X25519 + 32 Ed25519). + public let publicKeys: Data + + /// 64-byte concatenated private key blob (32 X25519 + 32 Ed25519). `nil` + /// for peer identities recalled from announces; set for the local + /// identity the user controls. + public let privateKeyBytes: Data? + + // MARK: - Initializers + + /// Generate a fresh identity. Private + public keys are derived via + /// CryptoKit; the hash is derived from the public-key blob. + public init() { + let x25519 = Curve25519.KeyAgreement.PrivateKey() + let ed25519 = Curve25519.Signing.PrivateKey() + + let encPriv = x25519.rawRepresentation + let sigPriv = ed25519.rawRepresentation + let encPub = x25519.publicKey.rawRepresentation + let sigPub = ed25519.publicKey.rawRepresentation + + self.privateKeyBytes = encPriv + sigPriv + self.publicKeys = encPub + sigPub + self.hash = Identity.deriveHash(fromPublicKeys: encPub + sigPub) + } + + /// Load identity from a 64-byte private-key blob (canonical RNS format). + public init(privateKeyBytes: Data) throws { + guard privateKeyBytes.count == 64 else { + throw IdentityError.invalidPrivateKeyLength(privateKeyBytes.count) + } + let encPriv = privateKeyBytes.prefix(32) + let sigPriv = privateKeyBytes.suffix(32) + + let x25519 = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: encPriv) + let ed25519 = try Curve25519.Signing.PrivateKey(rawRepresentation: sigPriv) + + let encPub = x25519.publicKey.rawRepresentation + let sigPub = ed25519.publicKey.rawRepresentation + + self.privateKeyBytes = Data(privateKeyBytes) + self.publicKeys = encPub + sigPub + self.hash = Identity.deriveHash(fromPublicKeys: encPub + sigPub) + } + + /// Construct from a 64-byte public-key blob (no private keys — peer identity). + public init(publicKeyBytes: Data) throws { + guard publicKeyBytes.count == 64 else { + throw IdentityError.invalidPublicKeyLength(publicKeyBytes.count) + } + self.privateKeyBytes = nil + self.publicKeys = publicKeyBytes + self.hash = Identity.deriveHash(fromPublicKeys: publicKeyBytes) + } + + /// Memberwise init for use by the bridge when reconstructing from Python state. + public init(hash: Data, publicKeys: Data, privateKeyBytes: Data?) { + self.hash = hash + self.publicKeys = publicKeys + self.privateKeyBytes = privateKeyBytes + } + + // MARK: - Hex helpers + + /// Lowercase hex of `hash`. e.g. `"a85afbd4342415caa45d29799d6f950e"`. + public var hexHash: String { hash.toHex() } + + /// Lowercase hex of `publicKeys`. Used by the AI Swift `Identity(_:String)` + /// recall form — preserved for call-site compatibility. + public var publicKeyHex: String { publicKeys.toHex() } + + public var hasPrivateKeys: Bool { privateKeyBytes != nil } + + // MARK: - Derivation + + /// Canonical RNS truncated-SHA256 hash of the public-key blob. + public static func deriveHash(fromPublicKeys publicKeys: Data) -> Data { + Data(SHA256.hash(data: publicKeys)).prefix(16) + } + + /// Export raw 64-byte private-key blob. Throws if no private keys. + public func exportPrivateKeys() throws -> Data { + guard let pk = privateKeyBytes else { throw IdentityError.noPrivateKeys } + return pk + } + + // MARK: - Keychain + + /// 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 { + guard let pk = privateKeyBytes else { throw IdentityError.noPrivateKeys } + let baseQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let attrs: [String: Any] = baseQuery.merging([ + kSecValueData as String: pk, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ]) { _, new in new } + let status = SecItemAdd(attrs as CFDictionary, nil) + if status == errSecDuplicateItem { + let update: [String: Any] = [kSecValueData as String: pk] + let upStatus = SecItemUpdate(baseQuery as CFDictionary, update as CFDictionary) + guard upStatus == errSecSuccess else { + throw IdentityError.keychainWriteFailed(upStatus) + } + } else if status != errSecSuccess { + throw IdentityError.keychainWriteFailed(status) + } + } + + /// 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] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let data = item as? Data else { return nil } + return try Identity(privateKeyBytes: data) + case errSecItemNotFound: + return nil + default: + throw IdentityError.keychainReadFailed(status) + } + } + + @discardableResult + public static func deleteFromKeychain(service: String, account: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} + +public enum IdentityError: Error, LocalizedError { + case invalidPrivateKeyLength(Int) + case invalidPublicKeyLength(Int) + case noPrivateKeys + case keychainReadFailed(OSStatus) + case keychainWriteFailed(OSStatus) + case cryptoUnsupported(String) + + public var errorDescription: String? { + switch self { + case .invalidPrivateKeyLength(let n): + return "Invalid private key length: \(n) (expected 64)" + case .invalidPublicKeyLength(let n): + return "Invalid public key length: \(n) (expected 64)" + case .noPrivateKeys: + return "Identity has no private keys" + case .keychainReadFailed(let s): + return "Keychain read failed (\(s))" + case .keychainWriteFailed(let s): + return "Keychain write failed (\(s))" + case .cryptoUnsupported(let op): + return "Crypto operation '\(op)' is not implemented in v1 — comes back in Phase 2 via RNSCore" + } + } +} diff --git a/Sources/RNSAPI/Protocols/BackendCapabilities.swift b/Sources/RNSAPI/Protocols/BackendCapabilities.swift new file mode 100644 index 00000000..d31ed67e --- /dev/null +++ b/Sources/RNSAPI/Protocols/BackendCapabilities.swift @@ -0,0 +1,161 @@ +import Foundation + +/// Snapshot of what a backend implementation supports, observed by the UI +/// to gate features that are kotlin-only or python-only. +/// +/// Structured as a tree (one sub-record per feature area) rather than a +/// flat boolean bag so that grouping forces "which family does this +/// belong to" thinking at the call site, and groups of related +/// capabilities can grow without touching unrelated code. +/// +/// Surfaced as an `AsyncSequence` on the root +/// `RNSBackend` — observable so runtime-mutable capabilities (interface +/// status changes, etc.) propagate to the UI without a re-bind. +/// +/// Mirrors `BackendCapabilities.kt` in Columba Android's `rns-api`. +/// Identical shape; the iOS port drops the `@Parcelize` annotation +/// because Swift modules don't cross an AIDL boundary. +public struct BackendCapabilities: Equatable, Sendable { + public let backendId: BackendID + public let versions: Versions + public let interfaces: InterfaceCaps + public let telemetry: TelemetryCaps + public let performance: PerformanceCaps + + public init( + backendId: BackendID, + versions: Versions, + interfaces: InterfaceCaps, + telemetry: TelemetryCaps, + performance: PerformanceCaps + ) { + self.backendId = backendId + self.versions = versions + self.interfaces = interfaces + self.telemetry = telemetry + self.performance = performance + } + + /// Versions of the underlying protocol libraries. Co-located with + /// capability flags so the About screen reads them in one call instead + /// of four. `nil` means the library isn't shipped on this backend + /// (e.g., LXST has no canonical Python equivalent today). + public struct Versions: Equatable, Sendable { + public let reticulum: String? + public let lxmf: String? + public let lxst: String? + public let bleReticulum: String? + + public init(reticulum: String?, lxmf: String?, lxst: String?, bleReticulum: String?) { + self.reticulum = reticulum + self.lxmf = lxmf + self.lxst = lxst + self.bleReticulum = bleReticulum + } + } + + /// Interface management capabilities. The kotlin backend can + /// hot-reload RNS interface configs without restarting the protocol + /// stack; the python backend needs a full restart (~5–10s outage), so + /// the UI surfaces an explicit "Apply & Restart" button instead of + /// applying silently. + /// + /// `hotReloadInterfaces` is a single boolean rather than a tri-state + /// because every realistic implementation either applies live or + /// requires a restart — there is no "unsupported" state where + /// interface changes have no path to take effect at all. + public struct InterfaceCaps: Equatable, Sendable { + public let hotReloadInterfaces: Bool + public let degradationHint: String? + + public init(hotReloadInterfaces: Bool, degradationHint: String? = nil) { + self.hotReloadInterfaces = hotReloadInterfaces + self.degradationHint = degradationHint + } + } + + /// Telemetry collector host-mode capabilities. The python backend + /// ships upstream LXMF's well-tested `FIELD_TELEMETRY_STREAM` encoder; + /// the kotlin backend uses lxmf-kt's reimplementation. If a parity test + /// fails, the kotlin flag downgrades to `.experimental` — no UI change + /// required beyond rendering a "Beta" pill. + public struct TelemetryCaps: Equatable, Sendable { + public let collectorHostMode: Support + public let storeOwnTelemetry: Support + public let allowedRequestersFilter: Support + public let degradationHint: String? + + public init( + collectorHostMode: Support, + storeOwnTelemetry: Support, + allowedRequestersFilter: Support, + degradationHint: String? = nil + ) { + self.collectorHostMode = collectorHostMode + self.storeOwnTelemetry = storeOwnTelemetry + self.allowedRequestersFilter = allowedRequestersFilter + self.degradationHint = degradationHint + } + } + + /// Performance-tuning capabilities that aren't strictly protocol + /// concerns. `batteryProfileTuning` adjusts BLE scan intervals, + /// multicast lock acquisition, and AutoInterface aggressiveness — only + /// the kotlin backend has the hooks. `sharedInstanceAvailabilityChecks` + /// lets the UI detect when a co-located rnsd shared instance is + /// present. + public struct PerformanceCaps: Equatable, Sendable { + public let batteryProfileTuning: Support + public let sharedInstanceAvailabilityChecks: Bool + + public init(batteryProfileTuning: Support, sharedInstanceAvailabilityChecks: Bool) { + self.batteryProfileTuning = batteryProfileTuning + self.sharedInstanceAvailabilityChecks = sharedInstanceAvailabilityChecks + } + } + + /// Tri-state per-feature support indicator. + /// + /// - `full`: feature is implemented and ready for production use. + /// - `unsupported`: feature is not implemented; UI should hide or + /// replace the entry point with an unavailable-notice. + /// - `experimental`: feature is implemented but not yet + /// trust-validated. UI should render with a "Beta" indicator. + public enum Support: String, Equatable, Sendable { + case full + case unsupported + case experimental + } + + /// Which backend implementation is bound. The UI uses this for + /// informational purposes (About screen, debug tooling); behavior + /// gates should test specific capability flags rather than branching + /// on backend identity, so the seam can grow new backends without + /// churning UI code. + public enum BackendID: String, Equatable, Sendable { + case pythonEmbedded // iOS-only: BeeWare Python-Apple-support + canonical RNS + case swiftNative // native reticulum-swift / LXMF-swift / LXST-swift + } + + /// Sentinel snapshot returned before a backend binding has been + /// established (e.g., from the early seed of an `AsyncSequence` + /// observer between `RNSBackend` construction and `initialize()` + /// completing). Every capability is the safe-default; UI code that + /// gates on a capability before the first real snapshot lands behaves + /// as though the backend can't honour it, which is correct: there is + /// no backend. + public static let unknown: BackendCapabilities = BackendCapabilities( + backendId: .pythonEmbedded, + versions: .init(reticulum: nil, lxmf: nil, lxst: nil, bleReticulum: nil), + interfaces: .init(hotReloadInterfaces: false), + telemetry: .init( + collectorHostMode: .unsupported, + storeOwnTelemetry: .unsupported, + allowedRequestersFilter: .unsupported + ), + performance: .init( + batteryProfileTuning: .unsupported, + sharedInstanceAvailabilityChecks: false + ) + ) +} diff --git a/Sources/RNSAPI/Protocols/RNSError.swift b/Sources/RNSAPI/Protocols/RNSError.swift new file mode 100644 index 00000000..d6bf0c1d --- /dev/null +++ b/Sources/RNSAPI/Protocols/RNSError.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Typed error envelope for the Reticulum backend. +/// +/// On Android this crosses an AIDL boundary between the UI process and a +/// dedicated `:reticulum` backend process, so it has a manual `Parcelable` +/// implementation. iOS doesn't have that boundary (everything runs in the +/// app process), so the Swift port is a plain `Error` enum with associated +/// values — same surface, no marshalling glue. +/// +/// Most error cases have a typed variant so the UI can render a meaningful +/// message and choose recovery actions; truly novel failures fall through +/// to `.generic` with the original message and Python stack trace text +/// preserved for debugging. +/// +/// Mirrors `RnsError.kt` in Columba Android's `rns-api`. +public enum RNSError: Error, Equatable { + /// Unrecognised failure. `stackTraceText` is the formatted Python + /// traceback for the originating exception. May be `nil` for client-side + /// synthesized errors (e.g., timeout from outside the Python layer). + case generic(message: String, stackTraceText: String?) + + /// Backend hasn't completed `initialize()` yet. Surfaced by any + /// operation that requires the RNS stack to be running. UI should + /// either wait for the network-status observer to flip to ready or + /// surface a "still starting up" notice. + case backendNotReady + + /// Identity wasn't found in the backend's identity store. `hashHex` + /// is the truncated identity hash that was looked up. + case identityNotFound(hashHex: String) + + /// Operation took longer than the caller's timeout budget. + /// `operation` is a human-readable name of the call; `timeoutMs` is + /// the timeout in milliseconds. + case timeoutExceeded(operation: String, timeoutMs: Int64) + + /// Caller invoked a method whose corresponding capability is + /// `BackendCapabilities.Support.unsupported`. Should only occur if a + /// UI gate was missed — every UI surface that calls a capability-gated + /// method is supposed to check the capability first via + /// `BackendCapabilities`. The `feature` string names the specific + /// capability path (e.g., `"performance.batteryProfileTuning"`). + case featureUnsupported(feature: String) + + /// Telephony state machine refused the requested transition. + /// `expected` is what the operation needed (e.g., `"ESTABLISHED"`); + /// `actual` is the current state name. UI typically just shows a + /// toast and re-renders the call card from the latest `VoiceCallState`. + case callStateInvalid(expected: String, actual: String) + + /// NomadNet page request couldn't reach the destination or the + /// destination returned a 404-equivalent. `destHash` is the target + /// destination's hex hash; `path` is the requested page path + /// (`"/page/index.mu"` etc.). + case nomadnetPageNotFound(destHash: String, path: String) +} + +extension RNSError: LocalizedError { + public var errorDescription: String? { + switch self { + case .generic(let message, _): + return message + case .backendNotReady: + return "Backend not ready" + case .identityNotFound(let hashHex): + return "Identity not found: \(hashHex)" + case .timeoutExceeded(let operation, let timeoutMs): + return "Timeout (\(timeoutMs)ms) on \(operation)" + case .featureUnsupported(let feature): + return "Feature unsupported: \(feature)" + case .callStateInvalid(let expected, let actual): + return "Invalid call state: expected \(expected), was \(actual)" + case .nomadnetPageNotFound(let destHash, let path): + return "NomadNet page not found: \(destHash)\(path)" + } + } +} diff --git a/Sources/RNSAPI/Protocols/RnsBackend.swift b/Sources/RNSAPI/Protocols/RnsBackend.swift new file mode 100644 index 00000000..82d1fd15 --- /dev/null +++ b/Sources/RNSAPI/Protocols/RnsBackend.swift @@ -0,0 +1,252 @@ +// +// RnsBackend.swift +// RNSAPI +// +// The backend-agnostic protocol seam — iOS analog of Columba Android's +// `:rns-api` (`RnsCore` / `RnsTelephony` / `RnsTransportAdmin`). The UI talks to +// `AppServices` (the `:rns-host` facade); `AppServices` talks to `any RnsBackend`. +// Both backends conform and are selected at build time: +// • RNSBackendPy — embedded canonical Python RNS/LXMF +// • RNSBackendSwift — native reticulum-swift / LXMF-swift +// +// The neutral DTOs below replace the `PythonBridge.*`-namespaced ones so neither +// the UI nor `AppServices` reference a concrete backend. Each backend maps its +// own representation onto these. +// + +import Foundation + +// MARK: - DTOs + +/// Parameters to boot a backend. +public struct StartParams: Sendable { + public let configDir: String + public let identityPath: String + public let displayName: String + public let identityBytes: Data? + + public init(configDir: String, identityPath: String, displayName: String, identityBytes: Data? = nil) { + self.configDir = configDir + self.identityPath = identityPath + self.displayName = displayName + self.identityBytes = identityBytes + } +} + +/// Local identity + LXMF delivery destination, learned at `start`. +public struct LocalInfo: Equatable, Sendable { + public let identityHash: String + public let destinationHash: String + + public init(identityHash: String, destinationHash: String) { + self.identityHash = identityHash + self.destinationHash = destinationHash + } +} + +/// Result of an opportunistic LXMF send. `queued.messageHash` is the real LXMF +/// message hash hex (empty if unavailable); callers persist the outbound row +/// under it so a later `.delivery` event can correlate. +public enum SendOutcome: Equatable, Sendable { + case queued(messageHash: String) + case requestingPath + case badHash + case notStarted + case other(String) +} + +/// Events a backend surfaces to the host (drained from a stream). Mirrors the +/// announce / inbound / delivery-proof / RNS.Link event set the UI + LXST voice +/// state machine consume. +public enum BackendEvent: Equatable, Sendable { + case announce(destHash: String, appDataHex: String, aspect: String, publicKeysHex: String, interfaceName: String, hops: Int, t: Date) + /// `fieldsPacked` is the inbound LXMF field map as MessagePack bytes (empty + /// = no fields) — decode with `LxmfFieldCodec.unpack`. Carries telemetry / + /// attachments / reactions / replies / icon / cease through the seam. + case inbound(sourceHash: String, content: String, title: String, fieldsPacked: Data, t: Date) + case state(String, t: Date) + /// Delivery / failure proof for an outbound message, keyed by its LXMF + /// message hash hex. `state` is "delivered" or "failed". + case delivery(messageHash: String, state: String, t: Date) + // RNS.Link events — consumed by lxst-swift for voice calls. + case linkState(linkId: Int, state: String, reason: String, inbound: Bool, t: Date) + case linkPacket(linkId: Int, data: Data, t: Date) + case linkIdentified(linkId: Int, identityHashHex: String, t: Date) +} + +/// Outcome of a blocking propagation-node sync. +public struct PropagationSyncResult: Sendable, Equatable { + public enum State: String, Sendable { + case idle + case pathRequested = "path_requested" + case linkEstablishing = "link_establishing" + case linkEstablished = "link_established" + case requestSent = "request_sent" + case receiving + case responseReceived = "response_received" + case complete + case noPath = "no_path" + case transferFailed = "transfer_failed" + case noRouter = "no-router" + case notStarted = "not-started" + case noNode = "no-node" + case unknown + } + public let ok: Bool + public let state: State + public let receivedMessages: Int + public let reason: String + + public init(ok: Bool, state: State, receivedMessages: Int, reason: String) { + self.ok = ok + self.state = state + self.receivedMessages = receivedMessages + self.reason = reason + } +} + +/// Result of a one-shot NomadNet page fetch. +public struct NomadNetFetchResult: Sendable, Equatable { + public enum Status: String, Sendable { + case ok + case noPath = "no-path" + case linkFailed = "link-failed" + case requestFailed = "request-failed" + case timeout + case badHash = "bad-hash" + case notStarted = "not-started" + case unknown + } + public let ok: Bool + public let status: Status + public let data: Data + public let contentType: String + + public init(ok: Bool, status: Status, data: Data, contentType: String) { + self.ok = ok + self.status = status + self.data = data + self.contentType = contentType + } +} + +/// RNS Transport diagnostic snapshot — interfaces, online state, table sizes. +/// `Decodable` so the Python backend can decode its `status_json` straight into +/// it; the Swift backend builds it via the memberwise init. +public struct StatusSnapshot: Decodable, Sendable { + public let started: Bool + public let interfaces: [InterfaceStatus] + public let destinationTableSize: Int? + public let pathTableSize: Int? + + public init(started: Bool, interfaces: [InterfaceStatus], destinationTableSize: Int?, pathTableSize: Int?) { + self.started = started + self.interfaces = interfaces + self.destinationTableSize = destinationTableSize + self.pathTableSize = pathTableSize + } + + public struct InterfaceStatus: Decodable, Sendable { + public let sectionName: String + public let name: String + public let online: Bool + public let rxBytes: Int + public let txBytes: Int + + public init(sectionName: String, name: String, online: Bool, rxBytes: Int, txBytes: Int) { + self.sectionName = sectionName + self.name = name + self.online = online + self.rxBytes = rxBytes + self.txBytes = txBytes + } + + enum CodingKeys: String, CodingKey { + case sectionName = "section_name" + case name, online + case rxBytes = "rx_bytes" + case txBytes = "tx_bytes" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.sectionName = (try? c.decode(String?.self, forKey: .sectionName)) ?? "" + self.name = (try? c.decode(String?.self, forKey: .name)) ?? "" + 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 + } + } + + enum CodingKeys: String, CodingKey { + case started, interfaces + case destinationTableSize = "destination_table_size" + case pathTableSize = "path_table_size" + } +} + +// MARK: - Protocols (Android :rns-api parity — composed sub-interfaces) +// +// Mirrors Android Columba's rns-api: the umbrella `RnsBackend` composes focused +// sub-interfaces — `core` (RNS lifecycle/announce/status), `lxmf` (messaging), +// `telephony` (voice links), `telemetry` (location sharing), `nomadnet` (node +// browsing), `transportAdmin` (live interfaces). `RnsLxmf` / `RnsTelemetry` / +// `RnsNomadnet` live in their own files. + +/// Core RNS lifecycle, announces, status. (LXMF messaging is `RnsLxmf`; NomadNet +/// is `RnsNomadnet`; telemetry is `RnsTelemetry`.) +public protocol RnsCore: AnyObject, Sendable { + /// Local identity + delivery destination once started (nil before `start`). + var localInfo: LocalInfo? { get } + /// Stream of backend events (announce / inbound / delivery / link). The + /// first subscription starts the drain. + var events: AsyncStream { get } + + @discardableResult + func start(_ params: StartParams) async throws -> LocalInfo + func stop() async + @discardableResult func announce(displayName: String) async throws -> Bool + @discardableResult func announceTelephony(displayName: String) async throws -> Bool + func statusSnapshot() async -> StatusSnapshot? + @discardableResult func persist() async -> Bool +} + +/// RNS.Link operations backing LXST voice (the Swift state machine drives these; +/// the backend is the Link pipe). +public protocol RnsTelephony: AnyObject, Sendable { + func openLink(destHashHex: String, aspect: String) async throws -> (ok: Bool, linkId: Int, reason: String) + @discardableResult func linkSend(linkId: Int, data: Data) async throws -> Bool + @discardableResult func linkIdentify(linkId: Int) async throws -> Bool + @discardableResult func linkTeardown(linkId: Int) async throws -> Bool +} + +/// Live interface hot add/remove on the running transport (no restart). +public protocol RnsTransportAdmin: AnyObject, Sendable { + @discardableResult func addInterface(name: String) async throws -> (ok: Bool, reason: String) + @discardableResult func removeInterface(name: String) async throws -> (ok: Bool, reason: String) +} + +/// The umbrella the factory returns and `AppServices` holds. Composes the six +/// facets (Android `RnsBackend` parity) and exposes them as accessors so call +/// sites read `backend.lxmf.send…` / `backend.core.start…` like Android. +public protocol RnsBackend: RnsCore, RnsLxmf, RnsTelemetry, RnsNomadnet, RnsTelephony, RnsTransportAdmin { + /// What this backend can do — drives UI capability gating. + var capabilities: BackendCapabilities { get } +} + +// Facet accessors — a composition view over the conforming backend (the backend +// implements every facet, so each accessor is just `self` viewed as that facet). +public extension RnsBackend { + var core: RnsCore { self } + var lxmf: RnsLxmf { self } + var telephony: RnsTelephony { self } + var telemetry: RnsTelemetry { self } + var nomadnet: RnsNomadnet { self } + var transportAdmin: RnsTransportAdmin { self } +} + +public extension RnsTelephony { + func openLink(destHashHex: String) async throws -> (ok: Bool, linkId: Int, reason: String) { + try await openLink(destHashHex: destHashHex, aspect: "lxst.telephony") + } +} diff --git a/Sources/RNSAPI/Protocols/RnsLxmf.swift b/Sources/RNSAPI/Protocols/RnsLxmf.swift new file mode 100644 index 00000000..892b94cd --- /dev/null +++ b/Sources/RNSAPI/Protocols/RnsLxmf.swift @@ -0,0 +1,84 @@ +// +// RnsLxmf.swift +// RNSAPI +// +// LXMF messaging facet of the backend seam — iOS analog of Android Columba's +// `rns-api/RnsLxmf`. Outbound LXMF fields cross the seam as TYPED parameters +// (image / attachments / icon / reply) plus a raw `extraFields` escape hatch, +// rather than a `[UInt8: Any]` map (which isn't `Sendable`). The backend +// assembles the on-wire LXMF field map from these, using the canonical +// `LxmfFields` IDs so the encoding matches Sideband/upstream. +// + +import Foundation + +/// A file attachment carried in `FIELD_FILE_ATTACHMENTS` (0x05) as `[name, bytes]`. +public struct RnsFileAttachment: Sendable, Equatable { + public let name: String + public let data: Data + public init(name: String, data: Data) { + self.name = name + self.data = data + } +} + +public protocol RnsLxmf: AnyObject, Sendable { + + /// Send an LXMF message. Structured fields are passed typed; `extraFields` + /// carries any pre-encoded raw field bytes (e.g. custom metadata) keyed by + /// `LxmfFields` ID. The backend builds the wire field map and routes per + /// `method`. `replyToMessageHashHex` → `FIELD_REPLY_HASH` (0x30) and + /// `replyQuotedContent` → `FIELD_REPLY_QUOTE` (0x31), matching Android's + /// canonical reply encoding (not the legacy 0x10 dict). + @discardableResult + 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 + + /// Send a tap-back reaction via canonical `FIELD_REACTION` (0x40) on an + /// otherwise-empty message: `{0x00: targetHashBytes, 0x01: emojiUTF8}`. + @discardableResult + func sendReaction( + destHashHex: String, + targetMessageHashHex: String, + emoji: String + ) async throws -> SendOutcome + + /// Set / clear the outbound LXMF propagation node (empty hex clears). + @discardableResult + func setPropagationNode(destHashHex: String, stampCost: Int) async throws -> Bool + + /// Blocking sync from the configured propagation node. + func propagationSync(timeout: TimeInterval) async throws -> PropagationSyncResult +} + +// Ergonomic overloads (protocol requirements can't carry default arguments). +public extension RnsLxmf { + @discardableResult + func sendLxmfMessage(destHashHex: String, content: String, + method: LXDeliveryMethod = .opportunistic) async throws -> SendOutcome { + try await sendLxmfMessage( + destHashHex: destHashHex, content: content, method: method, + imageData: nil, imageFormat: nil, fileAttachments: nil, iconAppearance: nil, + replyToMessageHashHex: nil, replyQuotedContent: nil, extraFields: nil + ) + } + + @discardableResult + func setPropagationNode(destHashHex: String) async throws -> Bool { + try await setPropagationNode(destHashHex: destHashHex, stampCost: 0) + } + + func propagationSync() async throws -> PropagationSyncResult { + try await propagationSync(timeout: 60.0) + } +} diff --git a/Sources/RNSAPI/Protocols/RnsNomadnet.swift b/Sources/RNSAPI/Protocols/RnsNomadnet.swift new file mode 100644 index 00000000..beab972f --- /dev/null +++ b/Sources/RNSAPI/Protocols/RnsNomadnet.swift @@ -0,0 +1,29 @@ +// +// RnsNomadnet.swift +// RNSAPI +// +// NomadNet node-browsing facet — iOS analog of Android Columba's +// `rns-api/RnsNomadnet`. Split out of RnsCore so the seam mirrors Android's +// composed-interface shape. +// + +import Foundation + +public protocol RnsNomadnet: AnyObject, Sendable { + + /// One-shot NomadNet page fetch over a fresh RNS Link. `formFields`, when + /// present, are submitted as the request's MessagePack map (caller prefixes + /// `field_` per NomadNet's node-app convention). + func fetchNomadNetPage( + destHashHex: String, + path: String, + timeout: TimeInterval, + formFields: [String: String]? + ) async throws -> NomadNetFetchResult +} + +public extension RnsNomadnet { + func fetchNomadNetPage(destHashHex: String, path: String) async throws -> NomadNetFetchResult { + try await fetchNomadNetPage(destHashHex: destHashHex, path: path, timeout: 30.0, formFields: nil) + } +} diff --git a/Sources/RNSAPI/Protocols/RnsTelemetry.swift b/Sources/RNSAPI/Protocols/RnsTelemetry.swift new file mode 100644 index 00000000..2c73a22d --- /dev/null +++ b/Sources/RNSAPI/Protocols/RnsTelemetry.swift @@ -0,0 +1,41 @@ +// +// RnsTelemetry.swift +// RNSAPI +// +// Telemetry / location-sharing facet — iOS analog of Android Columba's +// `rns-api/RnsTelemetry`. Telemetry is its own interface (not folded into +// RnsLxmf) precisely because it's a per-backend *capability*: the Swift-native +// backend implements it; the Python backend declares it unsupported. +// +// Telemetry payloads cross the seam as Sideband-`Telemeter`-packed `Data` +// (the caller packs via the shared codec), which the backend places under +// `LxmfFields.FIELD_TELEMETRY` (0x02). This keeps the seam `Sendable`. +// + +import Foundation + +public protocol RnsTelemetry: AnyObject, Sendable { + + /// Send a single-shot location telemetry update to a peer. `packed` is the + /// Sideband-`Telemeter`-packed payload (`FIELD_TELEMETRY` 0x02); `customMeta` + /// is optional app-meta bytes carried in `FIELD_CUSTOM_META` (0xFD). + /// + /// A stop-sharing "cease" is not a separate method — it's this same call + /// with a zeroed-location Telemeter blob in `packed` and `customMeta` = + /// msgpack `{"cease": true}` (see `CeaseTelemetry`), matching Android + /// Columba's `sendCeaseMessage`. + @discardableResult + func sendLocationTelemetry(destHashHex: String, packed: Data, customMeta: Data?) async throws -> SendOutcome + + /// Act as a telemetry collector (accept/serve others' telemetry). Android parity. + @discardableResult + func setTelemetryCollectorMode(enabled: Bool) async -> Bool + + /// Store the local node's own latest telemetry (for collector responses). Android parity. + @discardableResult + func storeOwnTelemetry(packed: Data) async -> Bool + + /// Restrict which requester hashes may pull telemetry. Android parity. + @discardableResult + func setTelemetryAllowedRequesters(_ allowedHashesHex: Set) async -> Bool +} diff --git a/Sources/RNSAPI/Util/AppDataParser.swift b/Sources/RNSAPI/Util/AppDataParser.swift new file mode 100644 index 00000000..bbeeab4b --- /dev/null +++ b/Sources/RNSAPI/Util/AppDataParser.swift @@ -0,0 +1,70 @@ +// +// AppDataParser.swift +// RNSAPI +// +// Decodes the display name out of an LXMF announce's app_data. The Python +// bridge (`rns_bridge.py`) is intentionally thin — it forwards the raw +// app_data bytes and lets this layer interpret them, so the LXMF wire-format +// knowledge lives in one place on the Swift side alongside MsgPack and +// PropagationNodeInfo. +// +// app_data layout differs by aspect (see canonical Python LXMF): +// - lxmf.delivery — LXMRouter.get_announce_app_data: +// msgpack [display_name_bytes_or_nil, stamp_cost] → name at index 0 +// - lxmf.propagation — LXMRouter.get_propagation_node_app_data: +// msgpack [legacy_bool, timebase, node_state, per_transfer_limit, +// per_sync_limit, stamp_cost, metadata_map] +// → index 0 is a LEGACY BOOLEAN (False), NOT the name. The optional +// name lives in the metadata map at key PN_META_NAME (0x01). Reading +// index 0 here is what made propagation nodes display as "False". +// + +import Foundation + +public enum AppDataParser { + /// LXMF.PN_META_NAME — metadata-map key carrying a propagation node's name. + static let pnMetaName: UInt64 = 0x01 + + /// Best-effort display name from an announce's `appData`, given its aspect. + /// Returns "" when there's no name (common for propagation nodes) rather + /// than leaking a non-name field. Never throws — malformed data → "". + public static func displayName(from appData: Data, aspect: String) -> String { + guard !appData.isEmpty else { return "" } + + // Propagation node: name (if any) is in the metadata map at index 6. + if aspect == Aspects.lxmfPropagation { + guard let value = try? unpackMsgPack(appData), + case .array(let arr) = value, + arr.count > 6, + case .map(let metadata) = arr[6] else { return "" } + return Self.string(metadata[.uint(pnMetaName)] ?? metadata[.int(Int64(pnMetaName))]) ?? "" + } + + // Delivery (and other list-shaped app_data): name at index 0. + if let value = try? unpackMsgPack(appData), + case .array(let arr) = value, + let first = arr.first, + let name = Self.string(first) { + return name + } + + // Legacy pre-msgpack clients sent the raw UTF-8 name. Only honored for + // non-propagation aspects (propagation app_data is always msgpack, so + // raw-decoding its bytes would produce garbage). + if aspect != Aspects.lxmfPropagation, + let raw = String(data: appData, encoding: .utf8) { + return raw + } + return "" + } + + /// Pull a UTF-8 string out of a `.string` or `.binary` MessagePackValue. + /// `.nil` / other cases → nil (so callers can fall back to ""). + private static func string(_ value: MessagePackValue?) -> String? { + switch value { + case .string(let s): return s + case .binary(let d): return String(data: d, encoding: .utf8) + default: return nil + } + } +} diff --git a/Sources/RNSAPI/Util/Aspects.swift b/Sources/RNSAPI/Util/Aspects.swift new file mode 100644 index 00000000..32175ecc --- /dev/null +++ b/Sources/RNSAPI/Util/Aspects.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Announce aspect strings Columba tracks. +/// +/// Reticulum destinations identify themselves by an aspect (e.g. +/// `"lxmf.delivery"`) — the same string is the source of truth for routing, +/// destination construction, `Transport.registerKnownAspect` calls, and the +/// announce-handler aspect filter. Previously each backend + the shared +/// `AppDataParser` / `NodeType.fromAspect` listed the literals +/// independently; this enum centralises them so the protocol-leaf strings +/// cannot drift. +/// +/// Python-side, `event_bridge.py._KNOWN_ASPECTS` is a parallel tuple of the +/// same strings — kept in sync by hand because the embedded Python can't +/// read Swift constants. The strings here are the canonical reference. +/// +/// Mirrors `Aspects.kt` in Columba Android's `rns-api`. +public enum Aspects { + /// Peer-to-peer LXMF messaging destination (`.lxmf.delivery`). + public static let lxmfDelivery = "lxmf.delivery" + + /// LXMF propagation / store-and-forward node. + public static let lxmfPropagation = "lxmf.propagation" + + /// NomadNet content node (Sites, pages, files). + public static let nomadnetNode = "nomadnetwork.node" + + /// LXST telephony / voice call destination. + public static let lxstTelephony = "lxst.telephony" + + /// Every aspect Columba tracks — handy for set-membership tests. + public static let all: Set = [ + lxmfDelivery, + lxmfPropagation, + nomadnetNode, + lxstTelephony, + ] +} diff --git a/Sources/RNSAPI/Util/ColumbaMetaCodec.swift b/Sources/RNSAPI/Util/ColumbaMetaCodec.swift new file mode 100644 index 00000000..dab3e427 --- /dev/null +++ b/Sources/RNSAPI/Util/ColumbaMetaCodec.swift @@ -0,0 +1,81 @@ +// +// ColumbaMetaCodec.swift +// RNSAPI +// +// Codec for Columba's `FIELD_CUSTOM_META` (0xFD) payload — the location-share +// extras (`cease`, `expires`, `approxRadius`, ms-precision `ts`) that ride +// alongside a Sideband `Telemeter` blob (`FIELD_TELEMETRY` 0x02). +// +// iOS analog of Android `rns-api`'s `TelemeterCodec.packColumbaMeta` / +// `unpackColumbaMeta`. The wire form is **msgpack** `{cease?, expires?, +// approxRadius?, ts?}` — NOT JSON. Android decodes this field with a +// MessagePack unpacker; a JSON byte string (`{"cease": true}`) parses there as +// a bare positive-fixint (the leading `{` byte = 0x7B = 123), so the map is +// never seen and the cease is silently dropped. iOS historically packed it as +// JSON, which is exactly that interop break — this codec is the fix. +// + +import Foundation + +public enum ColumbaMetaCodec { + /// Decoded Columba extras. Mirror of Android's `TelemeterCodec.ColumbaMeta`. + public struct Meta: Equatable, Sendable { + public let cease: Bool + public let expires: Int64? + public let approxRadius: Int + /// ms-precision timestamp the sender carried alongside the + /// seconds-precision Telemeter `last_update`; nil when absent. + public let tsMillis: Int64? + + public init(cease: Bool = false, expires: Int64? = nil, approxRadius: Int = 0, tsMillis: Int64? = nil) { + self.cease = cease + self.expires = expires + self.approxRadius = approxRadius + self.tsMillis = tsMillis + } + } + + /// Pack the Columba extras as msgpack, or `nil` when there is nothing worth + /// sending (no cease / expiry / coarsening radius / sub-second ts) — the + /// caller should then OMIT `FIELD_CUSTOM_META` so Sideband peers see a clean + /// Telemeter-only payload. Matches Android's `packColumbaMeta`. + public static func pack(_ meta: Meta) -> Data? { + var pairs: [(MessagePackValue, MessagePackValue)] = [] + if meta.cease { pairs.append((.string("cease"), .bool(true))) } + if let expires = meta.expires { pairs.append((.string("expires"), .int(expires))) } + if meta.approxRadius > 0 { pairs.append((.string("approxRadius"), .int(Int64(meta.approxRadius)))) } + // ms-precision ts is worth carrying only when it's NOT a clean second + // boundary (Telemeter's last_update quantizes to seconds) — matches + // Android so the two codecs emit byte-identical meta. + if let ts = meta.tsMillis, ts % 1000 != 0 { pairs.append((.string("ts"), .int(ts))) } + guard !pairs.isEmpty else { return nil } + return packMsgPack(.map(Dictionary(uniqueKeysWithValues: pairs))) + } + + /// The canonical stop-sharing payload: msgpack `{"cease": true}`. + /// Non-nil by construction (the cease flag is always set). + public static func packCease() -> Data { + pack(Meta(cease: true)) ?? packMsgPack(.map([.string("cease"): .bool(true)])) + } + + /// Unpack a `FIELD_CUSTOM_META` blob. Returns `nil` when the bytes aren't a + /// msgpack map (e.g. a malformed peer payload). Mirror of `unpackColumbaMeta`. + public static func unpack(_ data: Data) -> Meta? { + guard let value = try? unpackMsgPack(data), case .map(let m) = value else { return nil } + func intValue(_ key: String) -> Int64? { + switch m[.string(key)] { + case .int(let n): return n + case .uint(let n): return Int64(exactly: n) + default: return nil + } + } + let cease: Bool + if case .bool(let b) = m[.string("cease")] { cease = b } else { cease = false } + return Meta( + cease: cease, + expires: intValue("expires"), + approxRadius: intValue("approxRadius").map(Int.init) ?? 0, + tsMillis: intValue("ts") + ) + } +} diff --git a/Sources/RNSAPI/Util/HexExt.swift b/Sources/RNSAPI/Util/HexExt.swift new file mode 100644 index 00000000..590f6ea9 --- /dev/null +++ b/Sources/RNSAPI/Util/HexExt.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Hex string ↔ `Data` extensions used everywhere a destination / identity +/// / packet hash crosses a boundary (logs, JSON keys, dict keys, the +/// Python bridge surface). Lowercase-hex is the canonical wire form across +/// the protocol layer and the UI. +/// +/// Centralised here because the toHex / hexToData pair was previously +/// inlined as `bytes.map { String(format: "%02x", $0) }.joined()` in +/// dozens of call sites across the AI-generated `ReticulumSwift` / +/// `LXMFSwift` libraries. +/// +/// Mirrors `HexExt.kt` in Columba Android's `rns-api`. +public extension Data { + /// Lowercase hex representation. e.g. `Data([0x01, 0xAB]).toHex() == "01ab"`. + func toHex() -> String { + map { String(format: "%02x", $0) }.joined() + } +} + +public extension String { + /// Hex string → `Data`. Caller is responsible for the hex being + /// even-length and well-formed; throws `HexDecodingError` otherwise. + /// Mixed case is accepted. + func hexToData() throws -> Data { + guard count % 2 == 0 else { + throw HexDecodingError.oddLength(length: count, sample: String(prefix(32))) + } + var data = Data(capacity: count / 2) + var idx = startIndex + while idx < endIndex { + let next = index(idx, offsetBy: 2) + guard let byte = UInt8(self[idx.. Data { + guard !fields.isEmpty else { return Data() } + return packMsgPack(value(fromFieldMap: fields)) + } + + /// Build the canonical on-wire LXMF field map from typed send params, shared + /// by both backends so they encode identically: FIELD_IMAGE (0x06) = + /// [format, bytes]; FIELD_FILE_ATTACHMENTS (0x05) = [[name, bytes], …]; + /// FIELD_ICON_APPEARANCE (0x04); FIELD_REPLY_HASH (0x30) = raw target-hash + /// bytes + optional FIELD_REPLY_QUOTE (0x31). `extraFields` (pre-encoded raw + /// bytes, e.g. telemetry/custom-meta) are merged last. + public static func buildFieldMap( + imageData: Data?, + imageFormat: String?, + fileAttachments: [RnsFileAttachment]?, + iconAppearance: IconAppearance?, + replyToMessageHashHex: String?, + replyQuotedContent: String?, + extraFields: [UInt8: Data]? + ) -> [UInt8: Any] { + var fields: [UInt8: Any] = [:] + if let imageData, let imageFormat { + fields[LxmfFields.FIELD_IMAGE] = [imageFormat, imageData] as [Any] + } + if let fileAttachments, !fileAttachments.isEmpty { + fields[LxmfFields.FIELD_FILE_ATTACHMENTS] = fileAttachments.map { [$0.name, $0.data] as [Any] } + } + if let iconAppearance { + fields[LxmfFields.FIELD_ICON_APPEARANCE] = iconAppearance.toLXMFFieldValue() + } + if let replyToMessageHashHex, let replyHash = try? replyToMessageHashHex.hexToData() { + fields[LxmfFields.FIELD_REPLY_HASH] = replyHash + if let replyQuotedContent { + fields[LxmfFields.FIELD_REPLY_QUOTE] = Data(replyQuotedContent.utf8) + } + } + if let extraFields { + for (k, v) in extraFields { fields[k] = v } + } + return fields + } + + /// Unpack MessagePack bytes back to an LXMF field map. Returns nil for empty + /// or malformed data, or if the top level isn't a map. + public static func unpack(_ data: Data) -> [UInt8: Any]? { + guard !data.isEmpty, let v = try? unpackMsgPack(data), case .map(let m) = v else { return nil } + var out: [UInt8: Any] = [:] + for (k, val) in m { + guard let key = uint8Key(k) else { continue } + out[key] = any(from: val) + } + return out.isEmpty ? nil : out + } + + // MARK: - [UInt8: Any] → MessagePackValue + + private static func value(fromFieldMap fields: [UInt8: Any]) -> MessagePackValue { + var m: [MessagePackValue: MessagePackValue] = [:] + for (k, v) in fields { m[.uint(UInt64(k))] = value(fromAny: v) } + return .map(m) + } + + private static func value(fromAny v: Any) -> MessagePackValue { + switch v { + case let d as Data: return .binary(d) + case let s as String: return .string(s) + case let b as Bool: return .bool(b) + case let i as Int: return .int(Int64(i)) + case let i as Int64: return .int(i) + case let u as UInt8: return .uint(UInt64(u)) + case let u as UInt: return .uint(UInt64(u)) + case let u as UInt64: return .uint(u) + case let d as Double: return .double(d) + case let f as Float: return .float(f) + case let arr as [Any]: return .array(arr.map { value(fromAny: $0) }) + case let dict as [String: Any]: + var m: [MessagePackValue: MessagePackValue] = [:] + for (k, val) in dict { m[.string(k)] = value(fromAny: val) } + return .map(m) + case let dict as [UInt8: Any]: + var m: [MessagePackValue: MessagePackValue] = [:] + for (k, val) in dict { m[.uint(UInt64(k))] = value(fromAny: val) } + return .map(m) + default: + return .nil + } + } + + // MARK: - MessagePackValue → Any + + private static func any(from v: MessagePackValue) -> Any { + switch v { + case .nil: return NSNull() + case .bool(let b): return b + case .int(let i): return Int(truncatingIfNeeded: i) + case .uint(let u): return Int(truncatingIfNeeded: 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 { any(from: $0) } + case .map(let m): + // String-keyed maps (e.g. legacy app_data {"reaction_to":…}) decode to + // [String: Any]; integer-keyed maps (nested field dicts like the + // canonical reaction {0x00:…,0x01:…}) decode to [UInt8: Any]. + let allStringKeys = m.keys.allSatisfy { if case .string = $0 { return true } else { return false } } + if allStringKeys { + var out: [String: Any] = [:] + for (k, val) in m { if case .string(let s) = k { out[s] = any(from: val) } } + return out + } else { + var out: [UInt8: Any] = [:] + for (k, val) in m { if let key = uint8Key(k) { out[key] = any(from: val) } } + return out + } + } + } + + private static func uint8Key(_ k: MessagePackValue) -> UInt8? { + switch k { + case .uint(let u): return UInt8(exactly: u) + case .int(let i): return UInt8(exactly: i) + default: return nil + } + } +} diff --git a/Sources/RNSAPI/Util/LxmfFields.swift b/Sources/RNSAPI/Util/LxmfFields.swift new file mode 100644 index 00000000..f022b46b --- /dev/null +++ b/Sources/RNSAPI/Util/LxmfFields.swift @@ -0,0 +1,70 @@ +// +// LxmfFields.swift +// RNSAPI +// +// LXMF protocol field IDs Columba reads or writes — the single source of truth +// for both backends (Python-flavor + Swift-native) and the UI. Mirrors Android +// Columba's `rns-api/util/LxmfFields.kt` so the two platforms stay in sync. +// +// Numeric values are the upstream LXMF spec (`LXMF/LXMF.py`). Only fields +// Columba actually uses on the wire are listed. +// +// ⚠️ Interop note: Columba-iOS historically used divergent values that did NOT +// match upstream/Sideband/Android — +// • telemetry on 0x08 (upstream 0x08 is FIELD_THREAD; Sideband reads telemetry +// at FIELD_TELEMETRY = 0x02), so iOS telemetry never interoperated; and +// • app-meta on an invented 0x70 (Android migrated this to the upstream +// canonical FIELD_CUSTOM_META = 0xFD). +// These constants are the corrected, Sideband-compatible values. +// + +import Foundation + +public enum LxmfFields { + + /// LXMF app name for delivery destinations (`LXMF.LXMRouter.APP_NAME`). + public static let APP_NAME = "lxmf" + /// Local aspect for LXMF delivery destinations (`LXMRouter.DELIVERY_ASPECT`). + public static let DELIVERY_ASPECT = "delivery" + + /// Single-shot telemetry payload (Sideband-compatible packed location). + public static let FIELD_TELEMETRY: UInt8 = 0x02 + /// Multi-entry telemetry stream — propagation collector responses. + public static let FIELD_TELEMETRY_STREAM: UInt8 = 0x03 + /// `[name, fgRgbBytes, bgRgbBytes]` — Sideband/MeshChat icon appearance. + public static let FIELD_ICON_APPEARANCE: UInt8 = 0x04 + /// Sideband-compatible file attachments — `[[name, bytes], ...]`. + public static let FIELD_FILE_ATTACHMENTS: UInt8 = 0x05 + /// Image payload — `[format, bytes]`. + public static let FIELD_IMAGE: UInt8 = 0x06 + /// Audio payload — `[mode, bytes]`. + public static let FIELD_AUDIO: UInt8 = 0x07 + /// Command structures (Sideband telemetry-request RPCs). + public static let FIELD_COMMANDS: UInt8 = 0x09 + + /// Canonical tap-back reaction — `fields[0x40] = {0x00: targetHashBytes, + /// 0x01: emojiUTF8Bytes}` (standardised upstream in LXMF.py). The reacting + /// user is derived from the inbound source hash, not carried on the wire. + public static let FIELD_REACTION: UInt8 = 0x40 + /// `FIELD_REACTION` dict key — raw bytes of the target `LXMessage.hash`. + public static let REACTION_TO: UInt8 = 0x00 + /// `FIELD_REACTION` dict key — UTF-8 bytes of the reaction content (emoji). + public static let REACTION_CONTENT: UInt8 = 0x01 + /// Legacy reaction `fields[0x10] = {reaction_to, emoji, sender}` — parse-only + /// fallback for un-upgraded peers; outbound uses `FIELD_REACTION` (0x40). + public static let FIELD_REACTION_LEGACY: UInt8 = 0x10 + + /// Reply-target message hash — `fields[0x30] = Data` (32 raw bytes, not hex). + public static let FIELD_REPLY_HASH: UInt8 = 0x30 + /// Optional reply quoted content — `fields[0x31] = Data` (UTF-8 of the + /// original content, so the recipient can render a preview without the + /// original in their local store). + public static let FIELD_REPLY_QUOTE: UInt8 = 0x31 + + /// Upstream LXMF `FIELD_CUSTOM_META` (0xFD) — documented extension point for + /// app-specific metadata other LXMF clients ignore. Columba carries the + /// `cease` / `expires` / `approxRadius` extras that ride alongside a + /// Sideband-compatible `FIELD_TELEMETRY` location share here. (Replaces the + /// previously-invented 0x70, matching Android's migration.) + public static let FIELD_CUSTOM_META: UInt8 = 0xFD +} diff --git a/Sources/RNSAPI/Util/MsgPack.swift b/Sources/RNSAPI/Util/MsgPack.swift new file mode 100644 index 00000000..33450d34 --- /dev/null +++ b/Sources/RNSAPI/Util/MsgPack.swift @@ -0,0 +1,342 @@ +// +// MsgPack.swift +// RNSAPI +// +// Minimal MessagePack implementation supporting the wire-format subset +// Reticulum / LXMF / LXST use: +// - nil, bool, ints (signed + unsigned, fixint and prefixed 1/2/4/8 byte), +// bin (binary blobs), str (utf-8), array, map +// - skipped: floats, ext types, timestamps (LXST wire format uses none) +// +// Public entry points: +// packMsgPack(_:) -> Data +// unpackMsgPack(_:) -> MessagePackValue? +// +// Mirrors the API shape from the deleted reticulum-swift package's +// vendored MessagePack module so the lxst-swift sources that referenced +// it compile against this drop-in replacement. + +import Foundation + +public enum MessagePackError: Error, Sendable { + case malformedData +} + +/// Pack a value into MessagePack-encoded bytes. +public func packMsgPack(_ value: MessagePackValue) -> Data { + var out = Data() + MsgPack.pack(value, into: &out) + return out +} + +/// Unpack a single MessagePack value from `data`. Throws if the bytes are +/// malformed or the prefix declares more bytes than are present. Throws +/// (rather than returning nil) so the canonical call site +/// `guard let v = try? unpackMsgPack(data) else { ... }` works without a +/// "no calls to throwing functions in 'try' expression" warning. +public func unpackMsgPack(_ data: Data) throws -> MessagePackValue { + var cursor = MsgPack.Cursor(data: data, index: data.startIndex) + guard let value = MsgPack.unpack(from: &cursor) else { + throw MessagePackError.malformedData + } + return value +} + +private enum MsgPack { + struct Cursor { + let data: Data + var index: Data.Index + + mutating func readByte() -> UInt8? { + guard index < data.endIndex else { return nil } + let b = data[index] + index = data.index(after: index) + return b + } + + mutating func readBytes(_ n: Int) -> Data? { + let end = data.index(index, offsetBy: n, limitedBy: data.endIndex) + guard let end else { return nil } + let slice = data[index.. UInt64? { + guard let bytes = readBytes(byteCount) else { return nil } + var v: UInt64 = 0 + for b in bytes { v = (v << 8) | UInt64(b) } + return v + } + + mutating func readInt(byteCount: Int) -> Int64? { + guard let raw = readUInt(byteCount: byteCount) else { return nil } + // Sign-extend from `byteCount` bytes. + let bits = byteCount * 8 + let signBit: UInt64 = 1 << (bits - 1) + if raw & signBit != 0 { + let mask: UInt64 = bits == 64 ? UInt64.max : (1 << bits) - 1 + let extended = raw | ~mask + return Int64(bitPattern: extended) + } + return Int64(raw) + } + } + + // MARK: - Pack + + static func pack(_ value: MessagePackValue, into out: inout Data) { + switch value { + case .nil: + out.append(0xc0) + case .bool(let b): + out.append(b ? 0xc3 : 0xc2) + case .uint(let u): + packUInt(u, into: &out) + case .int(let i): + packInt(i, into: &out) + case .float(let f): + out.append(0xca) + var be = f.bitPattern.bigEndian + withUnsafeBytes(of: &be) { out.append(contentsOf: $0) } + case .double(let d): + out.append(0xcb) + var be = d.bitPattern.bigEndian + withUnsafeBytes(of: &be) { out.append(contentsOf: $0) } + case .string(let s): + packStr(s, into: &out) + case .binary(let d): + packBin(d, into: &out) + case .array(let arr): + packArrayHeader(count: arr.count, into: &out) + for v in arr { pack(v, into: &out) } + case .map(let dict): + packMapHeader(count: dict.count, into: &out) + // Deterministic key ordering: sort by encoded-uint key when + // present (LXST wire format keys are all small ints); falls + // back to encoded-int / first-string key otherwise. + let sortedKeys = dict.keys.sorted { lhs, rhs in + msgpackSortKey(lhs) < msgpackSortKey(rhs) + } + for k in sortedKeys { + pack(k, into: &out) + if let v = dict[k] { pack(v, into: &out) } + } + } + } + + private static func msgpackSortKey(_ v: MessagePackValue) -> String { + switch v { + case .uint(let u): return String(format: "0:%020d", u) + case .int(let i): return String(format: "1:%020d", i + (Int64.max / 2)) + case .string(let s): return "2:\(s)" + default: return "3:" + } + } + + private static func packUInt(_ u: UInt64, into out: inout Data) { + if u <= 0x7f { + out.append(UInt8(u)) + } else if u <= UInt64(UInt8.max) { + out.append(0xcc); out.append(UInt8(u)) + } else if u <= UInt64(UInt16.max) { + out.append(0xcd); appendBigEndian(UInt16(u), into: &out) + } else if u <= UInt64(UInt32.max) { + out.append(0xce); appendBigEndian(UInt32(u), into: &out) + } else { + out.append(0xcf); appendBigEndian(u, into: &out) + } + } + + private static func packInt(_ i: Int64, into out: inout Data) { + if i >= 0 { packUInt(UInt64(i), into: &out); return } + if i >= -32 { + out.append(UInt8(bitPattern: Int8(i))) + } else if i >= Int64(Int8.min) { + out.append(0xd0); out.append(UInt8(bitPattern: Int8(i))) + } else if i >= Int64(Int16.min) { + out.append(0xd1); appendBigEndian(UInt16(bitPattern: Int16(i)), into: &out) + } else if i >= Int64(Int32.min) { + out.append(0xd2); appendBigEndian(UInt32(bitPattern: Int32(i)), into: &out) + } else { + out.append(0xd3); appendBigEndian(UInt64(bitPattern: i), into: &out) + } + } + + private static func packStr(_ s: String, into out: inout Data) { + let bytes = Data(s.utf8) + let n = bytes.count + if n <= 0x1f { + out.append(0xa0 | UInt8(n)) + } else if n <= UInt8.max { + out.append(0xd9); out.append(UInt8(n)) + } else if n <= UInt16.max { + out.append(0xda); appendBigEndian(UInt16(n), into: &out) + } else { + out.append(0xdb); appendBigEndian(UInt32(n), into: &out) + } + out.append(bytes) + } + + private static func packBin(_ d: Data, into out: inout Data) { + let n = d.count + if n <= UInt8.max { + out.append(0xc4); out.append(UInt8(n)) + } else if n <= UInt16.max { + out.append(0xc5); appendBigEndian(UInt16(n), into: &out) + } else { + out.append(0xc6); appendBigEndian(UInt32(n), into: &out) + } + out.append(d) + } + + private static func packArrayHeader(count n: Int, into out: inout Data) { + if n <= 0x0f { + out.append(0x90 | UInt8(n)) + } else if n <= UInt16.max { + out.append(0xdc); appendBigEndian(UInt16(n), into: &out) + } else { + out.append(0xdd); appendBigEndian(UInt32(n), into: &out) + } + } + + private static func packMapHeader(count n: Int, into out: inout Data) { + if n <= 0x0f { + out.append(0x80 | UInt8(n)) + } else if n <= UInt16.max { + out.append(0xde); appendBigEndian(UInt16(n), into: &out) + } else { + out.append(0xdf); appendBigEndian(UInt32(n), into: &out) + } + } + + private static func appendBigEndian(_ v: UInt16, into out: inout Data) { + var be = v.bigEndian + withUnsafeBytes(of: &be) { out.append(contentsOf: $0) } + } + private static func appendBigEndian(_ v: UInt32, into out: inout Data) { + var be = v.bigEndian + withUnsafeBytes(of: &be) { out.append(contentsOf: $0) } + } + private static func appendBigEndian(_ v: UInt64, into out: inout Data) { + var be = v.bigEndian + withUnsafeBytes(of: &be) { out.append(contentsOf: $0) } + } + + // MARK: - Unpack + + static func unpack(from cursor: inout Cursor) -> MessagePackValue? { + guard let prefix = cursor.readByte() else { return nil } + switch prefix { + case 0xc0: return .nil + case 0xc2: return .bool(false) + case 0xc3: return .bool(true) + + // float32 + case 0xca: + guard let bits = cursor.readUInt(byteCount: 4) else { return nil } + return .float(Float(bitPattern: UInt32(truncatingIfNeeded: bits))) + // float64 + case 0xcb: + guard let bits = cursor.readUInt(byteCount: 8) else { return nil } + return .double(Double(bitPattern: bits)) + + // uint family + case 0xcc: return cursor.readUInt(byteCount: 1).map { .uint($0) } + case 0xcd: return cursor.readUInt(byteCount: 2).map { .uint($0) } + case 0xce: return cursor.readUInt(byteCount: 4).map { .uint($0) } + case 0xcf: return cursor.readUInt(byteCount: 8).map { .uint($0) } + + // int family + case 0xd0: return cursor.readInt(byteCount: 1).map { .int($0) } + case 0xd1: return cursor.readInt(byteCount: 2).map { .int($0) } + case 0xd2: return cursor.readInt(byteCount: 4).map { .int($0) } + case 0xd3: return cursor.readInt(byteCount: 8).map { .int($0) } + + // bin family + case 0xc4: + guard let n = cursor.readUInt(byteCount: 1), let data = cursor.readBytes(Int(n)) else { return nil } + return .binary(data) + case 0xc5: + guard let n = cursor.readUInt(byteCount: 2), let data = cursor.readBytes(Int(n)) else { return nil } + return .binary(data) + case 0xc6: + guard let n = cursor.readUInt(byteCount: 4), let data = cursor.readBytes(Int(n)) else { return nil } + return .binary(data) + + // str family + case 0xd9: + guard let n = cursor.readUInt(byteCount: 1), let bytes = cursor.readBytes(Int(n)) else { return nil } + return .string(String(data: bytes, encoding: .utf8) ?? "") + case 0xda: + guard let n = cursor.readUInt(byteCount: 2), let bytes = cursor.readBytes(Int(n)) else { return nil } + return .string(String(data: bytes, encoding: .utf8) ?? "") + case 0xdb: + guard let n = cursor.readUInt(byteCount: 4), let bytes = cursor.readBytes(Int(n)) else { return nil } + return .string(String(data: bytes, encoding: .utf8) ?? "") + + // array family (16 / 32) + case 0xdc: + guard let n = cursor.readUInt(byteCount: 2) else { return nil } + return unpackArray(count: Int(n), from: &cursor) + case 0xdd: + guard let n = cursor.readUInt(byteCount: 4) else { return nil } + return unpackArray(count: Int(n), from: &cursor) + + // map family (16 / 32) + case 0xde: + guard let n = cursor.readUInt(byteCount: 2) else { return nil } + return unpackMap(count: Int(n), from: &cursor) + case 0xdf: + guard let n = cursor.readUInt(byteCount: 4) else { return nil } + return unpackMap(count: Int(n), from: &cursor) + + default: + // fixint family + if prefix & 0x80 == 0 { + return .uint(UInt64(prefix)) // positive fixint + } + if prefix >= 0xe0 { + return .int(Int64(Int8(bitPattern: prefix))) // negative fixint + } + // fixstr + if prefix & 0xe0 == 0xa0 { + let n = Int(prefix & 0x1f) + guard let bytes = cursor.readBytes(n) else { return nil } + return .string(String(data: bytes, encoding: .utf8) ?? "") + } + // fixarray + if prefix & 0xf0 == 0x90 { + let n = Int(prefix & 0x0f) + return unpackArray(count: n, from: &cursor) + } + // fixmap + if prefix & 0xf0 == 0x80 { + let n = Int(prefix & 0x0f) + return unpackMap(count: n, from: &cursor) + } + return nil + } + } + + private static func unpackArray(count: Int, from cursor: inout Cursor) -> MessagePackValue? { + var items: [MessagePackValue] = [] + items.reserveCapacity(count) + for _ in 0.. MessagePackValue? { + var dict: [MessagePackValue: MessagePackValue] = [:] + dict.reserveCapacity(count) + for _ in 0..? + private var eventContinuation: AsyncStream.Continuation? + + public private(set) var localInfo: LocalInfo? + + /// Stream of backend events. First subscription implicitly starts the drain. + public lazy var events: AsyncStream = { + AsyncStream { continuation in + self.eventContinuation = continuation + self.startDrainLoop() + continuation.onTermination = { _ in + self.eventDrainTask?.cancel() + self.eventDrainTask = nil + } + } + }() + + /// What the iOS Python backend can do. Notably: interface hot-reload IS + /// supported here (unlike Android's Chaquopy python). Telemetry SEND (peer-to-peer + /// location sharing via FIELD_TELEMETRY 0x02 + cease via FIELD_CUSTOM_META 0xFD) + /// is wired and Sideband-compatible; the collector-host responder (FIELD_COMMANDS + /// 0x09, FIELD_TELEMETRY_STREAM 0x03) is still unimplemented because it needs + /// Python-side state in event_bridge.py to track allowed-requesters / stored + /// own-telemetry — deferred (no iOS UI consumer for it yet). + public var capabilities: BackendCapabilities { + BackendCapabilities( + backendId: .pythonEmbedded, + versions: .init(reticulum: "1.3.1", lxmf: "0.9.9", lxst: nil, bleReticulum: "0.2.2"), + interfaces: .init(hotReloadInterfaces: true), + telemetry: .init( + collectorHostMode: .unsupported, + storeOwnTelemetry: .unsupported, + allowedRequestersFilter: .unsupported, + degradationHint: "Peer-to-peer location sharing is wired (FIELD_TELEMETRY 0x02, Sideband-compatible). Collector-host mode (acting as a hub for other peers' telemetry) isn't implemented yet." + ), + performance: .init(batteryProfileTuning: .unsupported, sharedInstanceAvailabilityChecks: false) + ) + } + + public init() {} + + @discardableResult + public func start(_ params: StartParams) async throws -> LocalInfo { + let info = try await bridge.start( + configDir: params.configDir, + identityPath: params.identityPath, + displayName: params.displayName, + identityBytes: params.identityBytes + ) + let mapped = LocalInfo(identityHash: info.identityHash, destinationHash: info.destinationHash) + self.localInfo = mapped + _ = self.events // lazy-init the stream + continuation on first start + // Restart the drain loop after a prior stop() cancelled eventDrainTask: + // the lazy `events` initializer only runs once, so on a stop/start cycle + // (identity switch, reconnect) it would otherwise never re-run and no + // Python events would reach the host. No-ops if already running. + startDrainLoop() + return mapped + } + + public func stop() async { + eventDrainTask?.cancel() + eventDrainTask = nil + do { try await bridge.stop() } catch {} + localInfo = nil + } + + @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 { + // Build the canonical LXMF field map (shared builder → identical to the + // Swift backend) and forward it MessagePack-packed (hex) to Python, which + // unpacks it onto the outbound LXMF message. + let fields = LxmfFieldCodec.buildFieldMap( + imageData: imageData, imageFormat: imageFormat, + fileAttachments: fileAttachments, iconAppearance: iconAppearance, + replyToMessageHashHex: replyToMessageHashHex, replyQuotedContent: replyQuotedContent, + extraFields: extraFields) + let fieldsHex = fields.isEmpty ? "" : LxmfFieldCodec.pack(fields).toHex() + // Forward the typed delivery method so Python sets the right + // LXMessage.desired_method (opportunistic / direct / propagated). + // Until this landed, the Python backend silently downgraded every + // send to OPPORTUNISTIC (upstream LXMF still auto-falls-back to + // DIRECT for large payloads, but PROPAGATED was unreachable from + // the Python backend's surface). + let methodString: String + switch method { + case .direct: methodString = "direct" + case .propagated: methodString = "propagated" + default: methodString = "opportunistic" + } + return Self.map(try await bridge.sendOpportunistic( + destHashHex: destHashHex, content: content, + fieldsHex: fieldsHex, method: methodString)) + } + + @discardableResult + public func sendReaction(destHashHex: String, targetMessageHashHex: String, emoji: String) async throws -> SendOutcome { + guard let targetHash = try? targetMessageHashHex.hexToData() else { return .badHash } + // Canonical FIELD_REACTION (0x40) on an empty-content message. + let reaction: [UInt8: Any] = [ + LxmfFields.REACTION_TO: targetHash, + LxmfFields.REACTION_CONTENT: Data(emoji.utf8), + ] + let fieldsHex = LxmfFieldCodec.pack([LxmfFields.FIELD_REACTION: reaction]).toHex() + return Self.map(try await bridge.sendOpportunistic(destHashHex: destHashHex, content: "", fieldsHex: fieldsHex)) + } + + /// Set / clear the outbound LXMF propagation node. Empty `destHashHex` clears. + @discardableResult + public func setPropagationNode(destHashHex: String, stampCost: Int = 0) async throws -> Bool { + try await bridge.setPropagationNode(destHashHex: destHashHex, stampCost: stampCost) + } + + /// Block until the configured propagation-node sync completes. + public func propagationSync(timeout: TimeInterval = 60.0) async throws -> PropagationSyncResult { + Self.map(try await bridge.propagationSync(timeout: timeout)) + } + + // MARK: - Telemetry (RnsTelemetry) + // + // The send half routes through the same `sendLxmfMessage` path as text / + // image / file sends — typed payloads land in the extraFields slot, + // which `LxmfFieldCodec.buildFieldMap` merges into the field map before + // it crosses to Python and gets packed by upstream LXMF. The wire bytes + // are Sideband-canonical: FIELD_TELEMETRY (0x02) = packed `Telemeter` + // bytes, FIELD_CUSTOM_META (0xFD) = JSON-encoded Columba extras (cease / + // expires / approxRadius). + // + // Collector-host mode (set/store/allowedRequesters) stays unsupported + // here — that's the "be a telemetry hub for other peers" path and needs + // event_bridge.py state we haven't ported yet. Capability declares it + // .unsupported so the SwiftUI gate hides the toggle. + + @discardableResult + public func sendLocationTelemetry(destHashHex: String, packed: Data, customMeta: Data?) async throws -> SendOutcome { + var extra: [UInt8: Data] = [LxmfFields.FIELD_TELEMETRY: packed] + if let customMeta { extra[LxmfFields.FIELD_CUSTOM_META] = customMeta } + return try await sendLxmfMessage( + destHashHex: destHashHex, content: "", method: .opportunistic, + imageData: nil, imageFormat: nil, fileAttachments: nil, iconAppearance: nil, + replyToMessageHashHex: nil, replyQuotedContent: nil, extraFields: extra + ) + } + + // A "cease" (stop sharing) is just sendLocationTelemetry with a zeroed + // Telemeter body + msgpack {"cease":true} meta (see CeaseTelemetry) — no + // dedicated method, matching Android Columba's seam. + + // Collector-host responder — still unsupported. See capability hint above. + public func setTelemetryCollectorMode(enabled: Bool) async -> Bool { false } + public func storeOwnTelemetry(packed: Data) async -> Bool { false } + public func setTelemetryAllowedRequesters(_ allowedHashesHex: Set) async -> Bool { false } + + /// Push a fresh LXMF delivery announce (Settings Announce button + auto-announce timer). + @discardableResult + public func announce(displayName: String) async throws -> Bool { + try await bridge.announce(displayName: displayName) + } + + /// Push a fresh LXST telephony announce — peers learn our voice-call destination. + @discardableResult + public func announceTelephony(displayName: String) async throws -> Bool { + try await bridge.announceTelephony(displayName: displayName) + } + + // MARK: - RNS.Link operations (voice / future Link-based protocols) + // + // The Swift LXST state machine (lxst-swift Telephone actor) drives these. + // Python is just the underlying Link pipe — frames marshalled over via + // openLink + linkSend + linkPacket events. + + public func openLink(destHashHex: String, aspect: String = "lxst.telephony") async throws -> (ok: Bool, linkId: Int, reason: String) { + try await bridge.openLink(destHashHex: destHashHex, aspect: aspect) + } + + @discardableResult + public func linkSend(linkId: Int, data: Data) async throws -> Bool { + try await bridge.linkSend(linkId: linkId, data: data) + } + + @discardableResult + public func linkIdentify(linkId: Int) async throws -> Bool { + try await bridge.linkIdentify(linkId: linkId) + } + + @discardableResult + public func linkTeardown(linkId: Int) async throws -> Bool { + try await bridge.linkTeardown(linkId: linkId) + } + + /// One-shot NomadNet page fetch. + public func fetchNomadNetPage( + destHashHex: String, + path: String, + timeout: TimeInterval = 30.0, + formFields: [String: String]? = nil + ) async throws -> NomadNetFetchResult { + Self.map(try await bridge.fetchNomadNetPage( + destHashHex: destHashHex, + path: path, + timeout: timeout, + formFields: formFields + )) + } + + /// Single-shot RNS Transport status probe (interfaces, online flags, table sizes). + public func statusSnapshot() async -> StatusSnapshot? { + guard let s = await bridge.status() else { return nil } + return Self.map(s) + } + + /// 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 + public func persist() async -> Bool { + await bridge.callModuleFunctionNoArgs(name: "persist") + } + + // MARK: - Live interface reconfiguration (no restart) + + @discardableResult + public func addInterface(name: String) async throws -> (ok: Bool, reason: String) { + try await bridge.applyInterface(name: name, add: true) + } + + @discardableResult + public func removeInterface(name: String) async throws -> (ok: Bool, reason: String) { + try await bridge.applyInterface(name: name, add: false) + } + + // MARK: - Python-backend-specific extras (NOT part of RnsBackend) + // + // These reach the raw bridge for Python-only wiring (BLE/RNode callback + // bridges, smoke-test hooks). AppServices uses them only on Python-specific + // paths, downcasting from `any RnsBackend` where needed. + + /// Direct access for the BLE/RNode callback bridges + stamp generator install. + public var pythonBridge: PythonBridge { bridge } + + @discardableResult + public func installBLETestRoundtripCallback() async -> Bool { + await bridge.callModuleFunctionNoArgs(name: "_install_test_roundtrip_callback") + } + + public func invokeBLETestRoundtrip(value: Int) -> Bool { + bridge.invokeBLECallbackBoolSync(slot: "_test_roundtrip", args: [.int(value)]) + } + + // MARK: - Event drain + raw→neutral mapping + + private func startDrainLoop() { + guard eventDrainTask == nil else { return } + eventDrainTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + let events = await self.bridge.drainEvents() + for event in events { + self.eventContinuation?.yield(Self.map(event)) + } + try? await Task.sleep(nanoseconds: 200_000_000) // 200ms + } + } + } + + private static func map(_ e: PythonBridge.Event) -> BackendEvent { + switch e { + case let .announce(d, a, asp, pk, ifn, h, t): + return .announce(destHash: d, appDataHex: a, aspect: asp, publicKeysHex: pk, interfaceName: ifn, hops: h, t: t) + case let .inbound(s, c, ti, fh, t): + // fieldsHex = MessagePack-packed LXMF field map (hex), extracted by + // rns_bridge.py's delivery callback; decode to the neutral fieldsPacked. + return .inbound(sourceHash: s, content: c, title: ti, fieldsPacked: (try? fh.hexToData()) ?? Data(), t: t) + case let .state(s, t): + return .state(s, t: t) + case let .delivery(m, s, t): + return .delivery(messageHash: m, state: s, t: t) + case let .linkState(l, s, r, i, t): + return .linkState(linkId: l, state: s, reason: r, inbound: i, t: t) + case let .linkPacket(l, dat, t): + return .linkPacket(linkId: l, data: dat, t: t) + case let .linkIdentified(l, idh, t): + return .linkIdentified(linkId: l, identityHashHex: idh, t: t) + } + } + + private static func map(_ o: PythonBridge.SendOutcome) -> SendOutcome { + switch o { + case let .queued(h): return .queued(messageHash: h) + case .requestingPath: return .requestingPath + case .badHash: return .badHash + case .notStarted: return .notStarted + case let .other(s): return .other(s) + } + } + + private static func map(_ r: PythonBridge.PropagationSyncResult) -> PropagationSyncResult { + PropagationSyncResult( + ok: r.ok, + state: PropagationSyncResult.State(rawValue: r.state.rawValue) ?? .unknown, + receivedMessages: r.receivedMessages, + reason: r.reason + ) + } + + private static func map(_ r: PythonBridge.NomadNetFetchResult) -> NomadNetFetchResult { + NomadNetFetchResult( + ok: r.ok, + status: NomadNetFetchResult.Status(rawValue: r.status.rawValue) ?? .unknown, + data: r.data, + contentType: r.contentType + ) + } + + private static func map(_ s: PythonBridge.StatusSnapshot) -> StatusSnapshot { + StatusSnapshot( + started: s.started, + interfaces: s.interfaces.map { + StatusSnapshot.InterfaceStatus( + sectionName: $0.sectionName, name: $0.name, online: $0.online, + rxBytes: $0.rxBytes, txBytes: $0.txBytes + ) + }, + destinationTableSize: s.destinationTableSize, + pathTableSize: s.pathTableSize + ) + } +} diff --git a/Sources/RNSBackendSwift/SwiftRNSBackend.swift b/Sources/RNSBackendSwift/SwiftRNSBackend.swift new file mode 100644 index 00000000..f9b188fd --- /dev/null +++ b/Sources/RNSBackendSwift/SwiftRNSBackend.swift @@ -0,0 +1,854 @@ +// +// SwiftRNSBackend.swift +// Columba (RNSBackendSwift — compiled into ColumbaApp) +// +// Native `RnsBackend` (Android `:rns-backend-kt` analog) over reticulum-swift + +// LXMF-swift. Ported from the pre-migration `main` integration, re-housed behind +// the `RnsBackend` protocol. Because RNSAPI's Compat layer duplicates +// reticulum-swift's type names (Destination/Link/Packet/Identity/ReticulumTransport), +// every reticulum-swift/LXMF-swift type is module-qualified here. +// +// Built up incrementally: conformance to `RnsBackend` + wiring into BackendFactory +// happens once every method is implemented (so the partial type still compiles). +// + +import Foundation +import os +import RNSAPI +import ReticulumSwift +import LXMFSwift + +@available(iOS 17.0, macOS 14.0, *) +public final class SwiftRNSBackend: RnsBackend, @unchecked Sendable { + + private static let log = Logger(subsystem: "network.columba.Columba", category: "SwiftRNSBackend") + + // MARK: - Stack (reticulum-swift / LXMF-swift), module-qualified + + private var identity: ReticulumSwift.Identity? + private var pathTable: ReticulumSwift.PathTable? + private var transport: ReticulumSwift.ReticulumTransport? + private var router: LXMFSwift.LXMRouter? + private var deliveryDestination: ReticulumSwift.Destination? + private var telephonyDestination: ReticulumSwift.Destination? + + public private(set) var localInfo: LocalInfo? + + /// Int→Link registry backing the Python-shaped `RnsTelephony` link API + /// (the protocol uses Int linkIds; reticulum-swift uses `Link` actors). + private var links: [Int: ReticulumSwift.Link] = [:] + /// The fire-and-forget Task draining each link's `stateUpdates`, keyed by + /// linkId. Tracked so it can be cancelled on linkTeardown/stop — otherwise + /// it keeps a strong capture of the (kept-open) eventContinuation and yields + /// stale linkState events into the next session after a restart. + private var linkStateTasks: [Int: Task] = [:] + private var nextLinkId: Int = 1 + /// Serializes the mutable registries that async methods touch from + /// different tasks: `nextLinkId`, `links`, `linkStateTasks`, and + /// `interfaceIds`. SwiftRNSBackend is a class (not an actor) so `linkSend` + /// stays hop-free on the audio path; without this lock, concurrent + /// `openLink` callers race the `nextLinkId` increment + dictionary writes + /// (two callers grab the same id, the second orphans the first link), and + /// `statusSnapshot()` can read `interfaceIds` while `start()`/`addInterface` + /// mutate it (Swift Dictionary read+write races are UB → release-build + /// crashes). Mirrors the Python backend's `_link_id_lock`. A Dictionary is + /// a value type, so snapshotting one under the lock (`let copy = dict`) is + /// a cheap COW retain. Never held across an `await`. + private let linkLock = NSLock() + + /// Section-name → reticulum interface id, backing the Python-shaped + /// `addInterface(name:)` / `removeInterface(name:)` contract (Python resolves + /// the name against its config file; we resolve it against InterfaceRepository). + private var interfaceIds: [String: String] = [:] + + // MARK: - Events + + private let eventStream: AsyncStream + private let eventContinuation: AsyncStream.Continuation + private var delegate: RouterDelegate? + + /// Polls the path table for received announces and bridges them onto the + /// event stream as `.announce`. reticulum-swift exposes no announce callback — + /// the path table IS the announce record (the UI's announces tab reads it the + /// same way), so we diff it on a short cadence, mirroring the Python backend's + /// periodic drain. + private var announcePoller: Task? + + public var events: AsyncStream { eventStream } + + /// Native backend capabilities. The Swift stack hot-reloads interfaces (live + /// add/remove via RnsTransportAdmin, no restart). Telemetry caps are honest- + /// unsupported for now: `RnsTelemetry.sendLocationTelemetry` is wired and + /// Sideband-compatible, but collector-host mode isn't implemented and the app + /// location feature is gated off — flip to `.full` once those land. Battery- + /// profile tuning hooks aren't implemented on this stack either. + public var capabilities: BackendCapabilities { + BackendCapabilities( + backendId: .swiftNative, + versions: .init(reticulum: "0.2.3", lxmf: "0.3.4", lxst: nil, bleReticulum: nil), + interfaces: .init(hotReloadInterfaces: true), + telemetry: .init( + collectorHostMode: .unsupported, + storeOwnTelemetry: .unsupported, + allowedRequestersFilter: .unsupported, + degradationHint: "Telemetry send is wired (Sideband-compatible FIELD_TELEMETRY 0x02); collector-host mode isn't implemented and the app location feature is currently gated off." + ), + performance: .init(batteryProfileTuning: .unsupported, sharedInstanceAvailabilityChecks: false) + ) + } + + public init() { + (eventStream, eventContinuation) = AsyncStream.makeStream() + } + + // MARK: - Lifecycle (ported from main's AppServices.initialize) + + @discardableResult + public func start(_ params: StartParams) async throws -> LocalInfo { + // 1. Identity from the saved private keys (or fresh if none provided). + let id: ReticulumSwift.Identity + if let bytes = params.identityBytes, !bytes.isEmpty { + id = try ReticulumSwift.Identity(privateKeyBytes: bytes) + } else { + id = ReticulumSwift.Identity() + } + self.identity = id + + // 2. Path table + transport. + let pt = ReticulumSwift.PathTable() + self.pathTable = pt + let tp = ReticulumSwift.ReticulumTransport(pathTable: pt) + self.transport = tp + await tp.registerPathRequestHandler() + + // 3. LXMRouter (owns its own LXMF store at databasePath). + let dbPath = (params.configDir as NSString).appendingPathComponent("lxmf-swift.db") + let rt = try await LXMFSwift.LXMRouter(identity: id, databasePath: dbPath) + self.router = rt + + // 4. LXMF delivery destination + ratchets. + let dest = ReticulumSwift.Destination( + identity: id, appName: "lxmf", aspects: ["delivery"], type: .single, direction: .in + ) + self.deliveryDestination = dest + await tp.registerDestination(dest) + let ratchetPath = (params.configDir as NSString).appendingPathComponent("ratchets") + try await dest.enableRatchets(storagePath: ratchetPath) + + // 5. Wire router → transport + ratchets + delivery + delegate. + await rt.setTransport(tp) + await rt.setRatchetManager(dest.ratchetManager) + try await rt.registerDeliveryDestination(dest) + let d = await MainActor.run { RouterDelegate(continuation: self.eventContinuation) } + self.delegate = d + await rt.setDelegate(d) + + // 6. Telephony destination for inbound voice (lxst.telephony). + let tel = ReticulumSwift.Destination( + identity: id, appName: "lxst", aspects: ["telephony"], type: .single, direction: .in + ) + self.telephonyDestination = tel + await tp.registerDestination(tel) + + // 6.5. Bring up the enabled interfaces on THIS backend's transport. + // + // The Python backend loads its interfaces from the RNS config file when + // `start()` runs `RNS.Reticulum(config_dir)`; the Swift backend has no + // such config file, so without this its transport would have zero + // interfaces and nothing would connect (the "connecting forever" bug — + // the legacy `AppServices.connectTCPInterface` startup path added them to + // a separate, pre-dual-backend reticulum-swift stack, never to this + // backend). Reuses the same per-type `buildAndAdd` that the hot-reload + // `addInterface(name:)` path uses, so startup and live edits share one + // path. Per-interface failures are non-fatal — one bad interface must not + // block the rest or the whole start. + for entity in InterfaceRepository().interfaces where entity.enabled { + let section = PythonConfigWriter.sectionName(for: entity) + do { + try await buildAndAdd(entity) + linkLock.lock() + interfaceIds[section] = entity.id + linkLock.unlock() + } catch { + Self.log.error("start: interface \(section, privacy: .public) bring-up failed: \(String(describing: error), privacy: .public)") + } + } + + // 7. Start bridging received announces (path-table diff) onto events. + startAnnouncePolling() + + let info = LocalInfo(identityHash: id.hexHash, destinationHash: dest.hexHash) + self.localInfo = info + return info + } + + public func stop() async { + announcePoller?.cancel() + announcePoller = nil + // Do NOT finish the continuation here: the stream is created once in + // init and must survive stop/start cycles (backend restart, identity + // switch). Finishing it permanently terminates the AsyncStream, so a + // later start() on the same instance would silently drop every event. + // PythonRNSBackend keeps its continuation open across stop() for the + // same reason. + router = nil + transport = nil + pathTable = nil + deliveryDestination = nil + telephonyDestination = nil + // Cancel the per-link stateUpdates drain tasks before dropping the + // links — otherwise they outlive stop() and keep yielding stale + // linkState events into the (deliberately kept-open) continuation. + linkLock.lock() + linkStateTasks.values.forEach { $0.cancel() } + linkStateTasks.removeAll() + links.removeAll() + linkLock.unlock() + localInfo = nil + } + + /// Diff the path table on a short cadence, emitting `.announce` for each + /// newly-seen or freshly re-announced known destination (lxmf.delivery / + /// lxmf.propagation / lxst.telephony / nomadnetwork.node). `lastSeen` is + /// task-local, so no shared mutable state escapes the poller. + private func startAnnouncePolling() { + // Cancel any prior poller first: start() has no idempotency guard, so a + // second start() without an intervening stop() would otherwise leak the + // old task (still polling the orphaned PathTable it captured) and yield + // every announce twice. + announcePoller?.cancel() + guard let pathTable else { return } + let cont = eventContinuation + announcePoller = Task { + var lastSeen: [String: Date] = [:] + while !Task.isCancelled { + for entry in await pathTable.allEntries() { + guard let aspect = entry.detectedAspect else { continue } + let hash = entry.destinationHash.hexHash + if let prev = lastSeen[hash], prev >= entry.timestamp { continue } + lastSeen[hash] = entry.timestamp + cont.yield(.announce( + destHash: hash, + appDataHex: (entry.appData ?? Data()).hexHash, + aspect: aspect, + publicKeysHex: entry.publicKeys.hexHash, + interfaceName: entry.interfaceId, + hops: Int(entry.hopCount), + t: entry.timestamp + )) + } + try? await Task.sleep(nanoseconds: 300_000_000) // 300ms + } + } + } + + // MARK: - Messaging (ported from main's sendAnnounce / handleOutbound) + + @discardableResult + public func announce(displayName: String) async throws -> Bool { + // Canonical LXMF (>= 0.5.0) delivery-announce app_data: + // msgpack([display_name_utf8_bytes, stamp_cost]). Mirrors LXMF + // LXMRouter.get_announce_app_data (what the Python backend and Sideband + // emit) so peers decode the name via the msgpack path rather than + // relying on LXMF's legacy raw-utf8 fallback (which also drops the + // stamp cost). stamp_cost is nil — Columba registers its delivery + // identity without an inbound stamp requirement, matching the Python + // backend's register_delivery_identity(display_name=…). + let appData = packMsgPack(.array([.binary(Data(displayName.utf8)), .null])) + return try await emitAnnounce(on: deliveryDestination, appData: appData, withRatchet: true) + } + + @discardableResult + public func announceTelephony(displayName: String) async throws -> Bool { + // The lxst.telephony announce app_data is LXST's, not LXMF's — keep the + // raw display-name bytes the Telephone layer expects. + try await emitAnnounce(on: telephonyDestination, appData: Data(displayName.utf8), withRatchet: false) + } + + private func emitAnnounce(on destination: ReticulumSwift.Destination?, appData: Data, withRatchet: Bool) async throws -> Bool { + guard let transport, let destination else { return false } + destination.appData = appData + var ratchetPub: Data? = nil + if withRatchet, let mgr = destination.ratchetManager { + await mgr.rotateIfNeeded() + ratchetPub = await mgr.currentRatchetPublicBytes() + } + let announce = ReticulumSwift.Announce(destination: destination, ratchet: ratchetPub) + let packet = try announce.buildPacket() + try await transport.send(packet: packet) + return true + } + + @discardableResult + public func sendLxmfMessage( + destHashHex: String, + content: String, + method: RNSAPI.LXDeliveryMethod, + imageData: Data?, + imageFormat: String?, + fileAttachments: [RnsFileAttachment]?, + iconAppearance: RNSAPI.IconAppearance?, + replyToMessageHashHex: String?, + replyQuotedContent: String?, + extraFields: [UInt8: Data]? + ) async throws -> SendOutcome { + guard let router, let id = identity else { return .notStarted } + guard let destHash = Self.hexData(destHashHex), !destHash.isEmpty else { return .badHash } + + // Build the canonical on-wire LXMF field map (shared with PythonRNSBackend + // so both backends encode identically). LXMessage.fields is [UInt8: Any]. + let fields = LxmfFieldCodec.buildFieldMap( + imageData: imageData, imageFormat: imageFormat, + fileAttachments: fileAttachments, iconAppearance: iconAppearance, + replyToMessageHashHex: replyToMessageHashHex, replyQuotedContent: replyQuotedContent, + extraFields: extraFields) + + var msg = LXMFSwift.LXMessage( + destinationHash: destHash, + sourceIdentity: id, + content: Data(content.utf8), + title: Data(), + fields: fields.isEmpty ? nil : fields, + desiredMethod: Self.lxmfMethod(method) + ) + try await router.handleOutbound(&msg) + return .queued(messageHash: msg.hash.hexHash) + } + + @discardableResult + public func sendReaction(destHashHex: String, targetMessageHashHex: String, emoji: String) async throws -> SendOutcome { + guard let router, let id = identity else { return .notStarted } + guard let destHash = Self.hexData(destHashHex), !destHash.isEmpty, + let targetHash = Self.hexData(targetMessageHashHex) else { return .badHash } + // Canonical FIELD_REACTION (0x40): {0x00: targetHashBytes, 0x01: emojiUTF8}. + let reaction: [UInt8: Any] = [ + LxmfFields.REACTION_TO: targetHash, + LxmfFields.REACTION_CONTENT: Data(emoji.utf8), + ] + var msg = LXMFSwift.LXMessage( + destinationHash: destHash, + sourceIdentity: id, + content: Data(), + title: Data(), + fields: [LxmfFields.FIELD_REACTION: reaction], + desiredMethod: .opportunistic + ) + try await router.handleOutbound(&msg) + return .queued(messageHash: msg.hash.hexHash) + } + + private static func lxmfMethod(_ m: RNSAPI.LXDeliveryMethod) -> LXMFSwift.LXDeliveryMethod { + switch m { + case .direct: return .direct + case .propagated: return .propagated + default: return .opportunistic + } + } + + // MARK: - Telemetry (RnsTelemetry) + // + // Location payloads are Sideband-`Telemeter`-packed by the caller (LXMF-swift's + // Telemetry codec is Sideband-compatible) and carried under FIELD_TELEMETRY + // (0x02); cease/extras ride FIELD_CUSTOM_META (0xFD). Collector-host mode isn't + // implemented on the Swift backend yet — the capability declares it unsupported. + + @discardableResult + public func sendLocationTelemetry(destHashHex: String, packed: Data, customMeta: Data?) async throws -> SendOutcome { + var extra: [UInt8: Data] = [LxmfFields.FIELD_TELEMETRY: packed] + if let customMeta { extra[LxmfFields.FIELD_CUSTOM_META] = customMeta } + return try await sendLxmfMessage( + destHashHex: destHashHex, content: "", method: .opportunistic, + imageData: nil, imageFormat: nil, fileAttachments: nil, iconAppearance: nil, + replyToMessageHashHex: nil, replyQuotedContent: nil, extraFields: extra + ) + } + + // A "cease" (stop sharing) is just sendLocationTelemetry with a zeroed + // Telemeter body + msgpack {"cease":true} meta (see CeaseTelemetry) — no + // dedicated method, matching Android Columba's seam. + + 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: - Propagation + persistence + + @discardableResult + public func setPropagationNode(destHashHex: String, stampCost: Int) async throws -> Bool { + guard let router else { return false } + let hash = destHashHex.isEmpty ? nil : Self.hexData(destHashHex) + await router.setOutboundPropagationNode(hash) + if stampCost > 0 { await router.setPropagationStampCost(stampCost) } + return true + } + + public func propagationSync(timeout: TimeInterval) async throws -> PropagationSyncResult { + guard let router else { + return PropagationSyncResult(ok: false, state: .noRouter, receivedMessages: 0, reason: "no router") + } + // syncFromPropagationNode() returns Void, but the router's syncState + // carries the count of messages pulled this sync (receivedMessages is + // incremented per message as they're retrieved). Read it after the + // await rather than hardcoding 0, so the UI's "N new messages" is real. + // + // The router applies only per-request timeouts internally — it has no + // overall deadline, so a stalled link or a missing response would block + // the caller forever. Bound the whole sync by the caller's `timeout` by + // racing it against a sleep; whichever finishes first wins and the + // loser is cancelled. + let timedOut = try await withThrowingTaskGroup(of: Bool.self) { group in + group.addTask { + try await router.syncFromPropagationNode() + return false + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) + return true + } + defer { group.cancelAll() } + return try await group.next() ?? false + } + let received = await router.syncState.receivedMessages + if timedOut { + return PropagationSyncResult(ok: false, state: .transferFailed, receivedMessages: received, reason: "timeout") + } + return PropagationSyncResult(ok: true, state: .complete, receivedMessages: received, reason: "") + } + + @discardableResult + public func persist() async -> Bool { + await router?.persistPendingState() + return true + } + + // MARK: - Telephony (RNS.Link pipe for LXST voice) + // + // The Swift LXST voice state machine drives these; the backend just owns the + // Link and bridges its inbound packets + state changes onto the neutral event + // stream. Ported from main's CallManager identity-resolve + initiateLink path. + + public func openLink(destHashHex: String, aspect: String) async throws -> (ok: Bool, linkId: Int, reason: String) { + guard let transport, let localId = identity, let pathTable else { + return (false, 0, "not started") + } + guard let destHash = Self.hexData(destHashHex), !destHash.isEmpty else { + return (false, 0, "bad hash") + } + + // 1. Ensure a path exists (request + await if not already known). + if await transport.hasPath(for: destHash) == false { + await transport.requestPath(for: destHash) + if await transport.awaitPath(for: destHash, timeout: 15.0) == false { + return (false, 0, "no path") + } + } + + // 2. Recall the peer identity from the path entry's announced public keys + // (64 bytes = enc + sig public keys), exactly as main's resolveIdentity. + guard let entry = await pathTable.lookup(destinationHash: destHash), + entry.publicKeys.count == 64, + let remoteIdentity = try? ReticulumSwift.Identity(publicKeyBytes: entry.publicKeys) else { + return (false, 0, "no identity") + } + + // 3. Build the outbound destination from the aspect ("lxst.telephony" → + // appName "lxst", aspects ["telephony"]) so its hash matches destHash. + let parts = aspect.split(separator: ".").map(String.init) + let appName = parts.first ?? "lxst" + let aspects = Array(parts.dropFirst()) + let dest = ReticulumSwift.Destination( + identity: remoteIdentity, appName: appName, aspects: aspects, + type: .single, direction: .out + ) + + // 4. Initiate the link (throws TransportError.noPathAvailable if the path + // evaporated between the check above and here). + let link = try await transport.initiateLink(to: dest, identity: localId) + let cont = eventContinuation + // Reserve the id and register BOTH the link and its stateUpdates drain + // task in one critical section. If these were split (task stored after + // the setPacketCallback await), a stop() racing that window would clear + // the maps in between and then openLink would re-insert an orphaned + // task into the freshly-emptied linkStateTasks — keeping the link actor + // alive and yielding stale events into the next session until the next + // stop(). The Task constructor is synchronous (its awaits run later), so + // it's safe to build under the lock; nothing in the task body touches + // linkLock, so there's no re-entrancy. + linkLock.lock() + let linkId = nextLinkId + nextLinkId += 1 + links[linkId] = link + linkStateTasks[linkId] = Task { + for await st in await link.stateUpdates { + let (s, r) = Self.linkStateString(st) + cont.yield(.linkState(linkId: linkId, state: s, reason: r, inbound: false, t: Date())) + } + } + linkLock.unlock() + + // 5. Bridge inbound link packets onto the event stream. stateUpdates + // (drained above) yields the terminal .closed(reason) itself, so a + // separate close callback would only duplicate it. + await link.setPacketCallback { data, _ in + cont.yield(.linkPacket(linkId: linkId, data: data, t: Date())) + } + return (true, linkId, "") + } + + @discardableResult + public func linkSend(linkId: Int, data: Data) async throws -> Bool { + linkLock.lock() + let link = links[linkId] + linkLock.unlock() + guard let link else { return false } + try await link.send(data) + return true + } + + @discardableResult + public func linkIdentify(linkId: Int) async throws -> Bool { + linkLock.lock() + let link = links[linkId] + linkLock.unlock() + guard let link, let localId = identity else { return false } + try await link.identify(identity: localId) + return true + } + + @discardableResult + public func linkTeardown(linkId: Int) async throws -> Bool { + linkLock.lock() + linkStateTasks.removeValue(forKey: linkId)?.cancel() + let link = links.removeValue(forKey: linkId) + linkLock.unlock() + guard let link else { return false } + await link.close(reason: .initiatorClosed) + return true + } + + private static func linkStateString(_ s: ReticulumSwift.LinkState) -> (String, String) { + switch s { + case .pending: return ("pending", "") + case .handshake: return ("handshake", "") + case .active: return ("active", "") + case .stale: return ("stale", "") + case .closed(let reason): return ("closed", String(describing: reason)) + } + } + + // MARK: - Status + + public func statusSnapshot() async -> StatusSnapshot? { + guard let transport else { return nil } + let snaps = await transport.getInterfaceSnapshots() + // Snapshot interfaceIds under the lock (cheap COW copy) so the map below + // can't race a concurrent start()/addInterface()/removeInterface() write. + linkLock.lock() + let interfaceIdsSnapshot = interfaceIds + linkLock.unlock() + let ifaces = snaps.map { s in + // Report the config *section name* (what AppServices matches against + // via PythonConfigWriter.sectionName), NOT the raw reticulum interface + // id. The reticulum id is the entity UUID (buildAndAdd uses it as the + // interface id), so reverse-map it through `interfaceIds` + // (section -> entity.id). Without this, AppServices.applyPythonInterfaceStatus + // never matches a Swift-backend interface to its entity, so the UI's + // connection badge stays "disconnected" even when the interface is up. + let section = interfaceIdsSnapshot.first(where: { $0.value == s.id })?.key ?? s.id + return StatusSnapshot.InterfaceStatus( + sectionName: section, + name: s.name, + online: s.state == .connected, + rxBytes: 0, // reticulum-swift's InterfaceSnapshot exposes no byte counters + txBytes: 0 + ) + } + let destCount = await transport.destinationCount + let pathCount = await transport.getPathTable().count + return StatusSnapshot( + started: identity != nil, + interfaces: ifaces, + destinationTableSize: destCount, + pathTableSize: pathCount + ) + } + + // MARK: - NomadNet (one-shot page fetch over a fresh RNS Link) + // + // Ported from main's NomadNetBrowserService: resolve a path + node identity, + // open a link to the `nomadnetwork.node` destination, wait for it to become + // established, issue an RNS request for the page path, and await the response + // (racing each wait against a timeout). The link is one-shot — torn down on + // return. Form fields arrive already "field_"-prefixed by the caller. + + public func fetchNomadNetPage( + destHashHex: String, + path: String, + timeout: TimeInterval, + formFields: [String: String]? + ) async throws -> NomadNetFetchResult { + func fail(_ s: NomadNetFetchResult.Status) -> NomadNetFetchResult { + NomadNetFetchResult(ok: false, status: s, data: Data(), contentType: "") + } + guard let transport, let localId = identity, let pathTable else { return fail(.notStarted) } + guard let destHash = Self.hexData(destHashHex), !destHash.isEmpty else { return fail(.badHash) } + + // 1. Ensure a path, then recall the node identity from its announce. + if await transport.hasPath(for: destHash) == false { + await transport.requestPath(for: destHash) + if await transport.awaitPath(for: destHash, timeout: 15.0) == false { return fail(.noPath) } + } + guard let entry = await pathTable.lookup(destinationHash: destHash), + entry.publicKeys.count == 64, + let nodeIdentity = try? ReticulumSwift.Identity(publicKeyBytes: entry.publicKeys) else { + return fail(.noPath) + } + + // 2. Establish a fresh link to the nomadnetwork.node destination. + let dest = ReticulumSwift.Destination( + identity: nodeIdentity, appName: "nomadnetwork", aspects: ["node"], + type: .single, direction: .out + ) + let link: ReticulumSwift.Link + do { + link = try await transport.initiateLink(to: dest, identity: localId) + } catch { + return fail(.linkFailed) + } + defer { let l = link; Task { await l.close(reason: .initiatorClosed) } } + guard await Self.awaitLinkEstablished(link, timeout: timeout) else { return fail(.linkFailed) } + + // 3. Build the request payload (form fields → msgpack map) and send it. + let requestData: ReticulumSwift.MessagePackValue? + if let formFields, !formFields.isEmpty { + var map: [ReticulumSwift.MessagePackValue: ReticulumSwift.MessagePackValue] = [:] + for (k, v) in formFields { map[.string(k)] = .string(v) } + requestData = .map(map) + } else { + requestData = nil + } + let receipt = try await link.request(path: path, data: requestData, timeout: timeout) + + // 4. Await the response, racing the status stream against a timeout. + let (data, status) = await Self.awaitResponse(receipt, timeout: timeout + 2.0) + guard status == .ok, let data else { return fail(status) } + return NomadNetFetchResult(ok: true, status: .ok, data: data, contentType: "") + } + + /// Wait for a link to reach an established state, racing against a timeout. + private static func awaitLinkEstablished(_ link: ReticulumSwift.Link, timeout: TimeInterval) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { + for await st in await link.stateUpdates { + if st.isEstablished { return true } + if case .closed = st { return false } + } + return false + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(max(1, timeout) * 1_000_000_000)) + return false + } + let ok = await group.next() ?? false + group.cancelAll() + return ok + } + } + + /// Await an RNS request response, racing the status stream against a timeout. + private static func awaitResponse(_ receipt: ReticulumSwift.RequestReceipt, timeout: TimeInterval) async -> (Data?, NomadNetFetchResult.Status) { + await withTaskGroup(of: (Data?, NomadNetFetchResult.Status).self) { group in + group.addTask { + for await status in await receipt.statusUpdates { + switch status { + case .responseReceived: + let raw = await receipt.responseData + return (raw.map { Self.unwrapResponseData($0) }, .ok) + case .failed: return (nil, .requestFailed) + case .timeout: return (nil, .timeout) + default: continue + } + } + return (nil, .requestFailed) + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(max(1, timeout) * 1_000_000_000)) + return (nil, .timeout) + } + let result = await group.next() ?? (nil, .unknown) + group.cancelAll() + return result + } + } + + /// NomadNet responses come back msgpack-wrapped (binary or string); unwrap to + /// the raw page bytes, falling back to the raw payload if it isn't msgpack. + private static func unwrapResponseData(_ data: Data) -> Data { + if let value = try? ReticulumSwift.unpackMsgPack(data) { + switch value { + case .binary(let bytes): return bytes + case .string(let str): return str.data(using: .utf8) ?? data + default: break + } + } + return data + } + + // MARK: - Transport admin (live interface add/remove, no restart) + // + // The Python backend resolves a section name against its on-disk RNS config; + // the Swift backend resolves it against the shared InterfaceRepository (the + // same entity store the UI writes), then builds the reticulum-swift interface + // for that entity — porting main's per-type bring-up (TCP/Auto/RNode/BLE/MPC). + + @discardableResult + public func addInterface(name: String) async throws -> (ok: Bool, reason: String) { + guard transport != nil, identity != nil else { return (false, "not started") } + // Idempotent: start() already brings up enabled interfaces, and the + // apply/hot-reload path may re-request one — don't add it twice. + linkLock.lock() + let alreadyAdded = interfaceIds[name] != nil + linkLock.unlock() + if alreadyAdded { return (true, "already added") } + guard let entity = Self.entity(forSection: name) else { + return (false, "no configured interface named \(name)") + } + do { + try await buildAndAdd(entity) + linkLock.lock() + interfaceIds[name] = entity.id + linkLock.unlock() + return (true, "") + } catch { + return (false, "\(error)") + } + } + + @discardableResult + public func removeInterface(name: String) async throws -> (ok: Bool, reason: String) { + guard let transport else { return (false, "not started") } + // Prefer the tracked id; fall back to the entity's id if we never tracked + // it (e.g. added before this process started). removeInterface disconnects. + linkLock.lock() + let tracked = interfaceIds[name] + linkLock.unlock() + guard let rid = tracked ?? Self.entity(forSection: name)?.id else { + return (false, "no interface named \(name)") + } + await transport.removeInterface(id: rid) + linkLock.lock() + interfaceIds.removeValue(forKey: name) + linkLock.unlock() + return (true, "") + } + + /// Resolve a config-section name back to its InterfaceEntity via the shared + /// repository (mirrors how PythonConfigWriter names sections on write). + private static func entity(forSection name: String) -> InterfaceEntity? { + InterfaceRepository().interfaces.first { PythonConfigWriter.sectionName(for: $0) == name } + } + + /// Build the reticulum-swift interface for an entity and register it on the + /// transport. The reticulum interface id is the entity id (stable across the + /// add/remove pair). Ported type-by-type from main's start*Interface methods. + private func buildAndAdd(_ entity: InterfaceEntity) async throws { + guard let transport, let localId = identity else { return } + let mode = ReticulumSwift.InterfaceMode(rawValue: entity.mode.rawValue) ?? .full + let rid = entity.id + + func config(_ type: ReticulumSwift.InterfaceType, host: String, port: UInt16) -> ReticulumSwift.InterfaceConfig { + ReticulumSwift.InterfaceConfig( + id: rid, name: entity.name, type: type, enabled: true, mode: mode, host: host, port: port + ) + } + + switch entity.config { + case .tcpClient(let c): + let iface = try ReticulumSwift.TCPInterface(config: config(.tcp, host: c.targetHost, port: c.targetPort)) + try await transport.addInterface(iface) + + case .tcpServer(let c): + let iface = try ReticulumSwift.TCPServerInterface(config: config(.tcp, host: c.listenIp, port: c.listenPort)) + try await transport.addInterface(iface) + + case .autoInterface(let c): + let iface = ReticulumSwift.AutoInterface(config: config(.autoInterface, host: c.groupId ?? "reticulum", port: 0)) + try await transport.addAutoInterface(iface) + + case .rnode(let c): + let iface = try ReticulumSwift.RNodeInterface(config: config(.rnode, host: c.deviceName, port: 0)) + // Compat.RadioConfig and ReticulumSwift.RadioConfig have identical + // fields; build the reticulum one directly from the entity config. + try await iface.configureRadio(ReticulumSwift.RadioConfig( + frequency: c.frequency, bandwidth: c.bandwidth, txPower: c.txPower, + spreadingFactor: c.spreadingFactor, codingRate: c.codingRate, + stAlock: c.stAlock, ltAlock: c.ltAlock + )) + try await transport.addInterface(iface) + + case .ble: + let driver = ReticulumSwift.CoreBluetoothBLEDriver(identityHash: localId.hash) + let iface = ReticulumSwift.BLEInterface( + config: config(.ble, host: "", port: 0), driver: driver, transportIdentity: localId.hash + ) + try await transport.addBLEInterface(iface) + + case .multipeer(let c): + let iface = ReticulumSwift.MPCInterface( + config: config(.multipeerConnectivity, host: c.serviceType, port: 0), + displayName: String(localId.hexHash.prefix(8)) + ) + try await transport.addMPCInterface(iface) + } + } + + /// Decode a hex string to Data (RNSAPI's HexExt is Data→String only, and + /// reticulum-swift's Data here would make a shared helper ambiguous). + private static func hexData(_ 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...Continuation + init(continuation: AsyncStream.Continuation) { self.continuation = continuation } + + func router(_ router: LXMFSwift.LXMRouter, didReceiveMessage message: LXMFSwift.LXMessage) { + let content = String(data: message.content, encoding: .utf8) ?? "" + let title = String(data: message.title, encoding: .utf8) ?? "" + let fieldsPacked = (message.fields?.isEmpty == false) ? LxmfFieldCodec.pack(message.fields!) : Data() + continuation.yield(.inbound(sourceHash: message.sourceHash.hexHash, content: content, title: title, fieldsPacked: fieldsPacked, t: Date())) + } + func router(_ router: LXMFSwift.LXMRouter, didUpdateMessage message: LXMFSwift.LXMessage) { + if message.state == .delivered { + continuation.yield(.delivery(messageHash: message.hash.hexHash, state: "delivered", t: Date())) + } + } + func router(_ router: LXMFSwift.LXMRouter, didFailMessage message: LXMFSwift.LXMessage, reason: LXMFSwift.LXMFError) { + continuation.yield(.delivery(messageHash: message.hash.hexHash, state: "failed", t: Date())) + } + func router(_ router: LXMFSwift.LXMRouter, didConfirmDelivery messageHash: Data) { + continuation.yield(.delivery(messageHash: messageHash.hexHash, state: "delivered", t: Date())) + } + func router(_ router: LXMFSwift.LXMRouter, didUpdateSyncState state: LXMFSwift.PropagationTransferState) {} + func router(_ router: LXMFSwift.LXMRouter, didCompleteSyncWithNewMessages newMessages: Int) {} + } +} + +// Small hex helper local to this module (Compat's HexExt is in RNSAPI; Data here +// is reticulum-swift's, so use a local extension to avoid ambiguity). +private extension Data { + var hexHash: String { map { String(format: "%02x", $0) }.joined() } +} diff --git a/app/ble/IOSBLEDriver.py b/app/ble/IOSBLEDriver.py new file mode 100644 index 00000000..205239d7 --- /dev/null +++ b/app/ble/IOSBLEDriver.py @@ -0,0 +1,450 @@ +"""IOSBLEDriver — Python side of the Reticulum BLE driver for iOS. + +Mirrors `AndroidBLEDriver.py`: + - Implements upstream `BLEDriverInterface` (14 methods + property pair). + - Routes outbound operations (start/stop/scan/advertise/connect/send) to + the Swift `SwiftBLEBridge` via ctypes-bound C-ABI shims compiled into + the app binary (exported by `Sources/SwiftBLEBridge/BleNativeBindings.swift`). + - Receives inbound events (`on_device_discovered` etc.) from Swift via the + Swift→Python callback registry in `rns_bridge` — Swift calls + `PythonBridge.invokeBLECallback(slot=..., args=...)` which looks up the + Python callable stored under that slot name. + - Tracks per-peer identity ↔ address reunification so the upstream + BLEInterface sees cross-platform MAC rotation as `on_address_changed` + events rather than as new connections. + - Synchronous `on_duplicate_identity_detected(addr, identity_bytes) -> bool` + is registered like the other callbacks; Swift uses + `invokeBLECallbackBoolSync` to fetch the result. +""" + +from __future__ import annotations + +import ctypes +import threading +from typing import Any, Callable, List, Optional + +import RNS +from ble_reticulum.bluetooth_driver import BLEDriverInterface, BLEDevice, DriverState + +# rns_bridge module hosts the BLE callback registry + bridge-handle slot. +# Imported once at module load; `set_ble_callback` etc. resolve through it. +import rns_bridge as _bridge_module + + +# ──────────────────────────────────────────────────────────────────── +# ctypes binding to SwiftBLEBridge's C-ABI shims +# (Sources/SwiftBLEBridge/BleNativeBindings.swift) +# ──────────────────────────────────────────────────────────────────── +# +# All shims live in the running process's symbol table; CDLL(None) opens +# the current binary. Return values: 0 on success, negative errno-like on +# failure. + +try: + _lib = ctypes.CDLL(None) +except OSError as e: + raise RuntimeError(f"IOSBLEDriver: failed to dlopen current process: {e}") + + +def _decl(name: str, argtypes: list, restype: Any) -> Optional[Callable]: + """Look up a symbol in the process binary and assign ctypes signatures. + Returns None if the symbol isn't present (so the driver can degrade + gracefully on platforms where the Swift bridge isn't loaded — e.g., + unit-test harnesses that import this module without a running app).""" + try: + fn = getattr(_lib, name) + except AttributeError: + return None + fn.argtypes = argtypes + fn.restype = restype + return fn + + +# C-ABI surface — exported from Swift via @_cdecl. +_columba_ble_start = _decl( + "columba_ble_start", + [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p], + ctypes.c_int32, +) +_columba_ble_stop = _decl("columba_ble_stop", [], ctypes.c_int32) +_columba_ble_set_identity = _decl( + "columba_ble_set_identity", [ctypes.c_char_p, ctypes.c_int32], ctypes.c_int32 +) +_columba_ble_start_scanning = _decl("columba_ble_start_scanning", [], ctypes.c_int32) +_columba_ble_stop_scanning = _decl("columba_ble_stop_scanning", [], ctypes.c_int32) +_columba_ble_start_advertising = _decl( + "columba_ble_start_advertising", + [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int32], + ctypes.c_int32, +) +_columba_ble_stop_advertising = _decl("columba_ble_stop_advertising", [], ctypes.c_int32) +_columba_ble_connect = _decl("columba_ble_connect", [ctypes.c_char_p], ctypes.c_int32) +_columba_ble_disconnect = _decl("columba_ble_disconnect", [ctypes.c_char_p], ctypes.c_int32) +_columba_ble_send = _decl( + "columba_ble_send", + [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int32], + ctypes.c_int32, +) +_columba_ble_configure_power = _decl( + "columba_ble_configure_power", [ctypes.c_char_p], ctypes.c_int32 +) + + +# Callback slot names — must match BleCallbackSlot.rawValue in Swift. +_SLOT_DISCOVERED = "on_device_discovered" +_SLOT_CONNECTED = "on_device_connected" +_SLOT_DISCONNECTED = "on_device_disconnected" +_SLOT_DATA = "on_data_received" +_SLOT_MTU = "on_mtu_negotiated" +_SLOT_IDENTITY = "on_identity_received" +_SLOT_ADDRESS_CHANGED = "on_address_changed" +_SLOT_DUPLICATE = "on_duplicate_identity_detected" +_SLOT_ERROR = "on_error" + + +class IOSBLEDriver(BLEDriverInterface): + """iOS implementation of BLEDriverInterface.""" + + def __init__(self, **kwargs: Any) -> None: + # Upstream BLEInterface passes 6 kwargs (discovery_interval, + # connection_timeout, min_rssi, service_discovery_delay, max_peers, + # adapter_index). iOS doesn't need any of them — CoreBluetooth + # auto-manages scan/connect lifecycles, and adapter_index is + # meaningless without dbus. Capture for debugging only. + super().__init__() + self._init_kwargs = kwargs + self._state: DriverState = DriverState.IDLE + self._connected_peers: list[str] = [] + # address (str) → identity hash (32-char hex). Updated by the + # `on_identity_received` callback once the peer's identity is + # known. Consulted to detect MAC rotation (same identity at new + # address) and to surface `on_address_changed` to BLEInterface. + self._address_to_identity: dict[str, str] = {} + self._identity_to_address: dict[str, str] = {} + self._lock = threading.Lock() + # Power preset captured in `set_power_mode` — passed to Swift via + # `configure_power` so the Settings/DiagLog status output reflects + # it. iOS auto-manages duty cycle so this is informational. + self._power_preset: str = "balanced" + self._register_callbacks() + + # ------------------------------------------------------------------ + # Callback registration — raw-arg handlers lift Swift's primitive + # args into the BLEDevice / bytes objects upstream expects. + # ------------------------------------------------------------------ + + def _register_callbacks(self) -> None: + _bridge_module.set_ble_callback(_SLOT_DISCOVERED, self._raw_on_device_discovered) + _bridge_module.set_ble_callback(_SLOT_CONNECTED, self._raw_on_device_connected) + _bridge_module.set_ble_callback(_SLOT_DISCONNECTED, self._raw_on_device_disconnected) + _bridge_module.set_ble_callback(_SLOT_DATA, self._raw_on_data_received) + _bridge_module.set_ble_callback(_SLOT_MTU, self._raw_on_mtu_negotiated) + _bridge_module.set_ble_callback(_SLOT_IDENTITY, self._raw_on_identity_received) + _bridge_module.set_ble_callback(_SLOT_ADDRESS_CHANGED, self._raw_on_address_changed) + _bridge_module.set_ble_callback(_SLOT_DUPLICATE, self._raw_on_duplicate_identity_detected) + _bridge_module.set_ble_callback(_SLOT_ERROR, self._raw_on_error) + + def _raw_on_device_discovered( + self, address: str, name: str, rssi: int, service_uuids: list + ) -> None: + if self.on_device_discovered is None: + return + device = BLEDevice( + address=address, + name=name or "", + rssi=int(rssi), + service_uuids=list(service_uuids or []), + ) + try: + self.on_device_discovered(device) + except Exception as e: + RNS.log(f"IOSBLEDriver: on_device_discovered raised: {e}", RNS.LOG_ERROR) + + def _raw_on_device_connected( + self, address: str, identity_bytes_or_none: Optional[bytes] + ) -> None: + if self.on_device_connected is None: + return + identity = identity_bytes_or_none if isinstance(identity_bytes_or_none, (bytes, bytearray)) else None + if identity is not None: + with self._lock: + self._address_to_identity[address] = bytes(identity).hex() + self._identity_to_address[bytes(identity).hex()] = address + if address not in self._connected_peers: + self._connected_peers.append(address) + try: + self.on_device_connected(address, identity) + except Exception as e: + RNS.log(f"IOSBLEDriver: on_device_connected raised: {e}", RNS.LOG_ERROR) + + def _raw_on_device_disconnected(self, address: str) -> None: + with self._lock: + ident = self._address_to_identity.pop(address, None) + if ident: + # Only drop the reverse mapping if it still points at this + # address — same-identity-different-address peers (MAC + # rotation) would otherwise lose their forward map. + if self._identity_to_address.get(ident) == address: + self._identity_to_address.pop(ident, None) + if address in self._connected_peers: + self._connected_peers.remove(address) + if self.on_device_disconnected is not None: + try: + self.on_device_disconnected(address) + except Exception as e: + RNS.log(f"IOSBLEDriver: on_device_disconnected raised: {e}", RNS.LOG_ERROR) + + def _raw_on_data_received(self, address: str, data: bytes) -> None: + if self.on_data_received is None: + return + try: + self.on_data_received(address, bytes(data)) + except Exception as e: + RNS.log(f"IOSBLEDriver: on_data_received raised: {e}", RNS.LOG_ERROR) + + def _raw_on_mtu_negotiated(self, address: str, mtu: int) -> None: + if self.on_mtu_negotiated is None: + return + try: + self.on_mtu_negotiated(address, int(mtu)) + except Exception as e: + RNS.log(f"IOSBLEDriver: on_mtu_negotiated raised: {e}", RNS.LOG_ERROR) + + def _raw_on_identity_received(self, address: str, identity_hex: str) -> None: + """Swift surfaces identity as a 32-char hex string. Update the + reunification maps; upstream interface uses these to detect + cross-platform MAC rotation.""" + try: + identity_bytes = bytes.fromhex(identity_hex) + except ValueError: + RNS.log(f"IOSBLEDriver: bad identity_hex={identity_hex!r}", RNS.LOG_ERROR) + return + with self._lock: + self._address_to_identity[address] = identity_hex + self._identity_to_address[identity_hex] = address + # Mirror the connected callback with identity now known so the + # upstream interface can spawn the peer interface if it hasn't yet. + if self.on_device_connected is not None: + try: + self.on_device_connected(address, identity_bytes) + except Exception as e: + RNS.log(f"IOSBLEDriver: on_device_connected (post-identity) raised: {e}", RNS.LOG_ERROR) + + def _raw_on_address_changed( + self, old_address: str, new_address: str, identity_hash_hex: str + ) -> None: + with self._lock: + self._address_to_identity.pop(old_address, None) + self._address_to_identity[new_address] = identity_hash_hex + self._identity_to_address[identity_hash_hex] = new_address + if old_address in self._connected_peers: + self._connected_peers.remove(old_address) + if new_address not in self._connected_peers: + self._connected_peers.append(new_address) + if self.on_address_changed is not None: + try: + self.on_address_changed(old_address, new_address, identity_hash_hex) + except Exception as e: + RNS.log(f"IOSBLEDriver: on_address_changed raised: {e}", RNS.LOG_ERROR) + + def _raw_on_duplicate_identity_detected( + self, address: str, identity_hex: str + ) -> bool: + """Synchronous bool-return — Swift blocks on this before accepting a + connection.""" + try: + identity_bytes = bytes.fromhex(identity_hex) + except ValueError: + return False + cb = getattr(self, "on_duplicate_identity_detected", None) + if cb is None: + return False + try: + return bool(cb(address, identity_bytes)) + except Exception as e: + RNS.log(f"IOSBLEDriver: on_duplicate_identity_detected raised: {e}", RNS.LOG_ERROR) + return False + + def _raw_on_error(self, severity: str, message: str) -> None: + if self.on_error is None: + return + try: + self.on_error(severity, message, None) + except Exception as e: + RNS.log(f"IOSBLEDriver: on_error raised: {e}", RNS.LOG_ERROR) + + # ------------------------------------------------------------------ + # BLEDriverInterface: lifecycle + # ------------------------------------------------------------------ + + def start( + self, + service_uuid: str, + rx_char_uuid: str, + tx_char_uuid: str, + identity_char_uuid: str, + ) -> None: + if _columba_ble_start is None: + raise RuntimeError("columba_ble_start symbol not found — Swift bridge missing?") + rc = _columba_ble_start( + service_uuid.encode("utf-8"), + rx_char_uuid.encode("utf-8"), + tx_char_uuid.encode("utf-8"), + identity_char_uuid.encode("utf-8"), + ) + if rc != 0: + raise RuntimeError(f"columba_ble_start failed: rc={rc}") + + def stop(self) -> None: + if _columba_ble_stop is None: + return + rc = _columba_ble_stop() + if rc != 0: + RNS.log(f"columba_ble_stop returned rc={rc}", RNS.LOG_WARNING) + self._state = DriverState.IDLE + with self._lock: + self._address_to_identity.clear() + self._identity_to_address.clear() + self._connected_peers.clear() + + def set_identity(self, identity_bytes: bytes) -> None: + if _columba_ble_set_identity is None: + raise RuntimeError("columba_ble_set_identity symbol not found") + data = bytes(identity_bytes) + rc = _columba_ble_set_identity(data, len(data)) + if rc != 0: + raise RuntimeError(f"columba_ble_set_identity failed: rc={rc}") + + # ------------------------------------------------------------------ + # BLEDriverInterface: state + # ------------------------------------------------------------------ + + @property + def state(self) -> DriverState: + return self._state + + @property + def connected_peers(self) -> List[str]: + with self._lock: + return list(self._connected_peers) + + # ------------------------------------------------------------------ + # BLEDriverInterface: scan + advertise + # ------------------------------------------------------------------ + + def start_scanning(self) -> None: + if _columba_ble_start_scanning is None: + raise RuntimeError("columba_ble_start_scanning symbol not found") + rc = _columba_ble_start_scanning() + if rc != 0: + raise RuntimeError(f"columba_ble_start_scanning failed: rc={rc}") + self._state = DriverState.SCANNING + + def stop_scanning(self) -> None: + if _columba_ble_stop_scanning is None: + return + _columba_ble_stop_scanning() + if self._state == DriverState.SCANNING: + self._state = DriverState.IDLE + + def start_advertising( + self, device_name: Optional[str], identity: bytes + ) -> None: + if _columba_ble_start_advertising is None: + raise RuntimeError("columba_ble_start_advertising symbol not found") + name_bytes = (device_name or "").encode("utf-8") + identity_bytes = bytes(identity) + rc = _columba_ble_start_advertising(name_bytes, identity_bytes, len(identity_bytes)) + if rc != 0: + raise RuntimeError(f"columba_ble_start_advertising failed: rc={rc}") + self._state = DriverState.ADVERTISING + + def stop_advertising(self) -> None: + if _columba_ble_stop_advertising is None: + return + _columba_ble_stop_advertising() + if self._state == DriverState.ADVERTISING: + self._state = DriverState.IDLE + + # ------------------------------------------------------------------ + # BLEDriverInterface: connect + send + # ------------------------------------------------------------------ + + def connect(self, address: str) -> None: + if _columba_ble_connect is None: + raise RuntimeError("columba_ble_connect symbol not found") + rc = _columba_ble_connect(address.encode("utf-8")) + if rc != 0: + RNS.log(f"columba_ble_connect failed for {address}: rc={rc}", RNS.LOG_WARNING) + + def disconnect(self, address: str) -> None: + if _columba_ble_disconnect is None: + return + _columba_ble_disconnect(address.encode("utf-8")) + + def send(self, address: str, data: bytes) -> None: + if _columba_ble_send is None: + raise RuntimeError("columba_ble_send symbol not found") + payload = bytes(data) + rc = _columba_ble_send(address.encode("utf-8"), payload, len(payload)) + if rc != 0: + RNS.log(f"columba_ble_send rc={rc} for addr={address}", RNS.LOG_WARNING) + + # ------------------------------------------------------------------ + # BLEDriverInterface: GATT char ops + # ------------------------------------------------------------------ + # Upstream BLEInterface uses these only for the Identity characteristic + # read (during the connection handshake). The Swift bridge bakes those + # ops into the connect path automatically and surfaces the result via + # on_identity_received, so the driver never explicitly calls + # read_characteristic. We keep the methods present (the abstract base + # class requires them) but raise NotImplementedError on misuse. + + def read_characteristic(self, address: str, char_uuid: str) -> bytes: + raise NotImplementedError( + "IOSBLEDriver.read_characteristic — identity read is automatic via Swift bridge" + ) + + def write_characteristic(self, address: str, char_uuid: str, data: bytes) -> None: + raise NotImplementedError( + "IOSBLEDriver.write_characteristic — handshake write is automatic via Swift bridge" + ) + + def start_notify( + self, + address: str, + char_uuid: str, + callback: Callable[[bytes], None], + ) -> None: + raise NotImplementedError( + "IOSBLEDriver.start_notify — CCCD enable is automatic via Swift bridge" + ) + + # ------------------------------------------------------------------ + # BLEDriverInterface: config + queries + # ------------------------------------------------------------------ + + def get_local_address(self) -> str: + # iOS hides the local Bluetooth MAC. The sentinel mirrors what + # Android does — upstream BLEInterface's MAC-sort dedup + # optimization isn't reliable across platforms anyway (per the + # plan's Q6 decision), so we accept the wasted-connection cost + # and let the synchronous on_duplicate_identity_detected check + # resolve races post-connect. + return "00:00:00:00:00:00" + + def get_peer_role(self, address: str) -> Optional[str]: + # Resolved synchronously by the Swift bridge via the connection + # state map. Phase 6 wires the query when peripheral-mode lands. + return None + + def set_service_discovery_delay(self, seconds: float) -> None: + # No-op on iOS: CoreBluetooth doesn't need bluezero's D-Bus + # registration delay workaround. + return None + + def set_power_mode(self, mode: str) -> None: + self._power_preset = mode + if _columba_ble_configure_power is None: + return + _columba_ble_configure_power(mode.encode("utf-8")) diff --git a/app/ble/IOSBLEInterface.py b/app/ble/IOSBLEInterface.py new file mode 100644 index 00000000..295e35bf --- /dev/null +++ b/app/ble/IOSBLEInterface.py @@ -0,0 +1,72 @@ +"""IOSBLEInterface — Reticulum custom interface for iOS BLE mesh. + +Mirrors Android's `AndroidBLEInterface.py`. Reticulum loads this file at config +parse time when an interface section has `type = IOSBLEInterface`: + + [interfaces] + [[ble0]] + enabled = yes + type = IOSBLEInterface + +The file is copied from `/app/ble/IOSBLEInterface.py` to +`/interfaces/IOSBLEInterface.py` at app startup by +`AppServices.startBLEInterface()`. The sibling `IOSBLEDriver.py` is also +copied; this file adds `interfacepath` to sys.path so the driver is +importable. + +The `interface_class` module attribute is what Reticulum's external-interface +loader extracts after exec()'ing this file (see RNS.Reticulum:1017). +""" + +import RNS +import sys +import os + +# Reticulum exec()'s this file with `Interface` and `RNS` injected as globals. +# To import sibling modules from the same interfaces dir, prepend it to +# sys.path. The path is configdir/interfaces, which Reticulum has already +# created by this point (see RNS.Reticulum:317). +_this_file = globals().get("__file__") +if _this_file: + _interfaces_dir = os.path.dirname(os.path.abspath(_this_file)) +else: + # Fallback: ask Reticulum where it just loaded us from. + _interfaces_dir = RNS.Reticulum.interfacepath +if _interfaces_dir and _interfaces_dir not in sys.path: + sys.path.insert(0, _interfaces_dir) + +# ble_reticulum is installed via pip into app_packages/ at build time and +# already on sys.path via PythonRuntime's site.addsitedir() call. +from ble_reticulum.BLEInterface import BLEInterface + +# Sibling driver. Lives next to this file in /interfaces/. +from IOSBLEDriver import IOSBLEDriver + + +class IOSBLEInterface(BLEInterface): + """iOS-flavored BLE interface. All peer management, fragmentation, + reassembly, MAC-rotation grace, and identity handshaking is inherited + from upstream `BLEInterface`. We just pin the platform driver and apply + iOS-specific power knobs.""" + + # Override driver class so the parent constructor picks the right impl. + driver_class = IOSBLEDriver + + def __init__(self, owner, config=None): + super().__init__(owner, config) + + # Power preset — informational on iOS (the OS auto-manages duty + # cycle). Forwarded to the Swift bridge so it can mirror Android's + # `configurePower` parity output in DiagLog. + if config and hasattr(self, "driver") and self.driver is not None: + preset = config.get("ble_power_preset", "balanced") + try: + self.driver.set_power_mode(preset) + except Exception as e: + RNS.log(f"IOSBLEInterface: set_power_mode failed: {e}", RNS.LOG_WARNING) + + RNS.log(f"iOS BLE Interface '{self.name}' initialized", RNS.LOG_INFO) + + +# Required by Reticulum's external-interface loader. +interface_class = IOSBLEInterface diff --git a/app/ble/__init__.py b/app/ble/__init__.py new file mode 100644 index 00000000..1f03cd7e --- /dev/null +++ b/app/ble/__init__.py @@ -0,0 +1,9 @@ +"""Columba iOS BLE interface — port of Columba Android's ble_modules. + +Two modules: + - ios_ble_interface.IOSBLEInterface — thin subclass of upstream BLEInterface + - ios_ble_driver.IOSBLEDriver — implements BLEDriverInterface, bridges to Swift + +The Swift `SwiftBLEBridge` is handed to Python via `rns_bridge.set_ble_bridge` +during app startup; `IOSBLEDriver` pulls the bridge handle from there. +""" diff --git a/app/rnode/IOSRNodeInterface.py b/app/rnode/IOSRNodeInterface.py new file mode 100644 index 00000000..fbfeee5a --- /dev/null +++ b/app/rnode/IOSRNodeInterface.py @@ -0,0 +1,1634 @@ +"""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 new file mode 100644 index 00000000..c8266c5d --- /dev/null +++ b/app/rns_bridge.py @@ -0,0 +1,1808 @@ +"""rns_bridge — Python-side glue for Columba iOS PoC. + +The Swift `PythonBridge` calls these module-level functions over the C-API +(via PyImport_ImportModule + PyObject_CallObject). Callbacks from Reticulum and +LXMF run on the RNS worker threads; rather than wire C-callable function pointers +across the Swift/Python boundary, we drop events onto a thread-safe queue that +Swift drains on a timer. Single-threaded simple, no GIL juggling for callbacks. + +Scope: TCPClientInterface only, lxmf.delivery announces, opportunistic LXMF only. +""" + +from __future__ import annotations + +import os +import queue +import threading +import time +from typing import Any + +import sys + +# Redirect Python stdout/stderr to NSLog via os.write to the simulator's stderr fd, +# which Xcode captures and `simctl spawn log stream` surfaces. Without this, RNS.log() +# output goes to a logfile inside the sandbox that's awkward to tail mid-run. +class _SysLogStream: + def __init__(self, prefix: str): + self.prefix = prefix + self.buf = "" + def write(self, s: str) -> int: + if not isinstance(s, str): s = str(s) + self.buf += s + while "\n" in self.buf: + line, self.buf = self.buf.split("\n", 1) + sys.__stderr__.write(f"[{self.prefix}] {line}\n") + sys.__stderr__.flush() + return len(s) + def flush(self) -> None: + if self.buf: + sys.__stderr__.write(f"[{self.prefix}] {self.buf}\n") + sys.__stderr__.flush() + self.buf = "" + +sys.stdout = _SysLogStream("py-stdout") +# stderr is left raw so Python tracebacks remain unfiltered. + +# Patch platform.system() to return "Darwin" on iOS before RNS imports. +# +# RNS.Interfaces.util.netinfo branches on `platform.system() == "Darwin" or +# "BSD" in platform.system()` to pick the right sockaddr ctypes struct +# layout. iOS' Python returns `platform.system() == "iOS"`, so netinfo +# falls through to the Linux layout (sa_family at offset 0, 2 bytes wide) +# and misreads every address. iOS is Darwin under the hood (sa_len at +# byte 0, sa_family at byte 1, confirmed via raw getifaddrs probe), so +# the Darwin path is the correct one — we just have to make netinfo +# take it. No upstream RNS code branches on "iOS" specifically, so +# nothing else cares about this rename. +import platform as _platform +_real_system = _platform.system +_platform.system = lambda *a, **kw: "Darwin" if _real_system(*a, **kw) == "iOS" else _real_system(*a, **kw) + +import RNS +import LXMF + + +# ── Native multi-threaded stamp PoW (iOS) ────────────────────────────────── +# iOS's embedded CPython ships no `_multiprocessing`, so upstream LXMF's +# `LXStamper.job_linux` throws `ModuleNotFoundError: _multiprocessing` and no +# stamp is ever produced — messages to peers that require a stamp cost (e.g. +# Sideband) never deliver. (iOS reports `sys.platform == "ios"`, which misses +# LXStamper's macOS `job_simple` branch.) We offload the proof-of-work to a +# native, multi-threaded Swift implementation reached via ctypes — the iOS +# analog of Columba Android's `event_bridge.install_external_stamp_generator` +# + Kotlin `StampGenerator`. `columba_stamp_generate` is a @_cdecl shim in +# SwiftBLEBridge (statically linked → resolvable through `CDLL(None)`). +import ctypes + +try: + _columba_lib = ctypes.CDLL(None) +except OSError: + _columba_lib = None + + +def _bind_stamp_fn(): + if _columba_lib is None: + return None + try: + fn = _columba_lib.columba_stamp_generate + except AttributeError: + return None + # (workblock, workblock_len, stamp_cost, out_stamp[32]) -> bytes_written + fn.argtypes = [ctypes.c_char_p, ctypes.c_int32, ctypes.c_int32, ctypes.c_char_p] + fn.restype = ctypes.c_int32 + return fn + + +_stamp_generate_fn = _bind_stamp_fn() + + +def _native_stamp_pow(workblock: bytes, stamp_cost: int): + """Run the stamp PoW natively (Swift, multi-threaded across cores). Returns + the 32-byte stamp, or None if the native symbol is unavailable / found + nothing.""" + if _stamp_generate_fn is None: + return None + out = ctypes.create_string_buffer(32) + n = _stamp_generate_fn(bytes(workblock), len(workblock), int(stamp_cost), out) + if n == 32: + return out.raw[:32] + return None + + +def _install_native_stamp_generator() -> None: + """Register the native (Swift, multi-threaded) PoW as LXMF's external stamp + generator. The torlando-tech LXMF fork's `LXStamper.set_external_generator` + hook makes `generate_stamp` delegate its proof-of-work to us — necessary on + iOS, whose embedded CPython has no `_multiprocessing` (stock `job_linux` + raises `ModuleNotFoundError`). LXMF still does its own workblock derivation + + value calc, so the stamp is byte-identical to what the receiver validates. + iOS analog of Android's `event_bridge.install_external_stamp_generator`. + No-ops (warning) if the native symbol or the fork hook is absent.""" + try: + from LXMF import LXStamper + except Exception as e: # noqa: BLE001 + RNS.log(f"native stamp gen: LXStamper import failed: {e}", RNS.LOG_DEBUG) + return + if _stamp_generate_fn is None: + RNS.log( + "native stamp gen: columba_stamp_generate symbol not found; stamp " + "generation will fail on iOS (no _multiprocessing module)", + RNS.LOG_WARNING, + ) + return + if not hasattr(LXStamper, "set_external_generator"): + RNS.log( + "native stamp gen: LXStamper has no set_external_generator — this is " + "stock LXMF, not the torlando fork; stamps will fail on iOS", + RNS.LOG_WARNING, + ) + return + + def _external_generator(workblock, stamp_cost): + # LXStamper contract: (workblock: bytes, stamp_cost: int) -> (stamp, rounds). + # `rounds` is cosmetic (generate_stamp recomputes value via stamp_value); + # the native generator doesn't surface a round count, so report 0. + _t0 = time.time() + stamp = _native_stamp_pow(bytes(workblock), int(stamp_cost)) + RNS.log( + f"native stamp: cost={stamp_cost} in {round((time.time()-_t0)*1000)}ms" + + ("" if stamp is not None else " (FAILED — no stamp)"), + RNS.LOG_INFO, + ) + return (stamp, 0) if stamp is not None else (None, 0) + + LXStamper.set_external_generator(_external_generator) + RNS.log("native multi-threaded stamp generator registered via set_external_generator", RNS.LOG_INFO) + + +_lock = threading.Lock() +# Bumped on every start()/stop()/reset_identity() so the post-start delayed +# re-announce daemon thread can detect it has been superseded (by a teardown +# or a restart) and bail before announcing a dead / previous-session +# destination. +_announce_generation = 0 +_events: "queue.Queue[dict]" = queue.Queue() +_state: dict[str, Any] = { + "started": False, + "reticulum": None, + "router": None, + "identity": None, + "destination": None, + "handler": None, + "config_dir": None, +} + + +def _put(kind: str, **payload: Any) -> None: + payload["kind"] = kind + payload["t"] = time.time() + _events.put(payload) + + +class _AspectAnnounceHandler: + """Per-aspect announce handler. RNS lets you register multiple of these, + each scoped to one `aspect_filter` — we register one for each of the four + aspects the Columba UI surfaces (LXMF delivery, LXMF propagation, + NomadNet node, LXST telephony) so the Network tab can show all of them + side-by-side with the right badge / filter chip.""" + + receive_path_responses = True + + def __init__(self, aspect: str): + self.aspect_filter = aspect + self._aspect = aspect + + def received_announce( + self, + destination_hash: bytes, + announced_identity: "RNS.Identity", + app_data: bytes | None, + ) -> None: + # Surface every handler invocation so we can tell aspect-filter + # mismatches apart from "handler never ran". Goes to stdout which + # rns_bridge redirects to NSLog; AppServices picks it up as + # [py-stdout] lines. + try: + print(f"[announce-handler] aspect={self._aspect} dest={destination_hash.hex()[:16]}", flush=True) + except Exception: + pass + # Pass the raw announce app_data straight up; Swift decodes the + # display name (aspect-specific layout knowledge lives there, in + # AppDataParser / PropagationNodeInfo, not in this thin bridge). + app_data_hex = app_data.hex() if app_data else "" + public_keys_hex = "" + try: + pub = getattr(announced_identity, "get_public_key", None) + if callable(pub): + public_keys_hex = pub().hex() + except Exception: + pass + # Look up the receiving interface and hop count for this announce. + # RNS stores both on the path_table entry immediately after the + # announce is processed (Transport.py:1999): + # entry = [now, received_from, announce_hops, expires, + # random_blobs, receiving_interface, packet_hash] + # So index 2 is `announce_hops` (number of network hops the + # announce traversed; 0 = direct neighbor, 1+ = transit through + # transport nodes). Index 5 is the receiving Interface object. + interface_name = "" + hops = 0 + try: + entry = RNS.Transport.path_table.get(destination_hash) + if entry: + if len(entry) > 5 and entry[5] is not None: + interface_name = getattr(entry[5], "name", None) or str(entry[5]) + if len(entry) > 2 and entry[2] is not None: + hops = int(entry[2]) + except Exception: + pass + _put( + "announce", + dest_hash=destination_hash.hex(), + app_data=app_data_hex, + aspect=self._aspect, + public_keys=public_keys_hex, + interface_name=interface_name, + hops=hops, + ) + + +# Aspects the Columba UI surfaces. Order matters: when the same destination +# announces under multiple aspects we'll emit one event per aspect. +_TRACKED_ASPECTS = ( + "lxmf.delivery", + "lxmf.propagation", + "nomadnetwork.node", + "lxst.telephony", +) + + +def _delivery_callback(message: "LXMF.LXMessage") -> None: + """Fires for every inbound LXMF message routed to our delivery destination.""" + try: + content = message.content_as_string() + except Exception: + content = "" + try: + title = message.title_as_string() + except Exception: + title = "" + # Carry the inbound LXMF field map (telemetry / attachments / reactions / + # replies / icon / cease) to Swift as MessagePack-packed hex. Swift decodes + # it via LxmfFieldCodec → IncomingMessageHandler. Empty when there are no + # fields or packing fails (graceful — content/title still flow). + fields_hex = "" + try: + if getattr(message, "fields", None): + from RNS.vendor import umsgpack + fields_hex = umsgpack.packb(message.fields).hex() + except Exception: + fields_hex = "" + src = message.source_hash.hex() if message.source_hash else "" + _put("inbound", source_hash=src, content=content, title=title, fields_hex=fields_hex) + + +def start( + config_dir: str, + identity_path: str, + display_name: str, + identity_bytes: bytes | None = None, +) -> dict[str, str]: + """Initialize Reticulum + LXMRouter. Idempotent — returns local_info if already up. + + The RNS config file at `/config` must already exist — Swift + writes it from the user's `InterfaceEntity` records before calling + `start()`. See `PythonConfigWriter.swift`. If the config file is missing, + Reticulum will fall back to its default behavior (create a default + config and try to auto-discover interfaces). + + Identity precedence (highest first): + 1. `identity_bytes` — raw 64-byte private key blob (preferred path for iOS; + Swift reads it from Keychain and hands it over). Identity is loaded via + RNS.Identity.from_bytes() and never touches the filesystem. + 2. `identity_path` — existing file on disk; loaded via RNS.Identity.from_file(). + 3. Neither — generate a fresh identity and persist to `identity_path` (PoC + and CLI use; iOS production should never hit this branch since the + Keychain entry is created on first launch by Swift).""" + with _lock: + if _state["started"]: + return _local_info() + + os.makedirs(config_dir, exist_ok=True) + _state["config_dir"] = config_dir + + # Both RNS.Reticulum.__init__ and LXMF.LXMRouter.__init__ call signal.signal() + # for SIGINT/SIGTERM. That requires Python's main thread; we're on a Swift + # dispatch queue, so the call raises ValueError. iOS apps don't receive these + # signals (UIKit handles app lifecycle), so stubbing them is safe. + import signal as _signal + _orig_signal = _signal.signal + _signal.signal = lambda *_a, **_kw: None + try: + reticulum = RNS.Reticulum(config_dir) + _state["reticulum"] = reticulum + + if identity_bytes is not None and len(identity_bytes) > 0: + identity = RNS.Identity.from_bytes(identity_bytes) + if identity is None: + raise RuntimeError("Failed to load identity from supplied bytes") + elif os.path.isfile(identity_path): + identity = RNS.Identity.from_file(identity_path) + if identity is None: + raise RuntimeError(f"Failed to load identity at {identity_path}") + else: + identity = RNS.Identity() + identity.to_file(identity_path) + _state["identity"] = identity + + storage_path = os.path.join(config_dir, "lxmf-storage") + os.makedirs(storage_path, exist_ok=True) + router = LXMF.LXMRouter(identity=identity, storagepath=storage_path) + router.register_delivery_callback(_delivery_callback) + _state["router"] = router + # Route LXMF stamp PoW to the native Swift generator (iOS has no + # _multiprocessing). Must be installed before the first outbound + # message to a stamp-requiring peer. + _install_native_stamp_generator() + finally: + _signal.signal = _orig_signal + + delivery_destination = router.register_delivery_identity( + identity, display_name=display_name + ) + if delivery_destination is None: + raise RuntimeError("register_delivery_identity returned None") + _state["destination"] = delivery_destination + + handlers = [] + for aspect in _TRACKED_ASPECTS: + h = _AspectAnnounceHandler(aspect) + RNS.Transport.register_announce_handler(h) + handlers.append(h) + # NOTE: no wildcard (aspect_filter=None) handler. One used to be + # registered here as a diagnostic, but it surfaced every announce the + # node heard — including aspects Columba doesn't display — as phantom + # "*"/Peer cards (e.g. a propagation announce whose bool-first app_data + # rendered as the display name "False"), and could clobber a + # correctly-typed announce since path entries are keyed by destination + # hash (last write wins). Only the four tracked aspects above feed the + # network list now. + _state["handler"] = handlers # list now, was singleton — stop() handles both + + # Register the lxst.telephony destination on our identity so + # incoming voice calls have somewhere to land. Inbound RNS.Link + # establishment on this destination emits a `link_state` event + # tagged with a fresh link_id; the Swift side (CallManager → + # lxst-swift Telephone) takes over from there. + global _telephony_destination + try: + _telephony_destination = RNS.Destination( + identity, RNS.Destination.IN, RNS.Destination.SINGLE, + "lxst", "telephony" + ) + + def _on_inbound_link(link: Any) -> None: + link_id = _alloc_link_id() + with _lock: + _links[link_id] = link + _wire_link_callbacks(link, link_id) + # The "established" callback fires only on outbound + # links — inbound links arrive already established, so + # synthesize the event manually so the Swift side sees + # one matching state transition either way. + _put("link_state", link_id=link_id, state="established", inbound=True) + + _telephony_destination.set_link_established_callback(_on_inbound_link) + _state["telephony_destination"] = _telephony_destination + except Exception as e: + _put("state", value=f"telephony-register-failed: {e}") + + # Announce ourselves so the network knows where we are. We re-announce + # after a short delay because the TCP interface needs ~1-2s to come + # online; the first announce can be dropped if the interface isn't + # connected yet. + delivery_destination.announce() + + global _announce_generation + _announce_generation += 1 + _reannounce_gen = _announce_generation + + def _delayed_reannounce() -> None: + for delay in (2, 5, 15, 30): + time.sleep(delay) + # stop()/reset_identity()/a restart bumps the generation; bail + # so we never re-announce a torn-down destination — or the + # previous session's destination after a stop->start restart. + if _announce_generation != _reannounce_gen: + return + try: + delivery_destination.announce() + except Exception: + pass + + threading.Thread(target=_delayed_reannounce, daemon=True).start() + + _state["started"] = True + _put("state", value="connected") + return _local_info() + + +def set_propagation_node(dest_hash_hex: str, stamp_cost: int = 0) -> dict[str, Any]: + """Configure which LXMF propagation node this client uses for store-and- + forward retrieval. `dest_hash_hex` must be the destination hash of an + `lxmf.propagation` announce we've already received (or empty string to + clear the selection).""" + with _lock: + if not _state["started"]: + return {"ok": False, "reason": "not-started"} + router = _state["router"] + if router is None: + return {"ok": False, "reason": "no-router"} + try: + if dest_hash_hex: + dest_hash = bytes.fromhex(dest_hash_hex) + router.set_outbound_propagation_node(dest_hash) + if hasattr(router, "set_propagation_node_stamp_cost"): + router.set_propagation_node_stamp_cost(int(stamp_cost)) + else: + # Clear selection — pass None / 0 to drop both the node + # and the stamp cost. + router.set_outbound_propagation_node(None) + if hasattr(router, "set_propagation_node_stamp_cost"): + router.set_propagation_node_stamp_cost(0) + except Exception as e: + return {"ok": False, "reason": f"set-failed: {e}"} + return {"ok": True, "reason": "ok"} + + +def propagation_sync(timeout: float = 60.0) -> dict[str, Any]: + """Block until the current LXMF propagation-node sync finishes (or + times out). Returns `{ok, state, received_messages, reason}`. + + `state` mirrors LXMRouter.PR_* (e.g. `complete`, `no_path`, + `transfer_failed`, `path_requested`, `link_established`, + `receiving`). `received_messages` is the count of new inbound + LXMessages that landed on the local delivery destination during + this sync. + + Requires set_propagation_node() to have been called with a valid + `lxmf.propagation` destination first.""" + with _lock: + if not _state["started"]: + return {"ok": False, "state": "not-started", "received_messages": 0, "reason": "not-started"} + router = _state["router"] + identity = _state["identity"] + if router is None or identity is None: + return {"ok": False, "state": "no-router", "received_messages": 0, "reason": "no-router"} + outbound = getattr(router, "outbound_propagation_node", None) + if outbound is None: + return {"ok": False, "state": "no-node", "received_messages": 0, "reason": "no-node-selected"} + + try: + router.request_messages_from_propagation_node(identity) + except Exception as e: + return {"ok": False, "state": "transfer-failed", "received_messages": 0, "reason": f"start-failed: {e}"} + + # Poll the router state outside the lock so the router can update + # propagation_transfer_state from its own thread. + deadline = time.monotonic() + timeout + last_seen_state: Any = None + while time.monotonic() < deadline: + try: + state_val = getattr(router, "propagation_transfer_state", None) + last_seen_state = state_val + # Only PR_COMPLETE / PR_NO_PATH / PR_TRANSFER_FAILED are + # truly terminal — link-established is intermediate. + real_terminal = { + getattr(LXMF.LXMRouter, "PR_COMPLETE", 5), + getattr(LXMF.LXMRouter, "PR_NO_PATH", 6), + getattr(LXMF.LXMRouter, "PR_TRANSFER_FAILED", 7), + } + if state_val in real_terminal: + break + except Exception: + pass + time.sleep(0.5) + + received = 0 + try: + received = int(getattr(router, "propagation_transfer_last_result", 0) or 0) + except Exception: + pass + + state_name = _propagation_state_name(last_seen_state) + ok = state_name == "complete" + return { + "ok": ok, + "state": state_name, + "received_messages": received, + "reason": "ok" if ok else state_name, + } + + +def _propagation_state_name(val: Any) -> str: + if val is None: + return "idle" + # LXMF.LXMRouter PR_* constants → lowercase names. Use getattr so the + # mapping stays correct even if the upstream enum gets reordered. + mapping = { + getattr(LXMF.LXMRouter, "PR_IDLE", -1): "idle", + getattr(LXMF.LXMRouter, "PR_PATH_REQUESTED", -2): "path_requested", + getattr(LXMF.LXMRouter, "PR_LINK_ESTABLISHING", -3): "link_establishing", + getattr(LXMF.LXMRouter, "PR_LINK_ESTABLISHED", -4): "link_established", + getattr(LXMF.LXMRouter, "PR_REQUEST_SENT", -5): "request_sent", + getattr(LXMF.LXMRouter, "PR_RECEIVING", -6): "receiving", + getattr(LXMF.LXMRouter, "PR_RESPONSE_RECEIVED", -7): "response_received", + getattr(LXMF.LXMRouter, "PR_COMPLETE", -8): "complete", + getattr(LXMF.LXMRouter, "PR_NO_PATH", -9): "no_path", + getattr(LXMF.LXMRouter, "PR_TRANSFER_FAILED", -10): "transfer_failed", + } + return mapping.get(val, f"state_{val}") + + +# ──────────────────────────────────────────────────────────────────── +# RNS.Link bridge — used by lxst-swift for voice calls. +# +# Audio frames stay on the Swift side end-to-end (capture / encode in +# AVAudioEngine + libopus, decode / playback symmetrically). Python's +# only job is to handle the underlying RNS.Link cryptography + +# framing + routing — opaque byte payloads flow in both directions via +# the event queue (link_packet) and `link_send`. +# +# Mirrors the lxst-kt Kotlin port that Columba Android uses on its +# native (non-Chaquopy) voice path. Swift is the LXST protocol owner; +# Python is just "Link as a pipe". +# ──────────────────────────────────────────────────────────────────── + +_links: dict[int, Any] = {} # link_id -> RNS.Link +_next_link_id_counter = 0 +# Dedicated lock for the counter, NOT the shared `_lock`: _alloc_link_id is +# called from both the bridge thread (outbound links) and RNS callback threads +# (inbound link-established), and some callers already hold `_lock`, so reusing +# it here would deadlock (threading.Lock is non-reentrant). +_link_id_lock = threading.Lock() +_telephony_destination: Any = None # RNS.Destination for lxst.telephony aspect + + +def _alloc_link_id() -> int: + global _next_link_id_counter + # `+= 1` is a read-modify-write — without a lock two threads can read the + # same value and hand out duplicate ids that overwrite live _links entries. + with _link_id_lock: + _next_link_id_counter += 1 + return _next_link_id_counter + + +def _wire_link_callbacks(link: Any, link_id: int) -> None: + """Hook the standard set of Link callbacks (established / closed / + packet / remote_identified) so the Swift side receives matching + events on the drain queue.""" + def _on_established(_l: Any) -> None: + _put("link_state", link_id=link_id, state="established") + + def _on_closed(l: Any) -> None: + reason = "" + try: + tr = getattr(l, "teardown_reason", None) + reason = str(tr) if tr is not None else "" + except Exception: + pass + _put("link_state", link_id=link_id, state="closed", reason=reason) + with _lock: + _links.pop(link_id, None) + + def _on_packet(data: bytes, _packet: Any) -> None: + try: + _put("link_packet", link_id=link_id, data_hex=data.hex() if data else "") + except Exception: + pass + + def _on_remote_identified(_l: Any, identity: Any) -> None: + try: + _put( + "link_identified", + link_id=link_id, + identity_hash=identity.hash.hex() if identity is not None else "", + ) + except Exception: + pass + + link.set_link_established_callback(_on_established) + link.set_link_closed_callback(_on_closed) + link.set_packet_callback(_on_packet) + try: + # RNS uses one of these two names depending on version + link.set_remote_identified_callback(_on_remote_identified) + except AttributeError: + try: + link.set_remote_identification_callback(_on_remote_identified) + except AttributeError: + pass + + +def open_link(dest_hash_hex: str, aspect: str = "lxst.telephony") -> dict[str, Any]: + """Initiate an outbound RNS.Link to a destination with the given + aspect (default `lxst.telephony` for voice). Returns a link_id + Swift can use to send/teardown/identify on the link. + + Returns: + {ok: bool, link_id: int, reason: str} + + Reasons: + ok — link initiated; subsequent link_state event will + fire `established` or `closed` + not-started — Python RNS hasn't been started yet + bad-hash — dest_hash_hex isn't a valid hex string + no-path — Identity hasn't been recalled; a request_path was + kicked off; caller should retry once we receive + an announce for `dest_hash` + """ + with _lock: + if not _state["started"]: + return {"ok": False, "link_id": 0, "reason": "not-started"} + try: + dest_hash = bytes.fromhex(dest_hash_hex) + except ValueError: + return {"ok": False, "link_id": 0, "reason": "bad-hash"} + + peer_identity = RNS.Identity.recall(dest_hash) + if peer_identity is None: + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + return {"ok": False, "link_id": 0, "reason": "no-path"} + + # Split the dotted aspect into app_name + remaining aspect tokens + # (RNS.Destination's variadic aspects parameter). + parts = aspect.split(".") if aspect else ["lxst", "telephony"] + app_name = parts[0] + aspects = parts[1:] if len(parts) > 1 else [] + + peer_dest = RNS.Destination( + peer_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, + app_name, *aspects + ) + try: + link = RNS.Link(peer_dest) + except Exception as e: + return {"ok": False, "link_id": 0, "reason": f"link-init-failed: {e}"} + + link_id = _alloc_link_id() + # Register the link in _links BEFORE wiring callbacks: _on_closed pops + # _links[link_id], so if the link closes fast (route rejection / quick + # timeout) before the entry exists, the pop misses and we'd later insert a + # permanently-zombie entry. Same ordering the inbound path uses. + with _lock: + _links[link_id] = link + _wire_link_callbacks(link, link_id) + _put("link_state", link_id=link_id, state="establishing") + return {"ok": True, "link_id": link_id, "reason": "ok"} + + +def link_send(link_id: int, data_hex: str) -> dict[str, Any]: + """Send opaque bytes over an established Link. `data_hex` is the + hex-encoded payload (Swift hex-encodes the byte buffer before + calling). The bytes get wrapped in a single RNS.Packet. + + Returns {ok, reason}. Reasons: ok / not-started / no-link / + not-established / bad-hex / send-failed.""" + with _lock: + if not _state["started"]: + return {"ok": False, "reason": "not-started"} + link = _links.get(int(link_id)) + if link is None: + return {"ok": False, "reason": "no-link"} + + if not getattr(link, "status", None) == RNS.Link.ACTIVE: + # Allow PENDING too if the caller knows what they're doing, + # but reject CLOSED / FAILED. + if getattr(link, "status", None) == RNS.Link.CLOSED: + return {"ok": False, "reason": "closed"} + + try: + data = bytes.fromhex(data_hex) + except ValueError: + return {"ok": False, "reason": "bad-hex"} + + try: + # RNS.Packet(link, data).send() is the canonical way to send + # opaque bytes over a Link — wraps them in a Link packet that + # the remote receives via the Link's packet callback. + packet = RNS.Packet(link, data) + packet.send() + except Exception as e: + return {"ok": False, "reason": f"send-failed: {e}"} + return {"ok": True, "reason": "ok"} + + +def link_identify(link_id: int) -> dict[str, Any]: + """Reveal our identity on the given Link to the remote (so the + remote's `link_identified` event fires with our identity hash). + Mirrors `link.identify(local_identity)` from Python RNS.""" + with _lock: + if not _state["started"]: + return {"ok": False, "reason": "not-started"} + link = _links.get(int(link_id)) + local_identity = _state["identity"] + if link is None: + return {"ok": False, "reason": "no-link"} + if local_identity is None: + return {"ok": False, "reason": "no-identity"} + try: + link.identify(local_identity) + except Exception as e: + return {"ok": False, "reason": f"identify-failed: {e}"} + return {"ok": True, "reason": "ok"} + + +def link_teardown(link_id: int) -> dict[str, Any]: + """Tear down a Link from our side. The closed_callback will fire + on both peers (which emits a `link_state=closed` event).""" + with _lock: + link = _links.pop(int(link_id), None) + if link is None: + return {"ok": False, "reason": "no-link"} + try: + link.teardown() + except Exception: + pass + return {"ok": True, "reason": "ok"} + + +def announce(display_name: str = "") -> dict[str, Any]: + """Re-broadcast the LXMF delivery destination's announce with optional + display name update. Called from the Settings UI's manual "Announce" + button and from the AutoAnnounceManager timer. + + Updates the delivery destination's display name and lets LXMF build the + announce app_data via its own `get_announce_app_data` (installed as the + destination's default-app-data callback), then calls + `delivery_destination.announce()` — the canonical LXMF delivery announce, + identical to what `LXMRouter.announce()` emits. Queues the announce packet + for every online interface. + + Returns `{ok: bool, reason: str}`. `not-started` when Python hasn't + booted yet, `no-destination` when register_delivery_identity failed + earlier in start(), `ok` on success.""" + with _lock: + if not _state["started"]: + return {"ok": False, "reason": "not-started"} + destination = _state["destination"] + router = _state["router"] + if destination is None or router is None: + return {"ok": False, "reason": "no-destination"} + + # Announce via LXMF's own delivery-destination machinery instead of + # hand-rolling app_data. Update the display name, (re)install LXMF's + # get_announce_app_data as the destination's default app_data, then call + # delivery_destination.announce(): RNS invokes the callable to build the + # canonical msgpack [display_name, stamp_cost] — the exact format + # Sideband and Android Columba emit — and the SAME app_data is reused for + # any RNS path-request re-announce. (We previously froze app_data to + # static [name, 0] bytes here, diverging from the real LXMF format.) + try: + if display_name: + destination.display_name = display_name + + def _get_app_data() -> bytes: + return router.get_announce_app_data(destination.hash) + destination.set_default_app_data(_get_app_data) + + destination.announce() + except Exception as e: + return {"ok": False, "reason": f"announce-error: {e}"} + return {"ok": True, "reason": "ok"} + + +def announce_telephony(display_name: str = "") -> dict[str, Any]: + """Re-broadcast the LXST telephony destination's announce so peers can + discover us for voice calls. Mirrors `announce()` but targets the + `_telephony_destination` instead of the LXMF delivery destination. + + The app data is the raw UTF-8 display name (no msgpack wrapper) — the + AnnounceHandler on the Swift side decodes it as a plain string for the + Network tab. Matches what Sideband / canonical LXST do.""" + with _lock: + if not _state["started"]: + return {"ok": False, "reason": "not-started"} + destination = _telephony_destination + if destination is None: + return {"ok": False, "reason": "no-telephony-destination"} + + try: + destination.set_default_app_data( + display_name.encode("utf-8", errors="replace") + ) + except Exception as e: + return {"ok": False, "reason": f"appdata-error: {e}"} + + try: + destination.announce() + except Exception as e: + return {"ok": False, "reason": f"announce-error: {e}"} + return {"ok": True, "reason": "ok"} + + +def _clear_transport_class_state() -> None: + """Drain the RNS.Transport class-level state that exit_handler() leaves + behind. RNS.Transport is a process-global: its destinations / interfaces / + path tables / announce handlers / identities persist across a Reticulum + exit_handler(), so a follow-on start() would raise "Attempt to register an + already registered destination" (Transport.register_destination) and reuse + stale interfaces/paths. BOTH stop() and reset_identity() must call this + before the next start() — keeping it in one place stops the two paths from + drifting (reset_identity previously omitted it and broke identity switch).""" + for _attr, _empty in ( + ("destinations", []), + ("interfaces", []), + ("path_table", {}), + ("destination_table", {}), + ("announce_handlers", []), + ("identities", {}), + ): + try: + setattr(RNS.Transport, _attr, _empty) + except Exception: + pass + # exit_handler() sets RNS.loglevel = LOG_NONE; restore it so the next init's + # RNS.log() calls are visible again. + try: + RNS.loglevel = RNS.LOG_VERBOSE + except Exception: + pass + + +def stop() -> None: + with _lock: + if not _state["started"]: + return + try: + existing = _state["handler"] + if existing is not None: + handlers_to_drop = existing if isinstance(existing, list) else [existing] + for h in handlers_to_drop: + try: + RNS.Transport.deregister_announce_handler(h) + except Exception: + pass + except Exception: + pass + try: + if _state["router"] is not None: + _state["router"].exit_handler() + except Exception: + pass + try: + if _state["reticulum"] is not None: + _state["reticulum"].exit_handler() + except Exception: + pass + + # RNS.Reticulum is a singleton: its __init__ raises OSError("Attempt to + # reinitialise Reticulum, when it was already running") if the class-level + # __instance is still set. exit_handler() does not clear it, so + # reconnect-after-disconnect explodes. Clear the mangled class attrs + # ourselves. Same for the exit-handler flags so a fresh init re-runs them. + try: + RNS.Reticulum._Reticulum__instance = None + RNS.Reticulum._Reticulum__exit_handler_ran = False + RNS.Reticulum._Reticulum__interface_detach_ran = False + except Exception: + pass + + # Drain the Transport class-level state exit_handler() leaves behind so + # the next start() sees a clean slate (shared with reset_identity). + _clear_transport_class_state() + + # Tear down any open RNS.Links and forget the telephony + # destination so a subsequent start() doesn't trip on stale + # callbacks pointing at a freed RNS.Transport. + global _telephony_destination + # Snapshot the links and clear the dict INSIDE the lock, then tear them + # down OUTSIDE it (below). link.teardown() can fire _on_closed + # synchronously once exit_handler() has stopped the transport threads, + # and _on_closed does `with _lock: _links.pop(...)` — holding the + # non-reentrant _lock across teardown() would deadlock the bridge. + links_to_teardown = list(_links.values()) + _links.clear() + _telephony_destination = None + + # Drop registered BLE + RNode 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()). + _announce_generation += 1 + + _state.update({ + "started": False, + "reticulum": None, + "router": None, + "identity": None, + "destination": None, + "handler": None, + "telephony_destination": None, + }) + _put("state", value="disconnected") + + # Outside the lock: tear down the snapshotted links so a synchronous + # _on_closed (which re-acquires _lock) can't deadlock. + for link in links_to_teardown: + try: + link.teardown() + except Exception: + pass + + +def add_interface(name: str) -> dict[str, Any]: + """Hot-add a single interface to the *running* Reticulum stack — no restart. + + RNS attaches interfaces to a live `Transport` without re-initialising (it's + exactly what the 1.x interface-discovery autoconnect path does — see + `RNS.Discovery.InterfaceDiscovery.autoconnect` → `Reticulum._add_interface`). + We reuse the higher-level `Reticulum._synthesize_interface()` — the same code + path startup uses to bring up `[interfaces]` sections — so every interface + type (TCP, Auto, RNode, and the external iOS BLE module) is handled by RNS's + own logic rather than reimplemented here. + + `name` is the ConfigObj section name (PythonConfigWriter's sanitized + "-" form); it becomes the interface's `iface.name`, which is + how `status()` and the Swift status poll match interfaces back to entities. + + The interface's `[[name]]` section is read *fresh from the on-disk config* + rather than the running `reticulum.config`: Swift's PythonConfigWriter has + already rewritten the full config file (for cold-launch durability) and the + in-memory `reticulum.config` was parsed at init, so it won't contain a + section added afterwards. + + Returns {"ok": bool, "reason": str}. Idempotent — re-adding a live + interface returns ok=True / "already-present". + """ + with _lock: + if not _state["started"]: + return {"ok": False, "reason": "not-started"} + reticulum = _state["reticulum"] + config_dir = _state["config_dir"] + if reticulum is None or config_dir is None: + return {"ok": False, "reason": "no-reticulum"} + + for iface in list(RNS.Transport.interfaces): + if getattr(iface, "name", None) == name: + return {"ok": True, "reason": "already-present"} + + from RNS.vendor.configobj import ConfigObj + config_path = os.path.join(config_dir, "config") + try: + cfg = ConfigObj(config_path) + except Exception as e: + return {"ok": False, "reason": f"config-read-failed: {e}"} + + if "interfaces" not in cfg or name not in cfg["interfaces"]: + return {"ok": False, "reason": f"section-not-found: {name}"} + section = cfg["interfaces"][name] + + # `_synthesize_interface` calls `RNS.panic()` (→ os._exit(255)) when an + # interface fails to construct — at startup that aborts cleanly, but on + # a *runtime* add a bad/unreachable config would take the whole app + # down. Swap panic() for an exception for the duration so the failure + # degrades to an error return. Also stub signal.signal: some interface + # constructors (RNode) install handlers, which raises off the main + # thread (we're on the Swift bridge queue). Both are safe because all + # bridge entry points are serialized under `_lock`. + import signal as _signal + orig_panic = RNS.panic + orig_signal = _signal.signal + + def _raise_panic(): + raise RuntimeError("interface synthesis panicked (bad config or unreachable endpoint)") + + RNS.panic = _raise_panic + _signal.signal = lambda *_a, **_kw: None + try: + reticulum._synthesize_interface(section, name, instance_init=False) + except Exception as e: + RNS.trace_exception(e) + return {"ok": False, "reason": f"synthesize-failed: {e}"} + finally: + RNS.panic = orig_panic + _signal.signal = orig_signal + + # Keep the live in-memory config consistent with what's now attached, + # so a later status()/stop() reasons over the same view. + try: + if "interfaces" not in reticulum.config: + reticulum.config["interfaces"] = {} + reticulum.config["interfaces"][name] = dict(section) + except Exception: + pass + + for iface in RNS.Transport.interfaces: + if getattr(iface, "name", None) == name: + RNS.log(f"Hot-added interface {name}", RNS.LOG_NOTICE) + return {"ok": True, "reason": "added"} + return {"ok": False, "reason": "not-attached"} + + +def remove_interface(name: str) -> dict[str, Any]: + """Hot-remove an interface from the running Reticulum stack — no restart. + + Calls the interface's `detach()` then drops it from + `RNS.Transport.interfaces`, along with any child interfaces that name it as + their `parent_interface` (e.g. AutoInterface's dynamically-spawned + AutoInterfacePeer rows). + + Teardown completeness depends on the interface type's `detach()`: + • TCPClientInterface.detach() shuts down + closes the socket — clean. + • AutoInterface.detach() upstream only sets `online = False`; it does NOT + close the multicast discovery sockets or join their daemon threads, so + the OS sockets stay bound until process exit. Re-adding the same + AutoInterface before a cold launch can therefore collide on the + multicast bind. (Tracked for an upstream RNS teardown fix; TCP/Backbone + removal is unaffected.) + + Returns {"ok": bool, "reason": str}. + """ + with _lock: + if not _state["started"]: + return {"ok": False, "reason": "not-started"} + + removed = 0 + for iface in list(RNS.Transport.interfaces): + is_target = getattr(iface, "name", None) == name + parent = getattr(iface, "parent_interface", None) + is_child = parent is not None and getattr(parent, "name", None) == name + if not (is_target or is_child): + continue + try: + iface.detach() + except Exception as e: + RNS.log(f"detach failed for {iface}: {e}", RNS.LOG_ERROR) + try: + RNS.Transport.interfaces.remove(iface) + removed += 1 + except ValueError: + pass + + try: + reticulum = _state["reticulum"] + if reticulum is not None and "interfaces" in reticulum.config \ + and name in reticulum.config["interfaces"]: + del reticulum.config["interfaces"][name] + except Exception: + pass + + if removed: + RNS.log(f"Hot-removed interface {name} ({removed} entr{'y' if removed == 1 else 'ies'})", RNS.LOG_NOTICE) + return {"ok": removed > 0, "reason": f"removed-{removed}" if removed else "not-found"} + + +def persist() -> dict[str, Any]: + """Force RNS to flush its path table + known destinations to disk now. + + RNS only persists on a 12-hour timer (`Reticulum.PERSIST_INTERVAL`) or in + `exit_handler` on a clean shutdown. iOS suspends/kills the app without a + clean exit and long before 12h, so left to itself RNS almost never writes + `/destination_table` or `known_destinations` — and a cold start + then reloads nothing. Columba calls this when the app backgrounds so RNS's + routing + recalled identities (and thus the ability to message previously + heard peers) survive an app restart. Mirrors what RNS's own periodic + `__persist_data` does, but unthrottled. + """ + with _lock: + if not _state["started"]: + return {"ok": False, "reason": "not-started"} + try: + RNS.Transport.persist_data() + except Exception as e: + RNS.log(f"persist: Transport.persist_data failed: {e}", RNS.LOG_ERROR) + try: + RNS.Identity.persist_data() + except Exception as e: + RNS.log(f"persist: Identity.persist_data failed: {e}", RNS.LOG_ERROR) + return {"ok": True, "reason": "persisted"} + + +def send_opportunistic(dest_hash_hex: str, content: str, fields_hex: str = "", + method: str = "opportunistic") -> dict[str, Any]: + """Send an LXMF message. Returns a dict with 'ok' (bool) and 'reason' + (string) describing the outcome. If the destination's identity isn't + recallable yet (no announce / no path), kicks off a `request_path` and + returns ok=False reason='requesting-path'. + + `method` selects the LXMF desired-method on the outbound message: + - "opportunistic" (default): single encrypted packet, no link. + Upstream LXMF auto-falls-back to DIRECT when the encrypted payload + exceeds packet size (e.g. an image attachment). + - "direct": opens an RNS.Link for the transfer (link-based). + - "propagated": uploads to the configured propagation node; the + recipient downloads when it next syncs. + + The function name is historical (was opportunistic-only); the bridge's + public Swift wrapper still uses `sendOpportunistic` for the same reason. + """ + with _lock: + if not _state["started"]: + return {"ok": False, "reason": "not-started"} + router = _state["router"] + local_dest = _state["destination"] + try: + dest_hash = bytes.fromhex(dest_hash_hex) + except ValueError: + return {"ok": False, "reason": "bad-hash"} + + peer_identity = RNS.Identity.recall(dest_hash) + if peer_identity is None: + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + return {"ok": False, "reason": "requesting-path"} + + peer_dest = RNS.Destination( + peer_identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + "lxmf", + "delivery", + ) + # Decode the MessagePack-packed LXMF field map from Swift (image / + # attachments / icon / reply / reaction / telemetry), if any. + fields = None + if fields_hex: + try: + from RNS.vendor import umsgpack + fields = umsgpack.unpackb(bytes.fromhex(fields_hex)) + except Exception: + fields = None + + # Map the public method string onto LXMF's three desired-method codes. + # Anything unrecognised falls back to OPPORTUNISTIC so a typo at the + # Swift caller doesn't silently change wire semantics in unexpected + # ways (the typo + opportunistic-text combo is the lowest-risk fallback). + desired_method = { + "opportunistic": LXMF.LXMessage.OPPORTUNISTIC, + "direct": LXMF.LXMessage.DIRECT, + "propagated": LXMF.LXMessage.PROPAGATED, + }.get(method, LXMF.LXMessage.OPPORTUNISTIC) + + msg = LXMF.LXMessage( + peer_dest, + local_dest, + content, + title="", + fields=fields, + desired_method=desired_method, + ) + + # Surface delivery / failure proofs to Swift so the chat UI can flip a + # sent message to the double-check (delivered) or failed state. The + # callbacks fire on RNS worker threads when a proof arrives; we drop a + # "delivery" event onto the queue keyed by the LXMF message hash so the + # Swift side can match it to the persisted message row. + def _on_delivered(m: "LXMF.LXMessage") -> None: + try: + _put("delivery", message_hash=m.hash.hex(), state="delivered") + except Exception: + pass + + def _on_failed(m: "LXMF.LXMessage") -> None: + try: + _put("delivery", message_hash=m.hash.hex(), state="failed") + except Exception: + pass + + msg.register_delivery_callback(_on_delivered) + msg.register_failed_callback(_on_failed) + + router.handle_outbound(msg) + + # `handle_outbound` packs the message, so `msg.hash` is now the real + # LXMF message hash. Return it so Swift persists the outbound message + # under the same key the delivery event will carry. + message_hash = msg.hash.hex() if msg.hash is not None else "" + return {"ok": True, "reason": "queued", "message_hash": message_hash} + + +def fetch_nomadnet_page( + dest_hash_hex: str, + path: str, + timeout: float = 30.0, + form_fields: dict[str, str] | None = None, +) -> dict[str, Any]: + """One-shot fetch of a NomadNet page over an RNS Link. + + Walks the full RNS request cycle synchronously so the Swift caller + doesn't have to manage Link / RequestReceipt lifecycles. Returns + `{ok, status, data, content_type}` where: + + - ok: True on success + - status: short string ('ok' | 'no-identity' | 'no-path' | + 'link-failed' | 'request-failed' | 'timeout') + - data: bytes (Micron markup or arbitrary file payload), or + empty bytes on failure + - content_type: optional mime hint when the server provides one + + `form_fields` is a `dict[str, str]` (form name -> value) for + POST-style submissions; pass `None` for a plain GET-equivalent + fetch. Form values are passed as msgpack to match Reticulum's + convention. Link is established fresh each call and closed + before return — no link caching on the Python side yet (Swift's + NomadNetBrowserService used to cache, but with this simplified + bridge we trade a few hundred ms of re-establishment for a much + smaller API surface).""" + with _lock: + if not _state["started"]: + return {"ok": False, "status": "not-started", "data": b"", "content_type": ""} + try: + dest_hash = bytes.fromhex(dest_hash_hex) + except ValueError: + return {"ok": False, "status": "bad-hash", "data": b"", "content_type": ""} + + # Recall the remote identity. If we haven't received an announce yet, + # kick off a request_path and bail — caller can retry once the path + # arrives. (Mirrors send_opportunistic's behavior.) + peer_identity = RNS.Identity.recall(dest_hash) + if peer_identity is None: + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + return {"ok": False, "status": "no-path", "data": b"", "content_type": ""} + + peer_dest = RNS.Destination( + peer_identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + "nomadnetwork", + "node", + ) + + # Use threading.Event for synchronous waits on the async RNS callbacks. + link_ready = threading.Event() + link_closed = threading.Event() + response_ready = threading.Event() + state = { + "response_data": None, + "response_error": None, + "link_close_reason": None, + } + + def _on_link_established(_link: Any) -> None: + link_ready.set() + + def _on_link_closed(link: Any) -> None: + try: + state["link_close_reason"] = getattr(link, "teardown_reason", None) + except Exception: + pass + link_closed.set() + # If we were waiting for a response when the link died, unblock. + response_ready.set() + + def _on_response(request_receipt: Any) -> None: + try: + state["response_data"] = request_receipt.response + except Exception as e: + state["response_error"] = f"response-extract-failed: {e}" + response_ready.set() + + def _on_failed(_request_receipt: Any) -> None: + state["response_error"] = "request-failed" + response_ready.set() + + link = RNS.Link(peer_dest, established_callback=_on_link_established, closed_callback=_on_link_closed) + + if not link_ready.wait(timeout=min(20.0, timeout)): + try: + link.teardown() + except Exception: + pass + return {"ok": False, "status": "link-failed", "data": b"", "content_type": ""} + + # Identify on the link AFTER it reaches ACTIVE (link_ready) — link.identify + # sends an encrypted identity-proof packet that a PENDING link cannot send, + # so identifying before the wait silently no-ops. The remote needs this; + # nomadnet's node app expects it for stateful pages. + # Snapshot the identity under _lock: a concurrent reset_identity()/stop() + # can null _state["identity"] between the None-check and link.identify(), + # making identify() silently no-op on None (mirrors link_identify()). + with _lock: + active_identity = _state["identity"] + try: + if active_identity is not None: + link.identify(active_identity) + except Exception: + pass + + # Pack form fields as msgpack when present (Reticulum/LXMF convention). + request_data: Any = None + if form_fields: + try: + from RNS.vendor import umsgpack + request_data = umsgpack.packb(dict(form_fields)) + except Exception: + # Fall back to nothing — better to send the request unform'd + # than to fail outright. + request_data = None + + try: + link.request( + path, + data=request_data, + response_callback=_on_response, + failed_callback=_on_failed, + timeout=timeout, + ) + except Exception as e: + try: + link.teardown() + except Exception: + pass + return {"ok": False, "status": "request-failed", "data": b"", "content_type": str(e)} + + if not response_ready.wait(timeout=timeout): + try: + link.teardown() + except Exception: + pass + return {"ok": False, "status": "timeout", "data": b"", "content_type": ""} + + try: + link.teardown() + except Exception: + pass + + if state["response_error"]: + return {"ok": False, "status": "request-failed", "data": b"", "content_type": state["response_error"]} + + response = state["response_data"] + if response is None: + return {"ok": False, "status": "request-failed", "data": b"", "content_type": "empty-response"} + + # NomadNet wraps payloads in msgpack as [page_data, [files]] for resource + # responses, or just raw bytes for short responses. Unwrap if it looks + # msgpack-packed; otherwise return as-is. + payload = response + if isinstance(response, (bytes, bytearray)): + payload = bytes(response) + elif isinstance(response, str): + payload = response.encode("utf-8", errors="replace") + elif isinstance(response, (list, tuple)) and len(response) >= 1: + first = response[0] + if isinstance(first, (bytes, bytearray)): + payload = bytes(first) + elif isinstance(first, str): + payload = first.encode("utf-8", errors="replace") + else: + payload = b"" + else: + payload = b"" + + return {"ok": True, "status": "ok", "data": payload, "content_type": ""} + + +def reset_identity(identity_path: str) -> None: + """Delete identity bytes on disk and tear down state. Caller must call + start() again after this. Safe to call when not started.""" + global _telephony_destination + with _lock: + # Tear down the router first (stops its LXMRouter background threads), + # then Reticulum — same order as stop(). Without the router teardown a + # follow-on start() spins up a second LXMRouter pointing at the same + # lxmf-storage SQLite, and the two threads race over the database. + try: + if _state["router"] is not None: + _state["router"].exit_handler() + except Exception: + pass + try: + if _state["reticulum"] is not None: + _state["reticulum"].exit_handler() + except Exception: + pass + # Clear the RNS.Reticulum singleton + exit-handler flags so the start() + # this function's docstring requires can actually re-init. exit_handler() + # leaves _Reticulum__instance set, so a follow-on __init__ would raise + # "Attempt to reinitialise Reticulum, when it was already running" and the + # app could only recover via a process restart. Mirrors stop(). + try: + RNS.Reticulum._Reticulum__instance = None + RNS.Reticulum._Reticulum__exit_handler_ran = False + RNS.Reticulum._Reticulum__interface_detach_ran = False + except Exception: + pass + # Drain RNS.Transport's process-global class state too — exit_handler() + # leaves destinations/interfaces/path tables populated, so the start() + # this function's docstring requires would otherwise raise "Attempt to + # register an already registered destination" and only a process restart + # could recover. Same teardown stop() does. + _clear_transport_class_state() + # Snapshot the links + clear inside the lock; tear them down OUTSIDE it + # (below) so a synchronous _on_closed — which re-acquires the + # non-reentrant _lock — can't deadlock the bridge (mirrors stop()). + links_to_teardown = list(_links.values()) + _links.clear() + _telephony_destination = None + # Drop registered BLE + RNode 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()). + _announce_generation += 1 + _state.update({ + "started": False, + "reticulum": None, + "router": None, + "identity": None, + "destination": None, + "handler": None, + "telephony_destination": None, + }) + # Outside the lock: tear down the snapshotted links. + for _link in links_to_teardown: + try: + _link.teardown() + except Exception: + pass + try: + if os.path.isfile(identity_path): + os.remove(identity_path) + except OSError: + pass + + +def _local_info() -> dict[str, str]: + identity = _state["identity"] + destination = _state["destination"] + return { + "identity_hash": identity.hash.hex() if identity is not None else "", + "destination_hash": destination.hash.hex() if destination is not None else "", + } + + +def local_info() -> dict[str, str]: + with _lock: + return _local_info() + + +def status() -> dict[str, Any]: + """Introspect RNS Transport state. Returns interface list w/ online status, + path-table size, announce-queue size.""" + with _lock: + out: dict[str, Any] = {"started": _state["started"]} + try: + interfaces = RNS.Transport.interfaces + iface_info = [] + for iface in interfaces: + # `iface.name` is the config section name (e.g. + # "Smoke_Test_Hub-smoke-"); str(iface) is the friendly + # "TCPInterface[name/host:port]" form. Swift matches by + # section name to update the TCPInterface stub's state. + # AutoInterfacePeer (spawned dynamically) sets `name=None`, + # so coerce to empty string — JSON null breaks Swift's + # String decoder and silently drops the whole snapshot. + section_name = getattr(iface, "name", None) or "" + iface_info.append({ + "section_name": section_name, + "name": str(iface), + "online": bool(getattr(iface, "online", False)), + "ifac_size": getattr(iface, "ifac_size", None), + "rx_bytes": getattr(iface, "rxb", 0), + "tx_bytes": getattr(iface, "txb", 0), + }) + out["interfaces"] = iface_info + except Exception as e: + out["interfaces_error"] = str(e) + try: + out["destination_table_size"] = len(RNS.Transport.destination_table) + except Exception: + out["destination_table_size"] = -1 + try: + out["path_table_size"] = len(RNS.Transport.path_table) + except Exception: + out["path_table_size"] = -1 + return out + + +def status_json() -> str: + """JSON-serialized form of `status()` for the Swift bridge to parse. + Avoids round-tripping a Python dict through the C API one PyObject at + a time — Swift just calls `json.JSONDecoder.decode(...)`.""" + import json as _json + return _json.dumps(status()) + + +def drain_events() -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + while True: + try: + out.append(_events.get_nowait()) + except queue.Empty: + break + return out + + +# ──────────────────────────────────────────────────────────────────── +# BLE bridge: registry for Swift→Python callbacks + handle to the +# Swift `SwiftBLEBridge` instance that the Python driver +# (`ios_ble_driver.IOSBLEDriver`) calls into. +# +# Direction matrix: +# Swift → Python (sync): PythonBridge.invokeBLECallback / -BoolSync +# looks up callables via `_ble_get_callback` +# and calls them under the GIL. +# Python → Swift (sync): Driver calls C-ABI shims exported by +# SwiftBLEBridge via ctypes (wired in Phase 3). +# ──────────────────────────────────────────────────────────────────── + +_ble_callbacks: dict[str, Any] = {} +_ble_bridge_handle: Any = None + + +def set_ble_bridge(handle: Any) -> None: + """Hand the SwiftBLEBridge instance handle to Python. Called from Swift's + `AppServices.startBLEInterface()` after the Swift bridge is constructed. + The IOSBLEDriver reads this on its first `start()` call so it knows where + to route outbound commands. + + `handle` is opaque to Python (currently a PyCapsule wrapping a Swift + object pointer); the ctypes shims in `ios_ble_driver.py` don't need it + because `ctypes.CDLL(None)` resolves through the process's symbol table. + Stashed here so future driver code that does need a per-bridge handle + can find it without a re-init.""" + global _ble_bridge_handle + _ble_bridge_handle = handle + + +def get_ble_bridge() -> Any: + """Driver-side accessor for the SwiftBLEBridge handle.""" + return _ble_bridge_handle + + +def set_ble_callback(slot: str, callable_: Any) -> None: + """Register a Python callable as the handler for a BLE event slot. Used + by IOSBLEDriver during `_setup_callbacks` after BLEInterface assigns its + own callbacks to the driver. Pass `None` to clear.""" + if callable_ is None: + _ble_callbacks.pop(slot, None) + else: + _ble_callbacks[slot] = callable_ + + +def _ble_get_callback(slot: str) -> Any: + """Swift-called lookup. Returns the stored callable for `slot`, or None. + PythonBridge.invokeBLECallback uses this to fetch the PyObject* ref then + calls it via `PyObject_CallObject`.""" + return _ble_callbacks.get(slot) + + +def clear_ble_callbacks() -> None: + """Drop every registered BLE callback. Called from `stop()` / restart so + we don't keep references to closures bound to a torn-down driver.""" + _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` +# to validate the Phase 2 wiring without needing a real BLE peer. +def _install_test_roundtrip_callback() -> None: + """Register a `_test_roundtrip` slot that returns True iff the int arg is even.""" + def _cb(value: int) -> bool: + return int(value) % 2 == 0 + set_ble_callback("_test_roundtrip", _cb) + + +def diagnose_path_table() -> str: + """Dump the first N entries of `RNS.Transport.path_table` with the + receiving-interface attribution we'd plumb up to a Node Details view. + Mirrors what `[Interface Heard]` should render — useful when we can't + screenshot the actual UI.""" + lines: list[str] = [] + try: + pt = RNS.Transport.path_table + except Exception as e: + return f"path_table unavailable: {e}" + if not pt: + return "path_table is empty (no announces received yet)" + lines.append(f"path_table count={len(pt)}") + for i, (dh, entry) in enumerate(list(pt.items())[:10]): + try: + dest_hex = dh.hex() if isinstance(dh, (bytes, bytearray)) else str(dh) + iface = entry[5] if entry and len(entry) > 5 else None + iface_name = getattr(iface, "name", None) or (str(iface) if iface else "") + timestamp = entry[0] if entry else "?" + hops = entry[2] if entry and len(entry) > 2 else "?" + lines.append(f" [{i}] dest={dest_hex[:16]} hops={hops} ts={timestamp} iface={iface_name}") + except Exception as e: + lines.append(f" [{i}] dump err: {e}") + return "\n".join(lines) + + +def diagnose_auto_interface() -> str: + """Introspect the running AutoInterface instance(s) and report the state + of peer discovery so we can tell whether multicast join / socket bind + is working at all.""" + import socket as _socket + lines: list[str] = [] + try: + ifs = RNS.Transport.interfaces + except Exception as e: + return f"Transport.interfaces unavailable: {e}" + all_types = [(type(i).__name__, getattr(i, "name", "?")) for i in ifs] + lines.append(f"all_interfaces ({len(ifs)}): {all_types}") + autos = [i for i in ifs if type(i).__name__ == "AutoInterface"] + if not autos: + return "\n".join(lines + ["no AutoInterface instance in Transport.interfaces"]) + for ai in autos: + lines.append(f"name={getattr(ai, 'name', '?')} online={getattr(ai, 'online', '?')}") + lines.append(f" group_id={getattr(ai, 'group_id', '?')}") + lines.append(f" discovery_scope={getattr(ai, 'discovery_scope', '?')}") + lines.append(f" discovery_port={getattr(ai, 'discovery_port', '?')}") + lines.append(f" data_port={getattr(ai, 'data_port', '?')}") + try: + lines.append(f" ifnames={list(getattr(ai, 'ifnames', []) or [])}") + except Exception: + pass + try: + ifs_addrs = getattr(ai, "interface_servers", None) or getattr(ai, "ifaddrs", None) + lines.append(f" ifaddrs/servers={ifs_addrs!r}") + except Exception: + pass + try: + peers = getattr(ai, "peers", {}) or {} + lines.append(f" peers={len(peers)} {list(peers.keys())[:5]}") + except Exception: + pass + try: + # AutoInterface has a per-ifname socket dict; introspect bindings. + sockets = [] + for attr in ("multicast_sockets", "sockets", "interface_servers"): + val = getattr(ai, attr, None) + if val: + sockets.append(f"{attr}={val!r}") + if sockets: + lines.append(" " + "; ".join(sockets)) + except Exception: + pass + # Also list the local IP interfaces visible to Python — confirms what + # iOS is exposing. If en0 isn't here, AutoInterface has nothing to bind. + import platform as _platform + lines.append(f" platform.system()={_platform.system()!r} platform.platform()={_platform.platform()!r}") + lines.append(f" AF_INET={_socket.AF_INET} AF_INET6={_socket.AF_INET6}") + # Try RNS' own netinfo first (uses libc getifaddrs via ctypes). + try: + from RNS.Interfaces.util import netinfo as _netinfo + ifs_seen = _netinfo.interfaces() + lines.append(f" netinfo.interfaces() (n={len(ifs_seen)}): {ifs_seen}") + # Specifically probe en0 — that's the WiFi interface on iOS. + for ifname in ("en0", "en1", "awdl0"): + if ifname in ifs_seen: + try: + addrs = _netinfo.ifaddresses(ifname) + lines.append(f" {ifname}: {addrs}") + except Exception as e: + lines.append(f" {ifname}: ifaddresses err={e}") + # Also poke directly at the libc layer to surface raw sa_familiy + # values for en0 — distinguishes "struct layout wrong" from + # "iOS sandbox returns nothing". + try: + import ctypes as _ct + libc = _ct.CDLL(_ct.util.find_library("c"), use_errno=True) + class ifaddrs(_ct.Structure): pass + ifaddrs._fields_ = [ + ("ifa_next", _ct.POINTER(ifaddrs)), + ("ifa_name", _ct.c_char_p), + ("ifa_flags", _ct.c_uint), + ("ifa_addr", _ct.POINTER(_ct.c_uint8 * 16)), + ] + ptr = _ct.POINTER(ifaddrs)() + if libc.getifaddrs(_ct.byref(ptr)) == 0: + en0_seen = [] + walker = ptr + while walker: + name = walker[0].ifa_name.decode("utf-8", errors="replace") if walker[0].ifa_name else "" + if name in ("en0", "en1"): + raw = walker[0].ifa_addr + if raw: + buf = list(bytes(raw[0])) + en0_seen.append(f"{name} sa_len={buf[0]} sa_family={buf[1]} bytes={buf[:8]}") + else: + en0_seen.append(f"{name} addr=NULL") + walker = walker[0].ifa_next + libc.freeifaddrs(ptr) + lines.append(f" raw getifaddrs en0/en1: {en0_seen}") + except Exception as e: + lines.append(f" raw getifaddrs probe failed: {e}") + except Exception as e: + lines.append(f" netinfo unavailable: {e}") + try: + host_info = _socket.gethostbyname_ex(_socket.gethostname()) + lines.append(f" hostname={host_info[0]} addrs={host_info[2]}") + except Exception as e: + lines.append(f" host lookup failed: {e}") + return "\n".join(lines) + + +def diagnose_ios_ble_interface() -> str: + """Smoke-test the same exec() path RNS uses for external interfaces. + Reads `/interfaces/IOSBLEInterface.py`, exec()s it in a + fresh namespace mirroring RNS.Reticulum:1011-1017, returns the + formatted exception (or "ok") so Swift can write it to DiagLog. + + Used to surface IOSBLEInterface import errors that RNS swallows + when `panic_on_interface_error = no` is set.""" + import os + import traceback as _tb + with _lock: + config_dir = _state.get("config_dir") or "" + if not config_dir: + return "no config_dir set" + path = os.path.join(config_dir, "interfaces", "IOSBLEInterface.py") + if not os.path.isfile(path): + return f"file missing: {path}" + interface_globals = {} + try: + import RNS as _RNS + interface_globals["Interface"] = _RNS.Interfaces.Interface.Interface + interface_globals["RNS"] = _RNS + # Important: set __file__ so the file's `_this_file` discovery works. + interface_globals["__file__"] = path + with open(path) as f: + code = f.read() + exec(code, interface_globals) + cls = interface_globals.get("interface_class") + if cls is None: + return "exec ok but interface_class is None" + except BaseException as e: + return "EXC at exec: " + _tb.format_exc() + # Try to instantiate exactly as RNS does: pass Transport + a minimal + # interface_config dict mirroring what Reticulum would build from + # the [[ble0]] section. + try: + config_section = { + "name": "ble-diagnose", + "type": "IOSBLEInterface", + "interface_enabled": True, + "enabled": True, + "mode": "full", + "ble_power_preset": "balanced", + } + instance = cls(_RNS.Transport, config_section) + return f"instantiate ok class={cls.__name__} repr={instance!r}" + except BaseException as e: + return "EXC at instantiate: " + _tb.format_exc() diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh index 8ef3c0ad..91770074 100755 --- a/ci_scripts/ci_post_clone.sh +++ b/ci_scripts/ci_post_clone.sh @@ -44,4 +44,17 @@ sed -i.bak -E \ "$PBXPROJ" rm -f "${PBXPROJ}.bak" +# Fetch Python wheels. The wheel dirs are gitignored (not committed), but the +# "Install Python stdlib & process dylibs" build phase hard-requires them, so a +# fresh CI clone must build them here. fetch-wheels.sh resolves RNS from the +# fork branch (torlando-tech/Reticulum @ patches/columba-ios) plus the +# cryptography/cffi binary wheels for iOS. Skipped if already present (local +# incremental runs). +if [ ! -d "$REPO_ROOT/wheels-iphoneos" ] || [ ! -d "$REPO_ROOT/wheels-iphonesimulator" ]; then + echo "Fetching Python wheels (RNS from fork branch + binary deps)..." + "$REPO_ROOT/support/fetch-wheels.sh" +else + echo "Python wheels already present, skipping fetch." +fi + echo "done." diff --git a/support/README.md b/support/README.md new file mode 100644 index 00000000..09e35324 --- /dev/null +++ b/support/README.md @@ -0,0 +1,53 @@ +# support/ — Python embedding setup scripts + +These scripts populate the gitignored `Frameworks/Python.xcframework/` and +`wheels-iphoneos/` + `wheels-iphonesimulator/` directories on every fresh clone. + +## One-time setup on a new machine + +```bash +support/fetch-python.sh # ~110 MB — BeeWare's Python-Apple-support 3.13-b13 +support/fetch-wheels.sh # ~33 MB total — iOS wheels for rns, lxmf, cryptography, cffi, pyserial +``` + +After both succeed: +- `Frameworks/Python.xcframework/` has the iOS CPython binary + stdlib +- `wheels-iphonesimulator/` has the simulator-arch wheels +- `wheels-iphoneos/` has the device-arch wheels + +The Xcode `install_python` build phase (in the ColumbaApp target) consumes +all three at build time, copying the stdlib into `/python/lib/` and +processing each `.so` extension module into a per-module `.framework` so iOS +codesigning is happy. + +## Pinned versions + +| Component | Source | Version | +|-------------------------|---------------------------------------------------------------------|----------------------| +| Python-Apple-support | github.com/beeware/Python-Apple-support | 3.13-b13 | +| CPython | bundled | 3.13.11 | +| OpenSSL | bundled | 3.0.18-1 | +| rns | PyPI | latest at fetch time | +| lxmf | PyPI | latest at fetch time | +| cryptography (iOS) | BeeWare anaconda channel (pypi.anaconda.org/beeware/simple) | 47.0.0 (pinned) | +| cffi (iOS) | BeeWare anaconda channel | 2.0.0 (pinned) | +| pyserial | PyPI (pure Python) | latest at fetch time | + +`msgpack` is **intentionally not installed** — RNS uses its vendored pure-Python +`RNS.vendor.umsgpack`. Installing the binary `msgpack` from PyPI pulls a macOS +wheel that won't load on iOS. + +## Bumping the Python build + +Edit `PY_VERSION` / `PY_BUILD` in `support/fetch-python.sh`, then re-run it. +The script removes the existing `Python.xcframework/` before extracting. + +If the Python *minor* version bumps (3.13 → 3.14), the wheel platform tags +in `support/fetch-wheels.sh` need to bump too (`cp313` → `cp314`). + +## Why these are scripts, not vendored + +The xcframework is ~110 MB and the wheels are ~33 MB combined. Vendoring 140 MB +of binaries in git would make every clone slow, blow up history, and make +license review confusing. Fetch scripts with pinned versions give the same +reproducibility without the cost. diff --git a/support/add-swift-backend-config.rb b/support/add-swift-backend-config.rb new file mode 100644 index 00000000..4307b385 --- /dev/null +++ b/support/add-swift-backend-config.rb @@ -0,0 +1,79 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# 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- +# agnostic (BackendFactory's `#if COLUMBA_BACKEND_SWIFT`). +# +# Additive + idempotent — only adds the new configs/scheme, never strips packages +# or other settings (unlike the stale configure-xcodeproj.rb). Safe to re-run. +# +# Usage: ruby support/add-swift-backend-config.rb + +require 'xcodeproj' + +PROJECT_PATH = File.expand_path('../Columba.xcodeproj', __dir__) +APP_TARGET = 'ColumbaApp' +BACKEND_CONDITION = 'COLUMBA_BACKEND_SWIFT' + +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) + list = owner.build_configuration_list + return if list.build_configurations.any? { |c| c.name == swift_name } + + 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 + end + + list.build_configurations << cfg + puts " + #{swift_name} on #{owner.respond_to?(:name) ? owner.name : 'project'}#{inject_condition ? " (#{BACKEND_CONDITION})" : ''}" +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. + project.targets.each do |target| + clone_config(target, base_name, swift_name, project, inject_condition: target.name == APP_TARGET) + end +end + +project.save +puts "Saved #{File.basename(PROJECT_PATH)}" + +# Shared `Columba-Swift` scheme: clone the existing Columba scheme, retarget its +# actions at the -Swift configs. +schemes_dir = File.join(PROJECT_PATH, 'xcshareddata', 'xcschemes') +base_scheme_path = File.join(schemes_dir, 'Columba.xcscheme') +if File.exist?(base_scheme_path) + scheme = Xcodeproj::XCScheme.new(base_scheme_path) + scheme.launch_action.build_configuration = 'Debug-Swift' + scheme.test_action.build_configuration = 'Debug-Swift' + scheme.analyze_action.build_configuration = 'Debug-Swift' + scheme.profile_action.build_configuration = 'Release-Swift' + scheme.archive_action.build_configuration = 'Release-Swift' + scheme.save_as(PROJECT_PATH, 'Columba-Swift', true) + puts 'Wrote Columba-Swift.xcscheme (shared)' +else + warn "WARN: #{base_scheme_path} not found — skipped scheme creation" +end diff --git a/support/configure-xcodeproj.rb b/support/configure-xcodeproj.rb new file mode 100755 index 00000000..945a43bf --- /dev/null +++ b/support/configure-xcodeproj.rb @@ -0,0 +1,468 @@ +#!/usr/bin/env ruby +# Configure Columba.xcodeproj for the embedded-Python build. +# +# Idempotent: safe to re-run after the initial setup. Edits the ColumbaApp +# target to: +# 1. Remove file references to voice files deleted in Phase 0 (CallManager, +# AudioManager, CodecProfileInfo, Views/Call/*, voice tests). +# 2. Remove SwiftPM remote refs for reticulum-swift / LXMF-swift / LXST-swift, +# and the product refs that pulled them in. +# 3. Add the new Sources/ColumbaApp/Python/ files +# (PythonRuntime.swift, PythonBridge.swift, Models/*.swift) to the +# Sources build phase. The bridging header lands as a file ref only +# (not in any build phase). +# 4. Link + embed Frameworks/Python.xcframework with codesigning on copy. +# 5. Add a Run Script build phase that invokes +# Frameworks/Python.xcframework/build/utils.sh::install_python to +# install the stdlib and convert each .so into a per-module .framework. +# 6. Add folder references for `app/` and `app_packages/` as resource +# copies (the build phase script expects them at /app and +# /app_packages respectively). +# 7. Add the bridging header + EXCLUDED_ARCHS[sdk=iphonesimulator*]=x86_64 + +# ONLY_ACTIVE_ARCH=YES build settings (the latter two work around the +# install_python script's fat-simulator-build bug). + +require 'xcodeproj' + +PROJECT_PATH = File.expand_path('../Columba.xcodeproj', __dir__) +project = Xcodeproj::Project.open(PROJECT_PATH) + +app_target = project.targets.find { |t| t.name == 'ColumbaApp' } +abort "Missing ColumbaApp target" unless app_target + +test_target = project.targets.find { |t| t.name == 'ColumbaAppTests' } + +# ────────────────────────────────────────────────────────────────────────── +# (1) Remove file refs to voice files deleted in Phase 0. +# ────────────────────────────────────────────────────────────────────────── + +# Phase 0 deleted the voice stack while ripping out the AI Swift libs; +# commit 3 of the lxst-wiring batch restored these files from git history +# (rewired onto RNSAPI + LXSTSwift). Tests-side voice files stay out — the +# test fixtures referenced AI types that aren't part of the new world. +DELETED_PATHS = %w[ + Tests/ColumbaAppTests/CallManagerCallKitTests.swift + Tests/ColumbaAppTests/AudioManagerConfigChangeTests.swift + Tests/ColumbaAppTests/AudioRingBufferTests.swift +].freeze + +deleted_basenames = DELETED_PATHS.map { |p| File.basename(p) } + +# General garbage-collect: nuke any file ref whose target no longer exists on +# disk (covers files moved/renamed/deleted outside Phase 0). Skip the +# xcframework + the `app` folder ref + anything outside the repo root. +project_root = File.expand_path('..', __dir__) + +removed = [] +project.files.dup.each do |f| + basename = File.basename(f.path.to_s) + abs = f.real_path.to_s rescue '' + is_dead = + deleted_basenames.include?(basename) || + ( + !abs.empty? && + abs.start_with?(project_root) && + f.path != 'Frameworks/Python.xcframework' && + f.path != 'app' && + !File.exist?(abs) + ) + next unless is_dead + # Detach from all build phases first. + [app_target, test_target].compact.each do |t| + t.build_phases.each do |phase| + next unless phase.respond_to?(:files) + phase.files.dup.each { |bf| bf.remove_from_project if bf.file_ref == f } + end + end + removed << f.path + f.remove_from_project +end +puts " Removed #{removed.size} dead file refs" unless removed.empty? + +# ────────────────────────────────────────────────────────────────────────── +# (2) Drop SwiftPM remote refs for reticulum-swift / LXMF-swift / LXST-swift. +# ────────────────────────────────────────────────────────────────────────── + +drop_pkg_urls = [ + 'https://github.com/torlando-tech/reticulum-swift.git', + 'https://github.com/torlando-tech/LXMF-swift.git', + 'https://github.com/torlando-tech/LXST-swift.git' +] + +# NOTE: LXSTSwift is no longer in this drop list — it's now a LOCAL +# SwiftPM target inside this repo (declared in Package.swift, +# dependencies path-based), and gets re-added below via the local- +# package reference path. +drop_product_names = %w[ReticulumSwift LXMFSwift] + +# Remove products from target (frameworks build phase + package_product_dependencies). +app_target.package_product_dependencies.dup.each do |dep| + next unless drop_product_names.include?(dep.product_name) + # Strip from PBXFrameworksBuildPhase + app_target.frameworks_build_phase.files.dup.each do |bf| + bf.remove_from_project if bf.product_ref == dep + end + app_target.package_product_dependencies.delete(dep) + dep.remove_from_project + puts " Removed package product dep: #{dep.product_name}" +end + +# Remove remote SPM package references from the project. +project.root_object.package_references.dup.each do |pkg| + next unless pkg.is_a?(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference) + if drop_pkg_urls.include?(pkg.repositoryURL) + project.root_object.package_references.delete(pkg) + pkg.remove_from_project + puts " Removed SPM package: #{pkg.repositoryURL}" + end +end + +# Add a local Swift Package reference to the repo root, exposing the +# LXSTSwift target (and its C dependencies COpus + CCodec2). Xcode +# treats the root Package.swift as a sibling package and compiles its +# products into the app target. This dodges the per-file pbxproj +# surgery that would otherwise be needed for ~380 C source files +# under Sources/{COpus, CCodec2}/. +local_pkg_class = Xcodeproj::Project::Object.const_get('XCLocalSwiftPackageReference') rescue nil +if local_pkg_class + # Some xcodeproj-gem versions don't ship XCLocalSwiftPackageReference; + # detect and warn so we can fall back to manual surgery if needed. + existing_local = project.root_object.package_references.find do |p| + p.is_a?(local_pkg_class) && (p.respond_to?(:relative_path) ? p.relative_path : nil) == '.' + end + unless existing_local + local_pkg = project.new(local_pkg_class) + local_pkg.relative_path = '.' + project.root_object.package_references << local_pkg + puts " Added local SPM package reference: ." + end + unless app_target.package_product_dependencies.any? { |d| d.product_name == 'LXSTSwift' } + product = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) + product.product_name = 'LXSTSwift' + app_target.package_product_dependencies << product + bf = project.new(Xcodeproj::Project::Object::PBXBuildFile) + bf.product_ref = product + app_target.frameworks_build_phase.files << bf + puts " Linked product: LXSTSwift (local)" + end + # RNSAPI: same story as LXSTSwift — compiled exactly once by SwiftPM and + # exposed to ColumbaApp as a product dependency. Without this, the + # `Sources/RNSAPI/**/*.swift` files that USED to be inlined into the + # ColumbaApp build phase would have to stay there, and every shared + # type would exist in both modules. See the NEW_SWIFT note. + unless app_target.package_product_dependencies.any? { |d| d.product_name == 'RNSAPI' } + product = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) + product.product_name = 'RNSAPI' + app_target.package_product_dependencies << product + bf = project.new(Xcodeproj::Project::Object::PBXBuildFile) + bf.product_ref = product + app_target.frameworks_build_phase.files << bf + puts " Linked product: RNSAPI (local)" + end + # SwiftBLEBridge: CoreBluetooth wrapper for the iOS BLE mesh interface. + # Pure-Swift SwiftPM target so `swift build` can typecheck it without + # the Python bridging header. PythonBLECallbackBridge.swift (auto-pulled + # into ColumbaApp via the Sources/PythonBridge/**/*.swift glob below) + # bridges between SwiftBLEBridge's `BleCallbackInvoker` protocol and + # the Python-side callback registry in rns_bridge.py. + unless app_target.package_product_dependencies.any? { |d| d.product_name == 'SwiftBLEBridge' } + product = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) + product.product_name = 'SwiftBLEBridge' + app_target.package_product_dependencies << product + bf = project.new(Xcodeproj::Project::Object::PBXBuildFile) + bf.product_ref = product + app_target.frameworks_build_phase.files << bf + puts " Linked product: SwiftBLEBridge (local)" + end +else + puts " WARNING: xcodeproj gem doesn't support XCLocalSwiftPackageReference; skip local-pkg wiring" +end + +# Strip any leftover Sources/RNSAPI/*.swift file refs that an earlier run +# of this script had added to ColumbaApp's compile phase. They have to come +# out now that RNSAPI is a SwiftPM product dependency — otherwise every +# shared type exists in two modules and call sites fail with "cannot +# convert value of type 'ColumbaApp.Identity' to 'RNSAPI.Identity'". +stale_rnsapi_count = 0 +app_target.source_build_phase.files.dup.each do |bf| + path = bf.file_ref&.real_path&.to_s + next unless path && path.include?('/Sources/RNSAPI/') + bf.remove_from_project + stale_rnsapi_count += 1 +end +project.files.dup.each do |f| + abs = f.real_path.to_s rescue '' + next unless abs.include?('/Sources/RNSAPI/') + f.remove_from_project +end +puts " Stripped #{stale_rnsapi_count} RNSAPI inline source ref(s) from ColumbaApp" if stale_rnsapi_count > 0 + +# ────────────────────────────────────────────────────────────────────────── +# (3) Add new Sources/ColumbaApp/Python/ files to Sources build phase. +# ────────────────────────────────────────────────────────────────────────── + +# Auto-discover all .swift sources under the new Sources/{PythonBridge,RNSAPI,RNSBackendPy}/ +# trees. These are SwiftPM library targets per Package.swift, but the Xcode +# build of ColumbaApp.app pulls them in as plain sources of the ColumbaApp +# target (because Xcode's app target isn't SwiftPM-based and adding them as +# SPM products would require a much larger pbxproj surgery). The Package.swift +# split is preserved for tooling (`swift build`, lint, code search) and for +# when we eventually move ColumbaApp onto SwiftPM. +project_root = File.expand_path('..', __dir__) +# NOTE: Sources/RNSAPI/ is NOT in this list. RNSAPI is compiled exactly once +# by SwiftPM via the XCLocalSwiftPackageReference below; ColumbaApp imports +# it as a SwiftPM product dependency. If we also added the .swift files to +# the ColumbaApp build phase, every type in RNSAPI would exist in two +# modules ('RNSAPI' from the framework + 'ColumbaApp' from inline compile) +# and call sites like `Telephone(identity: someIdentity)` would fail with +# "cannot convert value of type 'ColumbaApp.Identity' to expected argument +# type 'RNSAPI.Identity'". +NEW_SWIFT = ( + Dir.glob("#{project_root}/Sources/PythonBridge/**/*.swift") + + Dir.glob("#{project_root}/Sources/RNSBackendPy/**/*.swift") + + Dir.glob("#{project_root}/Sources/ColumbaApp/Views/Call/*.swift") + + %w[ + Sources/ColumbaApp/Python/Models/PyAnnounce.swift + Sources/ColumbaApp/Python/Models/PyMessage.swift + Sources/ColumbaApp/Python/Models/PyConversation.swift + Sources/ColumbaApp/Python/Models/PyLocalIdentity.swift + Sources/ColumbaApp/Services/PythonConfigWriter.swift + Sources/ColumbaApp/Services/CallManager.swift + Sources/ColumbaApp/Services/CallKitManager.swift + Sources/ColumbaApp/Services/AudioManager.swift + Sources/ColumbaApp/Models/CodecProfileInfo.swift + ].map { |p| File.expand_path(p, project_root) } +).uniq.map { |p| p.sub("#{project_root}/", '') }.sort.freeze + +# Place under a "Python" group inside the existing ColumbaApp group. The +# hand-written pbxproj attaches the ColumbaApp group (SRCS) directly to +# main_group with path "Sources/ColumbaApp" — so find_subpath('Sources/ColumbaApp') +# fails to match and would create a brand-new tree. Find it by path instead. +columba_group = project.main_group.children.find do |g| + g.is_a?(Xcodeproj::Project::Object::PBXGroup) && g.path == 'Sources/ColumbaApp' +end +abort "Could not locate Sources/ColumbaApp group in main_group" unless columba_group + +# Find or create top-level groups for the new SwiftPM-style targets so the +# Xcode navigator shows them outside the ColumbaApp/ tree. +def find_or_create_top_group(project, name) + existing = project.main_group.children.find do |g| + g.is_a?(Xcodeproj::Project::Object::PBXGroup) && + (g.name == name || g.path == "Sources/#{name}") + end + return existing if existing + group = project.main_group.new_group(name, "Sources/#{name}") + group.set_source_tree('') + group +end + +python_bridge_group = find_or_create_top_group(project, 'PythonBridge') +rns_api_group = find_or_create_top_group(project, 'RNSAPI') +rns_backend_py_group = find_or_create_top_group(project, 'RNSBackendPy') + +# Old ColumbaApp/Python/Models — kept for now; PyAnnounce etc. will collapse +# into RNSAPI/Models in a later commit. +columba_python_group = columba_group.children.find { |g| g.respond_to?(:name) && g.name == 'Python' } || + columba_group.new_group('Python').tap { |g| g.set_source_tree('') } +columba_python_models_group = columba_python_group.children.find { |g| g.respond_to?(:name) && g.name == 'Models' } || + columba_python_group.new_group('Models').tap { |g| g.set_source_tree('') } + +existing_paths = app_target.source_build_phase.files.map { |bf| bf.file_ref&.real_path&.to_s } + +NEW_SWIFT.each do |rel| + full = File.expand_path(rel, File.dirname(PROJECT_PATH)) + next if existing_paths.include?(full) + group = + if rel.start_with?('Sources/PythonBridge/') + python_bridge_group + elsif rel.start_with?('Sources/RNSAPI/') + # nest under Protocols/Models/Util subgroups for navigability + sub_name = rel.split('/')[2] # "Protocols" | "Models" | "Util" + next_group = rns_api_group.children.find { |g| g.respond_to?(:name) && g.name == sub_name } || + rns_api_group.new_group(sub_name).tap { |g| g.set_source_tree('') } + next_group + elsif rel.start_with?('Sources/RNSBackendPy/') + rns_backend_py_group + elsif rel.include?('/ColumbaApp/Python/Models/') + columba_python_models_group + elsif rel.include?('/ColumbaApp/Python/') + columba_python_group + elsif rel.start_with?('Sources/ColumbaApp/Services/') + services_group = columba_group.children.find { |g| g.respond_to?(:name) && (g.name == 'Services' || g.path == 'Services') } || + columba_group.new_group('Services').tap { |g| g.set_source_tree('') } + services_group + elsif rel.start_with?('Sources/ColumbaApp/Views/Call/') + views_group = columba_group.children.find { |g| g.respond_to?(:name) && (g.name == 'Views' || g.path == 'Views') } || + columba_group.new_group('Views').tap { |g| g.set_source_tree('') } + call_group = views_group.children.find { |g| g.respond_to?(:name) && (g.name == 'Call' || g.path == 'Call') } || + views_group.new_group('Call').tap { |g| g.set_source_tree('') } + call_group + elsif rel.start_with?('Sources/ColumbaApp/Models/') + models_group = columba_group.children.find { |g| g.respond_to?(:name) && (g.name == 'Models' || g.path == 'Models') } || + columba_group.new_group('Models').tap { |g| g.set_source_tree('') } + models_group + else + columba_group + end + ref = group.new_file(full) + app_target.source_build_phase.add_file_reference(ref) + puts " Added source: #{rel}" +end + +# Bridging header — file ref only, no build phase membership. Moved from +# ColumbaApp/Python/ to PythonBridge/ — register at the new location. +bridging_path = 'Sources/PythonBridge/ColumbaPython-Bridging-Header.h' +bridging_full = File.expand_path(bridging_path, File.dirname(PROJECT_PATH)) +unless python_bridge_group.files.any? { |f| f.real_path.to_s == bridging_full } + python_bridge_group.new_file(bridging_full) + puts " Added bridging header: #{bridging_path}" +end + +# Update SWIFT_OBJC_BRIDGING_HEADER path to the new location. +app_target.build_configurations.each do |config| + if config.build_settings['SWIFT_OBJC_BRIDGING_HEADER']&.include?('ColumbaApp/Python/') + config.build_settings['SWIFT_OBJC_BRIDGING_HEADER'] = bridging_path + end +end + +# Bundled JetBrains Mono TTFs — needed for stable cell metrics on the Micron +# renderer (SF Mono renders block-element glyphs ▗▄▖▝▀▘ at slightly different +# widths than ASCII, which breaks ASCII-art alignment on NomadNet pages). +# See commit cf00c97 — the same fix Columba Android ships under +# MicronComposables.kt::JetBrainsMonoFamily. +resources_group = columba_group.children.find do |g| + g.is_a?(Xcodeproj::Project::Object::PBXGroup) && (g.name == 'Resources' || g.path == 'Resources') +end || columba_group.new_group('Resources').tap { |g| g.set_source_tree('') } + +['JetBrainsMono-Regular.ttf', 'JetBrainsMono-Bold.ttf'].each do |ttf| + rel = "Sources/ColumbaApp/Resources/#{ttf}" + full = File.expand_path(rel, File.dirname(PROJECT_PATH)) + next unless File.exist?(full) + ref = resources_group.files.find { |f| f.real_path.to_s == full } || + resources_group.new_file(full) + unless app_target.resources_build_phase.files.any? { |bf| bf.file_ref == ref } + app_target.resources_build_phase.add_file_reference(ref) + puts " Added resource: #{ttf}" + end +end + +# ────────────────────────────────────────────────────────────────────────── +# (4) Link + embed Frameworks/Python.xcframework. +# ────────────────────────────────────────────────────────────────────────── + +xcfw_path = 'Frameworks/Python.xcframework' +# Find or create the project-level Frameworks group (the existing pbxproj +# doesn't have one; create it as a child of main_group). +frameworks_group = project.main_group.children.find do |g| + g.is_a?(Xcodeproj::Project::Object::PBXGroup) && + (g.path == 'Frameworks' || g.name == 'Frameworks') +end +unless frameworks_group + frameworks_group = project.main_group.new_group('Frameworks') + frameworks_group.set_source_tree('') +end + +xcfw_ref = frameworks_group.files.find { |f| f.path == xcfw_path } || + frameworks_group.new_file(File.expand_path(xcfw_path, File.dirname(PROJECT_PATH))) + +# Link. +unless app_target.frameworks_build_phase.files.any? { |bf| bf.file_ref == xcfw_ref } + app_target.frameworks_build_phase.add_file_reference(xcfw_ref) + puts " Linked Python.xcframework" +end + +# Embed (copy + codesign). +embed_phase = app_target.copy_files_build_phases.find { |p| p.symbol_dst_subfolder_spec == :frameworks } || + app_target.new_copy_files_build_phase('Embed Frameworks').tap do |p| + p.symbol_dst_subfolder_spec = :frameworks + end +unless embed_phase.files.any? { |bf| bf.file_ref == xcfw_ref } + bf = embed_phase.add_file_reference(xcfw_ref) + bf.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] } + puts " Embedded Python.xcframework with codesign-on-copy" +end + +# ────────────────────────────────────────────────────────────────────────── +# (5) Run Script build phase: install_python. +# ────────────────────────────────────────────────────────────────────────── + +INSTALL_SCRIPT_NAME = 'Install Python stdlib & process dylibs' +INSTALL_SCRIPT_BODY = <<~SH.strip + set -e + + # Copy platform-appropriate wheels into /app_packages/ before + # install_python processes the .so extensions inside them. + case "$EFFECTIVE_PLATFORM_NAME" in + -iphoneos) WHEELS_SRC="$PROJECT_DIR/wheels-iphoneos" ;; + -iphonesimulator) WHEELS_SRC="$PROJECT_DIR/wheels-iphonesimulator" ;; + *) echo "error: unsupported platform $EFFECTIVE_PLATFORM_NAME" >&2; exit 1 ;; + esac + [ -d "$WHEELS_SRC" ] || { + echo "error: $WHEELS_SRC missing — run support/fetch-wheels.sh" >&2 + exit 1 + } + mkdir -p "$CODESIGNING_FOLDER_PATH/app_packages" + rsync -au --delete "$WHEELS_SRC/" "$CODESIGNING_FOLDER_PATH/app_packages/" + + source "$PROJECT_DIR/Frameworks/Python.xcframework/build/utils.sh" + install_python Frameworks/Python.xcframework app_packages +SH + +install_phase = app_target.shell_script_build_phases.find { |p| p.name == INSTALL_SCRIPT_NAME } || + app_target.new_shell_script_build_phase(INSTALL_SCRIPT_NAME) +install_phase.shell_script = INSTALL_SCRIPT_BODY +install_phase.shell_path = '/bin/sh' +install_phase.input_paths = ['$(PROJECT_DIR)/Frameworks/Python.xcframework/build/utils.sh'] +install_phase.output_paths = [] +install_phase.show_env_vars_in_log = '0' +install_phase.always_out_of_date = '1' # rerun every build (wheels can change) +puts " Configured Run Script: #{INSTALL_SCRIPT_NAME}" + +# ────────────────────────────────────────────────────────────────────────── +# (6) Folder references for app/ (Python source) and app_packages/. +# ────────────────────────────────────────────────────────────────────────── +# +# `app_packages/` is populated by the install_python script at build time, +# so it doesn't need a folder reference. `app/` ships rns_bridge.py plus +# any future Python modules and DOES need to be copied to /app/. + +app_folder_path = File.expand_path('../app', __dir__) +app_folder_ref = project.main_group.files.find { |f| f.path == 'app' && f.last_known_file_type == 'folder' } +unless app_folder_ref + app_folder_ref = project.main_group.new_reference(app_folder_path) + app_folder_ref.last_known_file_type = 'folder' + app_folder_ref.set_source_tree('') + puts " Added app/ folder reference" +end + +unless app_target.resources_build_phase.files.any? { |bf| bf.file_ref == app_folder_ref } + app_target.resources_build_phase.add_file_reference(app_folder_ref) + puts " Added app/ to Copy Bundle Resources" +end + +# ────────────────────────────────────────────────────────────────────────── +# (7) Build settings: bridging header, EXCLUDED_ARCHS, ONLY_ACTIVE_ARCH. +# ────────────────────────────────────────────────────────────────────────── + +bridging_relative = 'Sources/PythonBridge/ColumbaPython-Bridging-Header.h' +app_target.build_configurations.each do |config| + config.build_settings['SWIFT_OBJC_BRIDGING_HEADER'] = bridging_relative + config.build_settings['CLANG_ENABLE_MODULES'] = 'YES' + # install_python's lib-$ARCHS path fails when ARCHS is multi-arch ("arm64 x86_64"). + # Force single-arch on simulator to dodge the bug. + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64' + config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES' if config.name == 'Debug' + # Don't strip Python's stdlib symlinks. + config.build_settings['COPY_PHASE_STRIP'] = 'NO' + # Make sure dyld can find the embedded framework at runtime. + rpaths = config.build_settings['LD_RUNPATH_SEARCH_PATHS'] || [] + rpaths = [rpaths] unless rpaths.is_a?(Array) + ['$(inherited)', '@executable_path/Frameworks'].each do |p| + rpaths << p unless rpaths.include?(p) + end + config.build_settings['LD_RUNPATH_SEARCH_PATHS'] = rpaths +end +puts " Set bridging header + EXCLUDED_ARCHS + LD_RUNPATH_SEARCH_PATHS" + +project.save +puts "\nColumba.xcodeproj configured." diff --git a/support/enable-nomadnet-release.rb b/support/enable-nomadnet-release.rb new file mode 100644 index 00000000..7c9f150a --- /dev/null +++ b/support/enable-nomadnet-release.rb @@ -0,0 +1,44 @@ +#!/usr/bin/env ruby +# Enable COLUMBA_NOMADNET_ENABLED in the Release / Release-Swift project +# configs, mirroring Debug / Debug-Swift. Idempotent. +# +# NomadNet browsing was originally gated to Debug only (project-level +# SWIFT_ACTIVE_COMPILATION_CONDITIONS carried the flag in Debug/Debug-Swift but +# not in the Release configs, which had no conditions line at all). Both +# backends now implement fetchNomadNetPage, so there is no reason to keep it +# out of Release. + +require 'xcodeproj' + +PROJECT = File.expand_path('../Columba.xcodeproj', __dir__) +FLAG = 'COLUMBA_NOMADNET_ENABLED' + +project = Xcodeproj::Project.open(PROJECT) + +%w[Release Release-Swift].each do |cfg_name| + cfg = project.build_configurations.find { |c| c.name == cfg_name } + raise "project config #{cfg_name.inspect} not found" unless cfg + + conds = cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] + tokens = + case conds + when nil then ['$(inherited)'] + when Array then conds.dup + else conds.split(/\s+/) + end + + if tokens.include?(FLAG) + puts "#{cfg_name}: already has #{FLAG} — no change" + next + end + + # Flag first, then $(inherited) — matches the Debug blocks' ordering. + rest = tokens.reject { |t| t == FLAG } + rest << '$(inherited)' unless rest.include?('$(inherited)') + cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = + ([FLAG] + rest).join(' ') + puts "#{cfg_name}: set SWIFT_ACTIVE_COMPILATION_CONDITIONS = #{cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'].inspect}" +end + +project.save +puts 'saved.' diff --git a/support/enable-rnode.rb b/support/enable-rnode.rb new file mode 100644 index 00000000..2a1d0259 --- /dev/null +++ b/support/enable-rnode.rb @@ -0,0 +1,48 @@ +#!/usr/bin/env ruby +# Enable COLUMBA_RNODE_ENABLED across project build configs. Idempotent. +# +# The RNode (LoRa) interface wizard was gated behind COLUMBA_RNODE_ENABLED, +# which was set in NO build config and had in fact never been compiled — so the +# interface-type picker offered "RNode" while its wizard was compiled out: +# selecting it dismissed the sheet and presented nothing (a silent dead-end). +# +# Default targets the Debug configs only (device-unverified hardware feature — +# kept out of Release until verified on real RNode hardware). Pass +# `--configs=Debug,Release,Debug-Swift,Release-Swift` to widen. + +require 'xcodeproj' + +PROJECT = File.expand_path('../Columba.xcodeproj', __dir__) +FLAG = 'COLUMBA_RNODE_ENABLED' + +arg = ARGV.find { |a| a.start_with?('--configs=') } +names = arg ? arg.split('=', 2).last.split(',') : %w[Debug Debug-Swift] + +project = Xcodeproj::Project.open(PROJECT) + +names.each do |cfg_name| + cfg = project.build_configurations.find { |c| c.name == cfg_name } + raise "project config #{cfg_name.inspect} not found" unless cfg + + conds = cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] + tokens = + case conds + when nil then ['$(inherited)'] + when Array then conds.dup + else conds.split(/\s+/) + end + + if tokens.include?(FLAG) + puts "#{cfg_name}: already has #{FLAG} — no change" + next + end + + inherited = tokens.delete('$(inherited)') + tokens << FLAG + tokens << '$(inherited)' if inherited + cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = tokens.join(' ') + puts "#{cfg_name}: set = #{cfg.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'].inspect}" +end + +project.save +puts 'saved.' diff --git a/support/fetch-python.sh b/support/fetch-python.sh new file mode 100755 index 00000000..1e99b34f --- /dev/null +++ b/support/fetch-python.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Fetch BeeWare's Python-Apple-support iOS distribution into Frameworks/. +# Produces Frameworks/Python.xcframework + Frameworks/VERSIONS. +# +# Idempotent: re-running is cheap; only re-downloads when the pinned version +# differs from what's already on disk. +# +# Pinned to a specific release for reproducibility. Bump intentionally. + +set -euo pipefail + +# ----- Pinned versions ----- +PY_VERSION="3.13" +PY_BUILD="b13" # bump when a newer iOS support release is needed +RELEASE_TAG="${PY_VERSION}-${PY_BUILD}" +TARBALL="Python-${PY_VERSION}-iOS-support.${PY_BUILD}.tar.gz" +URL="https://github.com/beeware/Python-Apple-support/releases/download/${RELEASE_TAG}/${TARBALL}" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FW_DIR="$ROOT/Frameworks" +mkdir -p "$FW_DIR" + +# Bail early if the right version is already installed. +if [ -f "$FW_DIR/VERSIONS" ] && grep -q "Build: ${PY_BUILD}" "$FW_DIR/VERSIONS"; then + echo "Python.xcframework already at $RELEASE_TAG; nothing to fetch." + exit 0 +fi + +echo "==> Fetching $TARBALL" +curl -fL --progress-bar -o "$FW_DIR/$TARBALL" "$URL" + +echo "==> Removing stale Python.xcframework (if any)" +rm -rf "$FW_DIR/Python.xcframework" "$FW_DIR/testbed" "$FW_DIR/VERSIONS" + +echo "==> Extracting" +tar -xzf "$FW_DIR/$TARBALL" -C "$FW_DIR" +rm "$FW_DIR/$TARBALL" +# The testbed/ directory is a BeeWare reference Xcode project — we don't need it +# in this repo. Saves ~5 MB on a fresh checkout. +rm -rf "$FW_DIR/testbed" + +echo "==> Done. Python.xcframework installed:" +cat "$FW_DIR/VERSIONS" diff --git a/support/fetch-wheels.sh b/support/fetch-wheels.sh new file mode 100755 index 00000000..8a3654ca --- /dev/null +++ b/support/fetch-wheels.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Fetch and unpack Python wheels for iOS into per-platform directories. +# +# Output: +# wheels-iphonesimulator/ simulator wheels (cryptography iOS-sim build + common pure-Python) +# wheels-iphoneos/ device wheels (cryptography iOS-device build + common pure-Python) +# +# Pure-Python wheels are duplicated into both dirs so the build-phase copy is platform-agnostic. +# +# Requires: +# * python3 with pip on the host +# * Internet (BeeWare anaconda + PyPI) + +set -euo pipefail + +POC_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SIM_DIR="$POC_ROOT/wheels-iphonesimulator" +DEV_DIR="$POC_ROOT/wheels-iphoneos" + +BEEWARE_INDEX="https://pypi.anaconda.org/beeware/simple" +CRYPTO_VERSION="47.0.0" +PYTHON_TAG="cp313" +PLATFORM_SIM="ios_13_0_arm64_iphonesimulator" +PLATFORM_DEV="ios_13_0_arm64_iphoneos" + +# Pure-Python pinned versions — empty means latest. Pin in production. +# (msgpack intentionally NOT installed: RNS and LXMF use the vendored pure-Python +# umsgpack in RNS.vendor.umsgpack. Installing the binary msgpack wheel from PyPI +# pulls a macOS-built .so that won't load on iOS.) +# +# RNS is sourced permanently from Torlando's fork branch (not PyPI, not a local +# checkout) so every build — CI and local — resolves the same dependency. The +# patches/columba-ios branch carries iOS-specific patches (e.g. the +# AutoInterface.detach() teardown that lets Columba hot-add / hot-remove +# interfaces on a running stack). +# +# To develop the fork itself, point at a local working copy explicitly: +# RETICULUM_LOCAL=~/repos/Reticulum support/fetch-wheels.sh +# Otherwise the GitHub branch is always used — the local checkout is never +# picked up implicitly (that made builds depend on whatever branch happened to +# be checked out on the dev's machine). +RETICULUM_BRANCH="patches/columba-ios" +if [ -n "${RETICULUM_LOCAL:-}" ]; then + echo "==> RETICULUM_LOCAL set — using local Reticulum checkout: $RETICULUM_LOCAL" + RNS_SPEC="$RETICULUM_LOCAL" +else + RNS_SPEC="git+https://github.com/torlando-tech/Reticulum.git@${RETICULUM_BRANCH}" +fi +# LXMF: torlando-tech fork with the external stamp generator hook. iOS embedded +# CPython has no `_multiprocessing`, so stock LXStamper's job_linux crashes and +# no stamp is produced (messages to stamp-cost peers like Sideband never +# deliver). The fork's `set_external_generator` lets us run the PoW in native +# Swift (see app/rns_bridge.py + Sources/SwiftBLEBridge/StampGenerator.swift). +# Branch is 0.9.9 + the hook (forward-port of feature/external-stamp-generator). +# Point at a local working copy to develop the fork: +# LXMF_LOCAL=~/repos/LXMF support/fetch-wheels.sh (branch feature/external-stamp-generator-0.9.9) +LXMF_BRANCH="feature/external-stamp-generator-0.9.9" +if [ -n "${LXMF_LOCAL:-}" ]; then + echo "==> LXMF_LOCAL set — using local LXMF checkout: $LXMF_LOCAL" + LXMF_SPEC="$LXMF_LOCAL" +else + LXMF_SPEC="git+https://github.com/torlando-tech/LXMF.git@${LXMF_BRANCH}" +fi +PYSERIAL_SPEC="pyserial>=3.5" +# ble-reticulum is not on PyPI; install from GitHub unless an explicit local +# checkout is requested via env-var (mirrors LXMF_LOCAL / RETICULUM_LOCAL). A +# local checkout is NEVER picked up implicitly — that made builds depend on +# whatever branch happened to be checked out on the dev's machine. +# BLE_RETICULUM_LOCAL=~/repos/ble-reticulum support/fetch-wheels.sh +# Pure-Python, zero runtime deps. +# Pinned to a commit (not a bare repo URL or a moving branch) so CI and dev +# builds are reproducible — bump deliberately. The local checkout is 49 commits +# past the v0.2.2 tag, so a tag pin would regress; this is origin/main@07d9413. +BLE_RETICULUM_REF="${BLE_RETICULUM_REF:-07d941304c9a1dc3a8e58087b3b974ff3d229e56}" +if [ -n "${BLE_RETICULUM_LOCAL:-}" ]; then + echo "==> BLE_RETICULUM_LOCAL set — using local ble-reticulum checkout: $BLE_RETICULUM_LOCAL" + BLE_RETICULUM_SPEC="$BLE_RETICULUM_LOCAL" +else + BLE_RETICULUM_SPEC="git+https://github.com/torlando-tech/ble-reticulum.git@${BLE_RETICULUM_REF}" +fi + +rm -rf "$SIM_DIR" "$DEV_DIR" +mkdir -p "$SIM_DIR" "$DEV_DIR" + +install_binary_wheel() { + # $1 platform tag, $2 destination dir, $3+ pkg specs + local platform=$1 dst=$2; shift 2 + echo "==> Fetching binary wheels for $platform: $@" + python3 -m pip install \ + --index-url "$BEEWARE_INDEX" \ + --platform "$platform" \ + --python-version 3.13 \ + --implementation cp \ + --only-binary :all: \ + --no-deps \ + --target "$dst" \ + --upgrade \ + "$@" +} + +install_pure_python() { + # $1 destination dir, $2+ specs + local dst=$1; shift + echo "==> Fetching pure-Python wheels into $dst" + python3 -m pip install \ + --no-deps \ + --target "$dst" \ + --upgrade \ + "$@" +} + +# cffi is needed because cryptography $CRYPTO_VERSION still depends on cffi for +# parts of its OpenSSL bindings. Pin cffi 2.0.0 (matching cp313 wheel +# availability on BeeWare). +BINARY_WHEELS=( + "cryptography==$CRYPTO_VERSION" + "cffi==2.0.0" +) +install_binary_wheel "$PLATFORM_SIM" "$SIM_DIR" "${BINARY_WHEELS[@]}" +install_binary_wheel "$PLATFORM_DEV" "$DEV_DIR" "${BINARY_WHEELS[@]}" + +for dst in "$SIM_DIR" "$DEV_DIR"; do + install_pure_python "$dst" "$RNS_SPEC" "$LXMF_SPEC" "$PYSERIAL_SPEC" "$BLE_RETICULUM_SPEC" +done + +echo +echo "Wheels installed:" +du -sh "$SIM_DIR" "$DEV_DIR" diff --git a/support/generate-module-graph.rb b/support/generate-module-graph.rb new file mode 100755 index 00000000..88c7b5ec --- /dev/null +++ b/support/generate-module-graph.rb @@ -0,0 +1,123 @@ +#!/usr/bin/env ruby +# Generate a deterministic module/target-level Mermaid graph for Columba-iOS. +# +# Merges two dependency systems into one diagram: +# - pbxproj targets + their target-to-target and SPM-product deps +# (parsed via the xcodeproj Ruby gem, same gem used by configure-xcodeproj.rb) +# - Internal Package.swift target-to-target deps +# (parsed via `swift package dump-package`) +# +# Writes a Mermaid `flowchart TD` block into ARCHITECTURE.md between the +# and markers. +# +# Regen: ruby support/generate-module-graph.rb + +require 'xcodeproj' +require 'json' +require 'open3' +require 'set' + +REPO_ROOT = File.expand_path('..', __dir__) +PROJECT_PATH = File.join(REPO_ROOT, 'Columba.xcodeproj') +ARCH_MD_PATH = File.join(REPO_ROOT, 'ARCHITECTURE.md') +START_MARKER = '' +END_MARKER = '' + +nodes = {} # name => { label:, kind: } +edges = Set.new # of "src --> dst" strings + +def pbxproj_kind(target) + case target.product_type + when 'com.apple.product-type.application' then :app + when /app-extension/ then :extension + when 'com.apple.product-type.framework', + 'com.apple.product-type.framework.static' then :bridge + else :bridge + end +end + +def spm_kind(name) + %w[COpus CCodec2].include?(name) ? :c_lib : :spm_lib +end + +# ──────── pbxproj side ──────── +project = Xcodeproj::Project.open(PROJECT_PATH) + +project.targets.each do |t| + # Skip test bundles — noise that doesn't belong in an architecture overview. + next if t.product_type&.include?('bundle.unit-test') + next if t.product_type&.include?('bundle.ui-testing') + + nodes[t.name] ||= { label: t.name, kind: pbxproj_kind(t) } + + # target → target deps (pbxproj-level) + t.dependencies.each do |dep| + dst = dep.target&.name + next unless dst + nodes[dst] ||= { label: dst, kind: :bridge } + edges << "#{t.name} --> #{dst}" + end + + # target → SPM product deps + t.package_product_dependencies.each do |pp| + name = pp.product_name + nodes[name] ||= { label: name, kind: spm_kind(name) } + edges << "#{t.name} --> #{name}" + end +end + +# ──────── SPM side (Package.swift internal target deps) ──────── +stdout, status = Open3.capture2('swift', 'package', 'dump-package', '--package-path', REPO_ROOT) +abort "swift package dump-package failed" unless status.success? +manifest = JSON.parse(stdout) + +manifest['targets'].each do |target| + name = target['name'] + nodes[name] ||= { label: name, kind: spm_kind(name) } + Array(target['dependencies']).each do |dep| + # Shapes: {"byName":[,null]} | {"target":[,null]} | {"product":[,,null,null]} + dst = + if dep['byName'] then dep['byName'].first + elsif dep['target'] then dep['target'].first + elsif dep['product'] then dep['product'].first + end + next unless dst + nodes[dst] ||= { label: dst, kind: spm_kind(dst) } + edges << "#{name} --> #{dst}" + end +end + +# ──────── Render Mermaid ──────── +CLASS_STYLES = { + app: 'classDef app fill:#1f6feb,stroke:#0d419d,color:#fff', + extension: 'classDef extension fill:#8957e5,stroke:#553098,color:#fff', + bridge: 'classDef bridge fill:#f0883e,stroke:#9e4c0f,color:#fff', + spm_lib: 'classDef spm_lib fill:#3fb950,stroke:#0f7a2e,color:#fff', + c_lib: 'classDef c_lib fill:#6e7681,stroke:#30363d,color:#fff', +}.freeze + +lines = ['```mermaid', 'flowchart TD'] +nodes.keys.sort.each { |id| lines << " #{id}[\"#{nodes[id][:label]}\"]" } +edges.sort.each { |e| lines << " #{e}" } +CLASS_STYLES.each_value { |s| lines << " #{s}" } +nodes.group_by { |_, m| m[:kind] }.each do |kind, members| + ids = members.map(&:first).sort.join(',') + lines << " class #{ids} #{kind}" unless ids.empty? +end +lines << '```' +block = lines.join("\n") + +# ──────── Inject between markers ──────── +md = File.read(ARCH_MD_PATH) +abort "Markers not found in #{ARCH_MD_PATH}" unless md.include?(START_MARKER) && md.include?(END_MARKER) + +# Anchor to start-of-line so inline-backtick mentions of the markers in the +# explanatory prose above don't match — only the bare marker pair does. +new_md = md.sub( + /^#{Regexp.escape(START_MARKER)}\n.*?^#{Regexp.escape(END_MARKER)}$/m, + "#{START_MARKER}\n#{block}\n#{END_MARKER}" +) +abort "Marker block not matched in #{ARCH_MD_PATH}" if new_md == md +File.write(ARCH_MD_PATH, new_md) + +puts "Wrote module graph: #{nodes.size} nodes, #{edges.size} edges → #{ARCH_MD_PATH}"