diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 33be032d5..6552af3a3 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1900,6 +1900,9 @@ } } } + }, + "8089" : { + }, "A channel index of 0 indicates the primary channel where broadcast packets are sent from. Location data is broadcast from the first channel where it is enabled with firmware 2.7 forward." : { "localizations" : { @@ -1910,6 +1913,9 @@ } } } + }, + "A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients." : { + }, "A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." : { "localizations" : { @@ -2463,6 +2469,9 @@ } } } + }, + "Add CA" : { + }, "Add Channel" : { "localizations" : { @@ -7671,6 +7680,12 @@ "Client Base should only favorite other nodes you control. Improper use will hurt your local mesh." : { "comment" : "A message displayed in a confirmation dialog when trying to favorite a node as a CLIENT_BASE.", "isCommentAutoGenerated" : true + }, + "Client CA Certificate" : { + + }, + "Client Configuration" : { + }, "Client Hidden" : { "localizations" : { @@ -8128,6 +8143,9 @@ } } } + }, + "Configuration" : { + }, "Configuration for: %@" : { "localizations" : { @@ -9710,6 +9728,9 @@ } } } + }, + "Delete All" : { + }, "Delete all config, keys and BLE bonds? " : { "localizations" : { @@ -12201,6 +12222,9 @@ } } } + }, + "Download TAK Server Data Package" : { + }, "Drag & Drop Firmware Update" : { "localizations" : { @@ -12714,6 +12738,9 @@ } } } + }, + "Enable TAK Server" : { + }, "Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : { "localizations" : { @@ -13227,6 +13254,12 @@ } } } + }, + "Enter P12 Password" : { + + }, + "Enter the password for the PKCS#12 file" : { + }, "environment" : { "localizations" : { @@ -14315,6 +14348,7 @@ } }, "Favorited and ignored nodes are always retained. Nodes without PKC keys are cleared from the app database on the schedule set by the user, nodes with PKC keys are cleared only if the interval is set to 7 days or longer. This feature only purges nodes from the app that are not stored in the device node database." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -14335,6 +14369,9 @@ } } } + }, + "Favorited and ignored nodes are always retained. Other nodes are cleared from the app database on the schedule set by the user. (Nodes with PKC keys are always retained for at least 7 days.) This feature only purges nodes from the app that are not stored in the device node database." : { + }, "Favorites" : { "localizations" : { @@ -15937,6 +15974,9 @@ } } } + }, + "Generate a data package (.zip) to configure ITAK or other TAK clients to connect to this server." : { + }, "Generate a new private key to replace the one currently in use. The public key will automatically be regenerated from your private key." : { "localizations" : { @@ -18225,6 +18265,18 @@ } } } + }, + "Import" : { + + }, + "Import .pem" : { + + }, + "Import Custom .p12" : { + + }, + "Import Error" : { + }, "Import Route" : { "localizations" : { @@ -22087,6 +22139,9 @@ } } } + }, + "mTLS" : { + }, "Multiplier" : { "localizations" : { @@ -26312,6 +26367,9 @@ } } } + }, + "Port" : { + }, "Position" : { "localizations" : { @@ -28852,6 +28910,9 @@ } } } + }, + "Reload Bundled Certificates" : { + }, "Remote administration for: %@" : { "localizations" : { @@ -29386,6 +29447,9 @@ } } } + }, + "Reset to Default" : { + }, "Restart" : { "localizations" : { @@ -29420,6 +29484,9 @@ } } } + }, + "Restart Server" : { + }, "Restart to the node you are connected to" : { "localizations" : { @@ -31290,6 +31357,9 @@ } } } + }, + "Secure mTLS connection on port 8089. Both server and client certificates are required." : { + }, "Security" : { "localizations" : { @@ -31617,6 +31687,9 @@ } } } + }, + "Select an emoji" : { + }, "Select Channel" : { "localizations" : { @@ -33101,6 +33174,9 @@ } } } + }, + "Server Certificate" : { + }, "Server Option" : { "localizations" : { @@ -33129,6 +33205,9 @@ } } } + }, + "Server Status" : { + }, "Set" : { "localizations" : { @@ -35045,6 +35124,9 @@ } } } + }, + "Status" : { + }, "Stay Connected Anywhere" : { "localizations" : { @@ -35457,6 +35539,9 @@ } } } + }, + "TAK Server" : { + }, "TAK Tracker" : { "localizations" : { @@ -37757,6 +37842,9 @@ } } } + }, + "TLS Certificates" : { + }, "TLS Enabled" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index b87ce5f2c..0b1dec82e 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -82,18 +82,29 @@ 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; }; 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; }; 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; }; + 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01028778B8BFD81F7A039593 /* TAKConnection.swift */; }; + 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */; }; 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; 3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; }; 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; }; 3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataFiles.swift */; }; + 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F203877F307073096C89179 /* FountainCodec.swift */; }; 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; }; 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */; }; 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; }; 6DEDA55C2A9592F900321D2E /* MessageEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */; }; + 7CCBCA0251DAB58FD9D63D06 /* GenericCoTHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */; }; + 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */; }; + 8A8F2D8A3769D24BAB88B4A1 /* CoTMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */; }; 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */; }; 8D3F8A412D44C2A6009EAAA4 /* PowerMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */; }; + 8E587743574CE17703E892C6 /* Certificates in Resources */ = {isa = PBXBuildFile; fileRef = 518D504DED9874EBF9D76578 /* Certificates */; }; + 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; + 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; + A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; ABB99DEB2E2EA1C500CFBD05 /* AppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */; }; + B16C760DB291CFAB5335EADB /* TAKCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */; }; B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399E8A32B6F486400E4488E /* RetryButton.swift */; }; B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; }; BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; }; @@ -115,6 +126,7 @@ D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D42B812B700066FBC8 /* MessageDestination.swift */; }; D93068D72B8146690066FBC8 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D62B8146690066FBC8 /* MessageText.swift */; }; D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D82B81509C0066FBC8 /* TapbackResponses.swift */; }; + D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D92B81509D0066FBC8 /* TapbackInputView.swift */; }; D93068DB2B81C85E0066FBC8 /* PowerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068DA2B81C85E0066FBC8 /* PowerConfig.swift */; }; D93068DD2B81CA820066FBC8 /* ConfigHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068DC2B81CA820066FBC8 /* ConfigHeader.swift */; }; D93069082B81DF040066FBC8 /* SaveConfigButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93069072B81DF040066FBC8 /* SaveConfigButton.swift */; }; @@ -296,6 +308,8 @@ DDF924CA26FBB953009FE055 /* ConnectedDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF924C926FBB953009FE055 /* ConnectedDevice.swift */; }; DDFEB3BB29900C1200EE7472 /* CurrentConditionsCompact.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFEB3BA29900C1200EE7472 /* CurrentConditionsCompact.swift */; }; DDFFA7472B3A7F3C004730DB /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFFA7462B3A7F3C004730DB /* Bundle.swift */; }; + E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */; }; + FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -330,6 +344,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; }; + 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = ""; }; + 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = ""; }; 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = ""; }; 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = ""; }; @@ -393,17 +410,27 @@ 25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; + 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = ""; }; + 3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = ""; }; 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = ""; }; 3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = ""; }; 3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = ""; }; + 3F203877F307073096C89179 /* FountainCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FountainCodec.swift; sourceTree = ""; }; + 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTMessage.swift; sourceTree = ""; }; + 518D504DED9874EBF9D76578 /* Certificates */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = Certificates; path = Certificates; sourceTree = ""; }; 6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = ""; }; 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = ""; }; 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = ""; }; 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEntityExtension.swift; sourceTree = ""; }; + 748E4806582595DE80D455CD /* CoTXMLParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTXMLParser.swift; sourceTree = ""; }; + 7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GenericCoTHandler.swift; sourceTree = ""; }; + 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+TAK.swift"; sourceTree = ""; }; + 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKMeshtasticBridge.swift; sourceTree = ""; }; 8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 49.xcdatamodel"; sourceTree = ""; }; 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetrics.swift; sourceTree = ""; }; 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = ""; }; + 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKDataPackageGenerator.swift; sourceTree = ""; }; ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconButton.swift; sourceTree = ""; }; ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPicker.swift; sourceTree = ""; }; B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = ""; }; @@ -427,6 +454,7 @@ D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = ""; }; D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = ""; }; D93068D82B81509C0066FBC8 /* TapbackResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackResponses.swift; sourceTree = ""; }; + D93068D92B81509D0066FBC8 /* TapbackInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackInputView.swift; sourceTree = ""; }; D93068DA2B81C85E0066FBC8 /* PowerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerConfig.swift; sourceTree = ""; }; D93068DC2B81CA820066FBC8 /* ConfigHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigHeader.swift; sourceTree = ""; }; D93069062B81D8900066FBC8 /* MeshtasticDataModelV 27.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 27.xcdatamodel"; sourceTree = ""; }; @@ -670,7 +698,17 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - DD4C11E02E8099C3003F2F2E /* PreferenceKeys */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = PreferenceKeys; sourceTree = ""; }; + DD4C11E02E8099C3003F2F2E /* PreferenceKeys */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = PreferenceKeys; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -788,6 +826,7 @@ 23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */, 23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */, 23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */, + 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */, ); path = "Accessory Manager"; sourceTree = ""; @@ -892,6 +931,24 @@ path = AppIntents; sourceTree = ""; }; + C37572859BC745C4284A9B42 /* TAK */ = { + isa = PBXGroup; + children = ( + 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */, + 748E4806582595DE80D455CD /* CoTXMLParser.swift */, + 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */, + 01028778B8BFD81F7A039593 /* TAKConnection.swift */, + 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */, + 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */, + 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */, + 3F203877F307073096C89179 /* FountainCodec.swift */, + 3D0A8ABAEF1E587683970927 /* EXICodec.swift */, + 7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */, + ); + name = TAK; + path = TAK; + sourceTree = ""; + }; D9C9839E2B79D0C600BDBE6A /* TextMessageField */ = { isa = PBXGroup; children = ( @@ -978,6 +1035,7 @@ DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */, DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */, + 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */, ); path = Settings; sourceTree = ""; @@ -1231,6 +1289,7 @@ DDB75A192A05EB67006ED576 /* alpha.png */, DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */, DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */, + 518D504DED9874EBF9D76578 /* Certificates */, ); path = Resources; sourceTree = ""; @@ -1250,6 +1309,7 @@ D93068D62B8146690066FBC8 /* MessageText.swift */, D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */, D93068D82B81509C0066FBC8 /* TapbackResponses.swift */, + D93068D92B81509D0066FBC8 /* TapbackInputView.swift */, ); path = Messages; sourceTree = ""; @@ -1295,6 +1355,7 @@ DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */, 6D825E612C34786C008DBEE4 /* CommonRegex.swift */, 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */, + C37572859BC745C4284A9B42 /* TAK */, ); path = Helpers; sourceTree = ""; @@ -1564,6 +1625,7 @@ DDC2E15C26CE248F0042C5E4 /* Assets.xcassets in Resources */, DD0E21012B8A6F1300F2D100 /* DeviceHardware.json in Resources */, DDDBC87B2BC62E4E001E8DF7 /* Settings.bundle in Resources */, + 8E587743574CE17703E892C6 /* Certificates in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1809,6 +1871,7 @@ DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */, 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */, D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */, + D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */, DDA9F5E82E77FAC100E70DEB /* AnimatedNodePin.swift in Sources */, DDF82CBD2D5BC69200DC25EC /* NavigateToButton.swift in Sources */, 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */, @@ -1869,6 +1932,18 @@ BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */, D93068D72B8146690066FBC8 /* MessageText.swift in Sources */, DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */, + 8A8F2D8A3769D24BAB88B4A1 /* CoTMessage.swift in Sources */, + 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */, + B16C760DB291CFAB5335EADB /* TAKCertificateManager.swift in Sources */, + 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */, + 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */, + E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */, + FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */, + A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */, + 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */, + 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */, + 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */, + 7CCBCA0251DAB58FD9D63D06 /* GenericCoTHandler.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2102,7 +2177,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.6; + MARKETING_VERSION = 2.7.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -2137,7 +2212,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.6; + MARKETING_VERSION = 2.7.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -2169,7 +2244,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.6; + MARKETING_VERSION = 2.7.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2202,7 +2277,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.6; + MARKETING_VERSION = 2.7.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b339cec1..cb5d36cf0 100644 --- a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2569905853aec088d5bac6b540eac77f78963f88b406e8dd95a88c40623cc8b4", + "originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4", "pins" : [ { "identity" : "cocoamqtt", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DataDog/dd-sdk-ios.git", "state" : { - "revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54", - "version" : "2.29.0" + "revision" : "2cddcb47c021365c5a6ebc377cb379aa979c450e", + "version" : "3.4.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "102a647b573f60f73afdce5613a51d71349fe507", - "version" : "1.30.0" + "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", + "version" : "1.33.3" } } ], diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift index 831ffe309..266b69459 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift @@ -52,8 +52,12 @@ extension AccessoryManager { existing.rssi = newDevice.rssi self.devices[index] = existing } else { - // This is a new device, add it to our list - self.devices.append(newDevice) + // This is a new device, add it to our list if we are in the foreground + if !(self.isInBackground) { + self.devices.append(newDevice) + } else { + Logger.transport.debug("πŸ”Ž [Discovery] Found a new device but not in the foreground, not adding to our list: peripheral \(newDevice.name)") + } } if self.shouldAutomaticallyConnectToPreferredPeripheral, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift index 5bcead9bb..d8e70f0e6 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift @@ -93,6 +93,8 @@ extension AccessoryManager { } tryClearExistingChannels() + // Initialize TAK bridge for TAK integration + initializeTAKBridge() } func handleNodeInfo(_ nodeInfo: NodeInfo) { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift new file mode 100644 index 000000000..d6c96783b --- /dev/null +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift @@ -0,0 +1,209 @@ +// +// AccessoryManager+TAK.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import MeshtasticProtobufs +import OSLog + +extension AccessoryManager { + + // MARK: - TAK Server Initialization + + /// Initialize the TAK bridge when connected to a Meshtastic device + func initializeTAKBridge() { + let takServer = TAKServerManager.shared + + // Create the bridge + let bridge = TAKMeshtasticBridge( + accessoryManager: self, + takServerManager: takServer + ) + bridge.context = self.context + + // Assign bridge to server + takServer.bridge = bridge + + Logger.tak.info("TAK bridge initialized") + + // Start server if enabled + if takServer.enabled && !takServer.isRunning { + Task { + do { + try await takServer.start() + Logger.tak.info("TAK Server auto-started on connection") + } catch { + Logger.tak.error("Failed to auto-start TAK Server: \(error.localizedDescription)") + } + } + } + } + + /// Clean up TAK bridge when disconnecting + func cleanupTAKBridge() { + // Note: We don't stop the server here - it can continue running + // even without a Meshtastic connection (for TAK connectivity) + Logger.tak.info("TAK bridge cleanup") + } + + // MARK: - Send TAK Packet to Mesh + + /// Send a TAK packet to the Meshtastic mesh network + /// - Parameters: + /// - takPacket: The TAKPacket protobuf to send + /// - channel: Channel to send on (0 = default/primary) + func sendTAKPacket(_ takPacket: TAKPacket, channel: UInt32 = 0) async throws { + Logger.tak.debug("=== Sending TAKPacket to Mesh ===") + + guard let activeConnection else { + Logger.tak.error("Not connected to Meshtastic device") + throw AccessoryError.connectionFailed("Not connected to Meshtastic device") + } + + guard let deviceNum = activeConnection.device.num else { + Logger.tak.error("No device number available") + throw AccessoryError.connectionFailed("No device number available") + } + + Logger.tak.debug("Device num: \(deviceNum)") + + // Log TAKPacket details before serialization + Logger.tak.debug("TAKPacket to send:") + Logger.tak.debug(" hasContact: \(takPacket.hasContact)") + if takPacket.hasContact { + Logger.tak.debug(" callsign: \(takPacket.contact.callsign)") + Logger.tak.debug(" deviceCallsign: \(takPacket.contact.deviceCallsign)") + } + Logger.tak.debug(" hasGroup: \(takPacket.hasGroup)") + if takPacket.hasGroup { + Logger.tak.debug(" team: \(takPacket.group.team.rawValue)") + Logger.tak.debug(" role: \(takPacket.group.role.rawValue)") + } + Logger.tak.debug(" hasStatus: \(takPacket.hasStatus)") + if takPacket.hasStatus { + Logger.tak.debug(" battery: \(takPacket.status.battery)") + } + Logger.tak.debug(" payloadVariant: \(String(describing: takPacket.payloadVariant))") + + // Serialize the TAK packet + let serialized: Data + do { + serialized = try takPacket.serializedData() + Logger.tak.debug("Serialized TAKPacket: \(serialized.count) bytes") + Logger.tak.debug("Serialized hex: \(serialized.map { String(format: "%02x", $0) }.joined(separator: " "))") + } catch { + Logger.tak.error("Failed to serialize TAKPacket: \(error.localizedDescription)") + throw AccessoryError.ioFailed("Failed to serialize TAKPacket") + } + + // Build the mesh packet + var dataMessage = DataMessage() + dataMessage.portnum = .atakPlugin // Port 72 + dataMessage.payload = serialized + + var meshPacket = MeshPacket() + meshPacket.to = 0xFFFFFFFF // Broadcast + meshPacket.from = UInt32(deviceNum) + meshPacket.channel = channel + meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..= 2 && payload[0] == 0x08 && payload[1] == 0x01 { + Logger.tak.debug("Ignoring compressed TAKPacket (duplicate of uncompressed)") + return + } + + // Parse uncompressed TAKPacket protobuf + let takPacket: TAKPacket + do { + takPacket = try TAKPacket(serializedBytes: payload) + } catch { + Logger.tak.warning("Failed to parse TAKPacket from mesh packet: \(error.localizedDescription)") + Logger.tak.debug("Parse error details: \(error)") + Logger.tak.debug("Raw payload hex: \(payload.map { String(format: "%02x", $0) }.joined(separator: " "))") + return + } + + Logger.tak.info("Received TAKPacket from mesh node \(packet.from)") + Logger.tak.debug(" hasContact: \(takPacket.hasContact), hasGroup: \(takPacket.hasGroup), hasStatus: \(takPacket.hasStatus)") + Logger.tak.debug(" payloadVariant: \(String(describing: takPacket.payloadVariant))") + + // Forward to TAK clients via bridge + Task { + await TAKServerManager.shared.bridge?.broadcastToTAKClients(takPacket, from: packet.from) + } + } + + // MARK: - Handle ATAK Forwarder Packet (Port 257) + + /// Handle incoming ATAK_FORWARDER packet for generic CoT events + /// These are EXI-compressed CoT XML, possibly fountain-coded for large messages + func handleATAKForwarderPacket(_ packet: MeshPacket) { + guard case let .decoded(data) = packet.payloadVariant else { + Logger.tak.warning("Received ATAK_FORWARDER packet without decoded payload") + return + } + + Logger.tak.debug("Received ATAK_FORWARDER packet: \(data.payload.count) bytes from node \(packet.from)") + + // Process through GenericCoTHandler on main actor + let packetCopy = packet + let accessoryManagerRef = self + Task { @MainActor in + let handler = GenericCoTHandler.shared + handler.accessoryManager = accessoryManagerRef + + if let cotMessage = handler.handleIncomingForwarderPacket(packetCopy) { + // Forward to TAK clients via the server manager + await TAKServerManager.shared.broadcast(cotMessage) + Logger.tak.info("Forwarded generic CoT to TAK clients: \(cotMessage.type)") + } + } + } +} diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 4fe2ffaf5..cd1d29610 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -441,8 +441,6 @@ extension AccessoryManager { Logger.services.error("Error while sending saveChannelSet request. No active device.") throw AccessoryError.ioFailed("No active device") } - var i: Int32 = 0 - var myInfo: MyInfoEntity // Before we get started delete the existing channels from the myNodeInfo if !addChannels { tryClearExistingChannels() @@ -451,64 +449,74 @@ extension AccessoryManager { let decodedString = base64UrlString.base64urlToBase64() if let decodedData = Data(base64Encoded: decodedString) { let channelSet: ChannelSet = try ChannelSet(serializedBytes: decodedData) + + var myInfo: MyInfoEntity! + var i: Int32 = 0 + + if addChannels { + let fetchMyInfoRequest = MyInfoEntity.fetchRequest() + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum)) + + let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) + if fetchedMyInfo.count != 1 { + throw AccessoryError.appError("MyInfo not found") + } + + // We are trying to add a channel so lets get the last index + myInfo = fetchedMyInfo[0] + i = Int32(myInfo.channels?.count ?? -1) + + // Bail out if the index is negative or bigger than our max of 8 + if i < 0 || i > 8 { + throw AccessoryError.appError("Index out of range \(i)") + } + } + for cs in channelSet.settings { + if addChannels { - // We are trying to add a channel so lets get the last index - let fetchMyInfoRequest = MyInfoEntity.fetchRequest() - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum)) - do { - let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) - if fetchedMyInfo.count == 1 { - i = Int32(fetchedMyInfo[0].channels?.count ?? -1) - myInfo = fetchedMyInfo[0] - // Bail out if the index is negative or bigger than our max of 8 - if i < 0 || i > 8 { - throw AccessoryError.appError("Index out of range \(i)") - } - // Bail out if there are no channels or if the same channel name already exists - guard let mutableChannels = myInfo.channels!.mutableCopy() as? NSMutableOrderedSet else { - throw AccessoryError.appError("No channels or channel") - } - if mutableChannels.first(where: {($0 as AnyObject).name == cs.name }) is ChannelEntity { - throw AccessoryError.appError("Channel already exists") - } - } - } catch { - Logger.data.error("Failed to find a node MyInfo to save these channels to: \(error.localizedDescription, privacy: .public)") + guard let mutableChannels = myInfo.channels?.mutableCopy() as? NSMutableOrderedSet else { + throw AccessoryError.appError("No channels or channel") + } + + // Bail out if there are no channels or if the same channel name already exists + if mutableChannels.first(where: { ($0 as AnyObject).name == cs.name }) is ChannelEntity { + throw AccessoryError.appError("Channel already exists") } } var chan = Channel() - if i == 0 { - chan.role = Channel.Role.primary - } else { - chan.role = Channel.Role.secondary - } + chan.role = (i == 0) ? .primary : .secondary chan.settings = cs chan.index = i i += 1 var adminPacket = AdminMessage() adminPacket.setChannel = chan - var meshPacket: MeshPacket = MeshPacket() + + var meshPacket = MeshPacket() meshPacket.to = UInt32(deviceNum) - meshPacket.from = UInt32(deviceNum) + meshPacket.from = UInt32(deviceNum) meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. [OSLogEntryLog] { diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 82e67773c..12bd86eed 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -80,6 +80,7 @@ extension UserDefaults { case showDeviceOnboarding case usageDataAndCrashReporting case autoconnectOnDiscovery + case purgeStaleNodeDays case manualConnections case testIntEnum } @@ -178,6 +179,9 @@ extension UserDefaults { @UserDefault(.autoconnectOnDiscovery, defaultValue: true) static var autoconnectOnDiscovery: Bool + @UserDefault(.purgeStaleNodeDays, defaultValue: 0) + static var purgeStaleNodeDays: Double + @UserDefault(.testIntEnum, defaultValue: .one) static var testIntEnum: TestIntEnum diff --git a/Meshtastic/Helpers/EmojiOnlyTextField.swift b/Meshtastic/Helpers/EmojiOnlyTextField.swift index 0982ab334..4928da732 100644 --- a/Meshtastic/Helpers/EmojiOnlyTextField.swift +++ b/Meshtastic/Helpers/EmojiOnlyTextField.swift @@ -7,6 +7,7 @@ import SwiftUI class SwiftUIEmojiTextField: UITextField { + var shouldBecomeFirstResponderOnAppear = false func setEmoji() { _ = self.textInputMode @@ -23,22 +24,39 @@ class SwiftUIEmojiTextField: UITextField { } return nil } + + override func didMoveToWindow() { + super.didMoveToWindow() + if shouldBecomeFirstResponderOnAppear && window != nil { + DispatchQueue.main.async { [weak self] in + self?.becomeFirstResponder() + } + } + } } struct EmojiOnlyTextField: UIViewRepresentable { @Binding var text: String var placeholder: String = "" + var onBecomeFirstResponder: (() -> Void)? + var onKeyboardTypeChanged: ((Bool) -> Void)? // true if emoji, false otherwise + var onKeyboardDismissed: (() -> Void)? // Called when keyboard is dismissed func makeUIView(context: Context) -> SwiftUIEmojiTextField { let emojiTextField = SwiftUIEmojiTextField() emojiTextField.placeholder = placeholder emojiTextField.text = text emojiTextField.delegate = context.coordinator + emojiTextField.shouldBecomeFirstResponderOnAppear = true + context.coordinator.textField = emojiTextField return emojiTextField } func updateUIView(_ uiView: SwiftUIEmojiTextField, context: Context) { uiView.text = text + context.coordinator.onBecomeFirstResponder = onBecomeFirstResponder + context.coordinator.onKeyboardTypeChanged = onKeyboardTypeChanged + context.coordinator.onKeyboardDismissed = onKeyboardDismissed } func makeCoordinator() -> Coordinator { @@ -47,13 +65,41 @@ struct EmojiOnlyTextField: UIViewRepresentable { class Coordinator: NSObject, UITextFieldDelegate { var parent: EmojiOnlyTextField + var textField: SwiftUIEmojiTextField? + var onBecomeFirstResponder: (() -> Void)? + var onKeyboardTypeChanged: ((Bool) -> Void)? + var onKeyboardDismissed: (() -> Void)? + var previousInputMode: String? + init(parent: EmojiOnlyTextField) { self.parent = parent } + + func textFieldDidBeginEditing(_ textField: UITextField) { + onBecomeFirstResponder?() + checkInputMode(textField) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + // Keyboard was dismissed + onKeyboardDismissed?() + } + func textFieldDidChangeSelection(_ textField: UITextField) { DispatchQueue.main.async { [weak self] in self?.parent.text = textField.text ?? "" } + checkInputMode(textField) + } + + private func checkInputMode(_ textField: UITextField) { + if let inputMode = textField.textInputMode { + let isEmoji = inputMode.primaryLanguage == "emoji" + if previousInputMode != inputMode.primaryLanguage { + previousInputMode = inputMode.primaryLanguage + onKeyboardTypeChanged?(!isEmoji) // true if NOT emoji (should dismiss) + } + } } } } diff --git a/Meshtastic/Helpers/Logger.swift b/Meshtastic/Helpers/Logger.swift deleted file mode 100644 index 35ee73376..000000000 --- a/Meshtastic/Helpers/Logger.swift +++ /dev/null @@ -1,19 +0,0 @@ -import OSLog - -extension Logger { - - /// The logger's subsystem. - private static var subsystem = Bundle.main.bundleIdentifier! - - /// All logs related to data such as decoding error, parsing issues, etc. - public static let data = Logger(subsystem: subsystem, category: "πŸ—„οΈ Data") - - /// All logs related to the mesh - public static let mesh = Logger(subsystem: subsystem, category: "πŸ•ΈοΈ Mesh") - - /// All logs related to services such as network calls, location, etc. - public static let services = Logger(subsystem: subsystem, category: "🍏 Services") - - /// All logs related to tracking and analytics. - public static let statistics = Logger(subsystem: subsystem, category: "πŸ“ˆ Stats") -} diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 255417c4f..8b7a74232 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -881,8 +881,8 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage let meshActivity = Activity.activities.first(where: { $0.attributes.nodeNum == connectedNode }) if meshActivity != nil { Task { - await meshActivity?.update(updatedContent, alertConfiguration: alertConfiguration) - // await meshActivity?.update(updatedContent) + // await meshActivity?.update(updatedContent, alertConfiguration: alertConfiguration) + await meshActivity?.update(updatedContent) Logger.services.debug("Updated live activity.") } } diff --git a/Meshtastic/Helpers/TAK/CoTMessage.swift b/Meshtastic/Helpers/TAK/CoTMessage.swift new file mode 100644 index 000000000..1d8419b8e --- /dev/null +++ b/Meshtastic/Helpers/TAK/CoTMessage.swift @@ -0,0 +1,527 @@ +// +// CoTMessage.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import MeshtasticProtobufs +import CoreLocation + +/// Cursor on Target (CoT) message representation +/// Handles both parsing incoming CoT XML and generating outgoing CoT XML +struct CoTMessage: Identifiable, Sendable { + let id = UUID() + + // MARK: - Core CoT Event Attributes + + /// Unique identifier for this event + var uid: String + + /// CoT type (e.g., "a-f-G-U-C" for friendly ground unit, "b-t-f" for chat) + var type: String + + /// Event generation time + var time: Date + + /// Start of event validity + var start: Date + + /// When this event becomes stale + var stale: Date + + /// How the event was generated (e.g., "m-g" for machine GPS, "h-g-i-g-o" for human generated) + var how: String + + // MARK: - Point Element (Location) + + /// Latitude in degrees + var latitude: Double + + /// Longitude in degrees + var longitude: Double + + /// Height above ellipsoid in meters + var hae: Double + + /// Circular error in meters + var ce: Double + + /// Linear error in meters + var le: Double + + // MARK: - Detail Elements + + /// Contact information (callsign, endpoint) + var contact: CoTContact? + + /// Group/team assignment + var group: CoTGroup? + + /// Device status (battery) + var status: CoTStatus? + + /// Movement track (speed, course) + var track: CoTTrack? + + /// Chat message details + var chat: CoTChat? + + /// Remarks/comments text + var remarks: String? + + /// Raw detail XML content for elements we don't explicitly parse + /// Used to preserve generic CoT elements (colors, shapes, labels, etc.) + var rawDetailXML: String? + + // MARK: - Initialization + + init( + uid: String, + type: String, + time: Date = Date(), + start: Date = Date(), + stale: Date = Date().addingTimeInterval(600), + how: String = "m-g", + latitude: Double = 0, + longitude: Double = 0, + hae: Double = 9999999.0, + ce: Double = 9999999.0, + le: Double = 9999999.0, + contact: CoTContact? = nil, + group: CoTGroup? = nil, + status: CoTStatus? = nil, + track: CoTTrack? = nil, + chat: CoTChat? = nil, + remarks: String? = nil, + rawDetailXML: String? = nil + ) { + self.uid = uid + self.type = type + self.time = time + self.start = start + self.stale = stale + self.how = how + self.latitude = latitude + self.longitude = longitude + self.hae = hae + self.ce = ce + self.le = le + self.contact = contact + self.group = group + self.status = status + self.track = track + self.chat = chat + self.remarks = remarks + self.rawDetailXML = rawDetailXML + } + + // MARK: - Factory Methods + + /// Create a PLI (Position Location Information) message for a friendly ground unit + static func pli( + uid: String, + callsign: String, + latitude: Double, + longitude: Double, + altitude: Double = 9999999.0, + speed: Double = 0, + course: Double = 0, + team: String = "Cyan", + role: String = "Team Member", + battery: Int = 100, + staleMinutes: Int = 10 + ) -> CoTMessage { + let now = Date() + return CoTMessage( + uid: uid, + type: "a-f-G-U-C", + time: now, + start: now, + stale: now.addingTimeInterval(TimeInterval(staleMinutes * 60)), + how: "m-g", + latitude: latitude, + longitude: longitude, + hae: altitude, + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"), + group: CoTGroup(name: team, role: role), + status: CoTStatus(battery: battery), + track: CoTTrack(speed: speed, course: course) + ) + } + + /// Create a chat message (b-t-f type for outgoing) + static func chat( + senderUid: String, + senderCallsign: String, + message: String, + chatroom: String = "All Chat Rooms" + ) -> CoTMessage { + let now = Date() + let messageId = UUID().uuidString + return CoTMessage( + uid: "GeoChat.\(senderUid).\(chatroom).\(messageId)", + type: "b-t-f", + time: now, + start: now, + stale: now.addingTimeInterval(86400), + how: "h-g-i-g-o", + latitude: 0, + longitude: 0, + hae: 9999999.0, + ce: 9999999.0, + le: 9999999.0, + chat: CoTChat( + message: message, + senderCallsign: senderCallsign, + chatroom: chatroom + ), + remarks: message + ) + } + + // MARK: - Create from Meshtastic TAKPacket + + /// Convert Meshtastic TAKPacket protobuf to CoT message + static func fromTAKPacket(_ takPacket: TAKPacket, deviceUid: String? = nil) -> CoTMessage? { + let currentDate = Date() + let staleDate = currentDate.addingTimeInterval(10 * 60) // 10 minute stale + + // Handle PLI (Position Location Information) + if case .pli(let pli) = takPacket.payloadVariant { + // Validate we have required fields + guard takPacket.hasContact, + pli.latitudeI != 0 || pli.longitudeI != 0 else { + return nil + } + + // Parse device_callsign in case it contains smuggled messageId (shouldn't for PLI, but be safe) + let (actualDeviceCallsign, _) = TAKMeshtasticBridge.parseDeviceCallsign(takPacket.contact.deviceCallsign) + let uid = actualDeviceCallsign.isEmpty + ? (deviceUid ?? UUID().uuidString) + : actualDeviceCallsign + + return CoTMessage( + uid: uid, + type: "a-f-G-U-C", + time: currentDate, + start: currentDate, + stale: staleDate, + how: "m-g", + latitude: Double(pli.latitudeI) * 1e-7, + longitude: Double(pli.longitudeI) * 1e-7, + hae: Double(pli.altitude), + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact( + callsign: takPacket.contact.callsign, + endpoint: "0.0.0.0:4242:tcp" + ), + group: takPacket.hasGroup ? CoTGroup( + name: takPacket.group.team.cotColorName, + role: takPacket.group.role.cotRoleName + ) : CoTGroup(name: "Cyan", role: "Team Member"), + status: takPacket.hasStatus ? CoTStatus( + battery: Int(takPacket.status.battery) + ) : nil, + track: CoTTrack( + speed: Double(pli.speed), + course: Double(pli.course) + ) + ) + } + + // Handle GeoChat + if case .chat(let geoChat) = takPacket.payloadVariant { + // Parse device_callsign which may contain smuggled messageId + // Format: "|" or just "" + let rawDeviceCallsign = takPacket.hasContact ? takPacket.contact.deviceCallsign : "" + let (actualDeviceCallsign, smuggledMessageId) = TAKMeshtasticBridge.parseDeviceCallsign(rawDeviceCallsign) + + let uid = actualDeviceCallsign.isEmpty + ? (deviceUid ?? UUID().uuidString) + : actualDeviceCallsign + + let chatroom = geoChat.hasTo ? geoChat.to : "All Chat Rooms" + // Use smuggled messageId if present, otherwise generate new one + let messageId = smuggledMessageId ?? UUID().uuidString + + return CoTMessage( + uid: "GeoChat.\(uid).\(chatroom).\(messageId)", + type: "b-t-f", + time: currentDate, + start: currentDate, + stale: currentDate.addingTimeInterval(86400), + how: "h-g-i-g-o", + latitude: 0, + longitude: 0, + hae: 9999999.0, + ce: 9999999.0, + le: 9999999.0, + contact: takPacket.hasContact ? CoTContact( + callsign: takPacket.contact.callsign, + endpoint: "0.0.0.0:4242:tcp" + ) : nil, + chat: CoTChat( + message: geoChat.message, + senderCallsign: takPacket.hasContact ? takPacket.contact.callsign : nil, + chatroom: chatroom + ), + remarks: geoChat.message + ) + } + + return nil + } + + // MARK: - XML Generation + + /// Generate CoT XML string for transmission to TAK clients + func toXML() -> String { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var cot = "" + cot += "" + cot += "", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } +} + +// MARK: - Team/Role Extensions for Meshtastic Protobufs + +extension Team { + /// Convert Meshtastic Team enum to CoT color name + var cotColorName: String { + switch self { + case .white: return "White" + case .yellow: return "Yellow" + case .orange: return "Orange" + case .magenta: return "Magenta" + case .red: return "Red" + case .maroon: return "Maroon" + case .purple: return "Purple" + case .darkBlue: return "Dark Blue" + case .blue: return "Blue" + case .cyan: return "Cyan" + case .teal: return "Teal" + case .green: return "Green" + case .darkGreen: return "Dark Green" + case .brown: return "Brown" + case .unspecifedColor: return "Cyan" + case .UNRECOGNIZED: return "Cyan" + } + } + + /// Create Team from CoT color name + static func fromColorName(_ name: String) -> Team { + switch name.lowercased() { + case "white": return .white + case "yellow": return .yellow + case "orange": return .orange + case "magenta": return .magenta + case "red": return .red + case "maroon": return .maroon + case "purple": return .purple + case "dark blue", "darkblue": return .darkBlue + case "blue": return .blue + case "cyan": return .cyan + case "teal": return .teal + case "green": return .green + case "dark green", "darkgreen": return .darkGreen + case "brown": return .brown + default: return .cyan + } + } +} + +extension MemberRole { + /// Convert Meshtastic MemberRole enum to CoT role name + var cotRoleName: String { + switch self { + case .teamMember: return "Team Member" + case .teamLead: return "Team Lead" + case .hq: return "HQ" + case .sniper: return "Sniper" + case .medic: return "Medic" + case .forwardObserver: return "Forward Observer" + case .rto: return "RTO" + case .k9: return "K9" + case .unspecifed: return "Team Member" + case .UNRECOGNIZED: return "Team Member" + } + } + + /// Create MemberRole from CoT role name + static func fromRoleName(_ name: String) -> MemberRole { + switch name.lowercased() { + case "team member": return .teamMember + case "team lead": return .teamLead + case "hq", "headquarters": return .hq + case "sniper": return .sniper + case "medic": return .medic + case "forward observer": return .forwardObserver + case "rto": return .rto + case "k9": return .k9 + default: return .teamMember + } + } +} + +// MARK: - XML Parsing + +extension CoTMessage { + /// Parse a CoT XML string into a CoTMessage + /// - Parameter xml: The CoT XML string + /// - Returns: Parsed CoTMessage, or nil if parsing failed + static func parse(from xml: String) -> CoTMessage? { + guard let data = xml.data(using: .utf8) else { + return nil + } + + // Use the existing CoTXMLParser class + let parser = CoTXMLParser(data: data) + do { + return try parser.parse() + } catch { + return nil + } + } +} diff --git a/Meshtastic/Helpers/TAK/CoTXMLParser.swift b/Meshtastic/Helpers/TAK/CoTXMLParser.swift new file mode 100644 index 000000000..1c189c3e3 --- /dev/null +++ b/Meshtastic/Helpers/TAK/CoTXMLParser.swift @@ -0,0 +1,343 @@ +// +// CoTXMLParser.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import OSLog + +/// XML Parser delegate for parsing incoming CoT (Cursor on Target) messages from TAK clients +final class CoTXMLParser: NSObject, XMLParserDelegate { + private let data: Data + private var cotMessage: CoTMessage? + private var parseError: Error? + + // Current parsing state + private var currentElement = "" + private var currentText = "" + + // Temporary attribute storage during parsing + private var eventAttributes: [String: String] = [:] + private var pointAttributes: [String: String] = [:] + private var contactAttributes: [String: String] = [:] + private var groupAttributes: [String: String] = [:] + private var statusAttributes: [String: String] = [:] + private var trackAttributes: [String: String] = [:] + private var chatAttributes: [String: String] = [:] + private var chatgrpAttributes: [String: String] = [:] + private var remarksAttributes: [String: String] = [:] + private var remarksText = "" + private var linkAttributes: [String: String] = [:] + + // Track element hierarchy for nested elements + private var elementStack: [String] = [] + + // Raw detail XML for unrecognized elements (markers, shapes, colors, etc.) + private var rawDetailXML = "" + private var isCapturingRawDetail = false + private var rawDetailDepth = 0 + + // Known detail elements we handle explicitly + private let knownDetailElements: Set = [ + "contact", "__group", "status", "track", "__chat", "chatgrp", + "remarks", "link", "uid", "__serverdestination" + ] + + init(data: Data) { + self.data = data + } + + /// Parse the XML data and return a CoTMessage + func parse() throws -> CoTMessage { + let parser = XMLParser(data: data) + parser.delegate = self + parser.shouldProcessNamespaces = false + parser.shouldReportNamespacePrefixes = false + + guard parser.parse() else { + if let error = parseError { + throw error + } + throw CoTParseError.parseFailed(parser.parserError?.localizedDescription ?? "Unknown error") + } + + guard let message = cotMessage else { + throw CoTParseError.invalidMessage + } + + return message + } + + // MARK: - XMLParserDelegate + + func parser(_ parser: XMLParser, didStartElement elementName: String, + namespaceURI: String?, qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:]) { + elementStack.append(elementName) + currentElement = elementName + currentText = "" + + // Check if we're inside and this is an unrecognized element + let isInsideDetail = elementStack.contains("detail") && elementName != "detail" + + if isCapturingRawDetail { + // Continue capturing nested elements + rawDetailDepth += 1 + rawDetailXML += buildOpeningTag(elementName, attributes: attributeDict) + } else if isInsideDetail && !knownDetailElements.contains(elementName) { + // Start capturing this unrecognized element + isCapturingRawDetail = true + rawDetailDepth = 1 + rawDetailXML += buildOpeningTag(elementName, attributes: attributeDict) + } + + switch elementName { + case "event": + eventAttributes = attributeDict + case "point": + pointAttributes = attributeDict + case "contact": + contactAttributes = attributeDict + case "__group": + groupAttributes = attributeDict + case "status": + statusAttributes = attributeDict + case "track": + trackAttributes = attributeDict + case "__chat": + chatAttributes = attributeDict + case "chatgrp": + chatgrpAttributes = attributeDict + case "remarks": + remarksAttributes = attributeDict + case "link": + linkAttributes = attributeDict + default: + break + } + } + + /// Build an XML opening tag with attributes + private func buildOpeningTag(_ elementName: String, attributes: [String: String]) -> String { + var tag = "<\(elementName)" + for (key, value) in attributes { + tag += " \(key)='\(value.xmlEscaped)'" + } + tag += ">" + return tag + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + currentText += string + + // Capture text content for raw detail elements + if isCapturingRawDetail { + rawDetailXML += string.xmlEscaped + } + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, + namespaceURI: String?, qualifiedName qName: String?) { + if elementName == "remarks" { + remarksText = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Handle raw detail element closing + if isCapturingRawDetail { + rawDetailXML += "" + rawDetailDepth -= 1 + if rawDetailDepth == 0 { + isCapturingRawDetail = false + } + } + + if elementName == "event" { + buildCoTMessage() + } + + elementStack.removeLast() + currentElement = elementStack.last ?? "" + } + + func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { + self.parseError = parseError + Logger.tak.error("CoT XML parse error: \(parseError.localizedDescription)") + } + + // MARK: - Build CoTMessage + + private func buildCoTMessage() { + Logger.tak.debug("=== Building CoTMessage from XML ===") + Logger.tak.debug("Event attributes: \(self.eventAttributes)") + Logger.tak.debug("Point attributes: \(self.pointAttributes)") + Logger.tak.debug("Contact attributes: \(self.contactAttributes)") + Logger.tak.debug("Group attributes: \(self.groupAttributes)") + Logger.tak.debug("Status attributes: \(self.statusAttributes)") + Logger.tak.debug("Track attributes: \(self.trackAttributes)") + Logger.tak.debug("Chat attributes: \(self.chatAttributes)") + Logger.tak.debug("Remarks text: \(self.remarksText)") + + // Parse timestamps + let time = parseDate(eventAttributes["time"]) + let start = parseDate(eventAttributes["start"]) + let stale = parseDate(eventAttributes["stale"]) + + // Build contact if present + var contact: CoTContact? + if !contactAttributes.isEmpty { + contact = CoTContact( + callsign: contactAttributes["callsign"] ?? "", + endpoint: contactAttributes["endpoint"], + phone: contactAttributes["phone"] + ) + Logger.tak.debug("Parsed contact: callsign=\(contact?.callsign ?? "nil")") + } + + // Build group if present + var group: CoTGroup? + if !groupAttributes.isEmpty { + group = CoTGroup( + name: groupAttributes["name"] ?? "Cyan", + role: groupAttributes["role"] ?? "Team Member" + ) + Logger.tak.debug("Parsed group: name=\(group?.name ?? "nil"), role=\(group?.role ?? "nil")") + } + + // Build status if present + var status: CoTStatus? + if let batteryStr = statusAttributes["battery"], let battery = Int(batteryStr) { + status = CoTStatus(battery: battery) + Logger.tak.debug("Parsed status: battery=\(battery)") + } + + // Build track if present + var track: CoTTrack? + if !trackAttributes.isEmpty { + let speed = Double(trackAttributes["speed"] ?? "0") ?? 0 + let course = Double(trackAttributes["course"] ?? "0") ?? 0 + track = CoTTrack(speed: speed, course: course) + Logger.tak.debug("Parsed track: speed=\(speed), course=\(course)") + } + + // Build chat if present + var chat: CoTChat? + if !chatAttributes.isEmpty { + chat = CoTChat( + message: remarksText, + senderCallsign: chatAttributes["senderCallsign"], + chatroom: chatAttributes["chatroom"] ?? chatAttributes["id"] ?? "All Chat Rooms" + ) + Logger.tak.debug("Parsed chat: message=\(self.remarksText.prefix(50)), chatroom=\(chat?.chatroom ?? "nil")") + } + + let uid = eventAttributes["uid"] ?? UUID().uuidString + let type = eventAttributes["type"] ?? "a-f-G-U-C" + let latitude = Double(pointAttributes["lat"] ?? "0") ?? 0 + let longitude = Double(pointAttributes["lon"] ?? "0") ?? 0 + let hae = Double(pointAttributes["hae"] ?? "9999999") ?? 9999999 + + Logger.tak.debug("Building CoTMessage: uid=\(uid), type=\(type)") + Logger.tak.debug(" location: lat=\(latitude), lon=\(longitude), hae=\(hae)") + + cotMessage = CoTMessage( + uid: uid, + type: type, + time: time, + start: start, + stale: stale, + how: eventAttributes["how"] ?? "m-g", + latitude: latitude, + longitude: longitude, + hae: hae, + ce: Double(pointAttributes["ce"] ?? "9999999") ?? 9999999, + le: Double(pointAttributes["le"] ?? "9999999") ?? 9999999, + contact: contact, + group: group, + status: status, + track: track, + chat: chat, + remarks: chat == nil && !remarksText.isEmpty ? remarksText : nil, + rawDetailXML: rawDetailXML.isEmpty ? nil : rawDetailXML + ) + + if !rawDetailXML.isEmpty { + Logger.tak.debug("Captured raw detail XML: \(self.rawDetailXML.prefix(200))...") + } + + Logger.tak.debug("=== CoTMessage built successfully ===") + } + + // MARK: - Date Parsing + + private func parseDate(_ string: String?) -> Date { + guard let string else { return Date() } + + // Try ISO8601 with fractional seconds first + let formatterWithFractional = ISO8601DateFormatter() + formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatterWithFractional.date(from: string) { + return date + } + + // Try ISO8601 without fractional seconds + let formatterWithoutFractional = ISO8601DateFormatter() + formatterWithoutFractional.formatOptions = [.withInternetDateTime] + if let date = formatterWithoutFractional.date(from: string) { + return date + } + + // Try basic date formatter + let basicFormatter = DateFormatter() + basicFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + basicFormatter.timeZone = TimeZone(identifier: "UTC") + if let date = basicFormatter.date(from: string) { + return date + } + + Logger.tak.warning("Failed to parse CoT date: \(string)") + return Date() + } +} + +// MARK: - Parse Error + +enum CoTParseError: LocalizedError { + case parseFailed(String) + case invalidMessage + case emptyData + + var errorDescription: String? { + switch self { + case .parseFailed(let reason): + return "Failed to parse CoT XML: \(reason)" + case .invalidMessage: + return "Invalid CoT message structure" + case .emptyData: + return "Empty data received" + } + } +} + +// MARK: - CoTMessage Parsing Extension + +extension CoTMessage { + /// Parse CoT XML data into a CoTMessage + static func parse(from data: Data) throws -> CoTMessage { + guard !data.isEmpty else { + throw CoTParseError.emptyData + } + + let parser = CoTXMLParser(data: data) + return try parser.parse() + } + + /// Parse CoT XML string into a CoTMessage + static func parse(from xmlString: String) throws -> CoTMessage { + guard let data = xmlString.data(using: .utf8) else { + throw CoTParseError.emptyData + } + return try parse(from: data) + } +} diff --git a/Meshtastic/Helpers/TAK/EXICodec.swift b/Meshtastic/Helpers/TAK/EXICodec.swift new file mode 100644 index 000000000..e1881e083 --- /dev/null +++ b/Meshtastic/Helpers/TAK/EXICodec.swift @@ -0,0 +1,148 @@ +// +// EXICodec.swift +// Meshtastic +// +// Zlib compression for CoT events over mesh network. +// Uses standard zlib format (78 xx header) for Android interoperability. +// +// IMPORTANT: Uses C zlib library directly to produce standard zlib format. +// Apple's Compression framework produces raw deflate which is NOT compatible +// with Android's standard zlib decompressor. +// +// Zlib header bytes: +// - 78 01: No compression +// - 78 9C: Default compression (what we use) +// - 78 DA: Best compression +// + +import Foundation +import zlib +import OSLog + +/// Codec for compressing/decompressing CoT XML using standard zlib +/// Named EXICodec for historical reasons - now uses zlib for Android compatibility +final class EXICodec { + + static let shared = EXICodec() + + private init() {} + + // MARK: - Compression + + /// Compress CoT XML to binary format using zlib + /// - Parameter xml: The CoT XML string + /// - Returns: Compressed data (78 9C header), or nil if compression failed + func compress(_ xml: String) -> Data? { + guard let xmlData = xml.data(using: .utf8) else { + Logger.tak.error("Zlib: Failed to convert XML to UTF-8 data") + return nil + } + + // Use standard zlib compression (produces 78 9C header that Android expects) + guard let compressed = compressZlib(xmlData) else { + Logger.tak.warning("Zlib: Compression failed, using raw data") + return xmlData + } + + let ratio = Double(compressed.count) / Double(xmlData.count) * 100 + Logger.tak.info("Zlib: Compressed \(xmlData.count) β†’ \(compressed.count) bytes (\(String(format: "%.1f", ratio))%)") + + // Log first few bytes to verify format (should start with 78 9C) + if compressed.count >= 2 { + Logger.tak.debug("Zlib: Header: \(String(format: "%02X %02X", compressed[0], compressed[1]))") + } + + return compressed + } + + /// Decompress zlib data to CoT XML + /// - Parameter data: The compressed data (expects 78 xx header) + /// - Returns: Decompressed XML string, or nil if decompression failed + func decompress(_ data: Data) -> String? { + // Log header for debugging + if data.count >= 2 { + Logger.tak.debug("Zlib: Decompressing data with header: \(String(format: "%02X %02X", data[0], data[1]))") + } + + // Try standard zlib decompression (78 xx header) + if let decompressed = decompressZlib(data) { + if let xml = String(data: decompressed, encoding: .utf8) { + Logger.tak.debug("Zlib: Decompressed \(data.count) β†’ \(decompressed.count) bytes") + return xml + } + } + + // Fallback: try interpreting as raw UTF-8 (uncompressed) + if let xml = String(data: data, encoding: .utf8) { + Logger.tak.debug("Zlib: Data was uncompressed UTF-8 (\(data.count) bytes)") + return xml + } + + Logger.tak.error("Zlib: Failed to decompress data (\(data.count) bytes)") + return nil + } + + // MARK: - Zlib Implementation + + /// Compress data using standard zlib format (78 9C header) + /// Uses C zlib library directly for Android compatibility + private func compressZlib(_ data: Data) -> Data? { + // Calculate maximum compressed size + var compressedLength = compressBound(uLong(data.count)) + var compressed = Data(count: Int(compressedLength)) + + let result = compressed.withUnsafeMutableBytes { destPtr in + data.withUnsafeBytes { srcPtr in + compress2( + destPtr.bindMemory(to: Bytef.self).baseAddress!, + &compressedLength, + srcPtr.bindMemory(to: Bytef.self).baseAddress!, + uLong(data.count), + Z_DEFAULT_COMPRESSION + ) + } + } + + guard result == Z_OK else { + Logger.tak.error("Zlib: compress2 failed with code \(result)") + return nil + } + + return compressed.prefix(Int(compressedLength)) + } + + /// Decompress standard zlib data (78 xx header) + private func decompressZlib(_ data: Data) -> Data? { + // Estimate uncompressed size (start with 10x, will retry if needed) + var uncompressedLength = uLong(data.count * 10) + var maxAttempts = 3 + + while maxAttempts > 0 { + var uncompressed = Data(count: Int(uncompressedLength)) + + let result = uncompressed.withUnsafeMutableBytes { destPtr in + data.withUnsafeBytes { srcPtr in + uncompress( + destPtr.bindMemory(to: Bytef.self).baseAddress!, + &uncompressedLength, + srcPtr.bindMemory(to: Bytef.self).baseAddress!, + uLong(data.count) + ) + } + } + + if result == Z_OK { + return uncompressed.prefix(Int(uncompressedLength)) + } else if result == Z_BUF_ERROR { + // Buffer too small, try larger + uncompressedLength *= 2 + maxAttempts -= 1 + } else { + Logger.tak.debug("Zlib: uncompress failed with code \(result)") + return nil + } + } + + return nil + } +} diff --git a/Meshtastic/Helpers/TAK/FountainCodec.swift b/Meshtastic/Helpers/TAK/FountainCodec.swift new file mode 100644 index 000000000..95f559a64 --- /dev/null +++ b/Meshtastic/Helpers/TAK/FountainCodec.swift @@ -0,0 +1,616 @@ +// +// FountainCodec.swift +// Meshtastic +// +// Fountain code (LT codes) implementation for reliable transfer over lossy mesh networks +// Based on the ATAK Meshtastic plugin protocol +// + +import Foundation +import CryptoKit +import OSLog + +// MARK: - Constants + +enum FountainConstants { + /// Magic bytes identifying fountain packets: "FTN" + static let magic: [UInt8] = [0x46, 0x54, 0x4E] + + /// Maximum payload size per block + static let blockSize = 220 + + /// Header size for data blocks + static let dataHeaderSize = 11 + + /// Size threshold for fountain coding (below this, send directly) + static let fountainThreshold = 233 + + /// Transfer type: CoT event + static let transferTypeCot: UInt8 = 0x00 + + /// Transfer type: File transfer + static let transferTypeFile: UInt8 = 0x01 + + /// ACK type: Transfer complete + static let ackTypeComplete: UInt8 = 0x02 + + /// ACK type: Need more blocks + static let ackTypeNeedMore: UInt8 = 0x03 + + /// ACK packet size + static let ackPacketSize = 19 +} + +// MARK: - Fountain Packet Types + +/// A received fountain block with its metadata +struct FountainBlock { + let seed: UInt16 + var indices: Set + var payload: Data + + func copy() -> FountainBlock { + return FountainBlock(seed: seed, indices: indices, payload: payload) + } +} + +/// State for receiving a fountain-coded transfer +class FountainReceiveState { + let transferId: UInt32 + let K: Int + let totalLength: Int + var blocks: [FountainBlock] = [] + let createdAt: Date + + init(transferId: UInt32, K: Int, totalLength: Int) { + self.transferId = transferId + self.K = K + self.totalLength = totalLength + self.createdAt = Date() + } + + func addBlock(_ block: FountainBlock) { + // Don't add duplicate seeds + if !blocks.contains(where: { $0.seed == block.seed }) { + blocks.append(block) + } + } + + var isExpired: Bool { + // Expire after 60 seconds + return Date().timeIntervalSince(createdAt) > 60 + } +} + +/// Parsed fountain data block header +struct FountainDataHeader { + let transferId: UInt32 // 24-bit, stored in lower 24 bits + let seed: UInt16 + let K: UInt8 + let totalLength: UInt16 +} + +/// Parsed fountain ACK packet +struct FountainAck { + let transferId: UInt32 + let type: UInt8 + let received: UInt16 + let needed: UInt16 + let dataHash: Data +} + +// MARK: - Java-Compatible Random Number Generator + +/// Java's java.util.Random implementation (Linear Congruential Generator) +/// CRITICAL: Must match Java exactly for Android interoperability +struct JavaRandom { + private var seed: Int64 + + init(seed: Int64) { + // Java's Random constructor: (seed ^ 0x5DEECE66DL) & ((1L << 48) - 1) + self.seed = (seed ^ 0x5DEECE66D) & ((Int64(1) << 48) - 1) + } + + /// Generate next random bits (Java's protected next(int bits) method) + mutating func next(bits: Int) -> Int32 { + // seed = (seed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1) + seed = (seed &* 0x5DEECE66D &+ 0xB) & ((Int64(1) << 48) - 1) + return Int32(truncatingIfNeeded: seed >> (48 - bits)) + } + + /// Generate random int in [0, bound) - matches Java's nextInt(int bound) + mutating func nextInt(bound: Int) -> Int { + guard bound > 0 else { return 0 } + + // Power of 2 optimization + if (bound & -bound) == bound { + return Int((Int64(bound) &* Int64(next(bits: 31))) >> 31) + } + + // Rejection sampling to avoid modulo bias + var bits: Int32 + var val: Int + repeat { + bits = next(bits: 31) + val = Int(bits) % bound + } while bits - Int32(val) + Int32(bound - 1) < 0 + + return val + } + + /// Generate random double in [0.0, 1.0) - matches Java's nextDouble() + mutating func nextDouble() -> Double { + let high = Int64(next(bits: 26)) + let low = Int64(next(bits: 27)) + return Double((high << 27) + low) / Double(Int64(1) << 53) + } +} + +// MARK: - Fountain Codec + +/// Encoder and decoder for fountain-coded transfers +final class FountainCodec { + + static let shared = FountainCodec() + + private var receiveStates: [UInt32: FountainReceiveState] = [:] + + private init() {} + + // MARK: - Transfer ID Generation + + /// Generate a unique random 24-bit transfer ID + /// CRITICAL: Must be random to avoid collisions with recent transfers + func generateTransferId() -> UInt32 { + let random = UInt32.random(in: 0...0xFFFFFF) + let time = UInt32(Date().timeIntervalSince1970) & 0xFFFF + return (random ^ time) & 0xFFFFFF + } + + // MARK: - Encoding + + /// Encode data into fountain-coded blocks + /// - Parameters: + /// - data: The data to encode (should include transfer type prefix) + /// - transferId: Unique transfer ID for this transmission + /// - Returns: Array of encoded block packets ready for transmission + func encode(data: Data, transferId: UInt32) -> [Data] { + // Guard against empty data + guard !data.isEmpty else { + Logger.tak.warning("Fountain encode: empty data") + return [] + } + + let K = max(1, Int(ceil(Double(data.count) / Double(FountainConstants.blockSize)))) + let overhead = getAdaptiveOverhead(K) + let blocksToSend = max(1, Int(ceil(Double(K) * (1.0 + overhead)))) + + // Split into source blocks (pad last block with zeros) + let sourceBlocks = splitIntoBlocks(data: data, K: K) + + // Debug: Log source block hashes to verify they're different + for (i, block) in sourceBlocks.enumerated() { + let hash = block.prefix(8).map { String(format: "%02X", $0) }.joined() + Logger.tak.debug("Fountain sourceBlock[\(i)]: first 8 bytes = \(hash)") + } + + var packets: [Data] = [] + + for i in 0.. [Data] { + var blocks: [Data] = [] + for i in 0.. Data { + var packet = Data() + + // Magic bytes + packet.append(contentsOf: FountainConstants.magic) + + // Transfer ID (24-bit, big-endian) + packet.append(UInt8((transferId >> 16) & 0xFF)) + packet.append(UInt8((transferId >> 8) & 0xFF)) + packet.append(UInt8(transferId & 0xFF)) + + // Seed (16-bit, big-endian) + packet.append(UInt8((seed >> 8) & 0xFF)) + packet.append(UInt8(seed & 0xFF)) + + // K (number of source blocks) + packet.append(K) + + // Total length (16-bit, big-endian) + packet.append(UInt8((totalLength >> 8) & 0xFF)) + packet.append(UInt8(totalLength & 0xFF)) + + // Payload + packet.append(payload) + + return packet + } + + // MARK: - Decoding + + /// Check if data is a fountain packet + static func isFountainPacket(_ data: Data) -> Bool { + guard data.count >= 3 else { return false } + return data[0] == FountainConstants.magic[0] + && data[1] == FountainConstants.magic[1] + && data[2] == FountainConstants.magic[2] + } + + /// Parse a fountain data block header + func parseDataHeader(_ data: Data) -> FountainDataHeader? { + guard data.count >= FountainConstants.dataHeaderSize else { return nil } + guard Self.isFountainPacket(data) else { return nil } + + let transferId = (UInt32(data[3]) << 16) | (UInt32(data[4]) << 8) | UInt32(data[5]) + let seed = (UInt16(data[6]) << 8) | UInt16(data[7]) + let K = data[8] + let totalLength = (UInt16(data[9]) << 8) | UInt16(data[10]) + + return FountainDataHeader(transferId: transferId, seed: seed, K: K, totalLength: totalLength) + } + + /// Handle an incoming fountain packet + /// - Parameters: + /// - data: The raw packet data + /// - senderNodeId: ID of the sending node + /// - Returns: Decoded data if transfer is complete, nil otherwise + func handleIncomingPacket(_ data: Data, senderNodeId: UInt32) -> (data: Data, transferId: UInt32)? { + // Clean up expired states + cleanupExpiredStates() + + guard let header = parseDataHeader(data) else { + Logger.tak.warning("Invalid fountain packet header") + return nil + } + + let payload = data.dropFirst(FountainConstants.dataHeaderSize) + guard payload.count == FountainConstants.blockSize else { + Logger.tak.warning("Invalid fountain payload size: \(payload.count)") + return nil + } + + // Get or create receive state + let state: FountainReceiveState + if let existing = receiveStates[header.transferId] { + state = existing + } else { + state = FountainReceiveState( + transferId: header.transferId, + K: Int(header.K), + totalLength: Int(header.totalLength) + ) + receiveStates[header.transferId] = state + Logger.tak.debug("New fountain transfer: id=\(header.transferId), K=\(header.K), len=\(header.totalLength)") + } + + // Regenerate source indices from seed + let indices = regenerateIndices(seed: header.seed, K: state.K, transferId: header.transferId) + + // Add block + let block = FountainBlock(seed: header.seed, indices: indices, payload: Data(payload)) + state.addBlock(block) + + Logger.tak.debug("Fountain block received: xferId=\(header.transferId), seed=\(header.seed), blocks=\(state.blocks.count)/\(state.K)") + + // Try to decode if we have enough blocks + if state.blocks.count >= state.K { + if let decoded = peelingDecode(state) { + // Remove completed state + receiveStates.removeValue(forKey: header.transferId) + Logger.tak.info("Fountain decode complete: \(decoded.count) bytes from \(state.blocks.count) blocks") + return (decoded, header.transferId) + } + } + + return nil + } + + /// Build an ACK packet + func buildAck(transferId: UInt32, type: UInt8, received: UInt16, needed: UInt16, dataHash: Data) -> Data { + var packet = Data() + + // Magic bytes + packet.append(contentsOf: FountainConstants.magic) + + // Transfer ID (24-bit, big-endian) + packet.append(UInt8((transferId >> 16) & 0xFF)) + packet.append(UInt8((transferId >> 8) & 0xFF)) + packet.append(UInt8(transferId & 0xFF)) + + // Type + packet.append(type) + + // Received (16-bit, big-endian) + packet.append(UInt8((received >> 8) & 0xFF)) + packet.append(UInt8(received & 0xFF)) + + // Needed (16-bit, big-endian) + packet.append(UInt8((needed >> 8) & 0xFF)) + packet.append(UInt8(needed & 0xFF)) + + // Data hash (8 bytes) + packet.append(dataHash.prefix(8)) + + return packet + } + + /// Parse an ACK packet + func parseAck(_ data: Data) -> FountainAck? { + guard data.count >= FountainConstants.ackPacketSize else { return nil } + guard Self.isFountainPacket(data) else { return nil } + + let transferId = (UInt32(data[3]) << 16) | (UInt32(data[4]) << 8) | UInt32(data[5]) + let type = data[6] + let received = (UInt16(data[7]) << 8) | UInt16(data[8]) + let needed = (UInt16(data[9]) << 8) | UInt16(data[10]) + let dataHash = Data(data[11..<19]) + + return FountainAck(transferId: transferId, type: type, received: received, needed: needed, dataHash: dataHash) + } + + // MARK: - Peeling Decoder + + /// Decode using the peeling algorithm + private func peelingDecode(_ state: FountainReceiveState) -> Data? { + var decoded: [Int: Data] = [:] + var workingBlocks = state.blocks.map { $0.copy() } + + var progress = true + while progress && decoded.count < state.K { + progress = false + + for i in 0..= state.K else { + Logger.tak.debug("Peeling decode incomplete: \(decoded.count)/\(state.K) blocks decoded") + return nil + } + + // Reassemble original data + var result = Data() + for i in 0.. Double { + if K <= 10 { return 0.50 } // 50% for very small + else if K <= 50 { return 0.25 } // 25% for small + else { return 0.15 } // 15% for larger + } + + /// Generate deterministic seed from transfer ID and block index + private func generateSeed(transferId: UInt32, blockIndex: Int) -> UInt16 { + let combined = Int(transferId) * 31337 + blockIndex * 7919 + return UInt16(combined & 0xFFFF) + } + + /// Generate indices for encoding a block + /// CRITICAL: Must match Android's exact algorithm for interoperability + /// Android uses Java's java.util.Random (LCG) with specific block 0 handling + private func generateBlockIndices(seed: UInt16, K: Int, blockIndex: Int) -> Set { + var rng = JavaRandom(seed: Int64(seed)) + + // ALWAYS sample degree first (advances RNG state) - matches Android + let sampledDegree = sampleRobustSolitonDegree(&rng, K: K) + + // For block 0: ignore sampled degree, use degree=1 instead + // For other blocks: use the sampled degree + // This matches Android's isFirstBlock logic + let degree = (blockIndex == 0) ? 1 : sampledDegree + + // Select indices with RNG now advanced past degree sampling + return selectIndices(&rng, K: K, degree: degree) + } + + /// Regenerate source indices from seed (must match sender's algorithm) + /// CRITICAL: Must use same RNG flow as generateBlockIndices for Android interop + private func regenerateIndices(seed: UInt16, K: Int, transferId: UInt32) -> Set { + var rng = JavaRandom(seed: Int64(seed)) + + // ALWAYS sample degree first (advances RNG state) - matches Android + let sampledDegree = sampleRobustSolitonDegree(&rng, K: K) + + // Check if this is block 0 (forced degree=1) + let expectedSeed0 = generateSeed(transferId: transferId, blockIndex: 0) + let degree = (seed == expectedSeed0) ? 1 : sampledDegree + + // Select indices with RNG now advanced past degree sampling + return selectIndices(&rng, K: K, degree: degree) + } + + /// Select source block indices using provided RNG + /// Matches Android's selectIndices algorithm exactly + private func selectIndices(_ rng: inout JavaRandom, K: Int, degree: Int) -> Set { + var indices = Set() + + // Select 'degree' unique indices + while indices.count < degree && indices.count < K { + let idx = rng.nextInt(bound: K) + indices.insert(idx) + } + + return indices + } + + /// Sample degree from Robust Soliton distribution using provided RNG + /// Matches Android's sampleDegree algorithm exactly + private func sampleRobustSolitonDegree(_ rng: inout JavaRandom, K: Int) -> Int { + let cdf = buildRobustSolitonCDF(K: K) + let u = rng.nextDouble() + + for d in 1...K { + if u <= cdf[d] { + return d + } + } + return K + } + + /// Build CDF for Robust Soliton distribution + private func buildRobustSolitonCDF(K: Int, c: Double = 0.1, delta: Double = 0.5) -> [Double] { + // Guard against K <= 0 + guard K > 0 else { + return [1.0] // Single element CDF + } + + // Ideal Soliton distribution + var rho = [Double](repeating: 0, count: K + 1) + rho[1] = 1.0 / Double(K) + for d in 2...K { + rho[d] = 1.0 / (Double(d) * Double(d - 1)) + } + + // Robust Soliton addition (tau) + let R = c * log(Double(K) / delta) * sqrt(Double(K)) + var tau = [Double](repeating: 0, count: K + 1) + let threshold = Int(Double(K) / R) + + for d in 1...K { + if d < threshold { + tau[d] = R / (Double(d) * Double(K)) + } else if d == threshold { + tau[d] = R * log(R / delta) / Double(K) + } + } + + // Combine and normalize + var mu = [Double](repeating: 0, count: K + 1) + var sum = 0.0 + for d in 1...K { + mu[d] = rho[d] + tau[d] + sum += mu[d] + } + + // Build CDF + var cdf = [Double](repeating: 0, count: K + 1) + var cumulative = 0.0 + for d in 1...K { + cumulative += mu[d] / sum + cdf[d] = cumulative + } + + return cdf + } + + /// XOR two data blocks + private func xor(_ a: Data, _ b: Data) -> Data { + // IMPORTANT: Rebase inputs to ensure 0-based indices + // Data slices keep original indices which causes crashes when accessing [i] + let aData = a.startIndex == 0 ? a : Data(a) + let bData = b.startIndex == 0 ? b : Data(b) + + var result = Data(count: max(aData.count, bData.count)) + for i in 0.. Data { + let digest = SHA256.hash(data: data) + return Data(digest.prefix(8)) + } + + /// Clean up expired receive states + private func cleanupExpiredStates() { + let expiredIds = receiveStates.filter { $0.value.isExpired }.map { $0.key } + for id in expiredIds { + receiveStates.removeValue(forKey: id) + Logger.tak.debug("Cleaned up expired fountain state: \(id)") + } + } +} diff --git a/Meshtastic/Helpers/TAK/GenericCoTHandler.swift b/Meshtastic/Helpers/TAK/GenericCoTHandler.swift new file mode 100644 index 000000000..6ed357fdf --- /dev/null +++ b/Meshtastic/Helpers/TAK/GenericCoTHandler.swift @@ -0,0 +1,399 @@ +// +// GenericCoTHandler.swift +// Meshtastic +// +// Handles generic CoT events that don't map to TAKPacket protobuf +// Uses EXI compression and Fountain codes for reliable transfer +// + +import Foundation +import MeshtasticProtobufs +import OSLog + +/// Port numbers for TAK communication +enum TAKPortNum: UInt32 { + /// TAKPacket protobuf (PLI, GeoChat) - small, structured messages + case atakPlugin = 72 + + /// EXI-compressed CoT XML - generic/large messages, fountain coded + case atakForwarder = 257 +} + +/// Handler for generic CoT events over the mesh network +@MainActor +final class GenericCoTHandler { + + static let shared = GenericCoTHandler() + + weak var accessoryManager: AccessoryManager? + + /// Pending outgoing fountain transfers awaiting ACK + private var pendingTransfers: [UInt32: PendingTransfer] = [:] + + private init() {} + + // MARK: - Outgoing CoT Classification + + /// Determine how a CoT message should be sent + enum CoTSendMethod { + /// Use TAKPacket.pli on ATAK_PLUGIN port + case takPacketPLI + /// Use TAKPacket.chat on ATAK_PLUGIN port + case takPacketChat + /// Use EXI compression on ATAK_FORWARDER port (small, no fountain) + case exiDirect + /// Use EXI + Fountain coding on ATAK_FORWARDER port (large) + case exiFountain + } + + /// Classify a CoT message to determine send method + func classifySendMethod(for cot: CoTMessage) -> CoTSendMethod { + // Self PLI (position) + if cot.type.hasPrefix("a-f-G") || cot.type.hasPrefix("a-f-g") { + return .takPacketPLI + } + + // GeoChat + if cot.type == "b-t-f" { + return .takPacketChat + } + + // Everything else goes through EXI/Forwarder + // Check compressed size to determine if fountain coding needed + let xml = cot.toXML() + if let compressed = EXICodec.shared.compress(xml) { + // +1 for transfer type byte + if compressed.count + 1 < FountainConstants.fountainThreshold { + return .exiDirect + } else { + return .exiFountain + } + } + + // Fallback to direct (compression failed, use raw) + return .exiDirect + } + + // MARK: - Sending Generic CoT + + /// Send a generic CoT event (markers, shapes, routes, etc.) + /// - Parameters: + /// - cot: The CoT message to send + /// - channel: Meshtastic channel (0 = primary) + func sendGenericCoT(_ cot: CoTMessage, channel: UInt32 = 0) async throws { + guard let accessoryManager else { + throw GenericCoTError.notConnected + } + + guard accessoryManager.isConnected else { + throw GenericCoTError.notConnected + } + + // Compress to EXI + let xml = cot.toXML() + guard let exiData = EXICodec.shared.compress(xml) else { + throw GenericCoTError.compressionFailed + } + + // Prepend transfer type + var payload = Data([FountainConstants.transferTypeCot]) + payload.append(exiData) + + Logger.tak.debug("Generic CoT: type=\(cot.type), xml=\(xml.count)B, compressed=\(payload.count)B") + + // Check if small enough to send directly + if payload.count < FountainConstants.fountainThreshold { + try await sendDirect(payload, channel: channel) + } else { + try await sendFountainCoded(payload, channel: channel) + } + } + + /// Send small payload directly (no fountain coding) + private func sendDirect(_ payload: Data, channel: UInt32) async throws { + guard let accessoryManager, let activeConnection = accessoryManager.activeConnection else { + throw GenericCoTError.notConnected + } + + guard let deviceNum = activeConnection.device.num else { + throw GenericCoTError.noDeviceNumber + } + + var dataMessage = DataMessage() + dataMessage.portnum = .atakForwarder // Port 257 + dataMessage.payload = payload + + var meshPacket = MeshPacket() + meshPacket.to = 0xFFFFFFFF // Broadcast + meshPacket.from = UInt32(deviceNum) + meshPacket.channel = channel + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. CoTMessage? { + guard case let .decoded(data) = packet.payloadVariant else { + Logger.tak.warning("ATAK_FORWARDER packet without decoded payload") + return nil + } + + let payload = data.payload + guard !payload.isEmpty else { + Logger.tak.warning("Empty ATAK_FORWARDER payload") + return nil + } + + // Check if this is a fountain packet (starts with "FTN" magic) + if FountainCodec.isFountainPacket(payload) { + // Distinguish between ACK (19 bytes) and data block (231 bytes) + // ACK: magic(3) + transferId(3) + type(1) + received(2) + needed(2) + hash(8) = 19 + // Data: magic(3) + transferId(3) + seed(2) + K(1) + totalLen(2) + payload(220) = 231 + if payload.count == FountainConstants.ackPacketSize { + // This is a fountain ACK - handle it and return nil (no CoT to forward) + handleIncomingAck(payload, from: packet.from) + return nil + } + return handleFountainPacket(payload, from: packet.from) + } + + // Direct packet (not fountain coded) + return handleDirectPacket(payload, from: packet.from) + } + + /// Handle direct (non-fountain) packet + private func handleDirectPacket(_ payload: Data, from nodeNum: UInt32) -> CoTMessage? { + guard payload.count > 1 else { + Logger.tak.warning("Direct packet too short: \(payload.count) bytes") + return nil + } + + let transferType = payload[0] + let exiData = payload.dropFirst() + + guard transferType == FountainConstants.transferTypeCot else { + Logger.tak.debug("Ignoring non-CoT transfer type: \(transferType)") + return nil + } + + // Decompress EXI to XML + guard let xml = EXICodec.shared.decompress(Data(exiData)) else { + Logger.tak.warning("Failed to decompress EXI data from node \(nodeNum)") + return nil + } + + // Parse CoT XML + guard let cot = CoTMessage.parse(from: xml) else { + Logger.tak.warning("Failed to parse CoT XML from node \(nodeNum)") + return nil + } + + Logger.tak.info("Received generic CoT from node \(nodeNum): \(cot.type)") + return cot + } + + /// Handle fountain-coded packet + private func handleFountainPacket(_ payload: Data, from nodeNum: UInt32) -> CoTMessage? { + // Pass to fountain codec + guard let (decodedData, transferId) = FountainCodec.shared.handleIncomingPacket(payload, senderNodeId: nodeNum) else { + // Not yet complete, waiting for more blocks + return nil + } + + // Transfer complete - send ACK (twice for redundancy) + let hash = FountainCodec.computeHash(decodedData) + Task { + await sendFountainAck(transferId: transferId, hash: hash, to: nodeNum) + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms delay + await sendFountainAck(transferId: transferId, hash: hash, to: nodeNum) + } + + // Extract transfer type and data + guard decodedData.count > 1 else { + Logger.tak.warning("Decoded fountain data too short") + return nil + } + + let transferType = decodedData[0] + let exiData = decodedData.dropFirst() + + guard transferType == FountainConstants.transferTypeCot else { + Logger.tak.debug("Ignoring non-CoT fountain transfer type: \(transferType)") + return nil + } + + // Decompress EXI to XML + guard let xml = EXICodec.shared.decompress(Data(exiData)) else { + Logger.tak.warning("Failed to decompress fountain EXI data") + return nil + } + + // Parse CoT XML + guard let cot = CoTMessage.parse(from: xml) else { + Logger.tak.warning("Failed to parse fountain CoT XML") + return nil + } + + Logger.tak.info("Received fountain-coded CoT from node \(nodeNum): \(cot.type)") + return cot + } + + /// Send fountain ACK + private func sendFountainAck(transferId: UInt32, hash: Data, to nodeNum: UInt32) async { + guard let accessoryManager, let activeConnection = accessoryManager.activeConnection else { + return + } + + guard let deviceNum = activeConnection.device.num else { + return + } + + let ackPacket = FountainCodec.shared.buildAck( + transferId: transferId, + type: FountainConstants.ackTypeComplete, + received: 0, + needed: 0, + dataHash: hash + ) + + var dataMessage = DataMessage() + dataMessage.portnum = .atakForwarder + dataMessage.payload = ackPacket + + var meshPacket = MeshPacket() + meshPacket.to = nodeNum + meshPacket.from = UInt32(deviceNum) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag, + kSecReturnRef as String: true + ] + var item: CFTypeRef? + return SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess + } + + /// Get the bundled CA certificate data for sharing to ITAK + func getBundledCACertificateData() -> Data? { + let pemURL = Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "ca", withExtension: "pem") + + guard let url = pemURL, let pemData = try? Data(contentsOf: url) else { + return nil + } + return pemData + } + + /// Get URL to bundled CA certificate for sharing + func getBundledCACertificateURL() -> URL? { + return Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "ca", withExtension: "pem") + } + + /// Get the bundled server P12 data for sharing to ITAK (used as truststore) + func getBundledServerP12Data() -> Data? { + let p12URL = Bundle.main.url(forResource: "server", withExtension: "p12", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "server", withExtension: "p12") + + guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else { + return nil + } + return p12Data + } + + /// Get the password for bundled certificates (for data package) + func getBundledCertificatePassword() -> String { + return bundledPassword + } + + /// Get the bundled client P12 data for sharing to ITAK (for mutual TLS) + func getBundledClientP12Data() -> Data? { + let p12URL = Bundle.main.url(forResource: "client", withExtension: "p12", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "client", withExtension: "p12") + + guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else { + return nil + } + return p12Data + } + + /// Check if a bundled client certificate exists + func hasBundledClientCertificate() -> Bool { + return getBundledClientP12Data() != nil + } + + // MARK: - Active Certificate Data (for Data Package) + + /// Get the active server P12 data (custom if available, otherwise bundled) + /// Used for generating data packages + func getActiveServerP12Data() -> Data? { + // Check for custom certificate first + if hasCustomServerCertificate(), + let customData = UserDefaults.standard.data(forKey: customServerP12DataKey) { + Logger.tak.debug("Using custom server P12 for data package") + return customData + } + // Fall back to bundled + Logger.tak.debug("Using bundled server P12 for data package") + return getBundledServerP12Data() + } + + /// Get the active client P12 data (custom if available, otherwise bundled) + /// Used for generating data packages + func getActiveClientP12Data() -> Data? { + // Check for custom certificate first + if let customData = UserDefaults.standard.data(forKey: customClientP12DataKey) { + Logger.tak.debug("Using custom client P12 for data package") + return customData + } + // Fall back to bundled + Logger.tak.debug("Using bundled client P12 for data package") + return getBundledClientP12Data() + } + + /// Get the password for the active server certificate + func getActiveServerCertificatePassword() -> String { + if hasCustomServerCertificate(), + let customPassword = UserDefaults.standard.string(forKey: customServerP12PasswordKey) { + return customPassword + } + return bundledPassword + } + + /// Get the password for the active client certificate + func getActiveClientCertificatePassword() -> String { + if let customPassword = UserDefaults.standard.string(forKey: customClientP12PasswordKey) { + return customPassword + } + return bundledPassword + } + + /// Import a custom client P12 certificate (for data package generation) + func importCustomClientP12(data: Data, password: String) { + UserDefaults.standard.set(data, forKey: customClientP12DataKey) + UserDefaults.standard.set(password, forKey: customClientP12PasswordKey) + Logger.tak.info("Custom client P12 imported for data package") + } + + /// Check if custom client P12 is available + func hasCustomClientP12() -> Bool { + return UserDefaults.standard.data(forKey: customClientP12DataKey) != nil + } + + /// Clear custom certificate data (called when resetting to defaults) + private func clearCustomCertificateData() { + UserDefaults.standard.removeObject(forKey: customServerP12DataKey) + UserDefaults.standard.removeObject(forKey: customServerP12PasswordKey) + UserDefaults.standard.removeObject(forKey: customClientP12DataKey) + UserDefaults.standard.removeObject(forKey: customClientP12PasswordKey) + Logger.tak.debug("Cleared custom certificate data") + } + + // MARK: - Server Identity (PKCS#12) + + /// Import server identity from PKCS#12 (.p12) file data + /// - Parameters: + /// - p12Data: The raw PKCS#12 file data + /// - password: Password to decrypt the PKCS#12 file + /// - isCustom: Whether this is a user-imported custom certificate (default: true) + /// - Returns: The imported SecIdentity + func importServerIdentity(from p12Data: Data, password: String, isCustom: Bool = true) throws -> SecIdentity { + let options: [String: Any] = [kSecImportExportPassphrase as String: password] + var items: CFArray? + + let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items) + + guard status == errSecSuccess else { + Logger.tak.error("Failed to import PKCS#12: \(status)") + throw TAKCertificateError.importFailed(status) + } + + guard let itemArray = items as? [[String: Any]], + let firstItem = itemArray.first, + let identity = firstItem[kSecImportItemIdentity as String] as! SecIdentity? else { + throw TAKCertificateError.noIdentityFound + } + + // Store in Keychain for persistence + try storeServerIdentity(identity, isCustom: isCustom) + + // Store the raw P12 data and password for data package generation (only for custom certs) + if isCustom { + UserDefaults.standard.set(p12Data, forKey: customServerP12DataKey) + UserDefaults.standard.set(password, forKey: customServerP12PasswordKey) + Logger.tak.debug("Stored custom server P12 data for data package generation") + } + + Logger.tak.info("Server identity imported successfully (custom: \(isCustom))") + return identity + } + + /// Store server identity in Keychain + private func storeServerIdentity(_ identity: SecIdentity, isCustom: Bool = true) throws { + let tag = isCustom ? serverIdentityCustomTag : serverIdentityTag + + // First delete any existing identity with this tag + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: tag + ] + SecItemDelete(deleteQuery as CFDictionary) + + // If storing custom cert, also delete the bundled one (custom takes precedence) + if isCustom { + let deleteBundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag + ] + SecItemDelete(deleteBundledQuery as CFDictionary) + } + + // Add new identity + let addQuery: [String: Any] = [ + kSecValueRef as String: identity, + kSecAttrLabel as String: tag, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + Logger.tak.error("Failed to store server identity in Keychain: \(status)") + throw TAKCertificateError.keychainError(status) + } + } + + /// Retrieve stored server identity from Keychain + /// Custom certificates take precedence over bundled ones + func getServerIdentity() -> SecIdentity? { + // First try custom certificate + let customQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag, + kSecReturnRef as String: true + ] + + var item: CFTypeRef? + var status = SecItemCopyMatching(customQuery as CFDictionary, &item) + + if status == errSecSuccess { + return (item as! SecIdentity) + } + + // Fall back to bundled certificate + let bundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag, + kSecReturnRef as String: true + ] + + status = SecItemCopyMatching(bundledQuery as CFDictionary, &item) + + guard status == errSecSuccess else { + if status != errSecItemNotFound { + Logger.tak.warning("Failed to retrieve server identity: \(status)") + } + return nil + } + + return (item as! SecIdentity) + } + + /// Check if server certificate is configured + func hasServerCertificate() -> Bool { + return getServerIdentity() != nil + } + + /// Delete custom server identity and reload bundled default + func deleteServerIdentity() { + // Delete custom certificate + let customQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag + ] + let customStatus = SecItemDelete(customQuery as CFDictionary) + + // Delete bundled certificate too + let bundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag + ] + let bundledStatus = SecItemDelete(bundledQuery as CFDictionary) + + if customStatus == errSecSuccess || bundledStatus == errSecSuccess { + Logger.tak.info("Server identity deleted") + } + + // Reload bundled default + loadBundledServerIdentity() + } + + /// Reset to bundled default certificate (deletes custom certificate) + func resetToDefaultServerCertificate() { + // Clear custom certificate data from UserDefaults + clearCustomCertificateData() + + // Delete custom certificate + let customQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag + ] + SecItemDelete(customQuery as CFDictionary) + + // Delete existing bundled and reload + let bundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag + ] + SecItemDelete(bundledQuery as CFDictionary) + + loadBundledServerIdentity() + Logger.tak.info("Reset to bundled default server certificate") + } + + /// Get certificate info for display purposes + func getServerCertificateInfo() -> String? { + guard let identity = getServerIdentity() else { return nil } + + var certificate: SecCertificate? + let status = SecIdentityCopyCertificate(identity, &certificate) + guard status == errSecSuccess, let cert = certificate else { return nil } + + let isCustom = hasCustomServerCertificate() + let prefix = isCustom ? "Custom: " : "Default: " + + if let summary = SecCertificateCopySubjectSummary(cert) as String? { + return prefix + summary + } + + return prefix + "Certificate loaded" + } + + // MARK: - Client CA Certificates (PEM) + + /// Import client CA certificate from PEM file data + /// - Parameter pemData: The raw PEM file data + /// - Returns: The imported SecCertificate + func importClientCACertificate(from pemData: Data) throws -> SecCertificate { + // Extract DER data from PEM format + let derData = try extractDERFromPEM(pemData) + + guard let certificate = SecCertificateCreateWithData(nil, derData as CFData) else { + throw TAKCertificateError.invalidCertificate + } + + // Store in Keychain + try storeClientCACertificate(certificate) + + Logger.tak.info("Client CA certificate imported successfully") + return certificate + } + + /// Extract DER-encoded certificate data from PEM format + private func extractDERFromPEM(_ pemData: Data) throws -> Data { + guard let pemString = String(data: pemData, encoding: .utf8) else { + throw TAKCertificateError.invalidPEM + } + + // Remove PEM headers and whitespace + let base64 = pemString + .replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "") + .replacingOccurrences(of: "-----END CERTIFICATE-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + + guard let derData = Data(base64Encoded: base64) else { + throw TAKCertificateError.invalidPEM + } + + return derData + } + + /// Store client CA certificate in Keychain + private func storeClientCACertificate(_ certificate: SecCertificate) throws { + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: certificate, + kSecAttrLabel as String: clientCATag, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + + // Ignore duplicate item errors (certificate already imported) + guard status == errSecSuccess || status == errSecDuplicateItem else { + Logger.tak.error("Failed to store client CA certificate: \(status)") + throw TAKCertificateError.keychainError(status) + } + } + + /// Get all stored client CA certificates + func getClientCACertificates() -> [SecCertificate] { + let query: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: clientCATag, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var items: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &items) + + guard status == errSecSuccess else { + if status != errSecItemNotFound { + Logger.tak.warning("Failed to retrieve client CA certificates: \(status)") + } + return [] + } + + // Handle both single item and array returns + if let certificates = items as? [SecCertificate] { + return certificates + } else if let certificate = items as! SecCertificate? { + return [certificate] + } + + return [] + } + + /// Check if at least one client CA certificate is configured + func hasClientCACertificate() -> Bool { + return !getClientCACertificates().isEmpty + } + + /// Delete all client CA certificates from Keychain + func deleteClientCACertificates() { + let query: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: clientCATag + ] + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess || status == errSecItemNotFound { + Logger.tak.info("Client CA certificates deleted") + } + } + + /// Get info about stored client CA certificates for display + func getClientCACertificateInfo() -> [String] { + let certificates = getClientCACertificates() + return certificates.compactMap { cert in + SecCertificateCopySubjectSummary(cert) as String? + } + } + + // MARK: - Certificate Validation + + /// Validate a client certificate against the stored CA certificates + func validateClientCertificate(_ trust: SecTrust) -> Bool { + let caCertificates = getClientCACertificates() + + guard !caCertificates.isEmpty else { + Logger.tak.warning("No client CA certificates configured for validation") + return false + } + + // Set the anchor certificates (trusted CAs) + SecTrustSetAnchorCertificates(trust, caCertificates as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + + var error: CFError? + let isValid = SecTrustEvaluateWithError(trust, &error) + + if !isValid { + Logger.tak.warning("Client certificate validation failed: \(error?.localizedDescription ?? "unknown")") + } + + return isValid + } +} + +// MARK: - Certificate Errors + +enum TAKCertificateError: LocalizedError { + case importFailed(OSStatus) + case noIdentityFound + case invalidCertificate + case invalidPEM + case keychainError(OSStatus) + case certificateExpired + case certificateNotYetValid + + var errorDescription: String? { + switch self { + case .importFailed(let status): + return "Failed to import PKCS#12: \(securityErrorMessage(status))" + case .noIdentityFound: + return "No identity (certificate + private key) found in PKCS#12 file" + case .invalidCertificate: + return "Invalid certificate data" + case .invalidPEM: + return "Invalid PEM format - ensure file contains a valid certificate" + case .keychainError(let status): + return "Keychain error: \(securityErrorMessage(status))" + case .certificateExpired: + return "Certificate has expired" + case .certificateNotYetValid: + return "Certificate is not yet valid" + } + } + + private func securityErrorMessage(_ status: OSStatus) -> String { + if let message = SecCopyErrorMessageString(status, nil) { + return message as String + } + return "Error code: \(status)" + } +} diff --git a/Meshtastic/Helpers/TAK/TAKConnection.swift b/Meshtastic/Helpers/TAK/TAKConnection.swift new file mode 100644 index 000000000..52d504e9a --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKConnection.swift @@ -0,0 +1,496 @@ +// +// TAKConnection.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import Network +import OSLog + +/// Actor managing a single TAK client TLS connection +/// Handles CoT XML streaming protocol (messages delimited by ) +/// Implements TAK Protocol negotiation and keepalive +actor TAKConnection { + private let connection: NWConnection + private var messageBuffer = Data() + private var readerTask: Task? + private var keepaliveTask: Task? + private var continuation: AsyncStream.Continuation? + + // CoT XML message delimiters (from StreamingCotProtocol.java) + private let startTag = " AsyncStream { + AsyncStream { continuation in + self.continuation = continuation + + continuation.onTermination = { [weak self] _ in + Task { [weak self] in + await self?.disconnect() + } + } + + // Set up state handler + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + Task { + await self.handleStateChange(state) + } + } + + // Start the connection + connection.start(queue: DispatchQueue(label: "tak.connection.\(UUID().uuidString)")) + } + } + + /// Handle connection state changes + private func handleStateChange(_ state: NWConnection.State) { + switch state { + case .ready: + isConnected = true + Logger.tak.info("TAK client connected: \(self.connection.endpoint.debugDescription)") + + // Extract client certificate info if available + extractClientInfo() + + // Notify connected + let info = clientInfo ?? TAKClientInfo(endpoint: connection.endpoint, connectedAt: Date()) + continuation?.yield(.connected(info)) + + // Send protocol support advertisement + Task { + await sendProtocolSupport() + } + + // Start reading data + startReading() + + // Start keepalive task + startKeepalive() + + case .failed(let error): + Logger.tak.error("TAK connection failed: \(error.localizedDescription)") + isConnected = false + continuation?.yield(.error(error)) + continuation?.yield(.disconnected) + continuation?.finish() + + case .cancelled: + Logger.tak.info("TAK connection cancelled") + isConnected = false + continuation?.yield(.disconnected) + continuation?.finish() + + case .waiting(let error): + Logger.tak.warning("TAK connection waiting: \(error.localizedDescription)") + + case .preparing: + Logger.tak.debug("TAK connection preparing") + + case .setup: + Logger.tak.debug("TAK connection setup") + + @unknown default: + break + } + } + + /// Extract client information from the TLS session + private func extractClientInfo() { + // Client callsign/uid will be updated when first CoT message is received + // For now just create basic client info with endpoint + clientInfo = TAKClientInfo( + endpoint: connection.endpoint, + callsign: nil, + uid: nil, + connectedAt: Date() + ) + Logger.tak.info("TAK client connected from: \(self.connection.endpoint.debugDescription)") + } + + /// Start the reader task to continuously read from the connection + private func startReading() { + readerTask = Task { + while !Task.isCancelled && isConnected { + do { + let data = try await receiveData() + if !data.isEmpty { + processReceivedData(data) + } + } catch { + if !Task.isCancelled { + Logger.tak.error("TAK read error: \(error.localizedDescription)") + continuation?.yield(.error(error)) + continuation?.yield(.disconnected) + } + break + } + } + } + } + + /// Receive data from the connection + private func receiveData() async throws -> Data { + try await withCheckedThrowingContinuation { cont in + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, isComplete, error in + if let error { + cont.resume(throwing: error) + return + } + if isComplete { + cont.resume(throwing: TAKConnectionError.connectionClosed) + return + } + if let content { + cont.resume(returning: content) + } else { + cont.resume(returning: Data()) + } + } + } + } + + /// Process received data using streaming CoT protocol + /// Based on StreamingCotProtocol.java parsing logic from TAK Server + private func processReceivedData(_ newData: Data) { + messageBuffer.append(newData) + + // Search for complete CoT messages (delimited by ) + while let endRange = messageBuffer.range(of: endTag) { + // Find the start tag before this end tag + guard let startRange = messageBuffer.range(of: startTag) else { + // No start tag found, discard data up to end tag + Logger.tak.warning("CoT end tag without start tag, discarding") + messageBuffer.removeSubrange(.. maxMessageSize { + Logger.tak.warning("Message buffer exceeded limit (\(self.messageBuffer.count) bytes), clearing") + messageBuffer.removeAll() + } + } + + /// Parse XML data and yield the message event + private func parseAndYieldMessage(_ data: Data) { + // Log raw XML for debugging + if let xmlString = String(data: data, encoding: .utf8) { + Logger.tak.debug("=== Received CoT XML (\(data.count) bytes) ===") + Logger.tak.debug("\(xmlString)") + Logger.tak.debug("=== End Raw XML ===") + } + + do { + let cotMessage = try CoTMessage.parse(from: data) + + // Handle TAK Protocol control messages + if cotMessage.type.hasPrefix("t-x-takp") { + Logger.tak.debug("Handling TAK Protocol control message: \(cotMessage.type)") + Task { + await handleProtocolControl(cotMessage) + } + return // Don't forward control messages to app + } + + // Handle ping/pong messages (don't forward, just acknowledge) + if cotMessage.type == "t-x-c-t" || cotMessage.uid == "ping" { + Logger.tak.debug("Received ping from client") + return + } + + // Update client info if we got contact details + if let contact = cotMessage.contact { + if clientInfo?.callsign == nil { + clientInfo?.callsign = contact.callsign + } + if clientInfo?.uid == nil { + clientInfo?.uid = cotMessage.uid + } + // Update the connected event with new info + if let info = clientInfo { + continuation?.yield(.clientInfoUpdated(info)) + } + } + + Logger.tak.info("Received CoT message: type=\(cotMessage.type), uid=\(cotMessage.uid)") + Logger.tak.debug(" contact: \(cotMessage.contact?.callsign ?? "nil")") + Logger.tak.debug(" lat/lon: \(cotMessage.latitude), \(cotMessage.longitude)") + continuation?.yield(.message(cotMessage)) + + } catch { + Logger.tak.warning("Failed to parse CoT message: \(error.localizedDescription)") + // Log the raw XML for debugging + if let xmlString = String(data: data, encoding: .utf8) { + Logger.tak.debug("Failed Raw CoT XML: \(xmlString.prefix(500))") + } + } + } + + // MARK: - Protocol Negotiation + + /// Send TAK Protocol Support advertisement to client + /// This tells the client what protocol versions we support (Version 0 = XML only) + private func sendProtocolSupport() async { + let now = ISO8601DateFormatter().string(from: Date()) + let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(60)) + + // TAK Protocol Support message - advertise version 0 (XML) only + // Type t-x-takp-v indicates TAK Protocol version advertisement + let xml = """ + + + + + + + + + """ + + do { + try await sendRawXML(xml) + Logger.tak.info("Sent TakProtocolSupport to client (version 0 - XML)") + } catch { + Logger.tak.error("Failed to send TakProtocolSupport: \(error.localizedDescription)") + } + } + + /// Handle TAK Protocol control messages (TakRequest, etc.) + private func handleProtocolControl(_ cotMessage: CoTMessage) async { + // Check for protocol request in the raw XML + // Type t-x-takp-q is a protocol request from client + if cotMessage.type == "t-x-takp-q" { + await sendProtocolResponse(accepted: true) + } + } + + /// Send protocol response to client + private func sendProtocolResponse(accepted: Bool) async { + let now = ISO8601DateFormatter().string(from: Date()) + let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(60)) + + // Type t-x-takp-r is TAK Protocol response + let xml = """ + + + + + + + + + """ + + do { + try await sendRawXML(xml) + protocolNegotiated = true + Logger.tak.info("Sent TakResponse (accepted: \(accepted))") + } catch { + Logger.tak.error("Failed to send TakResponse: \(error.localizedDescription)") + } + } + + // MARK: - Keepalive + + /// Start the keepalive task to send periodic pings + private func startKeepalive() { + keepaliveTask = Task { + while !Task.isCancelled && isConnected { + do { + try await Task.sleep(nanoseconds: keepaliveInterval) + if isConnected { + await sendKeepalive() + } + } catch { + break + } + } + } + } + + /// Send a keepalive/ping message to client + private func sendKeepalive() async { + let now = ISO8601DateFormatter().string(from: Date()) + let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(120)) + + // t-x-c-t is a ping/keepalive type, t-x-d-d is also used for takPong + let xml = """ + + + + + """ + + do { + try await sendRawXML(xml) + Logger.tak.debug("Sent keepalive to client") + } catch { + Logger.tak.warning("Failed to send keepalive: \(error.localizedDescription)") + } + } + + // MARK: - Send Methods + + /// Send raw XML string to the client + private func sendRawXML(_ xml: String) async throws { + guard isConnected else { + throw TAKConnectionError.notConnected + } + + guard let data = xml.data(using: .utf8) else { + throw TAKConnectionError.encodingFailed + } + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + cont.resume(throwing: error) + } else { + cont.resume() + } + }) + } + } + + /// Send a CoT message to this client + func send(_ cotMessage: CoTMessage) async throws { + guard isConnected else { + throw TAKConnectionError.notConnected + } + + let xml = cotMessage.toXML() + guard let data = xml.data(using: .utf8) else { + throw TAKConnectionError.encodingFailed + } + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + cont.resume(throwing: error) + } else { + cont.resume() + } + }) + } + + Logger.tak.debug("Sent CoT message to client: type=\(cotMessage.type)") + } + + /// Disconnect this client + func disconnect() { + guard isConnected else { return } + + Logger.tak.info("Disconnecting TAK client: \(self.connection.endpoint.debugDescription)") + + isConnected = false + readerTask?.cancel() + readerTask = nil + keepaliveTask?.cancel() + keepaliveTask = nil + connection.cancel() + messageBuffer.removeAll() + + continuation?.yield(.disconnected) + continuation?.finish() + continuation = nil + } +} + +// MARK: - Supporting Types + +/// Information about a connected TAK client +struct TAKClientInfo: Identifiable, Sendable { + let id = UUID() + let endpoint: NWEndpoint + var callsign: String? + var uid: String? + let connectedAt: Date + + init(endpoint: NWEndpoint, callsign: String? = nil, uid: String? = nil, connectedAt: Date = Date()) { + self.endpoint = endpoint + self.callsign = callsign + self.uid = uid + self.connectedAt = connectedAt + } + + var displayName: String { + callsign ?? uid ?? endpoint.debugDescription + } +} + +/// Events emitted by a TAK connection +enum TAKConnectionEvent: Sendable { + case connected(TAKClientInfo) + case clientInfoUpdated(TAKClientInfo) + case message(CoTMessage) + case disconnected + case error(Error) +} + +/// Errors specific to TAK connections +enum TAKConnectionError: LocalizedError { + case connectionClosed + case notConnected + case encodingFailed + case sendFailed(String) + + var errorDescription: String? { + switch self { + case .connectionClosed: + return "Connection was closed" + case .notConnected: + return "Not connected" + case .encodingFailed: + return "Failed to encode CoT message" + case .sendFailed(let reason): + return "Failed to send: \(reason)" + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift b/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift new file mode 100644 index 000000000..427a7aa8a --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift @@ -0,0 +1,261 @@ +// +// TAKDataPackageGenerator.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import OSLog + +/// Generates TAK data packages (.zip) for configuring TAK clients like ITAK +/// to connect to the Meshtastic TAK server +final class TAKDataPackageGenerator { + + static let shared = TAKDataPackageGenerator() + + private init() {} + + // MARK: - Data Package Generation + + /// Generate a TAK data package for ITAK client configuration + /// - Parameters: + /// - serverHost: The server hostname/IP (default: 127.0.0.1 for localhost) + /// - port: The server port + /// - useTLS: Whether to use TLS (ssl) with mTLS or plain TCP + /// - description: Description shown in TAK client + /// - Returns: URL to the generated zip file, or nil if generation failed + func generateDataPackage( + serverHost: String = "127.0.0.1", + port: Int, + useTLS: Bool = true, + description: String = "Meshtastic TAK Server" + ) -> URL? { + let fileManager = FileManager.default + + // Create temporary directory for package contents + let packageName = "Meshtastic_TAK_Server" + let tempDir = fileManager.temporaryDirectory.appendingPathComponent(packageName) + + do { + // Clean up any existing temp directory + if fileManager.fileExists(atPath: tempDir.path) { + try fileManager.removeItem(at: tempDir) + } + try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) + + // Create certs subdirectory (matches working data package structure) + let certsDir = tempDir.appendingPathComponent("certs") + try fileManager.createDirectory(at: certsDir, withIntermediateDirectories: true) + + // Generate preference file in certs directory + let prefFileName = "meshtastic-server.pref" + let configPref = generateConfigPref( + serverHost: serverHost, + port: port, + useTLS: useTLS, + description: description + ) + let configPrefURL = certsDir.appendingPathComponent(prefFileName) + try configPref.write(to: configPrefURL, atomically: true, encoding: .utf8) + Logger.tak.debug("Created certs/\(prefFileName)") + + // Copy certificates (only needed for TLS/mTLS mode) + if useTLS { + // Truststore (server cert for verifying server) - uses custom if available + if let serverP12Data = TAKCertificateManager.shared.getActiveServerP12Data() { + let truststoreURL = certsDir.appendingPathComponent("truststore.p12") + try serverP12Data.write(to: truststoreURL) + Logger.tak.debug("Created certs/truststore.p12 (custom: \(TAKCertificateManager.shared.hasCustomServerCertificate()))") + } else { + Logger.tak.warning("No server certificate data available") + } + + // Client certificate for mTLS - uses custom if available + if let clientP12Data = TAKCertificateManager.shared.getActiveClientP12Data() { + let clientURL = certsDir.appendingPathComponent("client.p12") + try clientP12Data.write(to: clientURL) + Logger.tak.debug("Created certs/client.p12 (custom: \(TAKCertificateManager.shared.hasCustomClientP12()))") + } else { + Logger.tak.warning("No client certificate data available") + } + } + + // Generate manifest.xml at root level (not in subdirectory) + let manifest = generateManifest(description: description, useTLS: useTLS, prefFileName: prefFileName) + let manifestURL = tempDir.appendingPathComponent("manifest.xml") + try manifest.write(to: manifestURL, atomically: true, encoding: .utf8) + Logger.tak.debug("Created manifest.xml") + + // Create the zip file in Documents directory for better share sheet compatibility + let zipFileName = "\(packageName).zip" + guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.tak.error("Could not get Documents directory") + return nil + } + let zipURL = documentsDir.appendingPathComponent(zipFileName) + + // Remove existing zip if present + if fileManager.fileExists(atPath: zipURL.path) { + try fileManager.removeItem(at: zipURL) + } + + // Create zip archive + try createZipArchive(from: tempDir, to: zipURL) + + // Verify zip was created + guard fileManager.fileExists(atPath: zipURL.path) else { + Logger.tak.error("ZIP file was not created") + return nil + } + + // Cleanup temp directory + try? fileManager.removeItem(at: tempDir) + + Logger.tak.info("Generated TAK data package: \(zipURL.path)") + return zipURL + + } catch { + Logger.tak.error("Failed to generate TAK data package: \(error.localizedDescription)") + try? fileManager.removeItem(at: tempDir) + return nil + } + } + + // MARK: - Pref File Generation (matches working TAK data package format) + + private func generateConfigPref(serverHost: String, port: Int, useTLS: Bool, description: String) -> String { + let protocolType = useTLS ? "ssl" : "tcp" + // Use active certificate passwords (custom if available, otherwise bundled) + let serverPassword = TAKCertificateManager.shared.getActiveServerCertificatePassword() + let clientPassword = TAKCertificateManager.shared.getActiveClientCertificatePassword() + + if useTLS { + // TLS mode with mTLS (mutual TLS with client certificate) + return """ + + + + 1 + \(escapeXML(description)) + true + \(serverHost):\(port):\(protocolType) + + + true + cert/truststore.p12 + \(serverPassword) + cert/client.p12 + \(clientPassword) + + + """ + } else { + // TCP mode - no certificates needed + return """ + + + + 1 + \(escapeXML(description)) + true + \(serverHost):\(port):\(protocolType) + + + true + + + """ + } + } + + // MARK: - Manifest Generation (matches working TAK data package format) + + private func generateManifest(description: String, useTLS: Bool, prefFileName: String) -> String { + let uid = UUID().uuidString + + if useTLS { + // TLS mode with mTLS - includes truststore and client certificate + return """ + + + + + + + + + + + + + """ + } else { + // TCP mode - just the pref file + return """ + + + + + + + + + + + """ + } + } + + // MARK: - Helper Methods + + private func escapeXML(_ string: String) -> String { + return string + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + // MARK: - ZIP Archive Creation + + /// Create a ZIP archive from a directory + private func createZipArchive(from sourceDir: URL, to destinationURL: URL) throws { + let fileManager = FileManager.default + var copyError: Error? + + // Use NSFileCoordinator to create zip - this is the built-in approach on iOS + var coordinatorError: NSError? + let coordinator = NSFileCoordinator() + + Logger.tak.debug("Creating ZIP from: \(sourceDir.path)") + + coordinator.coordinate( + readingItemAt: sourceDir, + options: .forUploading, + error: &coordinatorError + ) { zipURL in + Logger.tak.debug("Coordinator provided ZIP at: \(zipURL.path)") + do { + // The coordinator creates a temporary zip, copy it to our destination + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.copyItem(at: zipURL, to: destinationURL) + Logger.tak.debug("Copied ZIP to: \(destinationURL.path)") + } catch { + Logger.tak.error("Failed to copy ZIP: \(error.localizedDescription)") + copyError = error + } + } + + if let coordinatorError = coordinatorError { + Logger.tak.error("Coordinator error: \(coordinatorError.localizedDescription)") + throw coordinatorError + } + if let copyError = copyError { + throw copyError + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift new file mode 100644 index 000000000..9ed42c907 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift @@ -0,0 +1,516 @@ +// +// TAKMeshtasticBridge.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import MeshtasticProtobufs +import OSLog +import CoreData + +/// Bridges CoT messages between TAK clients and the Meshtastic mesh network +/// Handles bidirectional conversion and message routing +@MainActor +final class TAKMeshtasticBridge { + + weak var accessoryManager: AccessoryManager? + weak var takServerManager: TAKServerManager? + + /// Core Data context for node lookups + var context: NSManagedObjectContext? + + /// Lookup table mapping callsigns to device UIDs + /// Populated when receiving PLI packets from other TAK users + /// Key: callsign (e.g., "OLD SALT"), Value: device UID (e.g., "ANDROID-abc123-def456") + private var callsignToDeviceUID: [String: String] = [:] + + init(accessoryManager: AccessoryManager?, takServerManager: TAKServerManager?) { + self.accessoryManager = accessoryManager + self.takServerManager = takServerManager + } + + // MARK: - Callsign to Device UID Mapping + + /// Register a callsign β†’ device UID mapping (called when receiving PLI from other users) + func registerContact(callsign: String, deviceUID: String) { + guard !callsign.isEmpty, !deviceUID.isEmpty else { return } + // Extract actual device UID in case it has a smuggled messageId + let (actualDeviceUID, _) = Self.parseDeviceCallsign(deviceUID) + guard !actualDeviceUID.isEmpty else { return } + let previousUID = callsignToDeviceUID[callsign] + callsignToDeviceUID[callsign] = actualDeviceUID + if previousUID != actualDeviceUID { + Logger.tak.debug("Registered contact: \(callsign) β†’ \(actualDeviceUID)") + } + } + + // MARK: - Read Receipt Handling + + /// Receipt type for GeoChat read receipts + enum ReceiptType { + case delivered // ACK:D - Message delivered to device + case read // ACK:R - Message read by user + } + + /// Parsed read receipt from a GeoChat message + struct ParsedReceipt { + let type: ReceiptType + let messageId: String + } + + /// Check if a GeoChat message is a read receipt + /// Receipt format: "ACK:D:" or "ACK:R:" + /// - Parameter message: The GeoChat message content + /// - Returns: Parsed receipt if this is a receipt, nil otherwise + nonisolated static func parseReceipt(from message: String) -> ParsedReceipt? { + guard message.hasPrefix("ACK:") else { return nil } + + let parts = message.split(separator: ":", maxSplits: 2) + guard parts.count == 3 else { + return nil + } + + let receiptTypeString = String(parts[1]) + let messageId = String(parts[2]) + + guard !messageId.isEmpty else { return nil } + + let receiptType: ReceiptType + switch receiptTypeString { + case "D": + receiptType = .delivered + case "R": + receiptType = .read + default: + return nil + } + + return ParsedReceipt(type: receiptType, messageId: messageId) + } + + /// Check if a TAKPacket GeoChat is a read receipt + nonisolated static func isReceipt(_ takPacket: TAKPacket) -> Bool { + guard case .chat(let geoChat) = takPacket.payloadVariant else { + return false + } + return geoChat.message.hasPrefix("ACK:") + } + + // MARK: - MessageId Smuggling in device_callsign + + /// Parse a device_callsign that may contain a smuggled messageId + /// Format: "|" or just "" + /// - Parameter combined: The device_callsign field value + /// - Returns: Tuple of (actualDeviceCallsign, messageId) where messageId is nil if not present + nonisolated static func parseDeviceCallsign(_ combined: String?) -> (deviceCallsign: String, messageId: String?) { + guard let combined = combined, !combined.isEmpty else { + return ("", nil) + } + + if let separatorIndex = combined.firstIndex(of: "|") { + let deviceCallsign = String(combined[..|" + /// - Parameters: + /// - deviceCallsign: The actual device UID + /// - messageId: The message ID to smuggle + /// - Returns: Combined string with messageId appended + nonisolated static func createSmuggledDeviceCallsign(deviceCallsign: String, messageId: String) -> String { + return "\(deviceCallsign)|\(messageId)" + } + + /// Look up a device UID from a callsign + func lookupDeviceUID(forCallsign callsign: String) -> String? { + return callsignToDeviceUID[callsign] + } + + // MARK: - TAK β†’ Meshtastic (CoT to TAKPacket) + + /// Send a CoT message received from TAK to the Meshtastic mesh + func sendToMesh(_ cotMessage: CoTMessage) async { + guard let accessoryManager else { + Logger.tak.warning("Cannot send to mesh: AccessoryManager not available") + return + } + + guard accessoryManager.isConnected else { + Logger.tak.warning("Cannot send to mesh: Not connected to Meshtastic device") + return + } + + // Determine send method based on CoT type + let sendMethod = GenericCoTHandler.shared.classifySendMethod(for: cotMessage) + + switch sendMethod { + case .takPacketPLI, .takPacketChat: + // Use TAKPacket protobuf on ATAK_PLUGIN port (72) + guard let takPacket = convertToTAKPacket(cot: cotMessage) else { + Logger.tak.warning("Failed to convert CoT to TAKPacket: \(cotMessage.type)") + return + } + + do { + try await accessoryManager.sendTAKPacket(takPacket) + Logger.tak.info("Sent TAKPacket to mesh: \(cotMessage.type)") + } catch { + Logger.tak.error("Failed to send TAKPacket to mesh: \(error.localizedDescription)") + } + + case .exiDirect, .exiFountain: + // Use EXI compression on ATAK_FORWARDER port (257) + GenericCoTHandler.shared.accessoryManager = accessoryManager + do { + try await GenericCoTHandler.shared.sendGenericCoT(cotMessage) + Logger.tak.info("Sent generic CoT to mesh via ATAK_FORWARDER: \(cotMessage.type)") + } catch { + Logger.tak.error("Failed to send generic CoT to mesh: \(error.localizedDescription)") + } + } + } + + /// Convert CoT message to Meshtastic TAKPacket protobuf + func convertToTAKPacket(cot: CoTMessage) -> TAKPacket? { + Logger.tak.debug("=== CoT β†’ TAKPacket Conversion ===") + Logger.tak.debug("CoT Input:") + Logger.tak.debug(" uid: \(cot.uid)") + Logger.tak.debug(" type: \(cot.type)") + Logger.tak.debug(" lat: \(cot.latitude), lon: \(cot.longitude), hae: \(cot.hae)") + Logger.tak.debug(" contact: \(cot.contact?.callsign ?? "nil")") + Logger.tak.debug(" group: \(cot.group?.name ?? "nil") / \(cot.group?.role ?? "nil")") + Logger.tak.debug(" status.battery: \(cot.status?.battery ?? -1)") + Logger.tak.debug(" track: speed=\(cot.track?.speed ?? -1), course=\(cot.track?.course ?? -1)") + Logger.tak.debug(" chat: \(cot.chat?.message ?? "nil")") + Logger.tak.debug(" remarks: \(cot.remarks ?? "nil")") + + var takPacket = TAKPacket() + + // Contact information + if let contact = cot.contact { + var cotContact = Contact() + cotContact.callsign = contact.callsign + cotContact.deviceCallsign = cot.uid + takPacket.contact = cotContact + Logger.tak.debug("TAKPacket.contact: callsign=\(cotContact.callsign), deviceCallsign=\(cotContact.deviceCallsign)") + } + + // Group/Team information + if let group = cot.group { + var cotGroup = Group() + cotGroup.team = Team.fromColorName(group.name) + cotGroup.role = MemberRole.fromRoleName(group.role) + takPacket.group = cotGroup + Logger.tak.debug("TAKPacket.group: team=\(cotGroup.team.rawValue), role=\(cotGroup.role.rawValue)") + } + + // Status (battery) + if let status = cot.status { + var cotStatus = Status() + cotStatus.battery = UInt32(max(0, status.battery)) + takPacket.status = cotStatus + Logger.tak.debug("TAKPacket.status: battery=\(cotStatus.battery)") + } + + // Determine payload type based on CoT type + // Accept any friendly ground unit type (a-f-G...) for PLI + if cot.type.hasPrefix("a-f-G") || cot.type.hasPrefix("a-f-g") { + // Register this TAK client's contact info for future DM lookups + if let contact = cot.contact, !contact.callsign.isEmpty, !cot.uid.isEmpty { + registerContact(callsign: contact.callsign, deviceUID: cot.uid) + } + + // Atom type (position) - create PLI + var pli = PLI() + + // Convert lat/lon to integer format (degrees * 1e7) + let latI = Int32(cot.latitude * 1e7) + let lonI = Int32(cot.longitude * 1e7) + + // Handle altitude - clamp to valid Int32 range, use 0 for unknown (9999999) + let altitudeValue: Int32 + if cot.hae >= 9999999.0 || cot.hae.isNaN || cot.hae.isInfinite { + altitudeValue = 0 // Unknown altitude + } else { + altitudeValue = Int32(clamping: Int(cot.hae)) + } + + pli.latitudeI = latI + pli.longitudeI = lonI + pli.altitude = altitudeValue + + if let track = cot.track { + pli.speed = UInt32(max(0, track.speed)) + pli.course = UInt32(max(0, track.course)) + } + + takPacket.pli = pli + + Logger.tak.debug("TAKPacket.pli created:") + Logger.tak.debug(" latitudeI: \(pli.latitudeI) (from \(cot.latitude))") + Logger.tak.debug(" longitudeI: \(pli.longitudeI) (from \(cot.longitude))") + Logger.tak.debug(" altitude: \(pli.altitude) (from \(cot.hae))") + Logger.tak.debug(" speed: \(pli.speed), course: \(pli.course)") + + } else if cot.type == "b-t-f" { + // Chat message - MUST include contact field for sender identification + var geoChat = GeoChat() + + // Extract messageId from CoT uid if present + // CoT uid format: "GeoChat.{senderUid}.{chatroom}.{messageId}" + var messageId: String? + var actualDeviceUid = cot.uid + let uidComponents = cot.uid.components(separatedBy: ".") + if uidComponents.count >= 4 && uidComponents[0] == "GeoChat" { + // Extract the actual device UID (second component) + actualDeviceUid = uidComponents[1] + // Extract messageId (last component) + messageId = uidComponents.last + Logger.tak.debug("GeoChat: Extracted messageId=\(messageId ?? "nil") from uid") + } + + // If no messageId found, generate one + if messageId == nil || messageId?.isEmpty == true { + messageId = UUID().uuidString + Logger.tak.debug("GeoChat: Generated new messageId=\(messageId!)") + } + + // Ensure contact (sender info) is always set for chat messages + // This is REQUIRED for Android ATAK to process the message correctly + if !takPacket.hasContact { + var senderContact = Contact() + // Get sender callsign from chat.senderCallsign or cot.contact + if let senderCallsign = cot.chat?.senderCallsign, !senderCallsign.isEmpty { + senderContact.callsign = senderCallsign + } else if let contactCallsign = cot.contact?.callsign, !contactCallsign.isEmpty { + senderContact.callsign = contactCallsign + } else { + senderContact.callsign = "Unknown" + } + // Smuggle messageId into device_callsign for proper threading on Android + // Format: "|" + senderContact.deviceCallsign = Self.createSmuggledDeviceCallsign( + deviceCallsign: actualDeviceUid, + messageId: messageId! + ) + takPacket.contact = senderContact + Logger.tak.debug("GeoChat: Added sender contact - callsign=\(senderContact.callsign), smuggled deviceCallsign=\(senderContact.deviceCallsign)") + } else { + // Contact already set, but we still need to smuggle the messageId + var updatedContact = takPacket.contact + let existingDeviceCallsign = updatedContact.deviceCallsign.isEmpty ? actualDeviceUid : updatedContact.deviceCallsign + updatedContact.deviceCallsign = Self.createSmuggledDeviceCallsign( + deviceCallsign: existingDeviceCallsign, + messageId: messageId! + ) + takPacket.contact = updatedContact + Logger.tak.debug("GeoChat: Updated contact with smuggled messageId - deviceCallsign=\(updatedContact.deviceCallsign)") + } + + if let chat = cot.chat { + geoChat.message = chat.message + + // Handle recipient addressing + // chat.chatroom contains either "All Chat Rooms" or the recipient's callsign + if chat.chatroom == "All Chat Rooms" { + // Broadcast message - set to literal "All Chat Rooms" + geoChat.to = "All Chat Rooms" + Logger.tak.debug("GeoChat: Broadcast to All Chat Rooms") + } else { + // Direct message - need to look up recipient's device UID from their callsign + let recipientCallsign = chat.chatroom + if let recipientDeviceUID = lookupDeviceUID(forCallsign: recipientCallsign) { + // Found the recipient's device UID + geoChat.to = recipientDeviceUID + geoChat.toCallsign = recipientCallsign + Logger.tak.debug("GeoChat DM: to=\(recipientDeviceUID), toCallsign=\(recipientCallsign)") + } else { + // Recipient device UID not found - use callsign as fallback + // This may not work on Android but is better than nothing + geoChat.to = recipientCallsign + geoChat.toCallsign = recipientCallsign + Logger.tak.warning("GeoChat DM: Unknown device UID for '\(recipientCallsign)', using callsign as fallback") + } + } + } else if let remarks = cot.remarks { + geoChat.message = remarks + geoChat.to = "All Chat Rooms" + } + + takPacket.chat = geoChat + + Logger.tak.debug("TAKPacket.chat created:") + Logger.tak.debug(" message: \(geoChat.message)") + Logger.tak.debug(" to: \(geoChat.to)") + Logger.tak.debug(" toCallsign: \(geoChat.toCallsign)") + Logger.tak.debug(" sender.callsign: \(takPacket.contact.callsign)") + Logger.tak.debug(" sender.deviceCallsign: \(takPacket.contact.deviceCallsign)") + + } else { + // Unknown type, skip + Logger.tak.debug("Skipping CoT type not mapped to TAKPacket: \(cot.type)") + return nil + } + + // Log the final TAKPacket structure + Logger.tak.debug("TAKPacket output:") + Logger.tak.debug(" hasContact: \(takPacket.hasContact)") + Logger.tak.debug(" hasGroup: \(takPacket.hasGroup)") + Logger.tak.debug(" hasStatus: \(takPacket.hasStatus)") + Logger.tak.debug(" payloadVariant: \(String(describing: takPacket.payloadVariant))") + + // Log serialized size for debugging + do { + let serialized = try takPacket.serializedData() + Logger.tak.debug(" serializedSize: \(serialized.count) bytes") + Logger.tak.debug(" serializedHex: \(serialized.prefix(64).map { String(format: "%02x", $0) }.joined(separator: " "))\(serialized.count > 64 ? "..." : "")") + } catch { + Logger.tak.error(" Failed to serialize TAKPacket: \(error.localizedDescription)") + } + + Logger.tak.debug("=== End Conversion ===") + return takPacket + } + + // MARK: - Meshtastic β†’ TAK (TAKPacket to CoT) + + /// Broadcast a Meshtastic TAKPacket to all connected TAK clients + func broadcastToTAKClients(_ takPacket: TAKPacket, from nodeNum: UInt32) async { + // Register contact info from incoming TAKPackets (for callsign β†’ deviceUID lookup) + if takPacket.hasContact { + let callsign = takPacket.contact.callsign + let deviceUID = takPacket.contact.deviceCallsign + if !callsign.isEmpty && !deviceUID.isEmpty { + registerContact(callsign: callsign, deviceUID: deviceUID) + } + } + + // Check if this is a read receipt - don't forward to TAK clients as chat message + if case .chat(let geoChat) = takPacket.payloadVariant { + if let receipt = Self.parseReceipt(from: geoChat.message) { + // This is a read receipt, handle it internally + let typeString = receipt.type == .delivered ? "Delivered" : "Read" + Logger.tak.info("Received \(typeString) receipt for messageId: \(receipt.messageId) from node \(nodeNum)") + // TODO: Update message status in Core Data if we track sent messages + // For now, just log and don't forward to TAK clients + return + } + } + + guard let takServerManager else { + Logger.tak.debug("Cannot broadcast to TAK: TAKServerManager not available") + return + } + + guard takServerManager.isRunning else { + Logger.tak.debug("Cannot broadcast to TAK: Server not running") + return + } + + guard !takServerManager.connectedClients.isEmpty else { + Logger.tak.debug("No TAK clients connected, skipping broadcast") + return + } + + // Look up node info for additional context + let nodeInfo = lookupNodeInfo(nodeNum: nodeNum) + + // Convert to CoT + guard let cotMessage = convertToCoT(from: takPacket, nodeNum: nodeNum, nodeInfo: nodeInfo) else { + Logger.tak.warning("Failed to convert TAKPacket to CoT from node \(nodeNum)") + return + } + + // Broadcast to all TAK clients + await takServerManager.broadcast(cotMessage) + Logger.tak.info("Broadcast CoT to TAK clients: \(cotMessage.type) from node \(nodeNum)") + } + + /// Convert Meshtastic TAKPacket to CoT message + func convertToCoT(from takPacket: TAKPacket, nodeNum: UInt32, nodeInfo: NodeInfoEntity?) -> CoTMessage? { + // Use the factory method from CoTMessage which handles the conversion + let deviceUid = "MESHTASTIC-\(String(format: "%08X", nodeNum))" + return CoTMessage.fromTAKPacket(takPacket, deviceUid: deviceUid) + } + + /// Create a CoT PLI message from a Meshtastic node's position + func createCoTFromNode(_ node: NodeInfoEntity) -> CoTMessage? { + guard let position = node.latestPosition, + let latitude = position.latitude, + let longitude = position.longitude, + latitude != 0 || longitude != 0 else { + return nil + } + + let uid = "MESHTASTIC-\(String(format: "%08X", node.num))" + let callsign = node.user?.shortName ?? node.user?.longName ?? "MESH-\(node.num)" + + // Get battery level from device metrics + let battery = Int(node.latestDeviceMetrics?.batteryLevel ?? 100) + + return CoTMessage.pli( + uid: uid, + callsign: callsign, + latitude: latitude, + longitude: longitude, + altitude: Double(position.altitude), + speed: Double(position.speed), + course: Double(position.heading), + team: "Green", // Meshtastic nodes shown as green by default + role: "Team Member", + battery: battery, + staleMinutes: 15 // Meshtastic positions can be older + ) + } + + // MARK: - Broadcast All Mesh Nodes to TAK + + /// Send all known mesh node positions to TAK clients + /// Useful when a new TAK client connects + func broadcastAllNodesToTAK() async { + guard let takServerManager, takServerManager.isRunning else { return } + guard let context else { return } + + let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() + // Only nodes with valid positions + fetchRequest.predicate = NSPredicate(format: "latestPosition != nil") + + do { + let nodes = try context.fetch(fetchRequest) + + for node in nodes { + if let cotMessage = createCoTFromNode(node) { + await takServerManager.broadcast(cotMessage) + } + } + + Logger.tak.info("Broadcast \(nodes.count) mesh node positions to TAK clients") + } catch { + Logger.tak.error("Failed to fetch nodes for TAK broadcast: \(error.localizedDescription)") + } + } + + // MARK: - Helper Methods + + private func lookupNodeInfo(nodeNum: UInt32) -> NodeInfoEntity? { + guard let context else { return nil } + + let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "num == %d", Int64(nodeNum)) + fetchRequest.fetchLimit = 1 + + do { + return try context.fetch(fetchRequest).first + } catch { + Logger.tak.warning("Failed to lookup node info for \(nodeNum): \(error.localizedDescription)") + return nil + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKServerManager.swift b/Meshtastic/Helpers/TAK/TAKServerManager.swift new file mode 100644 index 000000000..b71fa8485 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKServerManager.swift @@ -0,0 +1,427 @@ +// +// TAKServerManager.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import Network +import OSLog +import Combine +import SwiftUI + +/// Manages the TAK Server lifecycle, TLS connections, and client management +/// Runs on MainActor for thread safety, following the AccessoryManager pattern +@MainActor +final class TAKServerManager: ObservableObject { + + static let shared = TAKServerManager() + + // MARK: - Published State + + @Published private(set) var isRunning = false + @Published private(set) var connectedClients: [TAKClientInfo] = [] + @Published var lastError: String? + + // MARK: - Configuration (persisted via AppStorage) + + @AppStorage("takServerEnabled") var enabled = false { + didSet { + Task { + if enabled && !isRunning { + try? await start() + } else if !enabled && isRunning { + stop() + } + } + } + } + + /// Fixed port - always use TLS port 8089 + static let defaultTLSPort = 8089 + static let defaultTCPPort = 8087 // Legacy, not used + + /// Port is fixed to 8089 (mTLS) + var port: Int { Self.defaultTLSPort } + + /// Always use TLS/mTLS + var useTLS: Bool { true } + + // MARK: - Bridge + + /// Bridge for converting between CoT and Meshtastic formats + var bridge: TAKMeshtasticBridge? + + // MARK: - Private Properties + + private var listener: NWListener? + private var connections: [ObjectIdentifier: TAKConnection] = [:] + private var connectionTasks: [ObjectIdentifier: Task] = [:] + private let queue = DispatchQueue(label: "tak.server", qos: .userInitiated) + + private init() {} + + // MARK: - Initialization + + /// Initialize the TAK server on app startup + /// Call this from app initialization to restore server state + func initializeOnStartup() { + guard enabled else { + Logger.tak.debug("TAK Server not enabled, skipping startup") + return + } + + guard !isRunning else { + Logger.tak.debug("TAK Server already running") + return + } + + Logger.tak.info("TAK Server enabled, starting on app launch") + Task { + do { + try await start() + } catch { + Logger.tak.error("Failed to start TAK Server on startup: \(error.localizedDescription)") + } + } + } + + // MARK: - Server Lifecycle + + /// Start the TAK server (TLS or TCP based on configuration) + func start() async throws { + guard !isRunning else { + Logger.tak.info("TAK Server already running") + return + } + + let mode = useTLS ? "TLS" : "TCP" + Logger.tak.info("Starting TAK Server on port \(self.port) (\(mode) mode)") + + let parameters: NWParameters + + if useTLS { + // Validate we have a server certificate for TLS mode + guard let identity = TAKCertificateManager.shared.getServerIdentity() else { + let error = TAKServerError.noServerCertificate + lastError = error.localizedDescription + enabled = false + throw error + } + + // Create TLS options + let tlsOptions = NWProtocolTLS.Options() + + // Set server identity (certificate + private key) + let secIdentity = sec_identity_create(identity)! + sec_protocol_options_set_local_identity( + tlsOptions.securityProtocolOptions, + secIdentity + ) + + // Set minimum TLS version to 1.2 (TAK standard) + sec_protocol_options_set_min_tls_protocol_version( + tlsOptions.securityProtocolOptions, + .TLSv12 + ) + + // Configure mTLS - always require client certificate for TLS mode + sec_protocol_options_set_peer_authentication_required( + tlsOptions.securityProtocolOptions, + true + ) + + // Set up client certificate validation + let clientCAs = TAKCertificateManager.shared.getClientCACertificates() + Logger.tak.info("Loaded \(clientCAs.count) CA certificate(s) for client validation") + if !clientCAs.isEmpty { + for (index, ca) in clientCAs.enumerated() { + if let summary = SecCertificateCopySubjectSummary(ca) as String? { + Logger.tak.info("CA[\(index)]: \(summary)") + } + } + let trustRoots = clientCAs as CFArray + sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { _, secTrust, completion in + // Convert sec_trust_t to SecTrust + let trust = sec_trust_copy_ref(secTrust).takeRetainedValue() + + // Set policy for client certificate validation + // Use SSL policy with server=false to validate client certificates + // This properly accepts clientAuth ExtendedKeyUsage + let clientPolicy = SecPolicyCreateSSL(false, nil) + SecTrustSetPolicies(trust, clientPolicy) + + SecTrustSetAnchorCertificates(trust, trustRoots) + SecTrustSetAnchorCertificatesOnly(trust, true) + var error: CFError? + let isValid = SecTrustEvaluateWithError(trust, &error) + if let error = error { + Logger.tak.error("Client cert validation error: \(error.localizedDescription)") + } + Logger.tak.info("Client certificate validation: \(isValid ? "passed" : "failed")") + completion(isValid) + }, + queue + ) + } else { + Logger.tak.warning("mTLS enabled but no CA certificates configured for client validation") + } + + // TCP options + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveIdle = 60 + + parameters = NWParameters(tls: tlsOptions, tcp: tcpOptions) + } else { + // Plain TCP mode (no TLS) + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveIdle = 60 + + parameters = NWParameters(tls: nil, tcp: tcpOptions) + } + + parameters.allowLocalEndpointReuse = true + + // Bind to localhost only - only allow TAK clients on the same device + parameters.requiredLocalEndpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host("127.0.0.1"), + port: NWEndpoint.Port(integerLiteral: UInt16(port)) + ) + + // Create and configure listener + do { + listener = try NWListener(using: parameters) + } catch { + lastError = "Failed to create listener: \(error.localizedDescription)" + Logger.tak.error("Failed to create TAK listener: \(error.localizedDescription)") + enabled = false + throw error + } + + // Set up state handler + listener?.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + self?.handleListenerState(state) + } + } + + // Set up new connection handler + listener?.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + await self?.handleNewConnection(connection) + } + } + + // Start listening + listener?.start(queue: queue) + } + + /// Stop the TAK server + func stop() { + Logger.tak.info("Stopping TAK Server") + + listener?.cancel() + listener = nil + + // Cancel all connection tasks + for (_, task) in connectionTasks { + task.cancel() + } + connectionTasks.removeAll() + + // Disconnect all clients + for (_, connection) in connections { + Task { + await connection.disconnect() + } + } + connections.removeAll() + connectedClients.removeAll() + + isRunning = false + lastError = nil + + Logger.tak.info("TAK Server stopped") + } + + /// Restart the server (useful after configuration changes) + func restart() async throws { + stop() + try await Task.sleep(nanoseconds: 500_000_000) // 0.5s delay + try await start() + } + + // MARK: - State Handling + + private func handleListenerState(_ state: NWListener.State) { + switch state { + case .ready: + isRunning = true + lastError = nil + Logger.tak.info("TAK Server listening on port \(self.port)") + + case .failed(let error): + isRunning = false + lastError = error.localizedDescription + enabled = false + Logger.tak.error("TAK Server failed: \(error.localizedDescription)") + + case .cancelled: + isRunning = false + Logger.tak.info("TAK Server cancelled") + + case .waiting(let error): + Logger.tak.warning("TAK Server waiting: \(error.localizedDescription)") + + case .setup: + Logger.tak.debug("TAK Server setup") + + @unknown default: + break + } + } + + // MARK: - Connection Management + + private func handleNewConnection(_ nwConnection: NWConnection) async { + let connectionId = ObjectIdentifier(nwConnection) + let connection = TAKConnection(connection: nwConnection) + + connections[connectionId] = connection + + Logger.tak.info("New TAK client connecting: \(nwConnection.endpoint.debugDescription)") + + // Start handling the connection + let eventStream = await connection.start() + + // Create task to handle connection events + let task = Task { + for await event in eventStream { + await handleConnectionEvent(event, connectionId: connectionId) + } + // Connection ended + await removeConnection(connectionId) + } + + connectionTasks[connectionId] = task + } + + private func handleConnectionEvent(_ event: TAKConnectionEvent, connectionId: ObjectIdentifier) async { + switch event { + case .connected(let clientInfo): + connectedClients.append(clientInfo) + Logger.tak.info("TAK client connected: \(clientInfo.displayName)") + + case .clientInfoUpdated(let clientInfo): + // Update the client info in our list + if let index = connectedClients.firstIndex(where: { $0.id == clientInfo.id }) { + connectedClients[index] = clientInfo + } + + case .message(let cotMessage): + Logger.tak.info("Received CoT from TAK client: \(cotMessage.type)") + // Forward to Meshtastic mesh via bridge + await bridge?.sendToMesh(cotMessage) + + case .disconnected: + await removeConnection(connectionId) + + case .error(let error): + Logger.tak.error("TAK client error: \(error.localizedDescription)") + } + } + + private func removeConnection(_ connectionId: ObjectIdentifier) async { + connectionTasks[connectionId]?.cancel() + connectionTasks.removeValue(forKey: connectionId) + + if let connection = connections.removeValue(forKey: connectionId) { + let endpoint = await connection.endpoint + connectedClients.removeAll { $0.endpoint.debugDescription == endpoint.debugDescription } + Logger.tak.info("TAK client disconnected") + } + } + + // MARK: - Message Distribution + + /// Broadcast a CoT message to all connected TAK clients + func broadcast(_ cotMessage: CoTMessage) async { + guard !connections.isEmpty else { return } + + Logger.tak.info("Broadcasting CoT to \(self.connections.count) TAK client(s): \(cotMessage.type)") + + for (connectionId, connection) in connections { + do { + try await connection.send(cotMessage) + } catch { + Logger.tak.error("Failed to send to TAK client: \(error.localizedDescription)") + // Remove failed connection + await removeConnection(connectionId) + } + } + } + + /// Send a CoT message to a specific client + func send(_ cotMessage: CoTMessage, to clientId: UUID) async throws { + guard let clientInfo = connectedClients.first(where: { $0.id == clientId }) else { + throw TAKServerError.clientNotFound + } + + for (_, connection) in connections { + let endpoint = await connection.endpoint + if endpoint.debugDescription == clientInfo.endpoint.debugDescription { + try await connection.send(cotMessage) + return + } + } + + throw TAKServerError.clientNotFound + } + + // MARK: - Status + + /// Get server status description + var statusDescription: String { + if isRunning { + let mode = useTLS ? "TLS" : "TCP" + return "Running on port \(port) (\(mode))" + } else if let error = lastError { + return "Error: \(error)" + } else { + return "Stopped" + } + } +} + +// MARK: - Server Errors + +enum TAKServerError: LocalizedError { + case noServerCertificate + case noClientCACertificate + case tlsConfigurationFailed + case listenerFailed(String) + case clientNotFound + case notRunning + + var errorDescription: String? { + switch self { + case .noServerCertificate: + return "No server certificate configured. Import a .p12 file with the server certificate and private key." + case .noClientCACertificate: + return "No client CA certificate configured. Import the CA certificate (.pem) used to sign client certificates." + case .tlsConfigurationFailed: + return "Failed to configure TLS settings." + case .listenerFailed(let reason): + return "Failed to start listener: \(reason)" + case .clientNotFound: + return "Client not found" + case .notRunning: + return "TAK Server is not running" + } + } +} diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index 4dbdb836e..e8c10bea0 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -25,6 +25,8 @@ com.apple.security.network.client + com.apple.security.network.server + com.apple.security.personal-information.location keychain-access-groups diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index d60ed940e..5c42dd22c 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -193,6 +193,7 @@ struct MeshtasticAppleApp: App { } } .onChange(of: scenePhase) { (_, newScenePhase) in + accessoryManager.isInBackground = (newScenePhase == .background) switch newScenePhase { case .background: Logger.services.info("🎬 [App] Scene is in the background") diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 195216015..2658a4bfa 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -25,6 +25,10 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat if locationsHandler.backgroundActivity { locationsHandler.backgroundActivity = true } + // Initialize TAK Server if enabled + Task { @MainActor in + TAKServerManager.shared.initializeOnStartup() + } return true } // Lets us show the notification in the app in the foreground diff --git a/Meshtastic/Resources/Certificates/backup/ca.pem b/Meshtastic/Resources/Certificates/backup/ca.pem new file mode 100644 index 000000000..f00e8c1ab --- /dev/null +++ b/Meshtastic/Resources/Certificates/backup/ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUZaXYUGEFhPeOcWWNXlwt5qyfIVgwDQYJKoZIhvcNAQEL +BQAwMTEaMBgGA1UEAwwRTWVzaHRhc3RpYyBUQUsgQ0ExEzARBgNVBAoMCk1lc2h0 +YXN0aWMwHhcNMjUxMjI3MjIyNDQ3WhcNMzUxMjI1MjIyNDQ3WjAxMRowGAYDVQQD +DBFNZXNodGFzdGljIFRBSyBDQTETMBEGA1UECgwKTWVzaHRhc3RpYzCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAL4kQSbJ3eqZg3DGAyD8XPMoeKS2ERy6 +i1w6Uyr70mE5cJoaUISlA+jYo+hKk2ysjct3byuB43XlZBeK0tUTt2900o3/EJXZ +ggRe0yIWrsiMqweRGf3TSgeusz6TrtmZ5KptYaLsc39/MGGKj2v00J+HmFSgDTRu +v5LY8do0haP+XaP5MxWgPcY0ySEB0yEYr7MtOOd6npZaHRJlw8UWALrvHznl7Yrv +80wYo3zBbQ8SeCamCOj+Is/Eye9fixosZi3UkR8FEMUONWtofTI83DfFfP1kDVaq +lWr2fzdlCebK7wY0pY0cBEbdpQadXFQ2PiqXDd3g7k6i+mjT7XzH/mMCAwEAAaNT +MFEwHQYDVR0OBBYEFF/jLHK/wvsWMW8TAbQMIV5BPSxUMB8GA1UdIwQYMBaAFF/j +LHK/wvsWMW8TAbQMIV5BPSxUMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL +BQADggEBAAAzYYf5ktEHxDRvAd4pf8fv1dgGpuWfdE23h5Tg4wE+0pXCvtXqKGmQ +mPiEr7hAFphSppJZdRl7bvdv+jzllqeCoHgEyUJvJvgMugfG8f8IhIKkg7Q7cd38 +LQrVimjH+g1UKK1/XmJpv7wyDo53wvBsKRxsIwwEPdM4TUkjNIfkgNY5YpnOBrrx +Ubj9T8ZdHc/tM+Z03bgotIejXqh1PbK+Cfq5kXfv37uscOJHBCq8anA1AXsSGS31 +R1IN9vXmQ6kItJErPSJyY1l0PSgniWhYCbxmRmsSIFYlZjVq0BvDQi1Va1W/9LiV +Vp2YyFUrzlbnng24dpvQiSJU+pl/9Lk= +-----END CERTIFICATE----- diff --git a/Meshtastic/Resources/Certificates/backup/server.p12 b/Meshtastic/Resources/Certificates/backup/server.p12 new file mode 100644 index 000000000..b74554894 Binary files /dev/null and b/Meshtastic/Resources/Certificates/backup/server.p12 differ diff --git a/Meshtastic/Resources/Certificates/ca.pem b/Meshtastic/Resources/Certificates/ca.pem new file mode 100644 index 000000000..1dc6e36f6 --- /dev/null +++ b/Meshtastic/Resources/Certificates/ca.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID4zCCAsugAwIBAgIUeM9XhqZCtta+QorYNjZSdAk3gkMwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQH +DA1TYW4gRnJhbmNpc2NvMRMwEQYDVQQKDApNZXNodGFzdGljMRMwEQYDVQQLDApU +QUsgU2VydmVyMRowGAYDVQQDDBFNZXNodGFzdGljIFRBSyBDQTAeFw0yNTEyMzEx +OTQwMDJaFw0yODA0MDQxOTQwMDJaMIGAMQswCQYDVQQGEwJVUzETMBEGA1UECAwK +Q2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzETMBEGA1UECgwKTWVz +aHRhc3RpYzETMBEGA1UECwwKVEFLIFNlcnZlcjEaMBgGA1UEAwwRTWVzaHRhc3Rp +YyBUQUsgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2F6/n1CI2 +4dGtLt0irkfiU+PRmqkkuE7m49i7/FeH+38SEn9+0B4egW0kYRoRXmYdPzRsVttu +23LZ3RLjwB6fFI3tiA27mxD58AuEMfwVR7J29oHqFwuVhuqDyjkNpUPFUomKwzvK +SPJvoiHGkbQwWTMNP6T06tCg9llSE7SIgJWjzikQ+JsI37SqVGZ8K2evs7LTuyQh +ssJfYVB7aE1kNNyi8YFHLoCWQMB7h8qJ3hRd7QGFG9gfWuNrWtim61iiHgBAPTRw +gMn+YSIZiV9/iOytBKxFppNTxffEowF/iKBvgXwd9KHxYkk1Nvtcz5NJynSL75PT +8B7XiHCGhcgzAgMBAAGjUzBRMB0GA1UdDgQWBBRRe/o9Raj93Fq22ArNSNrpsye3 +AzAfBgNVHSMEGDAWgBRRe/o9Raj93Fq22ArNSNrpsye3AzAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAsuSQ+j/1Bm7HbZWzN5qChH554vucWoqI0 +sVRHThvCASC6+wSosWZlx/Ag5KnRmBVsYA6CX5ztoF5keiSRy5G7qyRQVjITOq1o +4XUAHBtGxKdRCEzS84GnsW9qeWX7t/xxf2fFr9gPZ7Z4nuyNg7QyX5FM01BtAlZC +HbBhXvJyHRqJkMe7keYU7GmiAs1RZa+7593uEQ8DQ/kRvCzU0XswFSguJrd4Fnpi +PGesGOk0NHFQY9pIu9oshgPgMA9dEWnhhvAF3PZ3sLRn9sSuslj5oumFsTYboByE +aOKQshFe5xEX/4O7DI+wsD1Pt5gdT75nAuG7GEAIFKKGjQtUUYfH +-----END CERTIFICATE----- diff --git a/Meshtastic/Resources/Certificates/client.p12 b/Meshtastic/Resources/Certificates/client.p12 new file mode 100644 index 000000000..2f27bff2d Binary files /dev/null and b/Meshtastic/Resources/Certificates/client.p12 differ diff --git a/Meshtastic/Resources/Certificates/server.p12 b/Meshtastic/Resources/Certificates/server.p12 new file mode 100644 index 000000000..88b9fcba5 Binary files /dev/null and b/Meshtastic/Resources/Certificates/server.p12 differ diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index 48a97b93d..ca8284780 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -52,6 +52,7 @@ enum SettingsNavigationState: String { case debugLogs case appFiles case firmwareUpdates + case tak } struct NavigationState: Hashable { diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift index 631043202..14d5b3f77 100644 --- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -10,6 +10,7 @@ struct MessageContextMenuItems: View { let tapBackDestination: MessageDestination let isCurrentUser: Bool @Binding var isShowingDeleteConfirmation: Bool + @Binding var isShowingTapbackInput: Bool let onReply: () -> Void @State var relayDisplay: String? = nil @@ -29,30 +30,8 @@ struct MessageContextMenuItems: View { } } - Menu("Tapback") { - ForEach(Tapbacks.allCases) { tb in - Button { - Task { - do { - try await accessoryManager.sendMessage( - message: tb.emojiString, - toUserNum: tapBackDestination.userNum, - channel: tapBackDestination.channelNum, - isEmoji: true, - replyID: message.messageId - ) - Task { @MainActor in - self.context.refresh(tapBackDestination.managedObject, mergeChanges: true) - } - } catch { - Logger.services.warning("Failed to send tapback.") - } - } - } label: { - Text(tb.description) - Image(uiImage: tb.emojiString.image()!) - } - } + Button("Tapback") { + isShowingTapbackInput = true } Button(action: onReply) { diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift index 28df8fba9..98734b24d 100644 --- a/Meshtastic/Views/Messages/MessageText.swift +++ b/Meshtastic/Views/Messages/MessageText.swift @@ -27,13 +27,14 @@ struct MessageText: View { // State for handling channel URL sheet @State private var saveChannelLink: SaveChannelLinkData? @State private var isShowingDeleteConfirmation = false + @State private var isShowingTapbackInput = false + @State private var tapbackText = "" var body: some View { SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) { - let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) - return Text(markdownText) + Text(markdownText) .tint(Self.linkBlue) .padding(.vertical, 10) .padding(.horizontal, 8) @@ -91,6 +92,7 @@ struct MessageText: View { tapBackDestination: tapBackDestination, isCurrentUser: isCurrentUser, isShowingDeleteConfirmation: $isShowingDeleteConfirmation, + isShowingTapbackInput: $isShowingTapbackInput, onReply: onReply ) } @@ -132,6 +134,36 @@ struct MessageText: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $isShowingTapbackInput) { + TapbackInputView( + text: $tapbackText, + isPresented: $isShowingTapbackInput, + onEmojiSelected: { emoji in + Task { + do { + try await accessoryManager.sendMessage( + message: emoji, + toUserNum: tapBackDestination.userNum, + channel: tapBackDestination.channelNum, + isEmoji: true, + replyID: message.messageId + ) + Task { @MainActor in + switch tapBackDestination { + case let .channel(channel): + context.refresh(channel, mergeChanges: true) + case let .user(user): + context.refresh(user, mergeChanges: true) + } + } + } catch { + Logger.services.warning("Failed to send tapback.") + } + } + isShowingTapbackInput = false + } + ) + } .confirmationDialog( "Are you sure you want to delete this message?", isPresented: $isShowingDeleteConfirmation, diff --git a/Meshtastic/Views/Messages/TapbackInputView.swift b/Meshtastic/Views/Messages/TapbackInputView.swift new file mode 100644 index 000000000..4b9612952 --- /dev/null +++ b/Meshtastic/Views/Messages/TapbackInputView.swift @@ -0,0 +1,108 @@ +import SwiftUI +import UIKit + +struct TapbackInputView: View { + @Binding var text: String + @Binding var isPresented: Bool + let onEmojiSelected: (String) -> Void + + var body: some View { + NavigationView { + VStack(spacing: 0) { + EmojiOnlyTextField( + text: $text, + placeholder: "Tap to enter emoji", + onBecomeFirstResponder: { + // Text field will automatically become first responder + }, + onKeyboardTypeChanged: { shouldDismiss in + // Dismiss if keyboard switched away from emoji + if shouldDismiss { + isPresented = false + } + }, + onKeyboardDismissed: { + // Dismiss sheet when keyboard is dismissed + isPresented = false + } + ) + .frame(height: 50) + .padding(.horizontal) + .background( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.tertiary, lineWidth: 1) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(.systemBackground))) + ) + .padding(.horizontal) + .padding(.top, 8) + .onChange(of: text) { oldValue, newValue in + // Extract first emoji character and send it + if !newValue.isEmpty, let firstEmoji = extractFirstEmoji(from: newValue) { + onEmojiSelected(firstEmoji) + // Clear the text box after getting the emoji + text = "" + } + } + } + .navigationTitle("Tapback") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + isPresented = false + } + } + } + } + .presentationDetents([.height(120)]) + } + + private func extractFirstEmoji(from string: String) -> String? { + // Extract the first emoji character(s) - handle both single and multi-scalar emojis + guard !string.isEmpty else { return nil } + + // Try to get the first character + let firstChar = string[string.startIndex] + + // Check if it's an emoji using the existing extension + if firstChar.isEmoji { + // For multi-scalar emojis (like emojis with skin tones), we need to find the full emoji sequence + var emojiEnd = string.index(after: string.startIndex) + + // Check if there are continuation scalars (for emojis with skin tones, variation selectors, etc.) + while emojiEnd < string.endIndex { + let nextChar = string[emojiEnd] + // Check if this is a continuation (variation selector, skin tone modifier, zero-width joiner, etc.) + if let scalar = nextChar.unicodeScalars.first, + (scalar.properties.isVariationSelector || + scalar.value == 0xFE0F || // Variation selector + (scalar.value >= 0x1F3FB && scalar.value <= 0x1F3FF) || // Skin tone modifiers + scalar.value == 0x200D) { // Zero-width joiner + emojiEnd = string.index(after: emojiEnd) + } else if nextChar.isEmoji { + // If it's another emoji, include it (for compound emojis like flags) + emojiEnd = string.index(after: emojiEnd) + } else { + break + } + } + + return String(string[string.startIndex..( @@ -458,6 +470,7 @@ struct Settings: View { developersSection #endif firmwareSection + takSection } } .navigationDestination(for: SettingsNavigationState.self) { destination in @@ -521,6 +534,8 @@ struct Settings: View { AppData() case .firmwareUpdates: Firmware(node: node) + case .tak: + TAKServerConfig() } } .onChange(of: UserDefaults.preferredPeripheralNum ) { _, newConnectedNode in diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift new file mode 100644 index 000000000..37ccc8613 --- /dev/null +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -0,0 +1,390 @@ +// +// TAKServerConfig.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import SwiftUI +import UniformTypeIdentifiers +import OSLog + +enum CertificateImportType { + case p12 + case pem +} + +struct TAKServerConfig: View { + @StateObject private var takServer = TAKServerManager.shared + @State private var showingFileImporter = false + @State private var importType: CertificateImportType = .p12 + @State private var p12Password = "" + @State private var showingPasswordPrompt = false + @State private var pendingP12Data: Data? + @State private var importError: String? + @State private var showingImportError = false + @State private var showingFileExporter = false + @State private var dataPackageURL: URL? + + private let certManager = TAKCertificateManager.shared + + var body: some View { + Form { + serverStatusSection + serverConfigSection + certificatesSection + dataPackageSection + } + .navigationTitle("TAK Server") + .fileImporter( + isPresented: $showingFileImporter, + allowedContentTypes: [.item], + allowsMultipleSelection: false + ) { result in + switch importType { + case .p12: + handleP12Import(result) + case .pem: + handlePEMImport(result) + } + } + .alert("Enter P12 Password", isPresented: $showingPasswordPrompt) { + SecureField("Password", text: $p12Password) + Button("Import") { + importP12WithPassword() + } + Button("Cancel", role: .cancel) { + p12Password = "" + pendingP12Data = nil + } + } message: { + Text("Enter the password for the PKCS#12 file") + } + .alert("Import Error", isPresented: $showingImportError) { + Button("OK", role: .cancel) {} + } message: { + Text(importError ?? "Unknown error") + } + .fileExporter( + isPresented: $showingFileExporter, + document: dataPackageURL.map { ZipDocument(url: $0) }, + contentType: .zip, + defaultFilename: "Meshtastic_TAK_Server.zip" + ) { result in + switch result { + case .success(let url): + Logger.tak.info("Data package saved to: \(url.path)") + case .failure(let error): + importError = "Failed to save: \(error.localizedDescription)" + showingImportError = true + } + // Clean up the source file + if let sourceURL = dataPackageURL { + try? FileManager.default.removeItem(at: sourceURL) + } + dataPackageURL = nil + } + } + + // MARK: - Server Status Section + + private var serverStatusSection: some View { + Section { + HStack { + Label { + Text("Status") + } icon: { + Circle() + .fill(takServer.isRunning ? .green : .gray) + .frame(width: 10, height: 10) + } + Spacer() + Text(takServer.statusDescription) + .foregroundColor(.secondary) + } + + if let error = takServer.lastError { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(error) + .font(.caption) + .foregroundColor(.orange) + } + } + } header: { + Text("Server Status") + } + } + + // MARK: - Server Configuration Section + + private var serverConfigSection: some View { + Section { + Toggle(isOn: $takServer.enabled) { + Label("Enable TAK Server", systemImage: "antenna.radiowaves.left.and.right") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + HStack { + Label("Port", systemImage: "number") + Spacer() + Text("8089") + .foregroundColor(.secondary) + } + + HStack { + Label("Security", systemImage: "lock.fill") + Spacer() + Text("mTLS") + .foregroundColor(.secondary) + } + + if takServer.isRunning { + Button { + Task { + try? await takServer.restart() + } + } label: { + Label("Restart Server", systemImage: "arrow.clockwise") + } + } + } header: { + Text("Configuration") + } footer: { + Text("Secure mTLS connection on port 8089. Both server and client certificates are required.") + } + } + + // MARK: - Certificates Section + + private var certificatesSection: some View { + Section { + // Server Certificate + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Server Certificate", systemImage: "key.fill") + Spacer() + if certManager.hasServerCertificate() { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + } + + if let certInfo = certManager.getServerCertificateInfo() { + Text(certInfo) + .font(.caption) + .foregroundColor(.secondary) + } + + HStack { + Button { + importType = .p12 + showingFileImporter = true + } label: { + Text("Import Custom .p12") + } + .buttonStyle(.bordered) + + if certManager.hasCustomServerCertificate() { + Button { + certManager.resetToDefaultServerCertificate() + } label: { + Text("Reset to Default") + } + .buttonStyle(.bordered) + } + } + } + .padding(.vertical, 4) + + // Client CA Certificate + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Client CA Certificate", systemImage: "person.badge.shield.checkmark") + Spacer() + if certManager.hasClientCACertificate() { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + } + + let caInfo = certManager.getClientCACertificateInfo() + if !caInfo.isEmpty { + ForEach(caInfo, id: \.self) { info in + Text(info) + .font(.caption) + .foregroundColor(.secondary) + } + } + + HStack { + Button { + importType = .pem + showingFileImporter = true + } label: { + Text(certManager.hasClientCACertificate() ? "Add CA" : "Import .pem") + } + .buttonStyle(.bordered) + + if certManager.hasClientCACertificate() { + Button(role: .destructive) { + certManager.deleteClientCACertificates() + } label: { + Text("Delete All") + } + .buttonStyle(.bordered) + } + } + } + .padding(.vertical, 4) + + // Reset to bundled defaults + Button { + certManager.reloadBundledCertificates() + if takServer.isRunning { + Task { + try? await takServer.restart() + } + } + } label: { + Label("Reload Bundled Certificates", systemImage: "arrow.triangle.2.circlepath") + } + } header: { + Text("TLS Certificates") + } footer: { + Text("A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients.") + } + } + + // MARK: - Data Package Section + + private var dataPackageSection: some View { + Section { + Button { + generateAndShareDataPackage() + } label: { + Label("Download TAK Server Data Package", systemImage: "arrow.down.doc.fill") + } + } header: { + Text("Client Configuration") + } footer: { + Text("Generate a data package (.zip) to configure ITAK or other TAK clients to connect to this server.") + } + } + + + // MARK: - Import Handlers + + private func handleP12Import(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + + guard url.startAccessingSecurityScopedResource() else { + importError = "Cannot access file" + showingImportError = true + return + } + defer { url.stopAccessingSecurityScopedResource() } + + do { + pendingP12Data = try Data(contentsOf: url) + p12Password = "" + showingPasswordPrompt = true + } catch { + importError = "Failed to read file: \(error.localizedDescription)" + showingImportError = true + } + + case .failure(let error): + importError = error.localizedDescription + showingImportError = true + } + } + + private func importP12WithPassword() { + guard let data = pendingP12Data else { return } + + do { + _ = try certManager.importServerIdentity(from: data, password: p12Password) + Logger.tak.info("Server certificate imported successfully") + } catch { + importError = error.localizedDescription + showingImportError = true + } + + p12Password = "" + pendingP12Data = nil + } + + private func handlePEMImport(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + + guard url.startAccessingSecurityScopedResource() else { + importError = "Cannot access file" + showingImportError = true + return + } + defer { url.stopAccessingSecurityScopedResource() } + + do { + let data = try Data(contentsOf: url) + _ = try certManager.importClientCACertificate(from: data) + Logger.tak.info("Client CA certificate imported successfully") + } catch { + importError = error.localizedDescription + showingImportError = true + } + + case .failure(let error): + importError = error.localizedDescription + showingImportError = true + } + } + + // MARK: - Data Package Generation + + private func generateAndShareDataPackage() { + guard let url = TAKDataPackageGenerator.shared.generateDataPackage( + port: TAKServerManager.defaultTLSPort, + useTLS: true, + description: "Meshtastic TAK Server" + ) else { + importError = "Failed to generate data package" + showingImportError = true + return + } + + dataPackageURL = url + showingFileExporter = true + } +} + +// MARK: - Zip Document for File Exporter + +struct ZipDocument: FileDocument { + static var readableContentTypes: [UTType] { [.zip] } + + let data: Data + + init(url: URL) { + self.data = (try? Data(contentsOf: url)) ?? Data() + } + + init(configuration: ReadConfiguration) throws { + self.data = configuration.file.regularFileContents ?? Data() + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} diff --git a/itak-example-data-package/iphone.p12 b/itak-example-data-package/iphone.p12 new file mode 100644 index 000000000..7f836b2f7 Binary files /dev/null and b/itak-example-data-package/iphone.p12 differ diff --git a/itak-example-data-package/manifest.xml b/itak-example-data-package/manifest.xml new file mode 100644 index 000000000..f356ba952 --- /dev/null +++ b/itak-example-data-package/manifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/itak-example-data-package/server.p12 b/itak-example-data-package/server.p12 new file mode 100644 index 000000000..913f4692a Binary files /dev/null and b/itak-example-data-package/server.p12 differ diff --git a/itak-example-data-package/taky-server.pref b/itak-example-data-package/taky-server.pref new file mode 100644 index 000000000..82b1b864e --- /dev/null +++ b/itak-example-data-package/taky-server.pref @@ -0,0 +1,16 @@ + + + + 1 + Win10 Taky Server + true + 172.30.254.210:8089:ssl + + + true + cert/server.p12 + atakatak + atakatak + cert/iphone.p12 + +