TAK server -> TestFlight (#1567)

* Bump version

* Message list performance fixes into 2.7.6 (#1475)

* Remove extra want config call when adding a contact

* App badge and unnecessary notification fixes (#1455)

* - Fix for app badge not going to zero if a message arrives while you have that chat open
- Fix for push notifications popping up when a message is received while that chat is open

* Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message

* Fix: Channels help grammer fix (#1452)

* remove outdated TCP not available on Apple devices (#1450)

* Update initial onboarding view

* remove toggle gating for mac

* Update crash reporting opt out in real time

* Update onboarding text

* Use mDNS text records for node name

* TCP IP and port on the connection screen

* Hide app icon chooser on mac

* Infinite loop hang bugfixes and performance improvements for both `UserMessageList` and `ChannelMessageList` (#1465)

* 2.7.5 Working Changes (#1460)

* Remove extra want config call when adding a contact

* App badge and unnecessary notification fixes (#1455)

* - Fix for app badge not going to zero if a message arrives while you have that chat open
- Fix for push notifications popping up when a message is received while that chat is open

* Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message

* Fix: Channels help grammer fix (#1452)

* remove outdated TCP not available on Apple devices (#1450)

* Update initial onboarding view

* remove toggle gating for mac

* Update crash reporting opt out in real time

* Update onboarding text

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>

* UserEntity: add mostRecentMessage and unreadMessages with early exit when lastMessage is nil, and fetch 1 row (not N) otherwise

* UserList: replace 5 slow calls to user.messageList with new fast calls

* NodeList: always put the connected node at the top of list (if it matches the node filters)

* ChannelEntity: add faster mostRecentPrivateMessage and unreadMessages which fetch 1 row (not N)

* ChannelList: replace 5 calls to channel.allPrivateMessage with new fast calls

* Fix incorrect appState.unreadDirectMessages calculations

* MyInfoEntity: also fix unreadMessages count here to be fast, and use it for appState.unreadChannelMessages

* UserMessageList: use @FetchRequest to prevent the N^2 behavior that was happening in calls to allPrivateMessages

* Refactor ChannelEntityExtension and MyInfoEntityExtension to be more similar to UserEntityExtension

* Remove SwiftUI-infinite-loop-causing `.id(redrawTapbacksTrigger)` in ChannelMessageList and UserMessageList (duplicate row ids)

* MyInfoEntityExtension: exclude emoji tapbacks (which never get marked as read anyway) from unread message count

* Add SaveChannelLinkData so MessageText and MeshtasticApp can use .sheet(item: ...) and avoid infinite loop hang due to Binding rebuild

* ChannelMessageList and UserMessageList: switch to stable messageId for ForEach SwiftUI row identity

* ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification

* ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear

* ChannelMessageList and UserMessageList: block spurious markMessagesAsRead when this View is not active

---------

Co-authored-by: Garth Vander Houwen <garth@meshtastic.com>
Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>

* message-list-performance: revert scrolling changes (#1472)

* Revert e0f0b4a0f7 (ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear)

* Revert "ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification"

This reverts commit ee1a7c4415.

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu>

* Explicitly set unmessagable, seems unnessary

* Add back missing mesh map features

* Fix: "Retrieving nodes" significantly slower after reconnect  extracted from #1424 (#1477)

* Fix: "Retrieving nodes" significantly slower after reconnect (#1424)

The node database retrieval was calling context.save() for every single
NodeInfo packet received (250 saves for 250 nodes). This caused severe
performance degradation on reconnect when CoreData had accumulated state.

Root Cause:
- nodeInfoPacket() called context.save() immediately for each node
- With 250 nodes, this meant 250 individual CoreData save operations
- On first connection, CoreData is fresh and fast
- On reconnect, CoreData has accumulated change tracking, undo management,
  and memory pressure, making each save progressively slower
- This resulted in 10+ second retrieval times vs 1-2 seconds initially

Solution:
- Added deferSave parameter to nodeInfoPacket() function
- During database retrieval (.retrievingDatabase state), defer all saves
- Perform a single batch save when database retrieval completes
  (when NONCE_ONLY_DB configCompleteID is received)
- This reduces 250 saves to 1 save

Performance Impact:
- Eliminates N individual saves during node database sync
- Reduces database retrieval time back to 1-2 seconds on reconnect
- Matches first-connection performance consistently

Fixes #1424

* Revert *MessageListUnified files

---------

Co-authored-by: Martin Bogomolni <martinbogo@gmail.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>

* Hide route lines filter from mesh map

* Mesh Map: fuzz imprecise locations so they're distinguishable and clickable at the highest zoom levels (#1478)

Co-authored-by: Garth Vander Houwen <garth@meshtastic.com>

* Fix bad merge

* Update Meshtastic/Extensions/CoreData/UserEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Keep list of previous manual connections (#1484)

* Keep list of previous manual connections

* More descriptive manual connection rows

* Merge fixes and new way to show IP on Connect view

---------

Co-authored-by: Jake-B <jake-b@users.noreply.github.com>

* Show who relayed messages (#1486)

* Add identification for node that relayed text messages and add count for ammount of relayers of your message

* Ack Relays

* upsertPositionPacket: don't use future timestamps to set node's lastHeard (#1488)

* R1 NEO

* Neo

* Update Meshtastic/Views/Settings/AppSettings.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove bad if

* Git rid of extra environment variable

* Update Meshtastic/Accessory/Transports/TCP/TCPTransport.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* MeshMap performance: quick wins (#1490)

* MeshMap: change onMapCameraChange frequency to .onEnd so that zooming doesn't cause continuous SwiftUI reevaluation on every frame

* MeshMapContent: factor out reducedPrecisionMapCircles into a separate function

* MeshMapContent: when multiple reducedPrecisionCircles have the same (lat,lon,radius), just draw one (big perf boost in dense areas)

* NodeMap performance improvements for high # positions history (#1480)

* NodeMapContent: move Route Lines out of ForEach

* NodeMapContent: move Convex Hull out of ForEach

* NodeMapContent: Replace `position.nodePosition?` with `node`

* NodeMapContent: drop unnecessary LazyVStack in showNodeHistory

* NodeMapContent: hoist out nodeColorSwift

* Move lineCoords, loraCoords calculations within showRouteLines, showConvexHull respectively

* Hoist out repeated node.metadata?.positionFlags lookups / PositionFlags creation

* NodeMapContent: remove unused @State

* NodeMapSwiftUI: add NodeMapContentEquatableWrapper and NodeMapContentSignature to prevent frequent NodeMapContent recomputation and infinite render loops

* NodeMapSwiftUI: disable animation during SwiftUI transactions

* NodeMapContent: hoist nodeBorderColor and set allowsHitTesting(false) on history point views

* NodeMapContent: prerenderHistoryPointCircle and prerenderHistoryPointArrow to avoid thousands of vector draw operations

* NodeMapContent: Shared coordinate list for Route Lines and Convex Hull

* NodeMapContent.prerenderHistoryPointArrow: add .frame(width: 16, height: 16)

* Fix wantRangeTestPackets to correctly follow rangeTestConfig.enabled (#1489)

* Fix interval drop down formatter

* Clean up channel qr code functionality.

* perferredPeripheralId fix

* Set opt in

* Retry once 5 second timer. dont throw the error

* Queue for peripherals

* Fix: hoplimit of dms would always fallback to hops away of the node even when configured hops was higher (#1495)

* fix hops setting in dms

* Fix hops for exchange position

* Final fix

* Don't favorite client base

* Update device hardware

* Prevent nil environment metrics

* Bump datadog sdk

* fix setting device telemetry enabled (#1515)

* Update Muzi R1 Neo to actively supported

* fix setting device telemetry enabled

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* Don't subscribe to mqtt topic if downlink is not on (#1501)

* Dont sub if no downlink

* moved reload mqtt connect config

* Preview enabled in connected devices (#1509)

* Update Muzi R1 Neo to actively supported

* Preview enabled in connected devices

* Fixing indentation

* Fixing indentation

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* UpdateCoreData.updateAnyPacketFrom: mirror firmware's lastHeard/snr/rssi/hopsAway update logic from NodeDB::updateFrom (#1492)

* `CLIENT_BASE` add-favorite/role-change confirmation dialog (#1493)

* FavoriteNodeButton: refactor task out

* AccessoryManager.connectedDeviceRole helper

* FavoriteNodeButton: show confirmation dialog when a CLIENT_BASE is trying to add a favorite

* addContactFromURL: add comment referencing upcoming change in https://github.com/meshtastic/firmware/pull/8495

* DeviceConfig: role picker: show a warning when selecting CLIENT_BASE, similar to warning shown for ROUTER

* Adjust device configuration Client Base warning text

* Compass view (#1521)

* Added compass view

* Added Compass View

* Node colors in compass

* Update Muzi R1 Neo to actively supported

* Update PositionPopover.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Helpers/CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Helpers/CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove discovery queue

* revert problematic retry functionalliy

* format file

* Update & improve zh-Hans translation (#1523)

* Update Muzi R1 Neo to actively supported

* update & improve zh-Hans translation

rt

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* Update protobufs to 2.7.1

* Add long-turbo preset

* Disable Range Test module when primary channel is public/unsecured (#1512)

* Update Muzi R1 Neo to actively supported

* Disable Range Test module when primary channel is public/unsecured

Updated RangeTestConfig.swift to determine whether the primary channel (index 0) is operating without encryption or with a 1-byte minimal PSK.

Disabled Range Test UI controls when on a public/default channel to prevent user interaction.

Added safety enforcement in the save operation: Range Test enabled flag is automatically forced to false before sending updates to the device.

Introduced a computed property isPrimaryChannelPublic following existing code patterns and security indicators (e.g., hexDescription PSK length).

Matches the behavior implemented in the Android client for consistent policy across platforms.

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* Add new device images

* Remove print statement, get rid of cut and paste error

* Bump version

* update the translations (#1540)

update the translations

* Don't alert (with sound: .default) when updating Live Activity (#1536)

* Fix adding channels (#1532)

* Full translation into Spanish (#1529)

* tapback with any emoji (#1538)

* Call clearStaleNodes at start of sendWantConfig (#1535)

* NFC Tag contact (#1537)

* Accessorymanager background discovery (#1542)

* Don't add new BLE  devices to the device list in the backgournd

* Bump version

* Update Meshtastic/MeshtasticApp.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/MeshtasticApp.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Revert "Full translation into Spanish (#1529)" (#1543)

This reverts commit f25fdfb89f.

* Revert "update the translations (#1540)" (#1544)

This reverts commit cb2fd8cc15.

* Revert "NFC Tag contact (#1537)" (#1545)

This reverts commit 5c22b8b6e0.

* Update Muzi R1 Neo to actively supported

* Revert "Update Muzi R1 Neo to actively supported"

* 2.7.6 Working Changes (#1479)

* Bump version

* Message list performance fixes into 2.7.6 (#1475)

* Remove extra want config call when adding a contact

* App badge and unnecessary notification fixes (#1455)

* - Fix for app badge not going to zero if a message arrives while you have that chat open
- Fix for push notifications popping up when a message is received while that chat is open

* Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message

* Fix: Channels help grammer fix (#1452)

* remove outdated TCP not available on Apple devices (#1450)

* Update initial onboarding view

* remove toggle gating for mac

* Update crash reporting opt out in real time

* Update onboarding text

* Use mDNS text records for node name

* TCP IP and port on the connection screen

* Hide app icon chooser on mac

* Infinite loop hang bugfixes and performance improvements for both `UserMessageList` and `ChannelMessageList` (#1465)

* 2.7.5 Working Changes (#1460)

* Remove extra want config call when adding a contact

* App badge and unnecessary notification fixes (#1455)

* - Fix for app badge not going to zero if a message arrives while you have that chat open
- Fix for push notifications popping up when a message is received while that chat is open

* Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message

* Fix: Channels help grammer fix (#1452)

* remove outdated TCP not available on Apple devices (#1450)

* Update initial onboarding view

* remove toggle gating for mac

* Update crash reporting opt out in real time

* Update onboarding text

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>

* UserEntity: add mostRecentMessage and unreadMessages with early exit when lastMessage is nil, and fetch 1 row (not N) otherwise

* UserList: replace 5 slow calls to user.messageList with new fast calls

* NodeList: always put the connected node at the top of list (if it matches the node filters)

* ChannelEntity: add faster mostRecentPrivateMessage and unreadMessages which fetch 1 row (not N)

* ChannelList: replace 5 calls to channel.allPrivateMessage with new fast calls

* Fix incorrect appState.unreadDirectMessages calculations

* MyInfoEntity: also fix unreadMessages count here to be fast, and use it for appState.unreadChannelMessages

* UserMessageList: use @FetchRequest to prevent the N^2 behavior that was happening in calls to allPrivateMessages

* Refactor ChannelEntityExtension and MyInfoEntityExtension to be more similar to UserEntityExtension

* Remove SwiftUI-infinite-loop-causing `.id(redrawTapbacksTrigger)` in ChannelMessageList and UserMessageList (duplicate row ids)

* MyInfoEntityExtension: exclude emoji tapbacks (which never get marked as read anyway) from unread message count

* Add SaveChannelLinkData so MessageText and MeshtasticApp can use .sheet(item: ...) and avoid infinite loop hang due to Binding rebuild

* ChannelMessageList and UserMessageList: switch to stable messageId for ForEach SwiftUI row identity

* ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification

* ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear

* ChannelMessageList and UserMessageList: block spurious markMessagesAsRead when this View is not active

---------

Co-authored-by: Garth Vander Houwen <garth@meshtastic.com>
Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>

* message-list-performance: revert scrolling changes (#1472)

* Revert e0f0b4a0f7 (ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear)

* Revert "ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification"

This reverts commit ee1a7c4415.

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu>

* Explicitly set unmessagable, seems unnessary

* Add back missing mesh map features

* Fix: "Retrieving nodes" significantly slower after reconnect  extracted from #1424 (#1477)

* Fix: "Retrieving nodes" significantly slower after reconnect (#1424)

The node database retrieval was calling context.save() for every single
NodeInfo packet received (250 saves for 250 nodes). This caused severe
performance degradation on reconnect when CoreData had accumulated state.

Root Cause:
- nodeInfoPacket() called context.save() immediately for each node
- With 250 nodes, this meant 250 individual CoreData save operations
- On first connection, CoreData is fresh and fast
- On reconnect, CoreData has accumulated change tracking, undo management,
  and memory pressure, making each save progressively slower
- This resulted in 10+ second retrieval times vs 1-2 seconds initially

Solution:
- Added deferSave parameter to nodeInfoPacket() function
- During database retrieval (.retrievingDatabase state), defer all saves
- Perform a single batch save when database retrieval completes
  (when NONCE_ONLY_DB configCompleteID is received)
- This reduces 250 saves to 1 save

Performance Impact:
- Eliminates N individual saves during node database sync
- Reduces database retrieval time back to 1-2 seconds on reconnect
- Matches first-connection performance consistently

Fixes #1424

* Revert *MessageListUnified files

---------

Co-authored-by: Martin Bogomolni <martinbogo@gmail.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>

* Hide route lines filter from mesh map

* Mesh Map: fuzz imprecise locations so they're distinguishable and clickable at the highest zoom levels (#1478)

Co-authored-by: Garth Vander Houwen <garth@meshtastic.com>

* Fix bad merge

* Update Meshtastic/Extensions/CoreData/UserEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Keep list of previous manual connections (#1484)

* Keep list of previous manual connections

* More descriptive manual connection rows

* Merge fixes and new way to show IP on Connect view

---------

Co-authored-by: Jake-B <jake-b@users.noreply.github.com>

* Show who relayed messages (#1486)

* Add identification for node that relayed text messages and add count for ammount of relayers of your message

* Ack Relays

* upsertPositionPacket: don't use future timestamps to set node's lastHeard (#1488)

* R1 NEO

* Neo

* Update Meshtastic/Views/Settings/AppSettings.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove bad if

* Git rid of extra environment variable

* Update Meshtastic/Accessory/Transports/TCP/TCPTransport.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* MeshMap performance: quick wins (#1490)

* MeshMap: change onMapCameraChange frequency to .onEnd so that zooming doesn't cause continuous SwiftUI reevaluation on every frame

* MeshMapContent: factor out reducedPrecisionMapCircles into a separate function

* MeshMapContent: when multiple reducedPrecisionCircles have the same (lat,lon,radius), just draw one (big perf boost in dense areas)

* NodeMap performance improvements for high # positions history (#1480)

* NodeMapContent: move Route Lines out of ForEach

* NodeMapContent: move Convex Hull out of ForEach

* NodeMapContent: Replace `position.nodePosition?` with `node`

* NodeMapContent: drop unnecessary LazyVStack in showNodeHistory

* NodeMapContent: hoist out nodeColorSwift

* Move lineCoords, loraCoords calculations within showRouteLines, showConvexHull respectively

* Hoist out repeated node.metadata?.positionFlags lookups / PositionFlags creation

* NodeMapContent: remove unused @State

* NodeMapSwiftUI: add NodeMapContentEquatableWrapper and NodeMapContentSignature to prevent frequent NodeMapContent recomputation and infinite render loops

* NodeMapSwiftUI: disable animation during SwiftUI transactions

* NodeMapContent: hoist nodeBorderColor and set allowsHitTesting(false) on history point views

* NodeMapContent: prerenderHistoryPointCircle and prerenderHistoryPointArrow to avoid thousands of vector draw operations

* NodeMapContent: Shared coordinate list for Route Lines and Convex Hull

* NodeMapContent.prerenderHistoryPointArrow: add .frame(width: 16, height: 16)

* Fix wantRangeTestPackets to correctly follow rangeTestConfig.enabled (#1489)

* Fix interval drop down formatter

* Clean up channel qr code functionality.

* perferredPeripheralId fix

* Set opt in

* Retry once 5 second timer. dont throw the error

* Queue for peripherals

* Fix: hoplimit of dms would always fallback to hops away of the node even when configured hops was higher (#1495)

* fix hops setting in dms

* Fix hops for exchange position

* Final fix

* Don't favorite client base

* Update device hardware

* Prevent nil environment metrics

* Bump datadog sdk

* fix setting device telemetry enabled (#1515)

* Update Muzi R1 Neo to actively supported

* fix setting device telemetry enabled

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* Don't subscribe to mqtt topic if downlink is not on (#1501)

* Dont sub if no downlink

* moved reload mqtt connect config

* Preview enabled in connected devices (#1509)

* Update Muzi R1 Neo to actively supported

* Preview enabled in connected devices

* Fixing indentation

* Fixing indentation

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* UpdateCoreData.updateAnyPacketFrom: mirror firmware's lastHeard/snr/rssi/hopsAway update logic from NodeDB::updateFrom (#1492)

* `CLIENT_BASE` add-favorite/role-change confirmation dialog (#1493)

* FavoriteNodeButton: refactor task out

* AccessoryManager.connectedDeviceRole helper

* FavoriteNodeButton: show confirmation dialog when a CLIENT_BASE is trying to add a favorite

* addContactFromURL: add comment referencing upcoming change in https://github.com/meshtastic/firmware/pull/8495

* DeviceConfig: role picker: show a warning when selecting CLIENT_BASE, similar to warning shown for ROUTER

* Adjust device configuration Client Base warning text

* Compass view (#1521)

* Added compass view

* Added Compass View

* Node colors in compass

* Update Muzi R1 Neo to actively supported

* Update PositionPopover.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Helpers/CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Helpers/CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove discovery queue

* revert problematic retry functionalliy

* format file

* Update & improve zh-Hans translation (#1523)

* Update Muzi R1 Neo to actively supported

* update & improve zh-Hans translation

rt

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* Update protobufs to 2.7.1

* Add long-turbo preset

* Disable Range Test module when primary channel is public/unsecured (#1512)

* Update Muzi R1 Neo to actively supported

* Disable Range Test module when primary channel is public/unsecured

Updated RangeTestConfig.swift to determine whether the primary channel (index 0) is operating without encryption or with a 1-byte minimal PSK.

Disabled Range Test UI controls when on a public/default channel to prevent user interaction.

Added safety enforcement in the save operation: Range Test enabled flag is automatically forced to false before sending updates to the device.

Introduced a computed property isPrimaryChannelPublic following existing code patterns and security indicators (e.g., hexDescription PSK length).

Matches the behavior implemented in the Android client for consistent policy across platforms.

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* Add new device images

* Remove print statement, get rid of cut and paste error

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu>
Co-authored-by: Martin Bogomolni <martinbogo@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: jake-b <1012393+jake-b@users.noreply.github.com>
Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Charles Pinesky <25388414+Vaidios@users.noreply.github.com>
Co-authored-by: Radio <35003866+radiolee@users.noreply.github.com>
Co-authored-by: Jason Houk <dubsectordevelopment@gmail.com>

* Initial TAK Server implementation for IOS based TAK clients

This is my initial implementation for a TAK Server running inside Meshtastic-Apple.

* Update marketing version to 2.7.7

* Update MessageText.swift

* Update Meshtastic/Helpers/TAK/CoTMessage.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Helpers/TAK/TAKCertificateManager.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor TAKServerConfig file importer to conditionally allow p12 or pem types; update CoTMessage parsing method name for clarity; enhance mTLS logging in TAKServerManager.

---------

Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
Co-authored-by: Garth Vander Houwen <garth@meshtastic.com>
Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu>
Co-authored-by: Martin Bogomolni <martinbogo@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: jake-b <1012393+jake-b@users.noreply.github.com>
Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Charles Pinesky <25388414+Vaidios@users.noreply.github.com>
Co-authored-by: Radio <35003866+radiolee@users.noreply.github.com>
Co-authored-by: Jason Houk <dubsectordevelopment@gmail.com>
Co-authored-by: MGJ <62177301+MGJ520@users.noreply.github.com>
Co-authored-by: Alvaro Samudio <alvarosamudio@protonmail.com>
Co-authored-by: Mathew Kamkar <578302+matkam@users.noreply.github.com>
Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com>
This commit is contained in:
Ben Meadors 2026-01-30 11:55:45 -06:00 committed by GitHub
parent 0869a9a189
commit 4d7cb6dfc4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 5296 additions and 66 deletions

View file

@ -1910,6 +1910,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 +2466,9 @@
}
}
}
},
"Add CA" : {
},
"Add Channel" : {
"localizations" : {
@ -7671,6 +7677,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 +8140,9 @@
}
}
}
},
"Configuration" : {
},
"Configuration for: %@" : {
"localizations" : {
@ -9710,6 +9725,9 @@
}
}
}
},
"Delete All" : {
},
"Delete all config, keys and BLE bonds? " : {
"localizations" : {
@ -12201,6 +12219,9 @@
}
}
}
},
"Download TAK Server Data Package" : {
},
"Drag & Drop Firmware Update" : {
"localizations" : {
@ -12714,6 +12735,9 @@
}
}
}
},
"Enable TAK Server" : {
},
"Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : {
"localizations" : {
@ -13227,6 +13251,12 @@
}
}
}
},
"Enter P12 Password" : {
},
"Enter the password for the PKCS#12 file" : {
},
"environment" : {
"localizations" : {
@ -15947,6 +15977,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 +18268,18 @@
}
}
}
},
"Import" : {
},
"Import .pem" : {
},
"Import Custom .p12" : {
},
"Import Error" : {
},
"Import Route" : {
"localizations" : {
@ -22097,6 +22142,9 @@
}
}
}
},
"mTLS" : {
},
"Multiplier" : {
"localizations" : {
@ -26322,6 +26370,9 @@
}
}
}
},
"Port" : {
},
"Position" : {
"localizations" : {
@ -28862,6 +28913,9 @@
}
}
}
},
"Reload Bundled Certificates" : {
},
"Remote administration for: %@" : {
"localizations" : {
@ -29396,6 +29450,9 @@
}
}
}
},
"Reset to Default" : {
},
"Restart" : {
"localizations" : {
@ -29430,6 +29487,9 @@
}
}
}
},
"Restart Server" : {
},
"Restart to the node you are connected to" : {
"localizations" : {
@ -31300,6 +31360,9 @@
}
}
}
},
"Secure mTLS connection on port 8089. Both server and client certificates are required." : {
},
"Security" : {
"localizations" : {
@ -33114,6 +33177,9 @@
}
}
}
},
"Server Certificate" : {
},
"Server Option" : {
"localizations" : {
@ -33142,6 +33208,9 @@
}
}
}
},
"Server Status" : {
},
"Set" : {
"localizations" : {
@ -35058,6 +35127,9 @@
}
}
}
},
"Status" : {
},
"Stay Connected Anywhere" : {
"localizations" : {
@ -35470,6 +35542,9 @@
}
}
}
},
"TAK Server" : {
},
"TAK Tracker" : {
"localizations" : {
@ -37773,6 +37848,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,544 @@
//
// CoTMessage.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import Foundation
import MeshtasticProtobufs
import CoreLocation
/// Cursor on Target (CoT) message representation
/// Handles both parsing incoming CoT XML and generating outgoing CoT XML
struct CoTMessage: Identifiable, Sendable {
let id = UUID()
// MARK: - Core CoT Event Attributes
/// Unique identifier for this event
var uid: String
/// CoT type (e.g., "a-f-G-U-C" for friendly ground unit, "b-t-f" for chat)
var type: String
/// Event generation time
var time: Date
/// Start of event validity
var start: Date
/// When this event becomes stale
var stale: Date
/// How the event was generated (e.g., "m-g" for machine GPS, "h-g-i-g-o" for human generated)
var how: String
// MARK: - Point Element (Location)
/// Latitude in degrees
var latitude: Double
/// Longitude in degrees
var longitude: Double
/// Height above ellipsoid in meters
var hae: Double
/// Circular error in meters
var ce: Double
/// Linear error in meters
var le: Double
// MARK: - Detail Elements
/// Contact information (callsign, endpoint)
var contact: CoTContact?
/// Group/team assignment
var group: CoTGroup?
/// Device status (battery)
var status: CoTStatus?
/// Movement track (speed, course)
var track: CoTTrack?
/// Chat message details
var chat: CoTChat?
/// Remarks/comments text
var remarks: String?
/// Raw detail XML content for elements we don't explicitly parse
/// Used to preserve generic CoT elements (colors, shapes, labels, etc.)
var rawDetailXML: String?
// MARK: - Initialization
init(
uid: String,
type: String,
time: Date = Date(),
start: Date = Date(),
stale: Date = Date().addingTimeInterval(600),
how: String = "m-g",
latitude: Double = 0,
longitude: Double = 0,
hae: Double = 9999999.0,
ce: Double = 9999999.0,
le: Double = 9999999.0,
contact: CoTContact? = nil,
group: CoTGroup? = nil,
status: CoTStatus? = nil,
track: CoTTrack? = nil,
chat: CoTChat? = nil,
remarks: String? = nil,
rawDetailXML: String? = nil
) {
self.uid = uid
self.type = type
self.time = time
self.start = start
self.stale = stale
self.how = how
self.latitude = latitude
self.longitude = longitude
self.hae = hae
self.ce = ce
self.le = le
self.contact = contact
self.group = group
self.status = status
self.track = track
self.chat = chat
self.remarks = remarks
self.rawDetailXML = rawDetailXML
}
// MARK: - Factory Methods
/// Create a PLI (Position Location Information) message for a friendly ground unit
static func pli(
uid: String,
callsign: String,
latitude: Double,
longitude: Double,
altitude: Double = 9999999.0,
speed: Double = 0,
course: Double = 0,
team: String = "Cyan",
role: String = "Team Member",
battery: Int = 100,
staleMinutes: Int = 10
) -> CoTMessage {
let now = Date()
return CoTMessage(
uid: uid,
type: "a-f-G-U-C",
time: now,
start: now,
stale: now.addingTimeInterval(TimeInterval(staleMinutes * 60)),
how: "m-g",
latitude: latitude,
longitude: longitude,
hae: altitude,
ce: 9999999.0,
le: 9999999.0,
contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"),
group: CoTGroup(name: team, role: role),
status: CoTStatus(battery: battery),
track: CoTTrack(speed: speed, course: course)
)
}
/// Create a chat message (b-t-f type for outgoing)
static func chat(
senderUid: String,
senderCallsign: String,
message: String,
chatroom: String = "All Chat Rooms"
) -> CoTMessage {
let now = Date()
let messageId = UUID().uuidString
return CoTMessage(
uid: "GeoChat.\(senderUid).\(chatroom).\(messageId)",
type: "b-t-f",
time: now,
start: now,
stale: now.addingTimeInterval(86400),
how: "h-g-i-g-o",
latitude: 0,
longitude: 0,
hae: 9999999.0,
ce: 9999999.0,
le: 9999999.0,
chat: CoTChat(
message: message,
senderCallsign: senderCallsign,
chatroom: chatroom
),
remarks: message
)
}
// MARK: - Create from Meshtastic TAKPacket
/// Convert Meshtastic TAKPacket protobuf to CoT message
static func fromTAKPacket(_ takPacket: TAKPacket, deviceUid: String? = nil) -> CoTMessage? {
let currentDate = Date()
let staleDate = currentDate.addingTimeInterval(10 * 60) // 10 minute stale
// Handle PLI (Position Location Information)
if case .pli(let pli) = takPacket.payloadVariant {
// Validate we have required fields
guard takPacket.hasContact,
pli.latitudeI != 0 || pli.longitudeI != 0 else {
return nil
}
// Parse device_callsign in case it contains smuggled messageId (shouldn't for PLI, but be safe)
let (actualDeviceCallsign, _) = TAKMeshtasticBridge.parseDeviceCallsign(takPacket.contact.deviceCallsign)
let uid = actualDeviceCallsign.isEmpty
? (deviceUid ?? UUID().uuidString)
: actualDeviceCallsign
return CoTMessage(
uid: uid,
type: "a-f-G-U-C",
time: currentDate,
start: currentDate,
stale: staleDate,
how: "m-g",
latitude: Double(pli.latitudeI) * 1e-7,
longitude: Double(pli.longitudeI) * 1e-7,
hae: Double(pli.altitude),
ce: 9999999.0,
le: 9999999.0,
contact: CoTContact(
callsign: takPacket.contact.callsign,
endpoint: "0.0.0.0:4242:tcp"
),
group: takPacket.hasGroup ? CoTGroup(
name: takPacket.group.team.cotColorName,
role: takPacket.group.role.cotRoleName
) : CoTGroup(name: "Cyan", role: "Team Member"),
status: takPacket.hasStatus ? CoTStatus(
battery: Int(takPacket.status.battery)
) : nil,
track: CoTTrack(
speed: Double(pli.speed),
course: Double(pli.course)
)
)
}
// Handle GeoChat
if case .chat(let geoChat) = takPacket.payloadVariant {
// Parse device_callsign which may contain smuggled messageId
// Format: "<actual_device_callsign>|<messageId>" or just "<actual_device_callsign>"
let rawDeviceCallsign = takPacket.hasContact ? takPacket.contact.deviceCallsign : ""
let (actualDeviceCallsign, smuggledMessageId) = TAKMeshtasticBridge.parseDeviceCallsign(rawDeviceCallsign)
let uid = actualDeviceCallsign.isEmpty
? (deviceUid ?? UUID().uuidString)
: actualDeviceCallsign
let chatroom = geoChat.hasTo ? geoChat.to : "All Chat Rooms"
// Use smuggled messageId if present, otherwise generate new one
let messageId = smuggledMessageId ?? UUID().uuidString
return CoTMessage(
uid: "GeoChat.\(uid).\(chatroom).\(messageId)",
type: "b-t-f",
time: currentDate,
start: currentDate,
stale: currentDate.addingTimeInterval(86400),
how: "h-g-i-g-o",
latitude: 0,
longitude: 0,
hae: 9999999.0,
ce: 9999999.0,
le: 9999999.0,
contact: takPacket.hasContact ? CoTContact(
callsign: takPacket.contact.callsign,
endpoint: "0.0.0.0:4242:tcp"
) : nil,
chat: CoTChat(
message: geoChat.message,
senderCallsign: takPacket.hasContact ? takPacket.contact.callsign : nil,
chatroom: chatroom
),
remarks: geoChat.message
)
}
return nil
}
// MARK: - XML Generation
/// Generate CoT XML string for transmission to TAK clients
func toXML() -> String {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var cot = "<?xml version='1.0' encoding='UTF-8' standalone='yes'?>"
cot += "<event version='2.0' uid='\(uid.xmlEscaped)' "
cot += "type='\(type)' "
cot += "time='\(dateFormatter.string(from: time))' "
cot += "start='\(dateFormatter.string(from: start))' "
cot += "stale='\(dateFormatter.string(from: stale))' "
cot += "how='\(how)'>"
cot += "<point lat='\(latitude)' lon='\(longitude)' "
cot += "hae='\(hae)' ce='\(ce)' le='\(le)'/>"
cot += "<detail>"
// Contact element
if let contact {
cot += "<contact endpoint='\(contact.endpoint ?? "0.0.0.0:4242:tcp")' "
cot += "callsign='\(contact.callsign.xmlEscaped)'/>"
cot += "<uid Droid='\(contact.callsign.xmlEscaped)'/>"
}
// Group element
if let group {
cot += "<__group role='\(group.role.xmlEscaped)' name='\(group.name.xmlEscaped)'/>"
}
// Status element
if let status {
cot += "<status battery='\(status.battery)'/>"
}
// Track element
if let track {
cot += "<track course='\(track.course)' speed='\(track.speed)'/>"
}
// Chat elements (for b-t-f messages)
if let chat {
// Derive sender UID and messageId from GeoChat UID when possible, with safe fallbacks
let senderUid: String
let messageId: String
if uid.hasPrefix("GeoChat.") {
let components = uid.split(separator: ".")
if components.count >= 3 {
// Expected GeoChat format: GeoChat.<senderUid>.<messageId>
senderUid = String(components[1])
messageId = String(components[2])
} else {
// Malformed GeoChat UID; fall back safely
senderUid = uid
messageId = uid
}
} else {
// Non-GeoChat UID; use uid as both sender and stable message identifier
senderUid = uid
messageId = uid
}
cot += "<__chat parent='RootContactGroup' groupOwner='false' "
cot += "messageId='\(messageId)' "
cot += "chatroom='\(chat.chatroom.xmlEscaped)' id='\(chat.chatroom.xmlEscaped)' "
cot += "senderCallsign='\(chat.senderCallsign?.xmlEscaped ?? "")'>"
cot += "<chatgrp uid0='\(senderUid.xmlEscaped)' "
cot += "uid1='\(chat.chatroom.xmlEscaped)' id='\(chat.chatroom.xmlEscaped)'/>"
cot += "</__chat>"
cot += "<link uid='\(senderUid.xmlEscaped)' type='a-f-G-U-C' relation='p-p'/>"
cot += "<__serverdestination destinations='0.0.0.0:4242:tcp:\(senderUid.xmlEscaped)'/>"
cot += "<remarks source='BAO.F.ATAK.\(senderUid.xmlEscaped)' "
cot += "to='\(chat.chatroom.xmlEscaped)' "
cot += "time='\(dateFormatter.string(from: time))'>"
cot += "\(chat.message.xmlEscaped)</remarks>"
} else if let remarks, !remarks.isEmpty {
cot += "<remarks>\(remarks.xmlEscaped)</remarks>"
}
// Include raw detail XML for generic CoT elements (colors, shapes, labels, etc.)
// This preserves elements we don't explicitly parse
if let rawDetailXML, !rawDetailXML.isEmpty {
cot += rawDetailXML
}
cot += "</detail></event>"
return cot
}
}
// MARK: - Supporting Types
/// Contact information for a CoT event
struct CoTContact: Sendable, Equatable {
var callsign: String
var endpoint: String?
var phone: String?
init(callsign: String, endpoint: String? = nil, phone: String? = nil) {
self.callsign = callsign
self.endpoint = endpoint
self.phone = phone
}
}
/// Group/team assignment for a CoT event
struct CoTGroup: Sendable, Equatable {
/// Team color name (e.g., "Cyan", "Green", "Red")
var name: String
/// Role name (e.g., "Team Member", "Team Lead")
var role: String
init(name: String, role: String) {
self.name = name
self.role = role
}
}
/// Device status for a CoT event
struct CoTStatus: Sendable, Equatable {
var battery: Int
init(battery: Int) {
self.battery = battery
}
}
/// Movement track for a CoT event
struct CoTTrack: Sendable, Equatable {
var speed: Double
var course: Double
init(speed: Double, course: Double) {
self.speed = speed
self.course = course
}
}
/// Chat message details for a CoT event
struct CoTChat: Sendable, Equatable {
var message: String
var senderCallsign: String?
var chatroom: String
init(message: String, senderCallsign: String? = nil, chatroom: String = "All Chat Rooms") {
self.message = message
self.senderCallsign = senderCallsign
self.chatroom = chatroom
}
}
// MARK: - String Extension for XML Escaping
extension String {
/// Escape special XML characters
var xmlEscaped: String {
self.replacingOccurrences(of: "&", with: "&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,335 @@
//
// CoTXMLParser.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import Foundation
import OSLog
/// XML Parser delegate for parsing incoming CoT (Cursor on Target) messages from TAK clients
final class CoTXMLParser: NSObject, XMLParserDelegate {
private let data: Data
private var cotMessage: CoTMessage?
private var parseError: Error?
// Current parsing state
private var currentElement = ""
private var currentText = ""
// Temporary attribute storage during parsing
private var eventAttributes: [String: String] = [:]
private var pointAttributes: [String: String] = [:]
private var contactAttributes: [String: String] = [:]
private var groupAttributes: [String: String] = [:]
private var statusAttributes: [String: String] = [:]
private var trackAttributes: [String: String] = [:]
private var chatAttributes: [String: String] = [:]
private var chatgrpAttributes: [String: String] = [:]
private var remarksAttributes: [String: String] = [:]
private var remarksText = ""
private var linkAttributes: [String: String] = [:]
// Track element hierarchy for nested elements
private var elementStack: [String] = []
// Raw detail XML for unrecognized elements (markers, shapes, colors, etc.)
private var rawDetailXML = ""
private var isCapturingRawDetail = false
private var rawDetailDepth = 0
// Known detail elements we handle explicitly
private let knownDetailElements: Set<String> = [
"contact", "__group", "status", "track", "__chat", "chatgrp",
"remarks", "link", "uid", "__serverdestination"
]
init(data: Data) {
self.data = data
}
/// Parse the XML data and return a CoTMessage
func parse() throws -> CoTMessage {
let parser = XMLParser(data: data)
parser.delegate = self
parser.shouldProcessNamespaces = false
parser.shouldReportNamespacePrefixes = false
guard parser.parse() else {
if let error = parseError {
throw error
}
throw CoTParseError.parseFailed(parser.parserError?.localizedDescription ?? "Unknown error")
}
guard let message = cotMessage else {
throw CoTParseError.invalidMessage
}
return message
}
// MARK: - XMLParserDelegate
func parser(_ parser: XMLParser, didStartElement elementName: String,
namespaceURI: String?, qualifiedName qName: String?,
attributes attributeDict: [String: String] = [:]) {
elementStack.append(elementName)
currentElement = elementName
currentText = ""
// Check if we're inside <detail> and this is an unrecognized element
let isInsideDetail = elementStack.contains("detail") && elementName != "detail"
if isCapturingRawDetail {
// Continue capturing nested elements
rawDetailDepth += 1
rawDetailXML += buildOpeningTag(elementName, attributes: attributeDict)
} else if isInsideDetail && !knownDetailElements.contains(elementName) {
// Start capturing this unrecognized element
isCapturingRawDetail = true
rawDetailDepth = 1
rawDetailXML += buildOpeningTag(elementName, attributes: attributeDict)
}
switch elementName {
case "event":
eventAttributes = attributeDict
case "point":
pointAttributes = attributeDict
case "contact":
contactAttributes = attributeDict
case "__group":
groupAttributes = attributeDict
case "status":
statusAttributes = attributeDict
case "track":
trackAttributes = attributeDict
case "__chat":
chatAttributes = attributeDict
case "chatgrp":
chatgrpAttributes = attributeDict
case "remarks":
remarksAttributes = attributeDict
case "link":
linkAttributes = attributeDict
default:
break
}
}
/// Build an XML opening tag with attributes
private func buildOpeningTag(_ elementName: String, attributes: [String: String]) -> String {
var tag = "<\(elementName)"
for (key, value) in attributes {
tag += " \(key)='\(value.xmlEscaped)'"
}
tag += ">"
return tag
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
currentText += string
// Capture text content for raw detail elements
if isCapturingRawDetail {
rawDetailXML += string.xmlEscaped
}
}
func parser(_ parser: XMLParser, didEndElement elementName: String,
namespaceURI: String?, qualifiedName qName: String?) {
if elementName == "remarks" {
remarksText = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
}
// Handle raw detail element closing
if isCapturingRawDetail {
rawDetailXML += "</\(elementName)>"
rawDetailDepth -= 1
if rawDetailDepth == 0 {
isCapturingRawDetail = false
}
}
if elementName == "event" {
buildCoTMessage()
}
elementStack.removeLast()
currentElement = elementStack.last ?? ""
}
func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) {
self.parseError = parseError
Logger.tak.error("CoT XML parse error: \(parseError.localizedDescription)")
}
// MARK: - Build CoTMessage
private func buildCoTMessage() {
Logger.tak.debug("=== Building CoTMessage from XML ===")
Logger.tak.debug("Event attributes: \(self.eventAttributes)")
Logger.tak.debug("Point attributes: \(self.pointAttributes)")
Logger.tak.debug("Contact attributes: \(self.contactAttributes)")
Logger.tak.debug("Group attributes: \(self.groupAttributes)")
Logger.tak.debug("Status attributes: \(self.statusAttributes)")
Logger.tak.debug("Track attributes: \(self.trackAttributes)")
Logger.tak.debug("Chat attributes: \(self.chatAttributes)")
Logger.tak.debug("Remarks text: \(self.remarksText)")
// Parse timestamps
let time = parseDate(eventAttributes["time"])
let start = parseDate(eventAttributes["start"])
let stale = parseDate(eventAttributes["stale"])
// Build contact if present
var contact: CoTContact?
if !contactAttributes.isEmpty {
contact = CoTContact(
callsign: contactAttributes["callsign"] ?? "",
endpoint: contactAttributes["endpoint"],
phone: contactAttributes["phone"]
)
Logger.tak.debug("Parsed contact: callsign=\(contact?.callsign ?? "nil")")
}
// Build group if present
var group: CoTGroup?
if !groupAttributes.isEmpty {
group = CoTGroup(
name: groupAttributes["name"] ?? "Cyan",
role: groupAttributes["role"] ?? "Team Member"
)
Logger.tak.debug("Parsed group: name=\(group?.name ?? "nil"), role=\(group?.role ?? "nil")")
}
// Build status if present
var status: CoTStatus?
if let batteryStr = statusAttributes["battery"], let battery = Int(batteryStr) {
status = CoTStatus(battery: battery)
Logger.tak.debug("Parsed status: battery=\(battery)")
}
// Build track if present
var track: CoTTrack?
if !trackAttributes.isEmpty {
let speed = Double(trackAttributes["speed"] ?? "0") ?? 0
let course = Double(trackAttributes["course"] ?? "0") ?? 0
track = CoTTrack(speed: speed, course: course)
Logger.tak.debug("Parsed track: speed=\(speed), course=\(course)")
}
// Build chat if present
var chat: CoTChat?
if !chatAttributes.isEmpty {
chat = CoTChat(
message: remarksText,
senderCallsign: chatAttributes["senderCallsign"],
chatroom: chatAttributes["chatroom"] ?? chatAttributes["id"] ?? "All Chat Rooms"
)
Logger.tak.debug("Parsed chat: message=\(self.remarksText.prefix(50)), chatroom=\(chat?.chatroom ?? "nil")")
}
let uid = eventAttributes["uid"] ?? UUID().uuidString
let type = eventAttributes["type"] ?? "a-f-G-U-C"
let latitude = Double(pointAttributes["lat"] ?? "0") ?? 0
let longitude = Double(pointAttributes["lon"] ?? "0") ?? 0
let hae = Double(pointAttributes["hae"] ?? "9999999") ?? 9999999
Logger.tak.debug("Building CoTMessage: uid=\(uid), type=\(type)")
Logger.tak.debug(" location: lat=\(latitude), lon=\(longitude), hae=\(hae)")
cotMessage = CoTMessage(
uid: uid,
type: type,
time: time,
start: start,
stale: stale,
how: eventAttributes["how"] ?? "m-g",
latitude: latitude,
longitude: longitude,
hae: hae,
ce: Double(pointAttributes["ce"] ?? "9999999") ?? 9999999,
le: Double(pointAttributes["le"] ?? "9999999") ?? 9999999,
contact: contact,
group: group,
status: status,
track: track,
chat: chat,
remarks: chat == nil && !remarksText.isEmpty ? remarksText : nil,
rawDetailXML: rawDetailXML.isEmpty ? nil : rawDetailXML
)
if !rawDetailXML.isEmpty {
Logger.tak.debug("Captured raw detail XML: \(self.rawDetailXML.prefix(200))...")
}
Logger.tak.debug("=== CoTMessage built successfully ===")
}
// MARK: - Date Parsing
private func parseDate(_ string: String?) -> Date {
guard let string else { return Date() }
// Try ISO8601 with fractional seconds first
let formatterWithFractional = ISO8601DateFormatter()
formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatterWithFractional.date(from: string) {
return date
}
// Try ISO8601 without fractional seconds
let formatterWithoutFractional = ISO8601DateFormatter()
formatterWithoutFractional.formatOptions = [.withInternetDateTime]
if let date = formatterWithoutFractional.date(from: string) {
return date
}
// Try basic date formatter
let basicFormatter = DateFormatter()
basicFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
basicFormatter.timeZone = TimeZone(identifier: "UTC")
if let date = basicFormatter.date(from: string) {
return date
}
Logger.tak.warning("Failed to parse CoT date: \(string)")
return Date()
}
}
// MARK: - Parse Error
enum CoTParseError: LocalizedError {
case parseFailed(String)
case invalidMessage
case emptyData
var errorDescription: String? {
switch self {
case .parseFailed(let reason):
return "Failed to parse CoT XML: \(reason)"
case .invalidMessage:
return "Invalid CoT message structure"
case .emptyData:
return "Empty data received"
}
}
}
// MARK: - CoTMessage Parsing Extension
extension CoTMessage {
/// Parse CoT XML data into a CoTMessage (throwing version)
static func parseData(_ data: Data) throws -> CoTMessage {
guard !data.isEmpty else {
throw CoTParseError.emptyData
}
let parser = CoTXMLParser(data: data)
return try parser.parse()
}
}

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,626 @@
//
// TAKCertificateManager.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import Foundation
import Security
import OSLog
/// Manages TLS certificates for the TAK server
/// Handles server identity (PKCS#12) and client CA certificates (PEM)
final class TAKCertificateManager {
static let shared = TAKCertificateManager()
// Keychain tags for certificate storage
private let serverIdentityTag = "com.meshtastic.tak.server.identity"
private let serverIdentityCustomTag = "com.meshtastic.tak.server.identity.custom"
private let clientCATag = "com.meshtastic.tak.client.ca"
// Bundled certificate password
private let bundledPassword = "meshtastic"
// Storage keys for custom P12 data (for data package generation)
private let customServerP12DataKey = "tak.custom.server.p12.data"
private let customServerP12PasswordKey = "tak.custom.server.p12.password"
private let customClientP12DataKey = "tak.custom.client.p12.data"
private let customClientP12PasswordKey = "tak.custom.client.p12.password"
private init() {
// Load bundled defaults on first launch if no custom cert exists
loadBundledDefaultsIfNeeded()
}
/// Force reload all bundled certificates (useful after app update with new certs)
func reloadBundledCertificates() {
Logger.tak.info("Reloading bundled certificates...")
// Clear custom certificate data
clearCustomCertificateData()
// Delete existing certificates
deleteServerIdentity()
deleteClientCACertificates()
// Reload bundled defaults
loadBundledServerIdentity()
loadBundledClientCA()
Logger.tak.info("Bundled certificates reloaded")
}
// MARK: - Bundled Default Certificates
/// Load bundled default certificates if no custom certificates are configured
private func loadBundledDefaultsIfNeeded() {
// Only load if no custom server identity exists
if !hasCustomServerCertificate() && getServerIdentity() == nil {
loadBundledServerIdentity()
}
// Only load if no client CA exists
if !hasClientCACertificate() {
loadBundledClientCA()
}
}
/// Load the bundled server identity (p12)
private func loadBundledServerIdentity() {
// Try subdirectory first, then root level (Xcode may flatten folder structure)
let p12URL = Bundle.main.url(forResource: "server", withExtension: "p12", subdirectory: "Certificates")
?? Bundle.main.url(forResource: "server", withExtension: "p12")
guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else {
Logger.tak.warning("Bundled server.p12 not found in app bundle")
return
}
do {
_ = try importServerIdentity(from: p12Data, password: bundledPassword, isCustom: false)
Logger.tak.info("Loaded bundled default server certificate")
} catch {
Logger.tak.error("Failed to load bundled server certificate: \(error.localizedDescription)")
}
}
/// Load the bundled client CA certificate (pem)
private func loadBundledClientCA() {
// Try subdirectory first, then root level (Xcode may flatten folder structure)
let pemURL = Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates")
?? Bundle.main.url(forResource: "ca", withExtension: "pem")
guard let url = pemURL, let pemData = try? Data(contentsOf: url) else {
Logger.tak.warning("Bundled ca.pem not found in app bundle")
return
}
do {
_ = try importClientCACertificate(from: pemData)
Logger.tak.info("Loaded bundled default CA certificate")
} catch {
Logger.tak.error("Failed to load bundled CA certificate: \(error.localizedDescription)")
}
}
/// Check if using custom (user-imported) server certificate
func hasCustomServerCertificate() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityCustomTag,
kSecReturnRef as String: true
]
var item: CFTypeRef?
return SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess
}
/// Get the bundled CA certificate data for sharing to ITAK
func getBundledCACertificateData() -> Data? {
let pemURL = Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates")
?? Bundle.main.url(forResource: "ca", withExtension: "pem")
guard let url = pemURL, let pemData = try? Data(contentsOf: url) else {
return nil
}
return pemData
}
/// Get URL to bundled CA certificate for sharing
func getBundledCACertificateURL() -> URL? {
return Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates")
?? Bundle.main.url(forResource: "ca", withExtension: "pem")
}
/// Get the bundled server P12 data for sharing to ITAK (used as truststore)
func getBundledServerP12Data() -> Data? {
let p12URL = Bundle.main.url(forResource: "server", withExtension: "p12", subdirectory: "Certificates")
?? Bundle.main.url(forResource: "server", withExtension: "p12")
guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else {
return nil
}
return p12Data
}
/// Get the password for bundled certificates (for data package)
func getBundledCertificatePassword() -> String {
return bundledPassword
}
/// Get the bundled client P12 data for sharing to ITAK (for mutual TLS)
func getBundledClientP12Data() -> Data? {
let p12URL = Bundle.main.url(forResource: "client", withExtension: "p12", subdirectory: "Certificates")
?? Bundle.main.url(forResource: "client", withExtension: "p12")
guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else {
return nil
}
return p12Data
}
/// Check if a bundled client certificate exists
func hasBundledClientCertificate() -> Bool {
return getBundledClientP12Data() != nil
}
// MARK: - Active Certificate Data (for Data Package)
/// Get the active server P12 data (custom if available, otherwise bundled)
/// Used for generating data packages
func getActiveServerP12Data() -> Data? {
// Check for custom certificate first
if hasCustomServerCertificate(),
let customData = UserDefaults.standard.data(forKey: customServerP12DataKey) {
Logger.tak.debug("Using custom server P12 for data package")
return customData
}
// Fall back to bundled
Logger.tak.debug("Using bundled server P12 for data package")
return getBundledServerP12Data()
}
/// Get the active client P12 data (custom if available, otherwise bundled)
/// Used for generating data packages
func getActiveClientP12Data() -> Data? {
// Check for custom certificate first
if let customData = UserDefaults.standard.data(forKey: customClientP12DataKey) {
Logger.tak.debug("Using custom client P12 for data package")
return customData
}
// Fall back to bundled
Logger.tak.debug("Using bundled client P12 for data package")
return getBundledClientP12Data()
}
/// Get the password for the active server certificate
func getActiveServerCertificatePassword() -> String {
if hasCustomServerCertificate(),
let customPassword = UserDefaults.standard.string(forKey: customServerP12PasswordKey) {
return customPassword
}
return bundledPassword
}
/// Get the password for the active client certificate
func getActiveClientCertificatePassword() -> String {
if let customPassword = UserDefaults.standard.string(forKey: customClientP12PasswordKey) {
return customPassword
}
return bundledPassword
}
/// Import a custom client P12 certificate (for data package generation)
func importCustomClientP12(data: Data, password: String) {
UserDefaults.standard.set(data, forKey: customClientP12DataKey)
UserDefaults.standard.set(password, forKey: customClientP12PasswordKey)
Logger.tak.info("Custom client P12 imported for data package")
}
/// Check if custom client P12 is available
func hasCustomClientP12() -> Bool {
return UserDefaults.standard.data(forKey: customClientP12DataKey) != nil
}
/// Clear custom certificate data (called when resetting to defaults)
private func clearCustomCertificateData() {
UserDefaults.standard.removeObject(forKey: customServerP12DataKey)
UserDefaults.standard.removeObject(forKey: customServerP12PasswordKey)
UserDefaults.standard.removeObject(forKey: customClientP12DataKey)
UserDefaults.standard.removeObject(forKey: customClientP12PasswordKey)
Logger.tak.debug("Cleared custom certificate data")
}
// MARK: - Server Identity (PKCS#12)
/// Import server identity from PKCS#12 (.p12) file data
/// - Parameters:
/// - p12Data: The raw PKCS#12 file data
/// - password: Password to decrypt the PKCS#12 file
/// - isCustom: Whether this is a user-imported custom certificate (default: true)
/// - Returns: The imported SecIdentity
func importServerIdentity(from p12Data: Data, password: String, isCustom: Bool = true) throws -> SecIdentity {
let options: [String: Any] = [kSecImportExportPassphrase as String: password]
var items: CFArray?
let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items)
guard status == errSecSuccess else {
Logger.tak.error("Failed to import PKCS#12: \(status)")
throw TAKCertificateError.importFailed(status)
}
guard let itemArray = items as? [[String: Any]],
let firstItem = itemArray.first,
let identity = firstItem[kSecImportItemIdentity as String] as! SecIdentity? else {
throw TAKCertificateError.noIdentityFound
}
// Store in Keychain for persistence
try storeServerIdentity(identity, isCustom: isCustom)
// Store the raw P12 data and password for data package generation (only for custom certs)
if isCustom {
storeCustomServerP12InKeychain(p12Data: p12Data, password: password)
Logger.tak.debug("Stored custom server P12 data for data package generation in Keychain")
}
Logger.tak.info("Server identity imported successfully (custom: \(isCustom))")
return identity
}
/// Store custom server PKCS#12 data and its password in the Keychain
private func storeCustomServerP12InKeychain(p12Data: Data, password: String) {
let service = "com.meshtastic.tak"
// Helper to upsert a generic password item
func upsertKeychainItem(account: String, value: Data) -> OSStatus {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: value
]
return SecItemAdd(addQuery as CFDictionary, nil)
}
let dataStatus = upsertKeychainItem(account: customServerP12DataKey, value: p12Data)
if dataStatus != errSecSuccess {
Logger.tak.error("Failed to store custom server P12 data in Keychain: \(dataStatus)")
}
if let passwordData = password.data(using: .utf8) {
let passwordStatus = upsertKeychainItem(account: customServerP12PasswordKey, value: passwordData)
if passwordStatus != errSecSuccess {
Logger.tak.error("Failed to store custom server P12 password in Keychain: \(passwordStatus)")
}
} else {
Logger.tak.error("Failed to encode custom server P12 password as UTF-8 data")
}
}
/// Store server identity in Keychain
private func storeServerIdentity(_ identity: SecIdentity, isCustom: Bool = true) throws {
let tag = isCustom ? serverIdentityCustomTag : serverIdentityTag
// First delete any existing identity with this tag
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: tag
]
SecItemDelete(deleteQuery as CFDictionary)
// If storing custom cert, also delete the bundled one (custom takes precedence)
if isCustom {
let deleteBundledQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityTag
]
SecItemDelete(deleteBundledQuery as CFDictionary)
}
// Add new identity
let addQuery: [String: Any] = [
kSecValueRef as String: identity,
kSecAttrLabel as String: tag,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
guard status == errSecSuccess else {
Logger.tak.error("Failed to store server identity in Keychain: \(status)")
throw TAKCertificateError.keychainError(status)
}
}
/// Retrieve stored server identity from Keychain
/// Custom certificates take precedence over bundled ones
func getServerIdentity() -> SecIdentity? {
// First try custom certificate
let customQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityCustomTag,
kSecReturnRef as String: true
]
var item: CFTypeRef?
var status = SecItemCopyMatching(customQuery as CFDictionary, &item)
if status == errSecSuccess {
return (item as! SecIdentity)
}
// Fall back to bundled certificate
let bundledQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityTag,
kSecReturnRef as String: true
]
status = SecItemCopyMatching(bundledQuery as CFDictionary, &item)
guard status == errSecSuccess else {
if status != errSecItemNotFound {
Logger.tak.warning("Failed to retrieve server identity: \(status)")
}
return nil
}
return (item as! SecIdentity)
}
/// Check if server certificate is configured
func hasServerCertificate() -> Bool {
return getServerIdentity() != nil
}
/// Delete custom server identity and reload bundled default
func deleteServerIdentity() {
// Delete custom certificate
let customQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityCustomTag
]
let customStatus = SecItemDelete(customQuery as CFDictionary)
// Delete bundled certificate too
let bundledQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityTag
]
let bundledStatus = SecItemDelete(bundledQuery as CFDictionary)
if customStatus == errSecSuccess || bundledStatus == errSecSuccess {
Logger.tak.info("Server identity deleted")
}
// Reload bundled default
loadBundledServerIdentity()
}
/// Reset to bundled default certificate (deletes custom certificate)
func resetToDefaultServerCertificate() {
// Clear custom certificate data from UserDefaults
clearCustomCertificateData()
// Delete custom certificate
let customQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityCustomTag
]
SecItemDelete(customQuery as CFDictionary)
// Delete existing bundled and reload
let bundledQuery: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: serverIdentityTag
]
SecItemDelete(bundledQuery as CFDictionary)
loadBundledServerIdentity()
Logger.tak.info("Reset to bundled default server certificate")
}
/// Get certificate info for display purposes
func getServerCertificateInfo() -> String? {
guard let identity = getServerIdentity() else { return nil }
var certificate: SecCertificate?
let status = SecIdentityCopyCertificate(identity, &certificate)
guard status == errSecSuccess, let cert = certificate else { return nil }
let isCustom = hasCustomServerCertificate()
let prefix = isCustom ? "Custom: " : "Default: "
if let summary = SecCertificateCopySubjectSummary(cert) as String? {
return prefix + summary
}
return prefix + "Certificate loaded"
}
// MARK: - Client CA Certificates (PEM)
/// Import client CA certificate from PEM file data
/// - Parameter pemData: The raw PEM file data
/// - Returns: The imported SecCertificate
func importClientCACertificate(from pemData: Data) throws -> SecCertificate {
// Extract DER data from PEM format
let derData = try extractDERFromPEM(pemData)
guard let certificate = SecCertificateCreateWithData(nil, derData as CFData) else {
throw TAKCertificateError.invalidCertificate
}
// Store in Keychain
try storeClientCACertificate(certificate)
Logger.tak.info("Client CA certificate imported successfully")
return certificate
}
/// Extract DER-encoded certificate data from PEM format
private func extractDERFromPEM(_ pemData: Data) throws -> Data {
guard let pemString = String(data: pemData, encoding: .utf8) else {
throw TAKCertificateError.invalidPEM
}
// Remove PEM headers and whitespace
let base64 = pemString
.replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "")
.replacingOccurrences(of: "-----END CERTIFICATE-----", with: "")
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.trimmingCharacters(in: .whitespaces)
guard let derData = Data(base64Encoded: base64) else {
throw TAKCertificateError.invalidPEM
}
return derData
}
/// Store client CA certificate in Keychain
private func storeClientCACertificate(_ certificate: SecCertificate) throws {
let addQuery: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecValueRef as String: certificate,
kSecAttrLabel as String: clientCATag,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
// Ignore duplicate item errors (certificate already imported)
guard status == errSecSuccess || status == errSecDuplicateItem else {
Logger.tak.error("Failed to store client CA certificate: \(status)")
throw TAKCertificateError.keychainError(status)
}
}
/// Get all stored client CA certificates
func getClientCACertificates() -> [SecCertificate] {
let query: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: clientCATag,
kSecReturnRef as String: true,
kSecMatchLimit as String: kSecMatchLimitAll
]
var items: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &items)
guard status == errSecSuccess else {
if status != errSecItemNotFound {
Logger.tak.warning("Failed to retrieve client CA certificates: \(status)")
}
return []
}
// Handle both single item and array returns
if let certificates = items as? [SecCertificate] {
return certificates
} else if let certificate = items as! SecCertificate? {
return [certificate]
}
return []
}
/// Check if at least one client CA certificate is configured
func hasClientCACertificate() -> Bool {
return !getClientCACertificates().isEmpty
}
/// Delete all client CA certificates from Keychain
func deleteClientCACertificates() {
let query: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: clientCATag
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
Logger.tak.info("Client CA certificates deleted")
}
}
/// Get info about stored client CA certificates for display
func getClientCACertificateInfo() -> [String] {
let certificates = getClientCACertificates()
return certificates.compactMap { cert in
SecCertificateCopySubjectSummary(cert) as String?
}
}
// MARK: - Certificate Validation
/// Validate a client certificate against the stored CA certificates
func validateClientCertificate(_ trust: SecTrust) -> Bool {
let caCertificates = getClientCACertificates()
guard !caCertificates.isEmpty else {
Logger.tak.warning("No client CA certificates configured for validation")
return false
}
// Set the anchor certificates (trusted CAs)
SecTrustSetAnchorCertificates(trust, caCertificates as CFArray)
SecTrustSetAnchorCertificatesOnly(trust, true)
var error: CFError?
let isValid = SecTrustEvaluateWithError(trust, &error)
if !isValid {
Logger.tak.warning("Client certificate validation failed: \(error?.localizedDescription ?? "unknown")")
}
return isValid
}
}
// MARK: - Certificate Errors
enum TAKCertificateError: LocalizedError {
case importFailed(OSStatus)
case noIdentityFound
case invalidCertificate
case invalidPEM
case keychainError(OSStatus)
case certificateExpired
case certificateNotYetValid
var errorDescription: String? {
switch self {
case .importFailed(let status):
return "Failed to import PKCS#12: \(securityErrorMessage(status))"
case .noIdentityFound:
return "No identity (certificate + private key) found in PKCS#12 file"
case .invalidCertificate:
return "Invalid certificate data"
case .invalidPEM:
return "Invalid PEM format - ensure file contains a valid certificate"
case .keychainError(let status):
return "Keychain error: \(securityErrorMessage(status))"
case .certificateExpired:
return "Certificate has expired"
case .certificateNotYetValid:
return "Certificate is not yet valid"
}
}
private func securityErrorMessage(_ status: OSStatus) -> String {
if let message = SecCopyErrorMessageString(status, nil) {
return message as String
}
return "Error code: \(status)"
}
}

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,436 @@
//
// TAKServerManager.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import Foundation
import Network
import OSLog
import Combine
import SwiftUI
/// Manages the TAK Server lifecycle, TLS connections, and client management
/// Runs on MainActor for thread safety, following the AccessoryManager pattern
@MainActor
final class TAKServerManager: ObservableObject {
static let shared = TAKServerManager()
// MARK: - Published State
@Published private(set) var isRunning = false
@Published private(set) var connectedClients: [TAKClientInfo] = []
@Published var lastError: String?
// MARK: - Configuration (persisted via AppStorage)
@AppStorage("takServerEnabled") var enabled = false {
didSet {
Task {
if enabled && !isRunning {
try? await start()
} else if !enabled && isRunning {
stop()
}
}
}
}
/// Fixed port - always use TLS port 8089
static let defaultTLSPort = 8089
static let defaultTCPPort = 8087 // Legacy, not used
/// Port is fixed to 8089 (mTLS)
var port: Int { Self.defaultTLSPort }
/// Always use TLS/mTLS
var useTLS: Bool { true }
// MARK: - Bridge
/// Bridge for converting between CoT and Meshtastic formats
var bridge: TAKMeshtasticBridge?
// MARK: - Private Properties
private var listener: NWListener?
private var connections: [ObjectIdentifier: TAKConnection] = [:]
private var connectionTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
private let queue = DispatchQueue(label: "tak.server", qos: .userInitiated)
private init() {}
// MARK: - Initialization
/// Initialize the TAK server on app startup
/// Call this from app initialization to restore server state
func initializeOnStartup() {
guard enabled else {
Logger.tak.debug("TAK Server not enabled, skipping startup")
return
}
guard !isRunning else {
Logger.tak.debug("TAK Server already running")
return
}
Logger.tak.info("TAK Server enabled, starting on app launch")
Task {
do {
try await start()
} catch {
Logger.tak.error("Failed to start TAK Server on startup: \(error.localizedDescription)")
}
}
}
// MARK: - Server Lifecycle
/// Start the TAK server (TLS or TCP based on configuration)
func start() async throws {
guard !isRunning else {
Logger.tak.info("TAK Server already running")
return
}
let mode = useTLS ? "TLS" : "TCP"
Logger.tak.info("Starting TAK Server on port \(self.port) (\(mode) mode)")
let parameters: NWParameters
if useTLS {
// Validate we have a server certificate for TLS mode
guard let identity = TAKCertificateManager.shared.getServerIdentity() else {
let error = TAKServerError.noServerCertificate
lastError = error.localizedDescription
enabled = false
throw error
}
// Create TLS options
let tlsOptions = NWProtocolTLS.Options()
// Set server identity (certificate + private key)
let secIdentity = sec_identity_create(identity)!
sec_protocol_options_set_local_identity(
tlsOptions.securityProtocolOptions,
secIdentity
)
// Set minimum TLS version to 1.2 (TAK standard)
sec_protocol_options_set_min_tls_protocol_version(
tlsOptions.securityProtocolOptions,
.TLSv12
)
// Configure mTLS - always require client certificate for TLS mode
sec_protocol_options_set_peer_authentication_required(
tlsOptions.securityProtocolOptions,
true
)
// Set up client certificate validation
let clientCAs = TAKCertificateManager.shared.getClientCACertificates()
Logger.tak.info("Loaded \(clientCAs.count) CA certificate(s) for client validation")
if !clientCAs.isEmpty {
for (index, ca) in clientCAs.enumerated() {
if let summary = SecCertificateCopySubjectSummary(ca) as String? {
Logger.tak.info("CA[\(index)]: \(summary)")
}
}
let trustRoots = clientCAs as CFArray
sec_protocol_options_set_verify_block(
tlsOptions.securityProtocolOptions,
{ _, secTrust, completion in
// Convert sec_trust_t to SecTrust
let trust = sec_trust_copy_ref(secTrust).takeRetainedValue()
// Set policy for client certificate validation
// Use SSL policy with server=false to validate client certificates
// This properly accepts clientAuth ExtendedKeyUsage
let clientPolicy = SecPolicyCreateSSL(false, nil)
SecTrustSetPolicies(trust, clientPolicy)
SecTrustSetAnchorCertificates(trust, trustRoots)
SecTrustSetAnchorCertificatesOnly(trust, true)
var error: CFError?
let isValid = SecTrustEvaluateWithError(trust, &error)
if let error = error {
Logger.tak.error("Client cert validation error: \(error.localizedDescription)")
}
Logger.tak.info("Client certificate validation: \(isValid ? "passed" : "failed")")
completion(isValid)
},
queue
)
} else {
// No client CAs configured: keep mTLS enabled but reject all client certificates
Logger.tak.warning("mTLS enabled but no CA certificates configured for client validation; all client connections will be rejected")
sec_protocol_options_set_verify_block(
tlsOptions.securityProtocolOptions,
{ _, _, completion in
Logger.tak.error("Rejecting client connection because no client CA certificates are configured")
completion(false)
},
queue
)
}
// TCP options
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 60
parameters = NWParameters(tls: tlsOptions, tcp: tcpOptions)
} else {
// Plain TCP mode (no TLS)
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 60
parameters = NWParameters(tls: nil, tcp: tcpOptions)
}
parameters.allowLocalEndpointReuse = true
// Bind to localhost only - only allow TAK clients on the same device
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"),
port: NWEndpoint.Port(integerLiteral: UInt16(port))
)
// Create and configure listener
do {
listener = try NWListener(using: parameters)
} catch {
lastError = "Failed to create listener: \(error.localizedDescription)"
Logger.tak.error("Failed to create TAK listener: \(error.localizedDescription)")
enabled = false
throw error
}
// Set up state handler
listener?.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
self?.handleListenerState(state)
}
}
// Set up new connection handler
listener?.newConnectionHandler = { [weak self] connection in
Task { @MainActor in
await self?.handleNewConnection(connection)
}
}
// Start listening
listener?.start(queue: queue)
}
/// Stop the TAK server
func stop() {
Logger.tak.info("Stopping TAK Server")
listener?.cancel()
listener = nil
// Cancel all connection tasks
for (_, task) in connectionTasks {
task.cancel()
}
connectionTasks.removeAll()
// Disconnect all clients
for (_, connection) in connections {
Task {
await connection.disconnect()
}
}
connections.removeAll()
connectedClients.removeAll()
isRunning = false
lastError = nil
Logger.tak.info("TAK Server stopped")
}
/// Restart the server (useful after configuration changes)
func restart() async throws {
stop()
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s delay
try await start()
}
// MARK: - State Handling
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
isRunning = true
lastError = nil
Logger.tak.info("TAK Server listening on port \(self.port)")
case .failed(let error):
isRunning = false
lastError = error.localizedDescription
enabled = false
Logger.tak.error("TAK Server failed: \(error.localizedDescription)")
case .cancelled:
isRunning = false
Logger.tak.info("TAK Server cancelled")
case .waiting(let error):
Logger.tak.warning("TAK Server waiting: \(error.localizedDescription)")
case .setup:
Logger.tak.debug("TAK Server setup")
@unknown default:
break
}
}
// MARK: - Connection Management
private func handleNewConnection(_ nwConnection: NWConnection) async {
let connectionId = ObjectIdentifier(nwConnection)
let connection = TAKConnection(connection: nwConnection)
connections[connectionId] = connection
Logger.tak.info("New TAK client connecting: \(nwConnection.endpoint.debugDescription)")
// Start handling the connection
let eventStream = await connection.start()
// Create task to handle connection events
let task = Task {
for await event in eventStream {
await handleConnectionEvent(event, connectionId: connectionId)
}
// Connection ended
await removeConnection(connectionId)
}
connectionTasks[connectionId] = task
}
private func handleConnectionEvent(_ event: TAKConnectionEvent, connectionId: ObjectIdentifier) async {
switch event {
case .connected(let clientInfo):
connectedClients.append(clientInfo)
Logger.tak.info("TAK client connected: \(clientInfo.displayName)")
case .clientInfoUpdated(let clientInfo):
// Update the client info in our list
if let index = connectedClients.firstIndex(where: { $0.id == clientInfo.id }) {
connectedClients[index] = clientInfo
}
case .message(let cotMessage):
Logger.tak.info("Received CoT from TAK client: \(cotMessage.type)")
// Forward to Meshtastic mesh via bridge
await bridge?.sendToMesh(cotMessage)
case .disconnected:
await removeConnection(connectionId)
case .error(let error):
Logger.tak.error("TAK client error: \(error.localizedDescription)")
}
}
private func removeConnection(_ connectionId: ObjectIdentifier) async {
connectionTasks[connectionId]?.cancel()
connectionTasks.removeValue(forKey: connectionId)
if let connection = connections.removeValue(forKey: connectionId) {
let endpoint = await connection.endpoint
connectedClients.removeAll { $0.endpoint.debugDescription == endpoint.debugDescription }
Logger.tak.info("TAK client disconnected")
}
}
// MARK: - Message Distribution
/// Broadcast a CoT message to all connected TAK clients
func broadcast(_ cotMessage: CoTMessage) async {
guard !connections.isEmpty else { return }
Logger.tak.info("Broadcasting CoT to \(self.connections.count) TAK client(s): \(cotMessage.type)")
for (connectionId, connection) in connections {
do {
try await connection.send(cotMessage)
} catch {
Logger.tak.error("Failed to send to TAK client: \(error.localizedDescription)")
// Remove failed connection
await removeConnection(connectionId)
}
}
}
/// Send a CoT message to a specific client
func send(_ cotMessage: CoTMessage, to clientId: UUID) async throws {
guard let clientInfo = connectedClients.first(where: { $0.id == clientId }) else {
throw TAKServerError.clientNotFound
}
for (_, connection) in connections {
let endpoint = await connection.endpoint
if endpoint.debugDescription == clientInfo.endpoint.debugDescription {
try await connection.send(cotMessage)
return
}
}
throw TAKServerError.clientNotFound
}
// MARK: - Status
/// Get server status description
var statusDescription: String {
if isRunning {
let mode = useTLS ? "TLS" : "TCP"
return "Running on port \(port) (\(mode))"
} else if let error = lastError {
return "Error: \(error)"
} else {
return "Stopped"
}
}
}
// MARK: - Server Errors
enum TAKServerError: LocalizedError {
case noServerCertificate
case noClientCACertificate
case tlsConfigurationFailed
case listenerFailed(String)
case clientNotFound
case notRunning
var errorDescription: String? {
switch self {
case .noServerCertificate:
return "No server certificate configured. Import a .p12 file with the server certificate and private key."
case .noClientCACertificate:
return "No client CA certificate configured. Import the CA certificate (.pem) used to sign client certificates."
case .tlsConfigurationFailed:
return "Failed to configure TLS settings."
case .listenerFailed(let reason):
return "Failed to start listener: \(reason)"
case .clientNotFound:
return "Client not found"
case .notRunning:
return "TAK Server is not running"
}
}
}

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,9 +27,8 @@ struct MessageText: View {
// State for handling channel URL sheet
@State private var saveChannelLink: SaveChannelLinkData?
@State private var isShowingDeleteConfirmation = false
@FocusState private var isTapbackInputFocused: Bool
@State private var tapbackText = ""
@FocusState private var isTapbackInputFocused: Bool
var body: some View {
SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) {

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

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>