Merge pull request #1547 from meshtastic/tak-server

TAK Server (for bridging Meshtastic into TAK clients)
This commit is contained in:
Ben Meadors 2026-01-19 12:49:08 -06:00 committed by GitHub
commit 70da4a06d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 5338 additions and 64 deletions

View file

@ -1900,6 +1900,9 @@
}
}
}
},
"8089" : {
},
"A channel index of 0 indicates the primary channel where broadcast packets are sent from. Location data is broadcast from the first channel where it is enabled with firmware 2.7 forward." : {
"localizations" : {
@ -1910,6 +1913,9 @@
}
}
}
},
"A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients." : {
},
"A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." : {
"localizations" : {
@ -2463,6 +2469,9 @@
}
}
}
},
"Add CA" : {
},
"Add Channel" : {
"localizations" : {
@ -7671,6 +7680,12 @@
"Client Base should only favorite other nodes you control. Improper use will hurt your local mesh." : {
"comment" : "A message displayed in a confirmation dialog when trying to favorite a node as a CLIENT_BASE.",
"isCommentAutoGenerated" : true
},
"Client CA Certificate" : {
},
"Client Configuration" : {
},
"Client Hidden" : {
"localizations" : {
@ -8128,6 +8143,9 @@
}
}
}
},
"Configuration" : {
},
"Configuration for: %@" : {
"localizations" : {
@ -9710,6 +9728,9 @@
}
}
}
},
"Delete All" : {
},
"Delete all config, keys and BLE bonds? " : {
"localizations" : {
@ -12201,6 +12222,9 @@
}
}
}
},
"Download TAK Server Data Package" : {
},
"Drag & Drop Firmware Update" : {
"localizations" : {
@ -12714,6 +12738,9 @@
}
}
}
},
"Enable TAK Server" : {
},
"Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : {
"localizations" : {
@ -13227,6 +13254,12 @@
}
}
}
},
"Enter P12 Password" : {
},
"Enter the password for the PKCS#12 file" : {
},
"environment" : {
"localizations" : {
@ -15947,6 +15980,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 +18271,18 @@
}
}
}
},
"Import" : {
},
"Import .pem" : {
},
"Import Custom .p12" : {
},
"Import Error" : {
},
"Import Route" : {
"localizations" : {
@ -22097,6 +22145,9 @@
}
}
}
},
"mTLS" : {
},
"Multiplier" : {
"localizations" : {
@ -26322,6 +26373,9 @@
}
}
}
},
"Port" : {
},
"Position" : {
"localizations" : {
@ -28862,6 +28916,9 @@
}
}
}
},
"Reload Bundled Certificates" : {
},
"Remote administration for: %@" : {
"localizations" : {
@ -29396,6 +29453,9 @@
}
}
}
},
"Reset to Default" : {
},
"Restart" : {
"localizations" : {
@ -29430,6 +29490,9 @@
}
}
}
},
"Restart Server" : {
},
"Restart to the node you are connected to" : {
"localizations" : {
@ -31300,6 +31363,9 @@
}
}
}
},
"Secure mTLS connection on port 8089. Both server and client certificates are required." : {
},
"Security" : {
"localizations" : {
@ -33114,6 +33180,9 @@
}
}
}
},
"Server Certificate" : {
},
"Server Option" : {
"localizations" : {
@ -33142,6 +33211,9 @@
}
}
}
},
"Server Status" : {
},
"Set" : {
"localizations" : {
@ -35058,6 +35130,9 @@
}
}
}
},
"Status" : {
},
"Stay Connected Anywhere" : {
"localizations" : {
@ -35470,6 +35545,9 @@
}
}
}
},
"TAK Server" : {
},
"TAK Tracker" : {
"localizations" : {
@ -37773,6 +37851,9 @@
}
}
}
},
"TLS Certificates" : {
},
"TLS Enabled" : {
"localizations" : {

View file

@ -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;
};

View file

@ -1,5 +1,5 @@
{
"originHash" : "2569905853aec088d5bac6b540eac77f78963f88b406e8dd95a88c40623cc8b4",
"originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4",
"pins" : [
{
"identity" : "cocoamqtt",
@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/DataDog/dd-sdk-ios.git",
"state" : {
"revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54",
"version" : "2.29.0"
"revision" : "2cddcb47c021365c5a6ebc377cb379aa979c450e",
"version" : "3.4.0"
}
},
{
@ -60,8 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "102a647b573f60f73afdce5613a51d71349fe507",
"version" : "1.30.0"
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
}
],

View file

@ -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,

View file

@ -93,6 +93,8 @@ extension AccessoryManager {
}
tryClearExistingChannels()
// Initialize TAK bridge for TAK integration
initializeTAKBridge()
}
func handleNodeInfo(_ nodeInfo: NodeInfo) {

View file

@ -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)")
}
}
}
}

View file

@ -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

View file

@ -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)?
@ -577,7 +578,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:
@ -599,7 +600,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:

View file

@ -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] {

View file

@ -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")
}

View file

@ -0,0 +1,527 @@
//
// CoTMessage.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import Foundation
import MeshtasticProtobufs
import CoreLocation
/// Cursor on Target (CoT) message representation
/// Handles both parsing incoming CoT XML and generating outgoing CoT XML
struct CoTMessage: Identifiable, Sendable {
let id = UUID()
// MARK: - Core CoT Event Attributes
/// Unique identifier for this event
var uid: String
/// CoT type (e.g., "a-f-G-U-C" for friendly ground unit, "b-t-f" for chat)
var type: String
/// Event generation time
var time: Date
/// Start of event validity
var start: Date
/// When this event becomes stale
var stale: Date
/// How the event was generated (e.g., "m-g" for machine GPS, "h-g-i-g-o" for human generated)
var how: String
// MARK: - Point Element (Location)
/// Latitude in degrees
var latitude: Double
/// Longitude in degrees
var longitude: Double
/// Height above ellipsoid in meters
var hae: Double
/// Circular error in meters
var ce: Double
/// Linear error in meters
var le: Double
// MARK: - Detail Elements
/// Contact information (callsign, endpoint)
var contact: CoTContact?
/// Group/team assignment
var group: CoTGroup?
/// Device status (battery)
var status: CoTStatus?
/// Movement track (speed, course)
var track: CoTTrack?
/// Chat message details
var chat: CoTChat?
/// Remarks/comments text
var remarks: String?
/// Raw detail XML content for elements we don't explicitly parse
/// Used to preserve generic CoT elements (colors, shapes, labels, etc.)
var rawDetailXML: String?
// MARK: - Initialization
init(
uid: String,
type: String,
time: Date = Date(),
start: Date = Date(),
stale: Date = Date().addingTimeInterval(600),
how: String = "m-g",
latitude: Double = 0,
longitude: Double = 0,
hae: Double = 9999999.0,
ce: Double = 9999999.0,
le: Double = 9999999.0,
contact: CoTContact? = nil,
group: CoTGroup? = nil,
status: CoTStatus? = nil,
track: CoTTrack? = nil,
chat: CoTChat? = nil,
remarks: String? = nil,
rawDetailXML: String? = nil
) {
self.uid = uid
self.type = type
self.time = time
self.start = start
self.stale = stale
self.how = how
self.latitude = latitude
self.longitude = longitude
self.hae = hae
self.ce = ce
self.le = le
self.contact = contact
self.group = group
self.status = status
self.track = track
self.chat = chat
self.remarks = remarks
self.rawDetailXML = rawDetailXML
}
// MARK: - Factory Methods
/// Create a PLI (Position Location Information) message for a friendly ground unit
static func pli(
uid: String,
callsign: String,
latitude: Double,
longitude: Double,
altitude: Double = 9999999.0,
speed: Double = 0,
course: Double = 0,
team: String = "Cyan",
role: String = "Team Member",
battery: Int = 100,
staleMinutes: Int = 10
) -> CoTMessage {
let now = Date()
return CoTMessage(
uid: uid,
type: "a-f-G-U-C",
time: now,
start: now,
stale: now.addingTimeInterval(TimeInterval(staleMinutes * 60)),
how: "m-g",
latitude: latitude,
longitude: longitude,
hae: altitude,
ce: 9999999.0,
le: 9999999.0,
contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"),
group: CoTGroup(name: team, role: role),
status: CoTStatus(battery: battery),
track: CoTTrack(speed: speed, course: course)
)
}
/// Create a chat message (b-t-f type for outgoing)
static func chat(
senderUid: String,
senderCallsign: String,
message: String,
chatroom: String = "All Chat Rooms"
) -> CoTMessage {
let now = Date()
let messageId = UUID().uuidString
return CoTMessage(
uid: "GeoChat.\(senderUid).\(chatroom).\(messageId)",
type: "b-t-f",
time: now,
start: now,
stale: now.addingTimeInterval(86400),
how: "h-g-i-g-o",
latitude: 0,
longitude: 0,
hae: 9999999.0,
ce: 9999999.0,
le: 9999999.0,
chat: CoTChat(
message: message,
senderCallsign: senderCallsign,
chatroom: chatroom
),
remarks: message
)
}
// MARK: - Create from Meshtastic TAKPacket
/// Convert Meshtastic TAKPacket protobuf to CoT message
static func fromTAKPacket(_ takPacket: TAKPacket, deviceUid: String? = nil) -> CoTMessage? {
let currentDate = Date()
let staleDate = currentDate.addingTimeInterval(10 * 60) // 10 minute stale
// Handle PLI (Position Location Information)
if case .pli(let pli) = takPacket.payloadVariant {
// Validate we have required fields
guard takPacket.hasContact,
pli.latitudeI != 0 || pli.longitudeI != 0 else {
return nil
}
// Parse device_callsign in case it contains smuggled messageId (shouldn't for PLI, but be safe)
let (actualDeviceCallsign, _) = TAKMeshtasticBridge.parseDeviceCallsign(takPacket.contact.deviceCallsign)
let uid = actualDeviceCallsign.isEmpty
? (deviceUid ?? UUID().uuidString)
: actualDeviceCallsign
return CoTMessage(
uid: uid,
type: "a-f-G-U-C",
time: currentDate,
start: currentDate,
stale: staleDate,
how: "m-g",
latitude: Double(pli.latitudeI) * 1e-7,
longitude: Double(pli.longitudeI) * 1e-7,
hae: Double(pli.altitude),
ce: 9999999.0,
le: 9999999.0,
contact: CoTContact(
callsign: takPacket.contact.callsign,
endpoint: "0.0.0.0:4242:tcp"
),
group: takPacket.hasGroup ? CoTGroup(
name: takPacket.group.team.cotColorName,
role: takPacket.group.role.cotRoleName
) : CoTGroup(name: "Cyan", role: "Team Member"),
status: takPacket.hasStatus ? CoTStatus(
battery: Int(takPacket.status.battery)
) : nil,
track: CoTTrack(
speed: Double(pli.speed),
course: Double(pli.course)
)
)
}
// Handle GeoChat
if case .chat(let geoChat) = takPacket.payloadVariant {
// Parse device_callsign which may contain smuggled messageId
// Format: "<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 {
let messageId = UUID().uuidString
let senderUid = uid.hasPrefix("GeoChat.") ? String(uid.split(separator: ".")[1]) : 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
}
// 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
}
}
}

View file

@ -0,0 +1,343 @@
//
// CoTXMLParser.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import Foundation
import OSLog
/// XML Parser delegate for parsing incoming CoT (Cursor on Target) messages from TAK clients
final class CoTXMLParser: NSObject, XMLParserDelegate {
private let data: Data
private var cotMessage: CoTMessage?
private var parseError: Error?
// Current parsing state
private var currentElement = ""
private var currentText = ""
// Temporary attribute storage during parsing
private var eventAttributes: [String: String] = [:]
private var pointAttributes: [String: String] = [:]
private var contactAttributes: [String: String] = [:]
private var groupAttributes: [String: String] = [:]
private var statusAttributes: [String: String] = [:]
private var trackAttributes: [String: String] = [:]
private var chatAttributes: [String: String] = [:]
private var chatgrpAttributes: [String: String] = [:]
private var remarksAttributes: [String: String] = [:]
private var remarksText = ""
private var linkAttributes: [String: String] = [:]
// Track element hierarchy for nested elements
private var elementStack: [String] = []
// Raw detail XML for unrecognized elements (markers, shapes, colors, etc.)
private var rawDetailXML = ""
private var isCapturingRawDetail = false
private var rawDetailDepth = 0
// Known detail elements we handle explicitly
private let knownDetailElements: Set<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
static func parse(from data: Data) throws -> CoTMessage {
guard !data.isEmpty else {
throw CoTParseError.emptyData
}
let parser = CoTXMLParser(data: data)
return try parser.parse()
}
/// Parse CoT XML string into a CoTMessage
static func parse(from xmlString: String) throws -> CoTMessage {
guard let data = xmlString.data(using: .utf8) else {
throw CoTParseError.emptyData
}
return try parse(from: data)
}
}

View 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
}
}

View 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)")
}
}
}

View 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"
}
}
}

View file

@ -0,0 +1,589 @@
//
// 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 {
UserDefaults.standard.set(p12Data, forKey: customServerP12DataKey)
UserDefaults.standard.set(password, forKey: customServerP12PasswordKey)
Logger.tak.debug("Stored custom server P12 data for data package generation")
}
Logger.tak.info("Server identity imported successfully (custom: \(isCustom))")
return identity
}
/// Store server identity in Keychain
private func storeServerIdentity(_ identity: SecIdentity, isCustom: Bool = true) throws {
let tag = isCustom ? serverIdentityCustomTag : serverIdentityTag
// First delete any existing identity with this tag
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: tag
]
SecItemDelete(deleteQuery as CFDictionary)
// If storing custom cert, also delete the bundled one (custom takes precedence)
if isCustom {
let deleteBundledQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityTag
]
SecItemDelete(deleteBundledQuery as CFDictionary)
}
// Add new identity
let addQuery: [String: Any] = [
kSecValueRef as String: identity,
kSecAttrLabel as String: tag,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
guard status == errSecSuccess else {
Logger.tak.error("Failed to store server identity in Keychain: \(status)")
throw TAKCertificateError.keychainError(status)
}
}
/// Retrieve stored server identity from Keychain
/// Custom certificates take precedence over bundled ones
func getServerIdentity() -> SecIdentity? {
// First try custom certificate
let customQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityCustomTag,
kSecReturnRef as String: true
]
var item: CFTypeRef?
var status = SecItemCopyMatching(customQuery as CFDictionary, &item)
if status == errSecSuccess {
return (item as! SecIdentity)
}
// Fall back to bundled certificate
let bundledQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityTag,
kSecReturnRef as String: true
]
status = SecItemCopyMatching(bundledQuery as CFDictionary, &item)
guard status == errSecSuccess else {
if status != errSecItemNotFound {
Logger.tak.warning("Failed to retrieve server identity: \(status)")
}
return nil
}
return (item as! SecIdentity)
}
/// Check if server certificate is configured
func hasServerCertificate() -> Bool {
return getServerIdentity() != nil
}
/// Delete custom server identity and reload bundled default
func deleteServerIdentity() {
// Delete custom certificate
let customQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityCustomTag
]
let customStatus = SecItemDelete(customQuery as CFDictionary)
// Delete bundled certificate too
let bundledQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityTag
]
let bundledStatus = SecItemDelete(bundledQuery as CFDictionary)
if customStatus == errSecSuccess || bundledStatus == errSecSuccess {
Logger.tak.info("Server identity deleted")
}
// Reload bundled default
loadBundledServerIdentity()
}
/// Reset to bundled default certificate (deletes custom certificate)
func resetToDefaultServerCertificate() {
// Clear custom certificate data from UserDefaults
clearCustomCertificateData()
// Delete custom certificate
let customQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityCustomTag
]
SecItemDelete(customQuery as CFDictionary)
// Delete existing bundled and reload
let bundledQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityTag
]
SecItemDelete(bundledQuery as CFDictionary)
loadBundledServerIdentity()
Logger.tak.info("Reset to bundled default server certificate")
}
/// Get certificate info for display purposes
func getServerCertificateInfo() -> String? {
guard let identity = getServerIdentity() else { return nil }
var certificate: SecCertificate?
let status = SecIdentityCopyCertificate(identity, &certificate)
guard status == errSecSuccess, let cert = certificate else { return nil }
let isCustom = hasCustomServerCertificate()
let prefix = isCustom ? "Custom: " : "Default: "
if let summary = SecCertificateCopySubjectSummary(cert) as String? {
return prefix + summary
}
return prefix + "Certificate loaded"
}
// MARK: - Client CA Certificates (PEM)
/// Import client CA certificate from PEM file data
/// - Parameter pemData: The raw PEM file data
/// - Returns: The imported SecCertificate
func importClientCACertificate(from pemData: Data) throws -> SecCertificate {
// Extract DER data from PEM format
let derData = try extractDERFromPEM(pemData)
guard let certificate = SecCertificateCreateWithData(nil, derData as CFData) else {
throw TAKCertificateError.invalidCertificate
}
// Store in Keychain
try storeClientCACertificate(certificate)
Logger.tak.info("Client CA certificate imported successfully")
return certificate
}
/// Extract DER-encoded certificate data from PEM format
private func extractDERFromPEM(_ pemData: Data) throws -> Data {
guard let pemString = String(data: pemData, encoding: .utf8) else {
throw TAKCertificateError.invalidPEM
}
// Remove PEM headers and whitespace
let base64 = pemString
.replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "")
.replacingOccurrences(of: "-----END CERTIFICATE-----", with: "")
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.trimmingCharacters(in: .whitespaces)
guard let derData = Data(base64Encoded: base64) else {
throw TAKCertificateError.invalidPEM
}
return derData
}
/// Store client CA certificate in Keychain
private func storeClientCACertificate(_ certificate: SecCertificate) throws {
let addQuery: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecValueRef as String: certificate,
kSecAttrLabel as String: clientCATag,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
// Ignore duplicate item errors (certificate already imported)
guard status == errSecSuccess || status == errSecDuplicateItem else {
Logger.tak.error("Failed to store client CA certificate: \(status)")
throw TAKCertificateError.keychainError(status)
}
}
/// Get all stored client CA certificates
func getClientCACertificates() -> [SecCertificate] {
let query: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: clientCATag,
kSecReturnRef as String: true,
kSecMatchLimit as String: kSecMatchLimitAll
]
var items: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &items)
guard status == errSecSuccess else {
if status != errSecItemNotFound {
Logger.tak.warning("Failed to retrieve client CA certificates: \(status)")
}
return []
}
// Handle both single item and array returns
if let certificates = items as? [SecCertificate] {
return certificates
} else if let certificate = items as! SecCertificate? {
return [certificate]
}
return []
}
/// Check if at least one client CA certificate is configured
func hasClientCACertificate() -> Bool {
return !getClientCACertificates().isEmpty
}
/// Delete all client CA certificates from Keychain
func deleteClientCACertificates() {
let query: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: clientCATag
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
Logger.tak.info("Client CA certificates deleted")
}
}
/// Get info about stored client CA certificates for display
func getClientCACertificateInfo() -> [String] {
let certificates = getClientCACertificates()
return certificates.compactMap { cert in
SecCertificateCopySubjectSummary(cert) as String?
}
}
// MARK: - Certificate Validation
/// Validate a client certificate against the stored CA certificates
func validateClientCertificate(_ trust: SecTrust) -> Bool {
let caCertificates = getClientCACertificates()
guard !caCertificates.isEmpty else {
Logger.tak.warning("No client CA certificates configured for validation")
return false
}
// Set the anchor certificates (trusted CAs)
SecTrustSetAnchorCertificates(trust, caCertificates as CFArray)
SecTrustSetAnchorCertificatesOnly(trust, true)
var error: CFError?
let isValid = SecTrustEvaluateWithError(trust, &error)
if !isValid {
Logger.tak.warning("Client certificate validation failed: \(error?.localizedDescription ?? "unknown")")
}
return isValid
}
}
// MARK: - Certificate Errors
enum TAKCertificateError: LocalizedError {
case importFailed(OSStatus)
case noIdentityFound
case invalidCertificate
case invalidPEM
case keychainError(OSStatus)
case certificateExpired
case certificateNotYetValid
var errorDescription: String? {
switch self {
case .importFailed(let status):
return "Failed to import PKCS#12: \(securityErrorMessage(status))"
case .noIdentityFound:
return "No identity (certificate + private key) found in PKCS#12 file"
case .invalidCertificate:
return "Invalid certificate data"
case .invalidPEM:
return "Invalid PEM format - ensure file contains a valid certificate"
case .keychainError(let status):
return "Keychain error: \(securityErrorMessage(status))"
case .certificateExpired:
return "Certificate has expired"
case .certificateNotYetValid:
return "Certificate is not yet valid"
}
}
private func securityErrorMessage(_ status: OSStatus) -> String {
if let message = SecCopyErrorMessageString(status, nil) {
return message as String
}
return "Error code: \(status)"
}
}

View file

@ -0,0 +1,496 @@
//
// TAKConnection.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import Foundation
import Network
import OSLog
/// Actor managing a single TAK client TLS connection
/// Handles CoT XML streaming protocol (messages delimited by </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.parse(from: data)
// Handle TAK Protocol control messages
if cotMessage.type.hasPrefix("t-x-takp") {
Logger.tak.debug("Handling TAK Protocol control message: \(cotMessage.type)")
Task {
await handleProtocolControl(cotMessage)
}
return // Don't forward control messages to app
}
// Handle ping/pong messages (don't forward, just acknowledge)
if cotMessage.type == "t-x-c-t" || cotMessage.uid == "ping" {
Logger.tak.debug("Received ping from client")
return
}
// Update client info if we got contact details
if let contact = cotMessage.contact {
if clientInfo?.callsign == nil {
clientInfo?.callsign = contact.callsign
}
if clientInfo?.uid == nil {
clientInfo?.uid = cotMessage.uid
}
// Update the connected event with new info
if let info = clientInfo {
continuation?.yield(.clientInfoUpdated(info))
}
}
Logger.tak.info("Received CoT message: type=\(cotMessage.type), uid=\(cotMessage.uid)")
Logger.tak.debug(" contact: \(cotMessage.contact?.callsign ?? "nil")")
Logger.tak.debug(" lat/lon: \(cotMessage.latitude), \(cotMessage.longitude)")
continuation?.yield(.message(cotMessage))
} catch {
Logger.tak.warning("Failed to parse CoT message: \(error.localizedDescription)")
// Log the raw XML for debugging
if let xmlString = String(data: data, encoding: .utf8) {
Logger.tak.debug("Failed Raw CoT XML: \(xmlString.prefix(500))")
}
}
}
// MARK: - Protocol Negotiation
/// Send TAK Protocol Support advertisement to client
/// This tells the client what protocol versions we support (Version 0 = XML only)
private func sendProtocolSupport() async {
let now = ISO8601DateFormatter().string(from: Date())
let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(60))
// TAK Protocol Support message - advertise version 0 (XML) only
// Type t-x-takp-v indicates TAK Protocol version advertisement
let xml = """
<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)"
}
}
}

View 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
// 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
}
}
}

View 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
}
}
}

View file

@ -0,0 +1,427 @@
//
// TAKServerManager.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import Foundation
import Network
import OSLog
import Combine
import SwiftUI
/// Manages the TAK Server lifecycle, TLS connections, and client management
/// Runs on MainActor for thread safety, following the AccessoryManager pattern
@MainActor
final class TAKServerManager: ObservableObject {
static let shared = TAKServerManager()
// MARK: - Published State
@Published private(set) var isRunning = false
@Published private(set) var connectedClients: [TAKClientInfo] = []
@Published var lastError: String?
// MARK: - Configuration (persisted via AppStorage)
@AppStorage("takServerEnabled") var enabled = false {
didSet {
Task {
if enabled && !isRunning {
try? await start()
} else if !enabled && isRunning {
stop()
}
}
}
}
/// Fixed port - always use TLS port 8089
static let defaultTLSPort = 8089
static let defaultTCPPort = 8087 // Legacy, not used
/// Port is fixed to 8089 (mTLS)
var port: Int { Self.defaultTLSPort }
/// Always use TLS/mTLS
var useTLS: Bool { true }
// MARK: - Bridge
/// Bridge for converting between CoT and Meshtastic formats
var bridge: TAKMeshtasticBridge?
// MARK: - Private Properties
private var listener: NWListener?
private var connections: [ObjectIdentifier: TAKConnection] = [:]
private var connectionTasks: [ObjectIdentifier: Task<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 {
Logger.tak.warning("mTLS enabled but no CA certificates configured for client validation")
}
// TCP options
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 60
parameters = NWParameters(tls: tlsOptions, tcp: tcpOptions)
} else {
// Plain TCP mode (no TLS)
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 60
parameters = NWParameters(tls: nil, tcp: tcpOptions)
}
parameters.allowLocalEndpointReuse = true
// Bind to localhost only - only allow TAK clients on the same device
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"),
port: NWEndpoint.Port(integerLiteral: UInt16(port))
)
// Create and configure listener
do {
listener = try NWListener(using: parameters)
} catch {
lastError = "Failed to create listener: \(error.localizedDescription)"
Logger.tak.error("Failed to create TAK listener: \(error.localizedDescription)")
enabled = false
throw error
}
// Set up state handler
listener?.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
self?.handleListenerState(state)
}
}
// Set up new connection handler
listener?.newConnectionHandler = { [weak self] connection in
Task { @MainActor in
await self?.handleNewConnection(connection)
}
}
// Start listening
listener?.start(queue: queue)
}
/// Stop the TAK server
func stop() {
Logger.tak.info("Stopping TAK Server")
listener?.cancel()
listener = nil
// Cancel all connection tasks
for (_, task) in connectionTasks {
task.cancel()
}
connectionTasks.removeAll()
// Disconnect all clients
for (_, connection) in connections {
Task {
await connection.disconnect()
}
}
connections.removeAll()
connectedClients.removeAll()
isRunning = false
lastError = nil
Logger.tak.info("TAK Server stopped")
}
/// Restart the server (useful after configuration changes)
func restart() async throws {
stop()
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s delay
try await start()
}
// MARK: - State Handling
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
isRunning = true
lastError = nil
Logger.tak.info("TAK Server listening on port \(self.port)")
case .failed(let error):
isRunning = false
lastError = error.localizedDescription
enabled = false
Logger.tak.error("TAK Server failed: \(error.localizedDescription)")
case .cancelled:
isRunning = false
Logger.tak.info("TAK Server cancelled")
case .waiting(let error):
Logger.tak.warning("TAK Server waiting: \(error.localizedDescription)")
case .setup:
Logger.tak.debug("TAK Server setup")
@unknown default:
break
}
}
// MARK: - Connection Management
private func handleNewConnection(_ nwConnection: NWConnection) async {
let connectionId = ObjectIdentifier(nwConnection)
let connection = TAKConnection(connection: nwConnection)
connections[connectionId] = connection
Logger.tak.info("New TAK client connecting: \(nwConnection.endpoint.debugDescription)")
// Start handling the connection
let eventStream = await connection.start()
// Create task to handle connection events
let task = Task {
for await event in eventStream {
await handleConnectionEvent(event, connectionId: connectionId)
}
// Connection ended
await removeConnection(connectionId)
}
connectionTasks[connectionId] = task
}
private func handleConnectionEvent(_ event: TAKConnectionEvent, connectionId: ObjectIdentifier) async {
switch event {
case .connected(let clientInfo):
connectedClients.append(clientInfo)
Logger.tak.info("TAK client connected: \(clientInfo.displayName)")
case .clientInfoUpdated(let clientInfo):
// Update the client info in our list
if let index = connectedClients.firstIndex(where: { $0.id == clientInfo.id }) {
connectedClients[index] = clientInfo
}
case .message(let cotMessage):
Logger.tak.info("Received CoT from TAK client: \(cotMessage.type)")
// Forward to Meshtastic mesh via bridge
await bridge?.sendToMesh(cotMessage)
case .disconnected:
await removeConnection(connectionId)
case .error(let error):
Logger.tak.error("TAK client error: \(error.localizedDescription)")
}
}
private func removeConnection(_ connectionId: ObjectIdentifier) async {
connectionTasks[connectionId]?.cancel()
connectionTasks.removeValue(forKey: connectionId)
if let connection = connections.removeValue(forKey: connectionId) {
let endpoint = await connection.endpoint
connectedClients.removeAll { $0.endpoint.debugDescription == endpoint.debugDescription }
Logger.tak.info("TAK client disconnected")
}
}
// MARK: - Message Distribution
/// Broadcast a CoT message to all connected TAK clients
func broadcast(_ cotMessage: CoTMessage) async {
guard !connections.isEmpty else { return }
Logger.tak.info("Broadcasting CoT to \(self.connections.count) TAK client(s): \(cotMessage.type)")
for (connectionId, connection) in connections {
do {
try await connection.send(cotMessage)
} catch {
Logger.tak.error("Failed to send to TAK client: \(error.localizedDescription)")
// Remove failed connection
await removeConnection(connectionId)
}
}
}
/// Send a CoT message to a specific client
func send(_ cotMessage: CoTMessage, to clientId: UUID) async throws {
guard let clientInfo = connectedClients.first(where: { $0.id == clientId }) else {
throw TAKServerError.clientNotFound
}
for (_, connection) in connections {
let endpoint = await connection.endpoint
if endpoint.debugDescription == clientInfo.endpoint.debugDescription {
try await connection.send(cotMessage)
return
}
}
throw TAKServerError.clientNotFound
}
// MARK: - Status
/// Get server status description
var statusDescription: String {
if isRunning {
let mode = useTLS ? "TLS" : "TCP"
return "Running on port \(port) (\(mode))"
} else if let error = lastError {
return "Error: \(error)"
} else {
return "Stopped"
}
}
}
// MARK: - Server Errors
enum TAKServerError: LocalizedError {
case noServerCertificate
case noClientCACertificate
case tlsConfigurationFailed
case listenerFailed(String)
case clientNotFound
case notRunning
var errorDescription: String? {
switch self {
case .noServerCertificate:
return "No server certificate configured. Import a .p12 file with the server certificate and private key."
case .noClientCACertificate:
return "No client CA certificate configured. Import the CA certificate (.pem) used to sign client certificates."
case .tlsConfigurationFailed:
return "Failed to configure TLS settings."
case .listenerFailed(let reason):
return "Failed to start listener: \(reason)"
case .clientNotFound:
return "Client not found"
case .notRunning:
return "TAK Server is not running"
}
}
}

View file

@ -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>

View file

@ -193,6 +193,7 @@ struct MeshtasticAppleApp: App {
}
}
.onChange(of: scenePhase) { (_, newScenePhase) in
accessoryManager.isInBackground = (newScenePhase == .background)
switch newScenePhase {
case .background:
Logger.services.info("🎬 [App] Scene is in the background")

View file

@ -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

View 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-----

Binary file not shown.

View 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-----

Binary file not shown.

Binary file not shown.

View file

@ -52,6 +52,7 @@ enum SettingsNavigationState: String {
case debugLogs
case appFiles
case firmwareUpdates
case tak
}
struct NavigationState: Hashable {

View file

@ -27,12 +27,77 @@ struct MessageText: View {
// State for handling channel URL sheet
@State private var saveChannelLink: SaveChannelLinkData?
@State private var isShowingDeleteConfirmation = false
@State private var isShowingTapbackInput = false
@State private var tapbackText = ""
@FocusState private var isTapbackInputFocused: Bool
@State private var tapbackText = ""
var body: some View {
SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) {
let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
Text(markdownText)
.tint(Self.linkBlue)
.padding(.vertical, 10)
.padding(.horizontal, 8)
.foregroundColor(.white)
.background(isCurrentUser ? .accentColor : Color(.gray))
.cornerRadius(15)
.overlay {
/// Show the lock if the message is pki encrypted and has a real ack if sent by the current user, or is pki encrypted for incoming messages
if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted {
VStack(alignment: .trailing) {
Spacer()
HStack {
Spacer()
Image(systemName: "lock.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .green)
.font(.system(size: 20))
.offset(x: 8, y: 8)
}
}
}
let isStoreAndForward = message.portNum == Int32(PortNum.storeForwardApp.rawValue)
let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue)
if isStoreAndForward {
VStack(alignment: .trailing) {
Spacer()
HStack {
Spacer()
Image(systemName: "envelope.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .gray)
.font(.system(size: 20))
.offset(x: 8, y: 8)
}
}
}
if tapBackDestination.overlaySensorMessage {
VStack {
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.foregroundStyle(Color.orange)
.symbolRenderingMode(.multicolor)
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
.offset(x: 20, y: -20)
: nil
}
} else {
EmptyView()
}
}
.contextMenu {
MessageContextMenuItems(
message: message,
tapBackDestination: tapBackDestination,
isCurrentUser: isCurrentUser,
isShowingDeleteConfirmation: $isShowingDeleteConfirmation,
isShowingTapbackInput: $isShowingTapbackInput,
onReply: onReply
)
}
messageContent
.environment(\.openURL, OpenURLAction { url in
handleURL(url)
@ -46,6 +111,36 @@ struct MessageText: View {
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $isShowingTapbackInput) {
TapbackInputView(
text: $tapbackText,
isPresented: $isShowingTapbackInput,
onEmojiSelected: { emoji in
Task {
do {
try await accessoryManager.sendMessage(
message: emoji,
toUserNum: tapBackDestination.userNum,
channel: tapBackDestination.channelNum,
isEmoji: true,
replyID: message.messageId
)
Task { @MainActor in
switch tapBackDestination {
case let .channel(channel):
context.refresh(channel, mergeChanges: true)
case let .user(user):
context.refresh(user, mergeChanges: true)
}
}
} catch {
Logger.services.warning("Failed to send tapback.")
}
}
isShowingTapbackInput = false
}
)
}
.confirmationDialog(
"Are you sure you want to delete this message?",
isPresented: $isShowingDeleteConfirmation,

View file

@ -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
}
}

View file

@ -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

View 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: [.item],
allowsMultipleSelection: false
) { result in
switch importType {
case .p12:
handleP12Import(result)
case .pem:
handlePEMImport(result)
}
}
.alert("Enter P12 Password", isPresented: $showingPasswordPrompt) {
SecureField("Password", text: $p12Password)
Button("Import") {
importP12WithPassword()
}
Button("Cancel", role: .cancel) {
p12Password = ""
pendingP12Data = nil
}
} message: {
Text("Enter the password for the PKCS#12 file")
}
.alert("Import Error", isPresented: $showingImportError) {
Button("OK", role: .cancel) {}
} message: {
Text(importError ?? "Unknown error")
}
.fileExporter(
isPresented: $showingFileExporter,
document: dataPackageURL.map { ZipDocument(url: $0) },
contentType: .zip,
defaultFilename: "Meshtastic_TAK_Server.zip"
) { result in
switch result {
case .success(let url):
Logger.tak.info("Data package saved to: \(url.path)")
case .failure(let error):
importError = "Failed to save: \(error.localizedDescription)"
showingImportError = true
}
// Clean up the source file
if let sourceURL = dataPackageURL {
try? FileManager.default.removeItem(at: sourceURL)
}
dataPackageURL = nil
}
}
// MARK: - Server Status Section
private var serverStatusSection: some View {
Section {
HStack {
Label {
Text("Status")
} icon: {
Circle()
.fill(takServer.isRunning ? .green : .gray)
.frame(width: 10, height: 10)
}
Spacer()
Text(takServer.statusDescription)
.foregroundColor(.secondary)
}
if let error = takServer.lastError {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(error)
.font(.caption)
.foregroundColor(.orange)
}
}
} header: {
Text("Server Status")
}
}
// MARK: - Server Configuration Section
private var serverConfigSection: some View {
Section {
Toggle(isOn: $takServer.enabled) {
Label("Enable TAK Server", systemImage: "antenna.radiowaves.left.and.right")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
HStack {
Label("Port", systemImage: "number")
Spacer()
Text("8089")
.foregroundColor(.secondary)
}
HStack {
Label("Security", systemImage: "lock.fill")
Spacer()
Text("mTLS")
.foregroundColor(.secondary)
}
if takServer.isRunning {
Button {
Task {
try? await takServer.restart()
}
} label: {
Label("Restart Server", systemImage: "arrow.clockwise")
}
}
} header: {
Text("Configuration")
} footer: {
Text("Secure mTLS connection on port 8089. Both server and client certificates are required.")
}
}
// MARK: - Certificates Section
private var certificatesSection: some View {
Section {
// Server Certificate
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Server Certificate", systemImage: "key.fill")
Spacer()
if certManager.hasServerCertificate() {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
} else {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
}
if let certInfo = certManager.getServerCertificateInfo() {
Text(certInfo)
.font(.caption)
.foregroundColor(.secondary)
}
HStack {
Button {
importType = .p12
showingFileImporter = true
} label: {
Text("Import Custom .p12")
}
.buttonStyle(.bordered)
if certManager.hasCustomServerCertificate() {
Button {
certManager.resetToDefaultServerCertificate()
} label: {
Text("Reset to Default")
}
.buttonStyle(.bordered)
}
}
}
.padding(.vertical, 4)
// Client CA Certificate
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Client CA Certificate", systemImage: "person.badge.shield.checkmark")
Spacer()
if certManager.hasClientCACertificate() {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
} else {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
}
let caInfo = certManager.getClientCACertificateInfo()
if !caInfo.isEmpty {
ForEach(caInfo, id: \.self) { info in
Text(info)
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack {
Button {
importType = .pem
showingFileImporter = true
} label: {
Text(certManager.hasClientCACertificate() ? "Add CA" : "Import .pem")
}
.buttonStyle(.bordered)
if certManager.hasClientCACertificate() {
Button(role: .destructive) {
certManager.deleteClientCACertificates()
} label: {
Text("Delete All")
}
.buttonStyle(.bordered)
}
}
}
.padding(.vertical, 4)
// Reset to bundled defaults
Button {
certManager.reloadBundledCertificates()
if takServer.isRunning {
Task {
try? await takServer.restart()
}
}
} label: {
Label("Reload Bundled Certificates", systemImage: "arrow.triangle.2.circlepath")
}
} header: {
Text("TLS Certificates")
} footer: {
Text("A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients.")
}
}
// MARK: - Data Package Section
private var dataPackageSection: some View {
Section {
Button {
generateAndShareDataPackage()
} label: {
Label("Download TAK Server Data Package", systemImage: "arrow.down.doc.fill")
}
} header: {
Text("Client Configuration")
} footer: {
Text("Generate a data package (.zip) to configure ITAK or other TAK clients to connect to this server.")
}
}
// MARK: - Import Handlers
private func handleP12Import(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first else { return }
guard url.startAccessingSecurityScopedResource() else {
importError = "Cannot access file"
showingImportError = true
return
}
defer { url.stopAccessingSecurityScopedResource() }
do {
pendingP12Data = try Data(contentsOf: url)
p12Password = ""
showingPasswordPrompt = true
} catch {
importError = "Failed to read file: \(error.localizedDescription)"
showingImportError = true
}
case .failure(let error):
importError = error.localizedDescription
showingImportError = true
}
}
private func importP12WithPassword() {
guard let data = pendingP12Data else { return }
do {
_ = try certManager.importServerIdentity(from: data, password: p12Password)
Logger.tak.info("Server certificate imported successfully")
} catch {
importError = error.localizedDescription
showingImportError = true
}
p12Password = ""
pendingP12Data = nil
}
private func handlePEMImport(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first else { return }
guard url.startAccessingSecurityScopedResource() else {
importError = "Cannot access file"
showingImportError = true
return
}
defer { url.stopAccessingSecurityScopedResource() }
do {
let data = try Data(contentsOf: url)
_ = try certManager.importClientCACertificate(from: data)
Logger.tak.info("Client CA certificate imported successfully")
} catch {
importError = error.localizedDescription
showingImportError = true
}
case .failure(let error):
importError = error.localizedDescription
showingImportError = true
}
}
// MARK: - Data Package Generation
private func generateAndShareDataPackage() {
guard let url = TAKDataPackageGenerator.shared.generateDataPackage(
port: TAKServerManager.defaultTLSPort,
useTLS: true,
description: "Meshtastic TAK Server"
) else {
importError = "Failed to generate data package"
showingImportError = true
return
}
dataPackageURL = url
showingFileExporter = true
}
}
// MARK: - Zip Document for File Exporter
struct ZipDocument: FileDocument {
static var readableContentTypes: [UTType] { [.zip] }
let data: Data
init(url: URL) {
self.data = (try? Data(contentsOf: url)) ?? Data()
}
init(configuration: ReadConfiguration) throws {
self.data = configuration.file.regularFileContents ?? Data()
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: data)
}
}

Binary file not shown.

View 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>

Binary file not shown.

View 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>