mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge branch '2.7.8' into meshpackets-actor
This commit is contained in:
commit
070492fe24
37 changed files with 5305 additions and 64 deletions
|
|
@ -1901,6 +1901,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"8089" : {
|
||||
"comment" : "The port number for the TAK Server.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"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" : {
|
||||
"sr" : {
|
||||
|
|
@ -1910,6 +1914,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 +2470,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add CA" : {
|
||||
|
||||
},
|
||||
"Add Channel" : {
|
||||
"localizations" : {
|
||||
|
|
@ -7671,6 +7681,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 +8144,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Configuration" : {
|
||||
|
||||
},
|
||||
"Configuration for: %@" : {
|
||||
"localizations" : {
|
||||
|
|
@ -9710,6 +9729,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Delete All" : {
|
||||
|
||||
},
|
||||
"Delete all config, keys and BLE bonds? " : {
|
||||
"localizations" : {
|
||||
|
|
@ -12201,6 +12223,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Download TAK Server Data Package" : {
|
||||
|
||||
},
|
||||
"Drag & Drop Firmware Update" : {
|
||||
"localizations" : {
|
||||
|
|
@ -12714,6 +12739,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enable TAK Server" : {
|
||||
|
||||
},
|
||||
"Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : {
|
||||
"localizations" : {
|
||||
|
|
@ -13227,6 +13255,12 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enter P12 Password" : {
|
||||
|
||||
},
|
||||
"Enter the password for the PKCS#12 file" : {
|
||||
|
||||
},
|
||||
"environment" : {
|
||||
"localizations" : {
|
||||
|
|
@ -15947,6 +15981,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" : {
|
||||
|
|
@ -18235,6 +18272,18 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Import" : {
|
||||
|
||||
},
|
||||
"Import .pem" : {
|
||||
|
||||
},
|
||||
"Import Custom .p12" : {
|
||||
|
||||
},
|
||||
"Import Error" : {
|
||||
|
||||
},
|
||||
"Import Route" : {
|
||||
"localizations" : {
|
||||
|
|
@ -22097,6 +22146,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mTLS" : {
|
||||
|
||||
},
|
||||
"Multiplier" : {
|
||||
"localizations" : {
|
||||
|
|
@ -26322,6 +26374,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Port" : {
|
||||
|
||||
},
|
||||
"Position" : {
|
||||
"localizations" : {
|
||||
|
|
@ -28862,6 +28917,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Reload Bundled Certificates" : {
|
||||
|
||||
},
|
||||
"Remote administration for: %@" : {
|
||||
"localizations" : {
|
||||
|
|
@ -29396,6 +29454,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Reset to Default" : {
|
||||
|
||||
},
|
||||
"Restart" : {
|
||||
"localizations" : {
|
||||
|
|
@ -29430,6 +29491,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Restart Server" : {
|
||||
|
||||
},
|
||||
"Restart to the node you are connected to" : {
|
||||
"localizations" : {
|
||||
|
|
@ -31300,6 +31364,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Secure mTLS connection on port 8089. Both server and client certificates are required." : {
|
||||
|
||||
},
|
||||
"Security" : {
|
||||
"localizations" : {
|
||||
|
|
@ -33114,6 +33181,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Server Certificate" : {
|
||||
|
||||
},
|
||||
"Server Option" : {
|
||||
"localizations" : {
|
||||
|
|
@ -33142,6 +33212,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Server Status" : {
|
||||
|
||||
},
|
||||
"Set" : {
|
||||
"localizations" : {
|
||||
|
|
@ -35058,6 +35131,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Status" : {
|
||||
|
||||
},
|
||||
"Stay Connected Anywhere" : {
|
||||
"localizations" : {
|
||||
|
|
@ -35470,6 +35546,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TAK Server" : {
|
||||
|
||||
},
|
||||
"TAK Tracker" : {
|
||||
"localizations" : {
|
||||
|
|
@ -37773,6 +37852,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TLS Certificates" : {
|
||||
|
||||
},
|
||||
"TLS Enabled" : {
|
||||
"localizations" : {
|
||||
|
|
|
|||
|
|
@ -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 */; };
|
||||
|
|
@ -298,6 +309,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 */
|
||||
|
|
@ -332,6 +345,9 @@
|
|||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = "<group>"; };
|
||||
0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = "<group>"; };
|
||||
09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = "<group>"; };
|
||||
108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = "<group>"; };
|
||||
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = "<group>"; };
|
||||
230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = "<group>"; };
|
||||
|
|
@ -395,17 +411,27 @@
|
|||
25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = "<group>"; };
|
||||
3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = "<group>"; };
|
||||
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = "<group>"; };
|
||||
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = "<group>"; };
|
||||
3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = "<group>"; };
|
||||
3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = "<group>"; };
|
||||
3F203877F307073096C89179 /* FountainCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FountainCodec.swift; sourceTree = "<group>"; };
|
||||
4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTMessage.swift; sourceTree = "<group>"; };
|
||||
518D504DED9874EBF9D76578 /* Certificates */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = Certificates; path = Certificates; sourceTree = "<group>"; };
|
||||
6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = "<group>"; };
|
||||
6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = "<group>"; };
|
||||
6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = "<group>"; };
|
||||
6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEntityExtension.swift; sourceTree = "<group>"; };
|
||||
748E4806582595DE80D455CD /* CoTXMLParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTXMLParser.swift; sourceTree = "<group>"; };
|
||||
7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GenericCoTHandler.swift; sourceTree = "<group>"; };
|
||||
82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+TAK.swift"; sourceTree = "<group>"; };
|
||||
87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKMeshtasticBridge.swift; sourceTree = "<group>"; };
|
||||
8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 49.xcdatamodel"; sourceTree = "<group>"; };
|
||||
8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetrics.swift; sourceTree = "<group>"; };
|
||||
8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = "<group>"; };
|
||||
9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKDataPackageGenerator.swift; sourceTree = "<group>"; };
|
||||
ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconButton.swift; sourceTree = "<group>"; };
|
||||
ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPicker.swift; sourceTree = "<group>"; };
|
||||
B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -674,7 +700,17 @@
|
|||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
DD4C11E02E8099C3003F2F2E /* PreferenceKeys */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = PreferenceKeys; sourceTree = "<group>"; };
|
||||
DD4C11E02E8099C3003F2F2E /* PreferenceKeys */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = PreferenceKeys;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -792,6 +828,7 @@
|
|||
23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */,
|
||||
23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */,
|
||||
23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */,
|
||||
82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */,
|
||||
);
|
||||
path = "Accessory Manager";
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -897,6 +934,24 @@
|
|||
path = AppIntents;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
D9C9839E2B79D0C600BDBE6A /* TextMessageField */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -983,6 +1038,7 @@
|
|||
DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */,
|
||||
DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */,
|
||||
ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */,
|
||||
0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1236,6 +1292,7 @@
|
|||
DDB75A192A05EB67006ED576 /* alpha.png */,
|
||||
DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */,
|
||||
DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */,
|
||||
518D504DED9874EBF9D76578 /* Certificates */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1300,6 +1357,7 @@
|
|||
DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */,
|
||||
6D825E612C34786C008DBEE4 /* CommonRegex.swift */,
|
||||
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */,
|
||||
C37572859BC745C4284A9B42 /* TAK */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1570,6 +1628,7 @@
|
|||
DDC2E15C26CE248F0042C5E4 /* Assets.xcassets in Resources */,
|
||||
DD0E21012B8A6F1300F2D100 /* DeviceHardware.json in Resources */,
|
||||
DDDBC87B2BC62E4E001E8DF7 /* Settings.bundle in Resources */,
|
||||
8E587743574CE17703E892C6 /* Certificates in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -1877,6 +1936,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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"originHash" : "8c5f88cc59cef0f9e938df2478c1365017c6f2330f0482d58d917ab2eb144fda",
|
||||
"originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "cocoamqtt",
|
||||
|
|
@ -15,8 +15,8 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/DataDog/dd-sdk-ios.git",
|
||||
"state" : {
|
||||
"revision" : "8d67e973ff4a958cb536263cb816646ee904c508",
|
||||
"version" : "3.3.0"
|
||||
"revision" : "2cddcb47c021365c5a6ebc377cb379aa979c450e",
|
||||
"version" : "3.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ extension AccessoryManager {
|
|||
}
|
||||
tryClearExistingChannels()
|
||||
|
||||
// Initialize TAK bridge for TAK integration
|
||||
initializeTAKBridge()
|
||||
}
|
||||
|
||||
func handleNodeInfo(_ nodeInfo: NodeInfo) async {
|
||||
|
|
|
|||
|
|
@ -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)..<UInt32.max)
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
Logger.tak.debug("MeshPacket:")
|
||||
Logger.tak.debug(" to: \(String(format: "0x%08X", meshPacket.to))")
|
||||
Logger.tak.debug(" from: \(String(format: "0x%08X", meshPacket.from))")
|
||||
Logger.tak.debug(" channel: \(meshPacket.channel)")
|
||||
Logger.tak.debug(" id: \(meshPacket.id)")
|
||||
Logger.tak.debug(" portnum: \(dataMessage.portnum.rawValue)")
|
||||
Logger.tak.debug(" payload size: \(serialized.count)")
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
try await send(toRadio, debugDescription: "Sending TAKPacket to mesh")
|
||||
|
||||
Logger.tak.info("Sent TAKPacket to mesh (portnum=\(PortNum.atakPlugin.rawValue), channel=\(channel), size=\(serialized.count) bytes)")
|
||||
Logger.tak.debug("=== End Sending TAKPacket ===")
|
||||
}
|
||||
|
||||
/// Send a CoT message to the mesh by converting it to TAKPacket first
|
||||
func sendCoTToMesh(_ cotMessage: CoTMessage, channel: UInt32 = 0) async throws {
|
||||
let bridge = TAKServerManager.shared.bridge
|
||||
|
||||
guard let takPacket = bridge?.convertToTAKPacket(cot: cotMessage) else {
|
||||
throw AccessoryError.ioFailed("Failed to convert CoT to TAKPacket")
|
||||
}
|
||||
|
||||
try await sendTAKPacket(takPacket, channel: channel)
|
||||
}
|
||||
|
||||
// MARK: - Receive TAK Packet from Mesh
|
||||
|
||||
/// Handle incoming ATAK Plugin packet from the mesh network
|
||||
/// Forwards to connected TAK clients via the bridge
|
||||
func handleATAKPluginPacket(_ packet: MeshPacket) {
|
||||
guard case let .decoded(data) = packet.payloadVariant else {
|
||||
Logger.tak.warning("Received ATAK packet without decoded payload")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.tak.debug("Received ATAK packet: \(data.payload.count) bytes from node \(packet.from)")
|
||||
|
||||
// Check if packet is compressed (first bytes 08 01 indicate is_compressed = true)
|
||||
// Compressed packets are sent as duplicates of uncompressed ones, so we ignore them
|
||||
let payload = data.payload
|
||||
if payload.count >= 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)..<UInt32.max)
|
||||
meshPacket.priority = MeshPacket.Priority.reliable
|
||||
meshPacket.priority = MeshPacket.Priority.reliable
|
||||
meshPacket.wantAck = true
|
||||
meshPacket.channel = 0
|
||||
guard let adminData: Data = try? adminPacket.serializedData() else {
|
||||
|
||||
guard let adminData = try? adminPacket.serializedData() else {
|
||||
throw AccessoryError.ioFailed("saveChannelSet: Unable to serialize Admin packet")
|
||||
}
|
||||
|
||||
var dataMessage = DataMessage()
|
||||
dataMessage.payload = adminData
|
||||
dataMessage.portnum = PortNum.adminApp
|
||||
meshPacket.decoded = dataMessage
|
||||
var toRadio: ToRadio!
|
||||
toRadio = ToRadio()
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
let logString = String.localizedStringWithFormat("Sent a Channel for: %@ Channel Index %d".localized, String(deviceNum), chan.index)
|
||||
try await send(toRadio, debugDescription: logString)
|
||||
channelPacket(channel: chan, fromNum: self.activeDeviceNum ?? 0, context: context)
|
||||
}
|
||||
if !addChannels {
|
||||
// Save the LoRa Config and the device will reboot
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
@Published var lastConnectionError: Error?
|
||||
@Published var isConnected: Bool = false
|
||||
@Published var isConnecting: Bool = false
|
||||
@Published var isInBackground: Bool = false
|
||||
|
||||
var activeConnection: (device: Device, connection: any Connection)?
|
||||
|
||||
|
|
@ -576,7 +577,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
case .privateApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Private App UNHANDLED UNHANDLED")
|
||||
case .atakForwarder:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for ATAK Forwarder App UNHANDLED UNHANDLED")
|
||||
handleATAKForwarderPacket(packet)
|
||||
case .simulatorApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Simulator App UNHANDLED UNHANDLED")
|
||||
case .storeForwardPlusplusApp:
|
||||
|
|
@ -598,7 +599,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
case .max:
|
||||
Logger.services.info("MAX PORT NUM OF 511")
|
||||
case .atakPlugin:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for ATAK Plugin App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
handleATAKPluginPacket(packet)
|
||||
case .powerstressApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Power Stress App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .reticulumTunnelApp:
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ extension Logger {
|
|||
/// All logs related to the transport layer
|
||||
static let transport = Logger(subsystem: subsystem, category: "🚚 Transport")
|
||||
|
||||
/// All logs related to TAK server and CoT messages
|
||||
static let tak = Logger(subsystem: subsystem, category: "🎯 TAK")
|
||||
|
||||
/// Fetch from the logstore
|
||||
static public func fetch(predicateFormat: String) async throws -> [OSLogEntryLog] {
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
544
Meshtastic/Helpers/TAK/CoTMessage.swift
Normal file
544
Meshtastic/Helpers/TAK/CoTMessage.swift
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
//
|
||||
// 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: "<actual_device_callsign>|<messageId>" or just "<actual_device_callsign>"
|
||||
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 = "<?xml version='1.0' encoding='UTF-8' standalone='yes'?>"
|
||||
cot += "<event version='2.0' uid='\(uid.xmlEscaped)' "
|
||||
cot += "type='\(type)' "
|
||||
cot += "time='\(dateFormatter.string(from: time))' "
|
||||
cot += "start='\(dateFormatter.string(from: start))' "
|
||||
cot += "stale='\(dateFormatter.string(from: stale))' "
|
||||
cot += "how='\(how)'>"
|
||||
cot += "<point lat='\(latitude)' lon='\(longitude)' "
|
||||
cot += "hae='\(hae)' ce='\(ce)' le='\(le)'/>"
|
||||
cot += "<detail>"
|
||||
|
||||
// Contact element
|
||||
if let contact {
|
||||
cot += "<contact endpoint='\(contact.endpoint ?? "0.0.0.0:4242:tcp")' "
|
||||
cot += "callsign='\(contact.callsign.xmlEscaped)'/>"
|
||||
cot += "<uid Droid='\(contact.callsign.xmlEscaped)'/>"
|
||||
}
|
||||
|
||||
// Group element
|
||||
if let group {
|
||||
cot += "<__group role='\(group.role.xmlEscaped)' name='\(group.name.xmlEscaped)'/>"
|
||||
}
|
||||
|
||||
// Status element
|
||||
if let status {
|
||||
cot += "<status battery='\(status.battery)'/>"
|
||||
}
|
||||
|
||||
// Track element
|
||||
if let track {
|
||||
cot += "<track course='\(track.course)' speed='\(track.speed)'/>"
|
||||
}
|
||||
|
||||
// Chat elements (for b-t-f messages)
|
||||
if let chat {
|
||||
// Derive sender UID and messageId from GeoChat UID when possible, with safe fallbacks
|
||||
let senderUid: String
|
||||
let messageId: String
|
||||
|
||||
if uid.hasPrefix("GeoChat.") {
|
||||
let components = uid.split(separator: ".")
|
||||
if components.count >= 3 {
|
||||
// Expected GeoChat format: GeoChat.<senderUid>.<messageId>
|
||||
senderUid = String(components[1])
|
||||
messageId = String(components[2])
|
||||
} else {
|
||||
// Malformed GeoChat UID; fall back safely
|
||||
senderUid = uid
|
||||
messageId = uid
|
||||
}
|
||||
} else {
|
||||
// Non-GeoChat UID; use uid as both sender and stable message identifier
|
||||
senderUid = uid
|
||||
messageId = uid
|
||||
}
|
||||
cot += "<__chat parent='RootContactGroup' groupOwner='false' "
|
||||
cot += "messageId='\(messageId)' "
|
||||
cot += "chatroom='\(chat.chatroom.xmlEscaped)' id='\(chat.chatroom.xmlEscaped)' "
|
||||
cot += "senderCallsign='\(chat.senderCallsign?.xmlEscaped ?? "")'>"
|
||||
cot += "<chatgrp uid0='\(senderUid.xmlEscaped)' "
|
||||
cot += "uid1='\(chat.chatroom.xmlEscaped)' id='\(chat.chatroom.xmlEscaped)'/>"
|
||||
cot += "</__chat>"
|
||||
cot += "<link uid='\(senderUid.xmlEscaped)' type='a-f-G-U-C' relation='p-p'/>"
|
||||
cot += "<__serverdestination destinations='0.0.0.0:4242:tcp:\(senderUid.xmlEscaped)'/>"
|
||||
cot += "<remarks source='BAO.F.ATAK.\(senderUid.xmlEscaped)' "
|
||||
cot += "to='\(chat.chatroom.xmlEscaped)' "
|
||||
cot += "time='\(dateFormatter.string(from: time))'>"
|
||||
cot += "\(chat.message.xmlEscaped)</remarks>"
|
||||
} else if let remarks, !remarks.isEmpty {
|
||||
cot += "<remarks>\(remarks.xmlEscaped)</remarks>"
|
||||
}
|
||||
|
||||
// Include raw detail XML for generic CoT elements (colors, shapes, labels, etc.)
|
||||
// This preserves elements we don't explicitly parse
|
||||
if let rawDetailXML, !rawDetailXML.isEmpty {
|
||||
cot += rawDetailXML
|
||||
}
|
||||
|
||||
cot += "</detail></event>"
|
||||
|
||||
return cot
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
/// Contact information for a CoT event
|
||||
struct CoTContact: Sendable, Equatable {
|
||||
var callsign: String
|
||||
var endpoint: String?
|
||||
var phone: String?
|
||||
|
||||
init(callsign: String, endpoint: String? = nil, phone: String? = nil) {
|
||||
self.callsign = callsign
|
||||
self.endpoint = endpoint
|
||||
self.phone = phone
|
||||
}
|
||||
}
|
||||
|
||||
/// Group/team assignment for a CoT event
|
||||
struct CoTGroup: Sendable, Equatable {
|
||||
/// Team color name (e.g., "Cyan", "Green", "Red")
|
||||
var name: String
|
||||
/// Role name (e.g., "Team Member", "Team Lead")
|
||||
var role: String
|
||||
|
||||
init(name: String, role: String) {
|
||||
self.name = name
|
||||
self.role = role
|
||||
}
|
||||
}
|
||||
|
||||
/// Device status for a CoT event
|
||||
struct CoTStatus: Sendable, Equatable {
|
||||
var battery: Int
|
||||
|
||||
init(battery: Int) {
|
||||
self.battery = battery
|
||||
}
|
||||
}
|
||||
|
||||
/// Movement track for a CoT event
|
||||
struct CoTTrack: Sendable, Equatable {
|
||||
var speed: Double
|
||||
var course: Double
|
||||
|
||||
init(speed: Double, course: Double) {
|
||||
self.speed = speed
|
||||
self.course = course
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat message details for a CoT event
|
||||
struct CoTChat: Sendable, Equatable {
|
||||
var message: String
|
||||
var senderCallsign: String?
|
||||
var chatroom: String
|
||||
|
||||
init(message: String, senderCallsign: String? = nil, chatroom: String = "All Chat Rooms") {
|
||||
self.message = message
|
||||
self.senderCallsign = senderCallsign
|
||||
self.chatroom = chatroom
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String Extension for XML Escaping
|
||||
|
||||
extension String {
|
||||
/// Escape special XML characters
|
||||
var xmlEscaped: String {
|
||||
self.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", 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
|
||||
}
|
||||
}
|
||||
}
|
||||
335
Meshtastic/Helpers/TAK/CoTXMLParser.swift
Normal file
335
Meshtastic/Helpers/TAK/CoTXMLParser.swift
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
//
|
||||
// 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<String> = [
|
||||
"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 <detail> 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 += "</\(elementName)>"
|
||||
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 (throwing version)
|
||||
static func parseData(_ data: Data) throws -> CoTMessage {
|
||||
guard !data.isEmpty else {
|
||||
throw CoTParseError.emptyData
|
||||
}
|
||||
|
||||
let parser = CoTXMLParser(data: data)
|
||||
return try parser.parse()
|
||||
}
|
||||
}
|
||||
148
Meshtastic/Helpers/TAK/EXICodec.swift
Normal file
148
Meshtastic/Helpers/TAK/EXICodec.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
616
Meshtastic/Helpers/TAK/FountainCodec.swift
Normal file
616
Meshtastic/Helpers/TAK/FountainCodec.swift
Normal file
|
|
@ -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<Int>
|
||||
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..<blocksToSend {
|
||||
let seed = generateSeed(transferId: transferId, blockIndex: i)
|
||||
|
||||
// Generate indices - must match Android's algorithm exactly
|
||||
let indices = generateBlockIndices(seed: seed, K: K, blockIndex: i)
|
||||
|
||||
Logger.tak.debug("Fountain block \(i): seed=\(seed), degree=\(indices.count), indices=\(indices.sorted())")
|
||||
|
||||
// XOR selected source blocks together
|
||||
var blockPayload = Data(repeating: 0, count: FountainConstants.blockSize)
|
||||
for idx in indices {
|
||||
let before = blockPayload.prefix(4).map { String(format: "%02X", $0) }.joined()
|
||||
blockPayload = xor(blockPayload, sourceBlocks[idx])
|
||||
let after = blockPayload.prefix(4).map { String(format: "%02X", $0) }.joined()
|
||||
Logger.tak.debug(" XOR with sourceBlock[\(idx)]: \(before) → \(after)")
|
||||
}
|
||||
|
||||
// Log final payload hash
|
||||
let payloadHash = blockPayload.prefix(8).map { String(format: "%02X", $0) }.joined()
|
||||
Logger.tak.debug(" Final payload first 8 bytes: \(payloadHash)")
|
||||
|
||||
// Build data block packet
|
||||
let packet = buildDataBlock(
|
||||
transferId: transferId,
|
||||
seed: seed,
|
||||
K: UInt8(K),
|
||||
totalLength: UInt16(data.count),
|
||||
payload: blockPayload
|
||||
)
|
||||
packets.append(packet)
|
||||
}
|
||||
|
||||
Logger.tak.info("Fountain encode: \(data.count) bytes → \(K) source blocks → \(blocksToSend) packets")
|
||||
return packets
|
||||
}
|
||||
|
||||
/// Split data into K blocks, padding the last block with zeros
|
||||
private func splitIntoBlocks(data: Data, K: Int) -> [Data] {
|
||||
var blocks: [Data] = []
|
||||
for i in 0..<K {
|
||||
let start = i * FountainConstants.blockSize
|
||||
let end = min(start + FountainConstants.blockSize, data.count)
|
||||
|
||||
var block: Data
|
||||
if start < data.count {
|
||||
// IMPORTANT: Use Data() to rebase indices to 0
|
||||
// Data slices keep original indices which causes crashes
|
||||
block = Data(data[start..<end])
|
||||
// Pad if necessary
|
||||
if block.count < FountainConstants.blockSize {
|
||||
block.append(Data(repeating: 0, count: FountainConstants.blockSize - block.count))
|
||||
}
|
||||
} else {
|
||||
block = Data(repeating: 0, count: FountainConstants.blockSize)
|
||||
}
|
||||
blocks.append(block)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/// Build a fountain data block packet
|
||||
private func buildDataBlock(transferId: UInt32, seed: UInt16, K: UInt8, totalLength: UInt16, payload: 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))
|
||||
|
||||
// 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..<workingBlocks.count {
|
||||
var block = workingBlocks[i]
|
||||
|
||||
// Remove already-decoded indices by XORing out their data
|
||||
for idx in block.indices {
|
||||
if let decodedBlock = decoded[idx] {
|
||||
block.payload = xor(block.payload, decodedBlock)
|
||||
block.indices.remove(idx)
|
||||
}
|
||||
}
|
||||
workingBlocks[i] = block
|
||||
|
||||
// If only one unknown remains, we can decode it
|
||||
if block.indices.count == 1 {
|
||||
let idx = block.indices.first!
|
||||
decoded[idx] = block.payload
|
||||
progress = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if complete
|
||||
guard decoded.count >= 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..<state.K {
|
||||
if let block = decoded[i] {
|
||||
result.append(block)
|
||||
} else {
|
||||
Logger.tak.warning("Missing block \(i) in decoded data")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Trim to original length
|
||||
return Data(result.prefix(state.totalLength))
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
/// Get adaptive overhead based on K
|
||||
private func getAdaptiveOverhead(_ K: Int) -> 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<Int> {
|
||||
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<Int> {
|
||||
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<Int> {
|
||||
var indices = Set<Int>()
|
||||
|
||||
// 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..<result.count {
|
||||
let byteA = i < aData.count ? aData[i] : 0
|
||||
let byteB = i < bData.count ? bData[i] : 0
|
||||
result[i] = byteA ^ byteB
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Compute SHA-256 hash (first 8 bytes for ACK)
|
||||
static func computeHash(_ data: Data) -> 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
399
Meshtastic/Helpers/TAK/GenericCoTHandler.swift
Normal file
399
Meshtastic/Helpers/TAK/GenericCoTHandler.swift
Normal file
|
|
@ -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)..<UInt32.max)
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
try await accessoryManager.send(toRadio, debugDescription: "Generic CoT (direct)")
|
||||
|
||||
Logger.tak.info("Sent generic CoT directly: \(payload.count) bytes on port 257")
|
||||
}
|
||||
|
||||
/// Send large payload using fountain coding
|
||||
private func sendFountainCoded(_ 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
|
||||
}
|
||||
|
||||
let transferId = FountainCodec.shared.generateTransferId()
|
||||
let packets = FountainCodec.shared.encode(data: payload, transferId: transferId)
|
||||
|
||||
Logger.tak.info("Sending fountain-coded CoT: \(payload.count) bytes → \(packets.count) blocks, xferId=\(transferId)")
|
||||
|
||||
// Track pending transfer
|
||||
pendingTransfers[transferId] = PendingTransfer(
|
||||
transferId: transferId,
|
||||
totalBlocks: packets.count,
|
||||
dataHash: FountainCodec.computeHash(payload)
|
||||
)
|
||||
|
||||
// Send all blocks with inter-packet delay
|
||||
for (index, packetData) in packets.enumerated() {
|
||||
var dataMessage = DataMessage()
|
||||
dataMessage.portnum = .atakForwarder
|
||||
dataMessage.payload = packetData
|
||||
|
||||
var meshPacket = MeshPacket()
|
||||
meshPacket.to = 0xFFFFFFFF
|
||||
meshPacket.from = UInt32(deviceNum)
|
||||
meshPacket.channel = channel
|
||||
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
try await accessoryManager.send(toRadio, debugDescription: "Fountain block \(index + 1)/\(packets.count)")
|
||||
|
||||
// Inter-packet delay (100ms default, could be adjusted based on modem preset)
|
||||
if index < packets.count - 1 {
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.tak.info("Fountain transfer \(transferId) complete: sent \(packets.count) blocks")
|
||||
}
|
||||
|
||||
// MARK: - Receiving Generic CoT
|
||||
|
||||
/// Handle incoming ATAK_FORWARDER packet (port 257)
|
||||
/// - Parameters:
|
||||
/// - packet: The mesh packet
|
||||
/// - Returns: Decoded CoT message if successful
|
||||
func handleIncomingForwarderPacket(_ packet: MeshPacket) -> 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)..<UInt32.max)
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
do {
|
||||
try await accessoryManager.send(toRadio, debugDescription: "Fountain ACK")
|
||||
Logger.tak.debug("Sent fountain ACK for transfer \(transferId)")
|
||||
} catch {
|
||||
Logger.tak.warning("Failed to send fountain ACK: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming fountain ACK
|
||||
func handleIncomingAck(_ payload: Data, from nodeNum: UInt32) {
|
||||
guard let ack = FountainCodec.shared.parseAck(payload) else {
|
||||
Logger.tak.debug("Failed to parse fountain ACK from node \(nodeNum)")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.tak.debug("Received fountain ACK: xferId=\(ack.transferId), type=\(ack.type), from node \(nodeNum)")
|
||||
|
||||
if let pending = pendingTransfers[ack.transferId] {
|
||||
if ack.type == FountainConstants.ackTypeComplete {
|
||||
// Verify hash matches
|
||||
if ack.dataHash == pending.dataHash {
|
||||
Logger.tak.info("Fountain transfer \(ack.transferId) acknowledged by node \(nodeNum)")
|
||||
} else {
|
||||
Logger.tak.warning("Fountain ACK hash mismatch for transfer \(ack.transferId)")
|
||||
}
|
||||
pendingTransfers.removeValue(forKey: ack.transferId)
|
||||
} else if ack.type == FountainConstants.ackTypeNeedMore {
|
||||
Logger.tak.debug("Node \(nodeNum) needs \(ack.needed) more blocks for transfer \(ack.transferId)")
|
||||
// TODO: Send additional blocks
|
||||
}
|
||||
} else {
|
||||
// No pending transfer - might be echo of our own ACK or already completed
|
||||
Logger.tak.debug("Received ACK for unknown/completed transfer \(ack.transferId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
/// Tracks a pending outgoing fountain transfer
|
||||
private struct PendingTransfer {
|
||||
let transferId: UInt32
|
||||
let totalBlocks: Int
|
||||
let dataHash: Data
|
||||
let startTime: Date = Date()
|
||||
}
|
||||
|
||||
/// Errors for generic CoT handling
|
||||
enum GenericCoTError: LocalizedError {
|
||||
case notConnected
|
||||
case noDeviceNumber
|
||||
case compressionFailed
|
||||
case encodingFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected:
|
||||
return "Not connected to Meshtastic device"
|
||||
case .noDeviceNumber:
|
||||
return "No device number available"
|
||||
case .compressionFailed:
|
||||
return "Failed to compress CoT to EXI"
|
||||
case .encodingFailed:
|
||||
return "Failed to encode CoT for transmission"
|
||||
}
|
||||
}
|
||||
}
|
||||
626
Meshtastic/Helpers/TAK/TAKCertificateManager.swift
Normal file
626
Meshtastic/Helpers/TAK/TAKCertificateManager.swift
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
//
|
||||
// TAKCertificateManager.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by niccellular 12/26/25
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
import OSLog
|
||||
|
||||
/// Manages TLS certificates for the TAK server
|
||||
/// Handles server identity (PKCS#12) and client CA certificates (PEM)
|
||||
final class TAKCertificateManager {
|
||||
|
||||
static let shared = TAKCertificateManager()
|
||||
|
||||
// Keychain tags for certificate storage
|
||||
private let serverIdentityTag = "com.meshtastic.tak.server.identity"
|
||||
private let serverIdentityCustomTag = "com.meshtastic.tak.server.identity.custom"
|
||||
private let clientCATag = "com.meshtastic.tak.client.ca"
|
||||
|
||||
// Bundled certificate password
|
||||
private let bundledPassword = "meshtastic"
|
||||
|
||||
// Storage keys for custom P12 data (for data package generation)
|
||||
private let customServerP12DataKey = "tak.custom.server.p12.data"
|
||||
private let customServerP12PasswordKey = "tak.custom.server.p12.password"
|
||||
private let customClientP12DataKey = "tak.custom.client.p12.data"
|
||||
private let customClientP12PasswordKey = "tak.custom.client.p12.password"
|
||||
|
||||
private init() {
|
||||
// Load bundled defaults on first launch if no custom cert exists
|
||||
loadBundledDefaultsIfNeeded()
|
||||
}
|
||||
|
||||
/// Force reload all bundled certificates (useful after app update with new certs)
|
||||
func reloadBundledCertificates() {
|
||||
Logger.tak.info("Reloading bundled certificates...")
|
||||
|
||||
// Clear custom certificate data
|
||||
clearCustomCertificateData()
|
||||
|
||||
// Delete existing certificates
|
||||
deleteServerIdentity()
|
||||
deleteClientCACertificates()
|
||||
|
||||
// Reload bundled defaults
|
||||
loadBundledServerIdentity()
|
||||
loadBundledClientCA()
|
||||
|
||||
Logger.tak.info("Bundled certificates reloaded")
|
||||
}
|
||||
|
||||
// MARK: - Bundled Default Certificates
|
||||
|
||||
/// Load bundled default certificates if no custom certificates are configured
|
||||
private func loadBundledDefaultsIfNeeded() {
|
||||
// Only load if no custom server identity exists
|
||||
if !hasCustomServerCertificate() && getServerIdentity() == nil {
|
||||
loadBundledServerIdentity()
|
||||
}
|
||||
|
||||
// Only load if no client CA exists
|
||||
if !hasClientCACertificate() {
|
||||
loadBundledClientCA()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the bundled server identity (p12)
|
||||
private func loadBundledServerIdentity() {
|
||||
// Try subdirectory first, then root level (Xcode may flatten folder structure)
|
||||
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 {
|
||||
Logger.tak.warning("Bundled server.p12 not found in app bundle")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try importServerIdentity(from: p12Data, password: bundledPassword, isCustom: false)
|
||||
Logger.tak.info("Loaded bundled default server certificate")
|
||||
} catch {
|
||||
Logger.tak.error("Failed to load bundled server certificate: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the bundled client CA certificate (pem)
|
||||
private func loadBundledClientCA() {
|
||||
// Try subdirectory first, then root level (Xcode may flatten folder structure)
|
||||
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 {
|
||||
Logger.tak.warning("Bundled ca.pem not found in app bundle")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try importClientCACertificate(from: pemData)
|
||||
Logger.tak.info("Loaded bundled default CA certificate")
|
||||
} catch {
|
||||
Logger.tak.error("Failed to load bundled CA certificate: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if using custom (user-imported) server certificate
|
||||
func hasCustomServerCertificate() -> 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 {
|
||||
storeCustomServerP12InKeychain(p12Data: p12Data, password: password)
|
||||
Logger.tak.debug("Stored custom server P12 data for data package generation in Keychain")
|
||||
}
|
||||
|
||||
Logger.tak.info("Server identity imported successfully (custom: \(isCustom))")
|
||||
return identity
|
||||
}
|
||||
|
||||
/// Store custom server PKCS#12 data and its password in the Keychain
|
||||
private func storeCustomServerP12InKeychain(p12Data: Data, password: String) {
|
||||
let service = "com.meshtastic.tak"
|
||||
|
||||
// Helper to upsert a generic password item
|
||||
func upsertKeychainItem(account: String, value: Data) -> OSStatus {
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||
kSecValueData as String: value
|
||||
]
|
||||
|
||||
return SecItemAdd(addQuery as CFDictionary, nil)
|
||||
}
|
||||
|
||||
let dataStatus = upsertKeychainItem(account: customServerP12DataKey, value: p12Data)
|
||||
if dataStatus != errSecSuccess {
|
||||
Logger.tak.error("Failed to store custom server P12 data in Keychain: \(dataStatus)")
|
||||
}
|
||||
|
||||
if let passwordData = password.data(using: .utf8) {
|
||||
let passwordStatus = upsertKeychainItem(account: customServerP12PasswordKey, value: passwordData)
|
||||
if passwordStatus != errSecSuccess {
|
||||
Logger.tak.error("Failed to store custom server P12 password in Keychain: \(passwordStatus)")
|
||||
}
|
||||
} else {
|
||||
Logger.tak.error("Failed to encode custom server P12 password as UTF-8 data")
|
||||
}
|
||||
}
|
||||
/// 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)"
|
||||
}
|
||||
}
|
||||
498
Meshtastic/Helpers/TAK/TAKConnection.swift
Normal file
498
Meshtastic/Helpers/TAK/TAKConnection.swift
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
//
|
||||
// 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 </event>)
|
||||
/// Implements TAK Protocol negotiation and keepalive
|
||||
actor TAKConnection {
|
||||
private let connection: NWConnection
|
||||
private var messageBuffer = Data()
|
||||
private var readerTask: Task<Void, Never>?
|
||||
private var keepaliveTask: Task<Void, Never>?
|
||||
private var continuation: AsyncStream<TAKConnectionEvent>.Continuation?
|
||||
|
||||
// CoT XML message delimiters (from StreamingCotProtocol.java)
|
||||
private let startTag = "<event".data(using: .utf8)!
|
||||
private let endTag = "</event>".data(using: .utf8)!
|
||||
private let maxMessageSize = 8_388_608 // 8MB max per TAK Server spec
|
||||
private let bufferTrimSize = 1_000_000 // 1MB trim threshold
|
||||
|
||||
// Protocol state
|
||||
private var protocolNegotiated = false
|
||||
private let serverUID = "Meshtastic-TAK-Server-\(UUID().uuidString.prefix(8))"
|
||||
|
||||
// Keepalive interval (30 seconds)
|
||||
private let keepaliveInterval: UInt64 = 30_000_000_000 // nanoseconds
|
||||
|
||||
// Client information
|
||||
private(set) var clientInfo: TAKClientInfo?
|
||||
private(set) var isConnected = false
|
||||
|
||||
var endpoint: NWEndpoint {
|
||||
connection.endpoint
|
||||
}
|
||||
|
||||
init(connection: NWConnection) {
|
||||
self.connection = connection
|
||||
}
|
||||
|
||||
/// Start handling the connection and return an event stream
|
||||
func start() -> AsyncStream<TAKConnectionEvent> {
|
||||
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 </event>)
|
||||
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(..<endRange.upperBound)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure start is before end
|
||||
guard startRange.lowerBound < endRange.lowerBound else {
|
||||
// Malformed, discard up to end tag
|
||||
messageBuffer.removeSubrange(..<endRange.upperBound)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the complete message
|
||||
let messageData = messageBuffer.subdata(in: startRange.lowerBound..<endRange.upperBound)
|
||||
|
||||
// Remove processed data from buffer
|
||||
messageBuffer.removeSubrange(..<endRange.upperBound)
|
||||
|
||||
// Parse if within size limits
|
||||
if messageData.count <= maxMessageSize {
|
||||
parseAndYieldMessage(messageData)
|
||||
} else {
|
||||
Logger.tak.warning("CoT message too large: \(messageData.count) bytes, discarding")
|
||||
}
|
||||
}
|
||||
|
||||
// Clear buffer if it exceeds max size (malformed data protection)
|
||||
if messageBuffer.count > 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.parseData(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) {
|
||||
let snippet = String(xmlString.prefix(500))
|
||||
Logger.tak.debug("Failed Raw CoT XML: \(snippet)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = """
|
||||
<event version="2.0" uid="\(serverUID)" type="t-x-takp-v" time="\(now)" start="\(now)" stale="\(stale)" how="m-g">
|
||||
<point lat="0" lon="0" hae="0" ce="9999999" le="9999999"/>
|
||||
<detail>
|
||||
<TakControl>
|
||||
<TakProtocolSupport version="0"/>
|
||||
</TakControl>
|
||||
</detail>
|
||||
</event>
|
||||
"""
|
||||
|
||||
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 = """
|
||||
<event version="2.0" uid="\(serverUID)" type="t-x-takp-r" time="\(now)" start="\(now)" stale="\(stale)" how="m-g">
|
||||
<point lat="0" lon="0" hae="0" ce="9999999" le="9999999"/>
|
||||
<detail>
|
||||
<TakControl>
|
||||
<TakResponse status="\(accepted ? "true" : "false")"/>
|
||||
</TakControl>
|
||||
</detail>
|
||||
</event>
|
||||
"""
|
||||
|
||||
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 = """
|
||||
<event version="2.0" uid="takPong" type="t-x-d-d" time="\(now)" start="\(now)" stale="\(stale)" how="m-g">
|
||||
<point lat="0" lon="0" hae="0" ce="9999999" le="9999999"/>
|
||||
<detail/>
|
||||
</event>
|
||||
"""
|
||||
|
||||
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<Void, Error>) 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<Void, Error>) 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
261
Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift
Normal file
261
Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift
Normal file
|
|
@ -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 """
|
||||
<?xml version='1.0' encoding='ASCII' standalone='yes'?>
|
||||
<preferences>
|
||||
<preference version="1" name="cot_streams">
|
||||
<entry key="count" class="class java.lang.Integer">1</entry>
|
||||
<entry key="description0" class="class java.lang.String">\(escapeXML(description))</entry>
|
||||
<entry key="enabled0" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="connectString0" class="class java.lang.String">\(serverHost):\(port):\(protocolType)</entry>
|
||||
</preference>
|
||||
<preference version="1" name="com.atakmap.app_preferences">
|
||||
<entry key="displayServerConnectionWidget" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="caLocation" class="class java.lang.String">cert/truststore.p12</entry>
|
||||
<entry key="caPassword" class="class java.lang.String">\(serverPassword)</entry>
|
||||
<entry key="certificateLocation" class="class java.lang.String">cert/client.p12</entry>
|
||||
<entry key="clientPassword" class="class java.lang.String">\(clientPassword)</entry>
|
||||
</preference>
|
||||
</preferences>
|
||||
"""
|
||||
} else {
|
||||
// TCP mode - no certificates needed
|
||||
return """
|
||||
<?xml version='1.0' encoding='ASCII' standalone='yes'?>
|
||||
<preferences>
|
||||
<preference version="1" name="cot_streams">
|
||||
<entry key="count" class="class java.lang.Integer">1</entry>
|
||||
<entry key="description0" class="class java.lang.String">\(escapeXML(description))</entry>
|
||||
<entry key="enabled0" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="connectString0" class="class java.lang.String">\(serverHost):\(port):\(protocolType)</entry>
|
||||
</preference>
|
||||
<preference version="1" name="com.atakmap.app_preferences">
|
||||
<entry key="displayServerConnectionWidget" class="class java.lang.Boolean">true</entry>
|
||||
</preference>
|
||||
</preferences>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
// 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 """
|
||||
<MissionPackageManifest version="2">
|
||||
<Configuration>
|
||||
<Parameter name="uid" value="\(uid)"/>
|
||||
<Parameter name="name" value="Meshtastic_TAK_Server"/>
|
||||
<Parameter name="onReceiveDelete" value="true"/>
|
||||
</Configuration>
|
||||
<Contents>
|
||||
<Content ignore="false" zipEntry="certs\\\(prefFileName)"/>
|
||||
<Content ignore="false" zipEntry="certs\\truststore.p12"/>
|
||||
<Content ignore="false" zipEntry="certs\\client.p12"/>
|
||||
</Contents>
|
||||
</MissionPackageManifest>
|
||||
"""
|
||||
} else {
|
||||
// TCP mode - just the pref file
|
||||
return """
|
||||
<MissionPackageManifest version="2">
|
||||
<Configuration>
|
||||
<Parameter name="uid" value="\(uid)"/>
|
||||
<Parameter name="name" value="Meshtastic_TAK_Server"/>
|
||||
<Parameter name="onReceiveDelete" value="true"/>
|
||||
</Configuration>
|
||||
<Contents>
|
||||
<Content ignore="false" zipEntry="certs\\\(prefFileName)"/>
|
||||
</Contents>
|
||||
</MissionPackageManifest>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
516
Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift
Normal file
516
Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift
Normal file
|
|
@ -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:<messageId>" or "ACK:R:<messageId>"
|
||||
/// - 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: "<actual_device_callsign>|<messageId>" or just "<actual_device_callsign>"
|
||||
/// - 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[..<separatorIndex])
|
||||
let messageId = String(combined[combined.index(after: separatorIndex)...])
|
||||
return (deviceCallsign, messageId.isEmpty ? nil : messageId)
|
||||
}
|
||||
|
||||
return (combined, nil)
|
||||
}
|
||||
|
||||
/// Create a smuggled device_callsign containing the messageId
|
||||
/// Format: "<actual_device_callsign>|<messageId>"
|
||||
/// - 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: "<deviceUid>|<messageId>"
|
||||
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> = 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> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
436
Meshtastic/Helpers/TAK/TAKServerManager.swift
Normal file
436
Meshtastic/Helpers/TAK/TAKServerManager.swift
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
//
|
||||
// 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<Void, Never>] = [:]
|
||||
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 {
|
||||
// No client CAs configured: keep mTLS enabled but reject all client certificates
|
||||
Logger.tak.warning("mTLS enabled but no CA certificates configured for client validation; all client connections will be rejected")
|
||||
sec_protocol_options_set_verify_block(
|
||||
tlsOptions.securityProtocolOptions,
|
||||
{ _, _, completion in
|
||||
Logger.tak.error("Rejecting client connection because no client CA certificates are configured")
|
||||
completion(false)
|
||||
},
|
||||
queue
|
||||
)
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@
|
|||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
|
|
|
|||
|
|
@ -193,9 +193,13 @@ 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")
|
||||
// Stop Session Replay when app goes to background to prevent crashes
|
||||
// from accessing SwiftUI view hierarchy while backgrounded
|
||||
SessionReplay.stopRecording()
|
||||
accessoryManager.appDidEnterBackground()
|
||||
do {
|
||||
try persistenceController.container.viewContext.save()
|
||||
|
|
@ -209,6 +213,8 @@ struct MeshtasticAppleApp: App {
|
|||
Logger.services.info("🎬 [App] Scene is inactive")
|
||||
case .active:
|
||||
Logger.services.info("🎬 [App] Scene is active")
|
||||
// Resume Session Replay when app becomes active
|
||||
SessionReplay.startRecording()
|
||||
accessoryManager.appDidBecomeActive()
|
||||
@unknown default:
|
||||
Logger.services.error("🍎 [App] Apple must have changed something")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
Meshtastic/Resources/Certificates/backup/ca.pem
Normal file
20
Meshtastic/Resources/Certificates/backup/ca.pem
Normal file
|
|
@ -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-----
|
||||
BIN
Meshtastic/Resources/Certificates/backup/server.p12
Normal file
BIN
Meshtastic/Resources/Certificates/backup/server.p12
Normal file
Binary file not shown.
23
Meshtastic/Resources/Certificates/ca.pem
Normal file
23
Meshtastic/Resources/Certificates/ca.pem
Normal file
|
|
@ -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-----
|
||||
BIN
Meshtastic/Resources/Certificates/client.p12
Normal file
BIN
Meshtastic/Resources/Certificates/client.p12
Normal file
Binary file not shown.
BIN
Meshtastic/Resources/Certificates/server.p12
Normal file
BIN
Meshtastic/Resources/Certificates/server.p12
Normal file
Binary file not shown.
|
|
@ -52,6 +52,7 @@ enum SettingsNavigationState: String {
|
|||
case debugLogs
|
||||
case appFiles
|
||||
case firmwareUpdates
|
||||
case tak
|
||||
}
|
||||
|
||||
struct NavigationState: Hashable {
|
||||
|
|
|
|||
|
|
@ -27,9 +27,8 @@ struct MessageText: View {
|
|||
// State for handling channel URL sheet
|
||||
@State private var saveChannelLink: SaveChannelLinkData?
|
||||
@State private var isShowingDeleteConfirmation = false
|
||||
|
||||
@FocusState private var isTapbackInputFocused: Bool
|
||||
@State private var tapbackText = ""
|
||||
@FocusState private var isTapbackInputFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) {
|
||||
|
|
|
|||
|
|
@ -78,3 +78,16 @@ struct TapbackInputView: View {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
var firstResponder: UIView? {
|
||||
guard !isFirstResponder else { return self }
|
||||
for subview in subviews {
|
||||
if let firstResponder = subview.firstResponder {
|
||||
return firstResponder
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -327,6 +327,18 @@ struct Settings: View {
|
|||
}
|
||||
}
|
||||
|
||||
var takSection: some View {
|
||||
Section(header: Text("TAK")) {
|
||||
NavigationLink(value: SettingsNavigationState.tak) {
|
||||
Label {
|
||||
Text("TAK Server")
|
||||
} icon: {
|
||||
Image(systemName: "target")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(
|
||||
path: Binding<[SettingsNavigationState]>(
|
||||
|
|
@ -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
|
||||
|
|
|
|||
390
Meshtastic/Views/Settings/TAKServerConfig.swift
Normal file
390
Meshtastic/Views/Settings/TAKServerConfig.swift
Normal file
|
|
@ -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: importType == .p12 ? [UTType(filenameExtension: "p12")!, .pkcs12] : [UTType(filenameExtension: "pem")!],
|
||||
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)
|
||||
}
|
||||
}
|
||||
BIN
itak-example-data-package/iphone.p12
Normal file
BIN
itak-example-data-package/iphone.p12
Normal file
Binary file not shown.
12
itak-example-data-package/manifest.xml
Normal file
12
itak-example-data-package/manifest.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<MissionPackageManifest version="2">
|
||||
<Configuration>
|
||||
<Parameter name="uid" value="bcfaa4a5-2224-4095-bbe3-fdaa22a82741"/>
|
||||
<Parameter name="name" value="testbox_DP"/>
|
||||
<Parameter name="onReceiveDelete" value="true"/>
|
||||
</Configuration>
|
||||
<Contents>
|
||||
<Content ignore="false" zipEntry="certs\taky-server.pref"/>
|
||||
<Content ignore="false" zipEntry="certs\server.p12"/>
|
||||
<Content ignore="false" zipEntry="certs\iphone.p12"/>
|
||||
</Contents>
|
||||
</MissionPackageManifest>
|
||||
BIN
itak-example-data-package/server.p12
Normal file
BIN
itak-example-data-package/server.p12
Normal file
Binary file not shown.
16
itak-example-data-package/taky-server.pref
Normal file
16
itak-example-data-package/taky-server.pref
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version='1.0' encoding='ASCII' standalone='yes'?>
|
||||
<preferences>
|
||||
<preference version="1" name="cot_streams">
|
||||
<entry key="count" class="class java.lang.Integer">1</entry>
|
||||
<entry key="description0" class="class java.lang.String">Win10 Taky Server</entry>
|
||||
<entry key="enabled0" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="connectString0" class="class java.lang.String">172.30.254.210:8089:ssl</entry>
|
||||
</preference>
|
||||
<preference version="1" name="com.atakmap.app_preferences">
|
||||
<entry key="displayServerConnectionWidget" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="caLocation" class="class java.lang.String">cert/server.p12</entry>
|
||||
<entry key="caPassword" class="class java.lang.String">atakatak</entry>
|
||||
<entry key="clientPassword" class="class java.lang.String">atakatak</entry>
|
||||
<entry key="certificateLocation" class="class java.lang.String">cert/iphone.p12</entry>
|
||||
</preference>
|
||||
</preferences>
|
||||
Loading…
Add table
Add a link
Reference in a new issue