added read only mode cot to meshtastic parsing and warning to not enable on pub channel

This commit is contained in:
Benjamin Faershtein 2026-02-19 22:30:57 -08:00
parent d9e169142e
commit 740b194af2
6 changed files with 1239 additions and 40 deletions

View file

@ -5179,6 +5179,10 @@
}
}
},
"Auto-Fix Channel" : {
"comment" : "A button label that initiates the process of automatically fixing the TAK server's primary communication channel.",
"isCommentAutoGenerated" : true
},
"Automatically Connect" : {
"localizations" : {
"ru" : {
@ -6489,6 +6493,10 @@
}
}
},
"Bridge Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format" : {
"comment" : "A description of the Mesh to CoT Converter feature.",
"isCommentAutoGenerated" : true
},
"Broadcast Device Metrics" : {
"localizations" : {
"ru" : {
@ -8283,6 +8291,10 @@
}
}
},
"Channel Fixed!" : {
"comment" : "A message displayed when the primary channel is successfully fixed.",
"isCommentAutoGenerated" : true
},
"Channel Name" : {
"localizations" : {
"it" : {
@ -12940,6 +12952,10 @@
}
}
},
"Device role is \"%@\". Consider setting to TAK or TAK Tracker for optimal operation." : {
"comment" : "A warning about a device's role on the TAK network. The argument is the name of the device role.",
"isCommentAutoGenerated" : true
},
"Device Screen" : {
"localizations" : {
"it" : {
@ -17804,6 +17820,14 @@
}
}
},
"Fix Channel" : {
"comment" : "The text on a button that, when pressed, will attempt to fix the primary LoRaWAN channel.",
"isCommentAutoGenerated" : true
},
"Fix Primary Channel?" : {
"comment" : "A confirmation alert title.",
"isCommentAutoGenerated" : true
},
"Fixed Pin" : {
"localizations" : {
"de" : {
@ -22356,6 +22380,10 @@
}
}
},
"Later" : {
"comment" : "A button that dismisses an alert without taking any action.",
"isCommentAutoGenerated" : true
},
"Latitude" : {
"localizations" : {
"de" : {
@ -24705,6 +24733,10 @@
}
}
},
"Mesh to CoT Converter" : {
"comment" : "A feature that bridges Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format.",
"isCommentAutoGenerated" : true
},
"Meshtastic" : {
"localizations" : {
"ru" : {
@ -24721,6 +24753,10 @@
}
}
},
"Meshtastic -> TAK works, TAK -> Meshtastic blocked" : {
"comment" : "A description of the read-only mode feature in TAK Server.",
"isCommentAutoGenerated" : true
},
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : {
"localizations" : {
"ru" : {
@ -29456,6 +29492,10 @@
}
}
},
"Or fix it yourself in Channels settings, then return here." : {
"comment" : "A message explaining that the user can fix the primary channel settings manually and then return to the current view.",
"isCommentAutoGenerated" : true
},
"OS Log Entry Details" : {
"localizations" : {
"it" : {
@ -33154,6 +33194,10 @@
}
}
},
"Read-Only Mode" : {
"comment" : "A toggle that allows the user to enable or disable read-only mode for the TAK server.",
"isCommentAutoGenerated" : true
},
"Reboot" : {
"localizations" : {
"de" : {
@ -38947,6 +38991,10 @@
}
}
},
"Set a channel name" : {
"comment" : "A bullet point describing the action to set a name for the primary channel.",
"isCommentAutoGenerated" : true
},
"Set LoRa Region" : {
"localizations" : {
"de" : {
@ -39452,6 +39500,10 @@
}
}
},
"Share with TAK Buddies" : {
"comment" : "A button that, when pressed, will present a view allowing the user to share the TAK channel with other TAK-enabled devices.",
"isCommentAutoGenerated" : true
},
"Share your location in real-time and keep your group coordinated with integrated GPS features." : {
"localizations" : {
"de" : {
@ -41604,7 +41656,6 @@
}
},
"TAK" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@ -41650,6 +41701,10 @@
}
}
},
"TAK Cannot Be Used on Public Channel" : {
"comment" : "A warning label explaining that TAK cannot be used on the public channel.",
"isCommentAutoGenerated" : true
},
"TAK Server" : {
},
@ -43645,6 +43700,10 @@
}
}
},
"This will change your primary channel to:\n• Name: TAK\n• Encryption: New 256-bit AES key\n• LoRa preset: Short Fast (recommended for TAK)\n\nThis is required for TAK Server to work properly. Any existing channel sharing links will become invalid." : {
"comment" : "The message shown in the \"Fix Primary Channel?\" alert.",
"isCommentAutoGenerated" : true
},
"This will disable fixed position and remove the currently set position." : {
"localizations" : {
"it" : {
@ -46758,6 +46817,10 @@
}
}
},
"Use a 256-bit encryption key" : {
"comment" : "A bullet point describing the importance of using a 256-bit encryption key for the primary channel.",
"isCommentAutoGenerated" : true
},
"Use a PWM output (like the RAK Buzzer) for tunes instead of an on/off output. This will ignore the output, output duration and active settings and use the device config buzzer GPIO option instead." : {
"localizations" : {
"it" : {
@ -48086,6 +48149,10 @@
}
}
},
"Warning" : {
"comment" : "The header text for the \"Warning\" section in the TAKServerConfig view.",
"isCommentAutoGenerated" : true
},
"Wave" : {
"extractionState" : "stale",
"localizations" : {
@ -49318,6 +49385,10 @@
}
}
},
"You can fix this yourself by changing your primary channel:" : {
"comment" : "A description of how to fix the primary channel in the TAK Server configuration view.",
"isCommentAutoGenerated" : true
},
"You can send and receive channel (group chats) and direct messages. From any message you can long press to see available actions like copy, reply, tapback and delete as well as delivery details." : {
"localizations" : {
"de" : {
@ -49388,6 +49459,10 @@
}
}
},
"Your channel has been configured for TAK. To share the QR code: go to Settings > Share QR Code" : {
"comment" : "A message displayed when a user successfully configures their primary channel for TAK. It instructs the user to share the QR code to invite TAK buddies.",
"isCommentAutoGenerated" : true
},
"Your current location will be set as the fixed position and broadcast over the mesh on the position interval." : {
"localizations" : {
"it" : {
@ -49610,6 +49685,10 @@
}
}
},
"Your primary channel is using the default settings (no name or default encryption key). TAK Server is running in read-only mode." : {
"comment" : "A description of a situation where the user's primary channel is not configured with a name or encryption key, and TAK Server is running in read-only mode.",
"isCommentAutoGenerated" : true
},
"Your public key is generated from your private key and sent to other nodes on the mesh so they can compute a shared secret key with you." : {
"localizations" : {
"ru" : {
@ -49740,4 +49819,4 @@
}
},
"version" : "1.1"
}
}

View file

@ -512,12 +512,68 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
switch data.portnum {
case .textMessageApp, .detectionSensorApp, .alertApp:
await handleTextMessageAppPacket(packet)
// Broadcast text message to TAK clients
if let text = String(bytes: data.payload, encoding: .utf8) {
Logger.tak.debug("Text message received, calling broadcast")
let server = TAKServerManager.shared
if server.meshToCotEnabled && server.isRunning && !server.connectedClients.isEmpty {
if server.bridge == nil {
Logger.tak.info("Initializing bridge for text message")
let bridge = TAKMeshtasticBridge(
accessoryManager: AccessoryManager.shared,
takServerManager: server
)
bridge.context = AccessoryManager.shared.context
server.bridge = bridge
}
await server.bridge?.broadcastMeshTextMessageToTAK(text: text, from: packet.from, channel: packet.channel)
}
}
case .remoteHardwareApp:
Logger.mesh.info("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .positionApp:
await MeshPackets.shared.upsertPositionPacket(packet: packet)
// Broadcast position to TAK clients
if let position = try? Position(serializedBytes: data.payload) {
Logger.tak.debug("Position received, calling broadcast")
let server = TAKServerManager.shared
if server.meshToCotEnabled && server.isRunning && !server.connectedClients.isEmpty {
if server.bridge == nil {
Logger.tak.info("Initializing bridge for position")
let bridge = TAKMeshtasticBridge(
accessoryManager: AccessoryManager.shared,
takServerManager: server
)
bridge.context = AccessoryManager.shared.context
server.bridge = bridge
}
await server.bridge?.broadcastMeshPositionToTAK(position: position, from: packet.from)
}
}
case .waypointApp:
Logger.tak.info("WAYPOINT APP CASE REACHED")
await MeshPackets.shared.waypointPacket(packet: packet)
// Broadcast waypoint to TAK clients
if let waypoint = try? Waypoint(serializedBytes: data.payload) {
Logger.tak.info("WAYPOINT PARSED: \(waypoint.name)")
// Ensure bridge is initialized before calling (not optional chaining, or lazy init won't run)
let server = TAKServerManager.shared
if server.meshToCotEnabled && server.isRunning && !server.connectedClients.isEmpty {
// Force bridge initialization if needed
if server.bridge == nil {
Logger.tak.info("Initializing bridge on demand")
let bridge = TAKMeshtasticBridge(
accessoryManager: AccessoryManager.shared,
takServerManager: server
)
bridge.context = AccessoryManager.shared.context
server.bridge = bridge
}
await server.bridge?.broadcastMeshWaypointToTAK(waypoint: waypoint, from: packet.from)
} else {
Logger.tak.info("Waypoint broadcast skipped: server not ready or no clients")
}
}
case .nodeinfoApp:
guard let connectedNodeNum = self.activeDeviceNum else {
Logger.mesh.error("🕸️ Unable to determine connectedNodeNum for node info upsert.")

View file

@ -0,0 +1,255 @@
//
// MeshToCoTConverter.swift
// Meshtastic
//
// Converts Meshtastic packets to CoT format for TAK Server
//
import Foundation
import MeshtasticProtobufs
import CoreLocation
import OSLog
/// Converts Meshtastic packets to CoT format for bridging to TAK Server
final class MeshToCoTConverter: ObservableObject {
static let shared = MeshToCoTConverter()
private let logger = Logger(subsystem: "Meshtastic", category: "MeshToCoT")
private init() {}
// MARK: - Position // MARK: Packet to CoT
/// Convert a Meshtastic position packet to CoT message
func convertPosition(_ position: Position, from node: NodeInfoEntity) -> CoTMessage? {
guard let user = node.user else {
logger.warning("Cannot convert position: node has no user info")
return nil
}
let callsign = user.longName ?? user.shortName ?? "Unknown"
let uid = "MESHTASTIC-\(node.num.toHex())"
let latitude = Double(position.latitudeI) / 1e7
let longitude = Double(position.longitudeI) / 1e7
let altitude = Double(position.altitude)
var speed: Double = 0
var course: Double = 0
if position.speed != 0 {
speed = Double(position.speed) * 0.194384 // Convert to knots
}
if position.heading != 0 {
course = Double(position.heading)
}
let battery = Int(position.batteryLevel)
return CoTMessage.pli(
uid: uid,
callsign: callsign,
latitude: latitude,
longitude: longitude,
altitude: altitude,
speed: speed,
course: course,
team: "Meshtastic",
role: "Team Member",
battery: battery > 0 ? battery : 100,
staleMinutes: 10
)
}
// MARK: - Node Info to CoT
/// Convert node info to CoT message (for node presence updates)
func convertNodeInfo(_ node: NodeInfoEntity) -> CoTMessage? {
guard let user = node.user else {
logger.warning("Cannot convert node info: node has no user info")
return nil
}
let callsign = user.longName ?? user.shortName ?? "Unknown"
let uid = "MESHTASTIC-\(node.num.toHex())"
var latitude = 0.0
var longitude = 0.0
var altitude = 9999999.0
if let position = node.position {
latitude = Double(position.latitudeI) / 1e7
longitude = Double(position.longitudeI) / 1e7
if position.altitude != 0 {
altitude = Double(position.altitude)
}
}
// Determine CoT type based on device role
let cotType = getCoTTypeForRole(user.role)
let now = Date()
return CoTMessage(
uid: uid,
type: cotType,
time: now,
start: now,
stale: now.addingTimeInterval(3600), // 1 hour stale for node info
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: "Meshtastic", role: getRoleNameForDeviceRole(user.role)),
remarks: "Meshtastic Node: \(callsign)"
)
}
// MARK: - Waypoint to CoT
/// Convert a Meshtastic waypoint to CoT message
func convertWaypoint(_ waypoint: Waypoint, from node: NodeInfoEntity?) -> CoTMessage? {
let uid = "WAYPOINT-\(waypoint.id)"
let latitude = Double(waypoint.latitudeI) / 1e7
let longitude = Double(waypoint.longitudeI) / 1e7
let altitude = waypoint.altitude > 0 ? Double(waypoint.altitude) : 9999999.0
let name = waypoint.name.isEmpty ? "Unnamed Waypoint" : waypoint.name
let description = waypoint.description_p.isEmpty ? "Meshtastic Waypoint" : waypoint.description_p
// Get emoji based on waypoint icon/expire time
let iconEmoji = getEmojiForWaypoint(waypoint)
return CoTMessage(
uid: uid,
type: "b-ttf-ff", // Point feature friend
time: Date(),
start: Date(),
stale: Date().addingTimeInterval(TimeInterval(waypoint.expire * 60)),
how: "m-g",
latitude: latitude,
longitude: longitude,
hae: altitude,
ce: 100.0,
le: 100.0,
contact: CoTContact(callsign: "\(iconEmoji) \(name)", endpoint: "0.0.0.0:4242:tcp"),
remarks: "\(description)\nCreated by: \(node?.user?.longName ?? "Unknown")"
)
}
// MARK: - Text Message to CoT
/// Convert a Meshtastic text message to CoT chat message
func convertTextMessage(_ message: MessageEntity, from sender: NodeInfoEntity) -> CoTMessage? {
guard let user = sender.user,
let text = message.text else {
return nil
}
let senderName = user.longName ?? user.shortName ?? "Unknown"
let senderUid = "MESHTASTIC-\(sender.num.toHex())"
let messageId = "MSG-\(message.id)"
return CoTMessage.chat(
senderUid: senderUid,
senderCallsign: senderName,
messageId: messageId,
message: text,
channelName: "Primary"
)
}
// MARK: - Helper Methods
/// Get CoT type based on device role
private func getCoTTypeForRole(_ role: UInt32) -> String {
switch DeviceRoles(rawValue: Int(role)) {
case .router, .routerLate:
return "a-f-G-E" // Group entity (router)
case .tracker:
return "a-f-G-T-C" // Ground unit tracker
case .tak:
return "a-f-G-U-C" // TAK client
case .takTracker:
return "a-f-G-T-C" // TAK tracker
case .sensor:
return "a-s" // Sensor
case .client, .clientMute, .clientHidden, .lostAndFound:
return "a-f-G-U-C" // Friendly ground unit
default:
return "a-f-G-U-C" // Default to friendly unit
}
}
/// Get role name for device role
private func getRoleNameForDeviceRole(_ role: UInt32) -> String {
switch DeviceRoles(rawValue: Int(role)) {
case .router, .routerLate:
return "Router"
case .tracker:
return "Tracker"
case .tak:
return "TAK"
case .takTracker:
return "TAK Tracker"
case .sensor:
return "Sensor"
case .client:
return "Client"
case .clientMute:
return "Muted"
case .clientHidden:
return "Hidden"
default:
return "User"
}
}
/// Get emoji for waypoint based on icon
private func getEmojiForWaypoint(_ waypoint: Waypoint) -> String {
// Use icon field if available, otherwise use expire time to guess
if waypoint.icon != 0 {
switch waypoint.icon {
case 1: return "📍" // Marker
case 2: return "🚗" // Car
case 3: return "🚶" // Person
case 4: return "🏠" // Home
case 5: return "" // Camp
case 6: return "⚠️" // Warning
case 7: return "🏁" // Flag
case 8: return "🔍" // Search
case 9: return "🏥" // Medical
case 10: return "🔥" // Fire
case 11: return "🚁" // Helicopter
case 12: return "" // Boat
case 13: return "🛸" // UFO
default: return "📍"
}
}
// Fallback based on name
let name = waypoint.name.lowercased()
if name.contains("help") || name.contains("emergency") {
return "🆘"
} else if name.contains("medical") || name.contains("hospital") {
return "🏥"
} else if name.contains("danger") || name.contains("warning") {
return "⚠️"
} else if name.contains("camp") {
return ""
} else if name.contains("home") || name.contains("house") {
return "🏠"
} else if name.contains("car") || name.contains("vehicle") {
return "🚗"
} else if name.contains("flag") {
return "🏁"
} else if name.contains("person") || name.contains("me") {
return "🚶"
} else {
return "📍"
}
}
}

View file

@ -137,6 +137,16 @@ final class TAKMeshtasticBridge {
/// Send a CoT message received from TAK to the Meshtastic mesh
func sendToMesh(_ cotMessage: CoTMessage) async {
guard let takServerManager else {
Logger.tak.warning("Cannot send to mesh: TAKServerManager not available")
return
}
guard !takServerManager.userReadOnlyMode else {
Logger.tak.info("TAK Server in read-only mode: Ignoring message from TAK client")
return
}
guard let accessoryManager else {
Logger.tak.warning("Cannot send to mesh: AccessoryManager not available")
return
@ -474,24 +484,78 @@ final class TAKMeshtasticBridge {
/// Send all known mesh node positions to TAK clients
/// Useful when a new TAK client connects
/// Only sends nodes with positions updated within the last hour
/// Excludes the node we're currently connected to
func broadcastAllNodesToTAK() async {
guard let takServerManager, takServerManager.isRunning else { return }
guard let context else { return }
// Get context - try the bridge's context first, then fall back to PersistenceController
let context = self.context ?? PersistenceController.shared.container.viewContext
let twoHoursAgo = Date().addingTimeInterval(-7200)
// Get the connected node number to exclude it
let connectedNodeNum = AccessoryManager.shared.activeDeviceNum ?? 0
Logger.tak.info("Starting broadcast of all mesh nodes to TAK (excluding node \(connectedNodeNum))")
// Fetch all nodes - be more lenient, include any node that's been heard from
// We'll check positions when creating CoT messages
let fetchRequest: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
// Only nodes with valid positions
fetchRequest.predicate = NSPredicate(format: "latestPosition != nil")
fetchRequest.predicate = NSPredicate(
format: "user != nil"
)
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)]
do {
let nodes = try context.fetch(fetchRequest)
Logger.tak.info("Found \(nodes.count) total nodes with user info, connected node: \(connectedNodeNum)")
var broadcastCount = 0
var skippedNoPosition = 0
var skippedConnected = 0
var skippedInvalidPosition = 0
var skippedTooOld = 0
for node in nodes {
// Skip the connected node - it's our own device
// Use the same pattern as other parts of the codebase: node.num == accessoryManager.activeDeviceNum
if node.num == connectedNodeNum {
Logger.tak.info("Skipping connected node \(node.num)")
skippedConnected += 1
continue
}
// Get position - use the extension's latestPosition computed property
guard let position = node.latestPosition,
let latitude = position.latitude,
let longitude = position.longitude else {
skippedNoPosition += 1
continue
}
// Skip nodes with invalid positions (0,0)
guard latitude != 0 || longitude != 0 else {
skippedInvalidPosition += 1
continue
}
// Check if node has been heard from recently (within last 2 hours)
if let lastHeard = node.lastHeard, lastHeard < twoHoursAgo {
skippedTooOld += 1
continue
}
if let cotMessage = createCoTFromNode(node) {
await takServerManager.broadcast(cotMessage)
broadcastCount += 1
// Small delay to avoid flooding the client
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
}
}
Logger.tak.info("Broadcast \(nodes.count) mesh node positions to TAK clients")
Logger.tak.info("Broadcast complete: \(broadcastCount) nodes sent, \(skippedConnected) skipped (connected), \(skippedNoPosition) skipped (no position), \(skippedInvalidPosition) skipped (invalid position), \(skippedTooOld) skipped (too old)")
} catch {
Logger.tak.error("Failed to fetch nodes for TAK broadcast: \(error.localizedDescription)")
}
@ -500,10 +564,12 @@ final class TAKMeshtasticBridge {
// MARK: - Helper Methods
private func lookupNodeInfo(nodeNum: UInt32) -> NodeInfoEntity? {
guard let context else { return nil }
// Use PersistenceController's viewContext directly to ensure we can find nodes
let context = PersistenceController.shared.container.viewContext
// Use the same format as MeshPackets - num is Int64
let fetchRequest: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "num == %d", Int64(nodeNum))
fetchRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum))
fetchRequest.fetchLimit = 1
do {
@ -513,4 +579,380 @@ final class TAKMeshtasticBridge {
return nil
}
}
}
// MARK: - Mesh to CoT Broadcasting
/// Broadcast a Meshtastic position packet to connected TAK clients
/// Called when a new position is received from the mesh
func broadcastMeshPositionToTAK(position: Position, from nodeNum: UInt32) async {
// Lazy initialization of bridge if needed
if TAKServerManager.shared.bridge == nil {
Logger.tak.info("Initializing bridge lazily for position broadcast")
let bridge = TAKMeshtasticBridge(
accessoryManager: AccessoryManager.shared,
takServerManager: TAKServerManager.shared
)
bridge.context = AccessoryManager.shared.context
TAKServerManager.shared.bridge = bridge
}
let server = TAKServerManager.shared
guard server.meshToCotEnabled, server.isRunning else { return }
guard server.connectedClients.isEmpty == false else { return }
guard let node = lookupNodeInfo(nodeNum: nodeNum) else { return }
if let cotMessage = createCoTFromNode(node) {
await server.broadcast(cotMessage)
Logger.tak.info("Broadcast mesh position to TAK: \(node.user?.longName ?? "Unknown")")
}
}
/// Broadcast a Meshtastic text message to connected TAK clients
/// Called when a text message is received from the mesh
func broadcastMeshTextMessageToTAK(text: String, from nodeNum: UInt32, channel: UInt32) async {
// Lazy initialization of bridge if needed
if TAKServerManager.shared.bridge == nil {
Logger.tak.info("Initializing bridge lazily for text message broadcast")
let bridge = TAKMeshtasticBridge(
accessoryManager: AccessoryManager.shared,
takServerManager: TAKServerManager.shared
)
bridge.context = AccessoryManager.shared.context
TAKServerManager.shared.bridge = bridge
}
let server = TAKServerManager.shared
guard server.meshToCotEnabled, server.isRunning else { return }
guard server.connectedClients.isEmpty == false else { return }
guard let node = lookupNodeInfo(nodeNum: nodeNum),
let user = node.user else { return }
let senderName = user.longName ?? user.shortName ?? "Unknown"
let uid = "MSG-\(nodeNum)-\(Int(Date().timeIntervalSince1970))"
let cotMessage = CoTMessage(
uid: "GeoChat.MESHTASTIC-\(String(format: "%08X", nodeNum)).Channel\(channel).\(uid)",
type: "b-t-f",
time: Date(),
start: Date(),
stale: Date().addingTimeInterval(86400),
how: "h-g-i-g-o",
latitude: 0,
longitude: 0,
hae: 9999999.0,
ce: 9999999.0,
le: 9999999.0,
chat: CoTChat(
message: text,
senderCallsign: senderName,
chatroom: "All Chat Rooms"
),
remarks: text
)
await server.broadcast(cotMessage)
Logger.tak.info("Broadcast mesh text message to TAK: \(senderName)")
}
/// Broadcast a Meshtastic waypoint to connected TAK clients
/// Called when a waypoints is received from the mesh
func broadcastMeshWaypointToTAK(waypoint: Waypoint, from nodeNum: UInt32) async {
// Lazy initialization of bridge if needed - set on singleton
if TAKServerManager.shared.bridge == nil {
Logger.tak.info("Initializing bridge lazily on singleton")
let bridge = TAKMeshtasticBridge(
accessoryManager: AccessoryManager.shared,
takServerManager: TAKServerManager.shared
)
bridge.context = AccessoryManager.shared.context
TAKServerManager.shared.bridge = bridge
}
let server = TAKServerManager.shared
Logger.tak.info("Waypoint broadcast check: meshToCot=\(server.meshToCotEnabled), isRunning=\(server.isRunning), clients=\(server.connectedClients.count)")
guard server.meshToCotEnabled, server.isRunning else {
Logger.tak.warning("Waypoint broadcast skipped: server not ready")
return
}
guard let context, server.connectedClients.isEmpty == false else {
Logger.tak.warning("Waypoint broadcast skipped: no clients")
return
}
let node = lookupNodeInfo(nodeNum: nodeNum)
Logger.tak.info("Node lookup for \(nodeNum) (0x\(String(format: "%08X", nodeNum))): \(node != nil ? "found" : "NOT FOUND")")
if let node = node {
Logger.tak.info(" Node user: \(node.user?.longName ?? "nil"), shortName: \(node.user?.shortName ?? "nil")")
}
let senderName = node?.user?.longName ?? node?.user?.shortName ?? "Unknown Node"
let uid = "WAYPOINT-\(waypoint.id)"
let latitude = Double(waypoint.latitudeI) / 1e7
let longitude = Double(waypoint.longitudeI) / 1e7
let name = waypoint.name.isEmpty ? "Dropped Pin" : waypoint.name
let description = waypoint.description_p.isEmpty ? "Meshtastic Waypoint" : waypoint.description_p
Logger.tak.info("Broadcasting waypoint: \(name) at \(latitude), \(longitude), sender: \(senderName)")
// Map Meshtastic emoji icon to appropriate TAK icon
let (cotType, iconPath, colorArgb) = getTakIconForWaypoint(waypoint: waypoint)
let userIconXML = "<usericon iconsetpath='\(iconPath)'/>"
let colorXML = "<color argb='\(colorArgb)'/>"
Logger.tak.info("Waypoint icon: emoji=0x\(String(format: "%08X", waypoint.icon)) -> \(iconPath)")
// Handle expiry - if expire is 0, never expire. Otherwise use the expire time
let stale: Date
if waypoint.expire == 0 {
// Never expire - set to 1 year from now
stale = Date().addingTimeInterval(365 * 24 * 60 * 60)
Logger.tak.info("Waypoint set to never expire")
} else {
// expire is Unix timestamp when waypoint expires
let expireDate = Date(timeIntervalSince1970: TimeInterval(waypoint.expire))
if expireDate > Date() {
stale = expireDate
} else {
// Already expired, don't broadcast
Logger.tak.warning("Waypoint already expired, skipping broadcast")
return
}
}
// Include the usericon and color in the detail
let rawDetail = "<precisionlocation geopointsrc='GPS' altsrc='GPS'></precisionlocation>\(userIconXML)\(colorXML)"
let cotMessage = CoTMessage(
uid: uid,
type: cotType,
time: Date(),
start: Date(),
stale: stale,
how: "m-g",
latitude: latitude,
longitude: longitude,
hae: 0,
ce: 10.0,
le: 10.0,
contact: CoTContact(callsign: "\(name) - \(senderName)", endpoint: "0.0.0.0:4242:tcp"),
remarks: "\(description)\nFrom: \(senderName) [\(String(format: "%08X", nodeNum))]",
rawDetailXML: rawDetail
)
await server.broadcast(cotMessage)
Logger.tak.info("Broadcast mesh waypoint to TAK: \(name) from \(senderName)")
}
/// Map Meshtastic waypoint emoji to TAK icon using Google markers
/// Returns (cotType, iconPath, colorArgb)
/// Google markers are the only reliable icon type across all TAK clients (ATAK, iTAK, WinTAK)
private func getTakIconForWaypoint(waypoint: Waypoint) -> (String, String, String) {
let icon = waypoint.icon
// Google marker base UUID
let googleBase = "f7f71666-8b28-4b57-9fbb-e38e61d33b79/Google"
switch icon {
// 📍 📌 Pushpin - RED pushpin
case 0x1F4CD, 0x1F4CC, 1: // 📍 📌
return ("a-u-G", "\(googleBase)/red-pushpin.png", "-16776961")
// === EMERGENCY ===
// 🔥 Fire - firedept
case 0x1F525, 10: // 🔥
return ("a-u-G", "\(googleBase)/firedept.png", "-16776961")
// 🚨 Siren - caution
case 0x1F6A8, 6: // 🚨
return ("a-u-G", "\(googleBase)/caution.png", "-256")
// 🏥 Hospital - hospital
case 0x1F3E5, 0x2695, 9: // 🏥
return ("a-u-G", "\(googleBase)/hospital.png", "-16776961")
// 🚑 Ambulance - ambulance
case 0x1F691: // 🚑
return ("a-u-G", "\(googleBase)/ambulance.png", "-16776961")
// Warning - caution
case 0x26A0: //
return ("a-u-G", "\(googleBase)/caution.png", "-256")
// === TRANSPORT ===
// 🚗 Car - car
case 0x1F697, 0x1F695, 2: // 🚗 🚕
return ("a-u-G", "\(googleBase)/car.png", "-256")
// 🚁 Helicopter - heliport
case 0x1F681, 11: // 🚁
return ("a-u-G", "\(googleBase)/heliport.png", "-16776961")
// Boat - marina
case 0x1F6B5, 12: //
return ("a-u-G", "\(googleBase)/marina.png", "-16776961")
// 🚢 Ship - dock
case 0x1F6A2: // 🚢
return ("a-u-G", "\(googleBase)/dock.png", "-16776961")
// 🚀 Rocket - rocket
case 0x1F680: // 🚀
return ("a-u-G", "\(googleBase)/rocket.png", "-16776961")
// 🛸 UFO - unidentified (use pushpin)
case 0x1F6B5, 13: // 🛸
return ("a-u-G", "\(googleBase)/purple-p pushpin.png", "-65281")
// === PEOPLE ===
// 🚶 Person - man
case 0x1F464, 0x1F465, 3: // 👤 👥
return ("a-u-G", "\(googleBase)/man.png", "-16711936")
// 🏃 Runner - runner
case 0x1F3C3: // 🏃
return ("a-u-G", "\(googleBase)/runner.png", "-16711936")
// === STRUCTURES ===
// 🏠 House - home
case 0x1F3E0, 0x1F3E1, 4: // 🏠 🏡
return ("a-u-G", "\(googleBase)/home.png", "-16711936")
// Tent - campground
case 0x26FA, 0x1F3D5, 5: // 🏕
return ("a-u-G", "\(googleBase)/campground.png", "-256")
// 🏰 Castle - castle
case 0x1F3F0: // 🏰
return ("a-u-G", "\(googleBase)/castle.png", "-16776961")
// === NATURE ===
// 🌲 Tree - park
case 0x1F332: // 🌲
return ("a-u-G", "\(googleBase)/park.png", "-16711936")
// 🌳 Tree - park
case 0x1F333: // 🌳
return ("a-u-G", "\(googleBase)/park.png", "-16711936")
// 🏔 Mountain - mountain
case 0x1F3D4: // 🏔
return ("a-u-G", "\(googleBase)/mountain.png", "-1")
// Mountain - mountain
case 0x26F0: //
return ("a-u-G", "\(googleBase)/mountain.png", "-1")
// 💧 Water - water
case 0x1F4A7: // 💧
return ("a-u-G", "\(googleBase)/water.png", "-16776961")
// 🌊 Wave - water
case 0x1F30A: // 🌊
return ("a-u-G", "\(googleBase)/water.png", "-16776961")
// Cloud - cloudy
case 0x2601, 0x2602: //
return ("a-u-G", "\(googleBase)/cloudy.png", "-1")
// 🌙 Moon - moon
case 0x1F319: // 🌙
return ("a-u-G", "\(googleBase)/moon.png", "-16776961")
// Anchor - anchor
case 0x2693: //
return ("a-u-G", "\(googleBase)/anchor.png", "-16776961")
// Star - star
case 0x2B50, 0x1F31F: // 🌟
return ("a-u-G", "\(googleBase)/star.png", "-256")
// === FLAGS/MARKERS ===
// 🚩 Flag - flag
case 0x1F6A9: // 🚩
return ("a-u-G", "\(googleBase)/flag.png", "-16776961")
// 🏁 Checkered flag - finish
case 0x1F3C1, 7: // 🏁
return ("a-u-G", "\(googleBase)/finish.png", "-1")
// === OBJECTS ===
// 💎 Gem - diamond
case 0x1F48E: // 💎
return ("a-u-G", "\(googleBase)/diamond.png", "-16776961")
// 🔔 Bell - bell
case 0x1F514: // 🔔
return ("a-u-G", "\(googleBase)/bell.png", "-256")
// 🗺 Map - map
case 0x1F5FA: // 🗺
return ("a-u-G", "\(googleBase)/map.png", "-1")
// 🎁 Gift - gift
case 0x1F381: // 🎁
return ("a-u-G", "\(googleBase)/gift.png", "-16776961")
// 💀 Skull - skull
case 0x1F480: // 💀
return ("a-u-G", "\(googleBase)/skull.png", "-1")
// Snowflake - snow
case 0x2744: //
return ("a-u-G", "\(googleBase)/snowflake.png", "-1")
// Umbrella - umbrella
case 0x26F1: //
return ("a-u-G", "\(googleBase)/umbrella.png", "-16776961")
// 💡 Light - lightbulb
case 0x1F4A1: // 💡
return ("a-u-G", "\(googleBase)/lightbulb.png", "-256")
// 🔋 Battery - battery
case 0x1F50B: // 🔋
return ("a-u-G", "\(googleBase)/battery.png", "-16711936")
// 📻 Radio - radio
case 0x1F4FB: // 📻
return ("a-u-G", "\(googleBase)/radio.png", "-16711936")
// 📞 Phone - phone
case 0x1F4DE, 0x1F4F1: // 📞 📱
return ("a-u-G", "\(googleBase)/phone.png", "-16711936")
// 💥 Collision - warning
case 0x1F4A5: // 💥
return ("a-u-G", "\(googleBase)/warning.png", "-16776961")
// === SYMBOLS ===
// Heart - heart
case 0x2764, 0x1F493, 0x1F49A, 0x1F499: // 💓 💚 💙
return ("a-u-G", "\(googleBase)/heart.png", "-16776961")
// Check - check
case 0x2705, 0x1F7E2: // 🟢
return ("a-u-G", "\(googleBase)/check.png", "-16711936")
// X - x
case 0x274C, 0x1F6AB: // 🚫
return ("a-u-G", "\(googleBase)/x.png", "-16776961")
// === WEATHER ===
// 🌤 Sun behind cloud - partlycloudy
case 0x1F324: // 🌤
return ("a-u-G", "\(googleBase)/partlycloudy.png", "-256")
// 🌧 Rain - rainy
case 0x1F327: // 🌧
return ("a-u-G", "\(googleBase)/rainy.png", "-16776961")
// 🌨 Snow - snow
case 0x1F328: // 🌨
return ("a-u-G", "\(googleBase)/snow.png", "-1")
// 🌩 Lightning - lightning
case 0x1F329: // 🌩
return ("a-u-G", "\(googleBase)/lightning.png", "-256")
// 🌀 Cyclone - windy
case 0x1F300: // 🌀
return ("a-u-G", "\(googleBase)/windy.png", "-16776961")
// 🌈 Rainbow - rainbow
case 0x1F308: // 🌈
return ("a-u-G", "\(googleBase)/rainbow.png", "-16776961")
// 🌪 Tornado - tornado
case 0x1F32A: // 🌪
return ("a-u-G", "\(googleBase)/tornado.png", "-1")
// === GLOBE ===
// 🌍 Globe - globe
case 0x1F30D, 0x1F30E, 0x1F30F, 0x1F310: // 🌍 🌎 🌏 🌐
return ("a-u-G", "\(googleBase)/globe.png", "-16776961")
// === FOOD ===
// 🍔 Burger - restaurant
case 0x1F354: // 🍔
return ("a-u-G", "\(googleBase)/restaurant.png", "-256")
// 🍕 Pizza - restaurant
case 0x1F355: // 🍕
return ("a-u-G", "\(googleBase)/restaurant.png", "-256")
// Coffee - coffee
case 0x2615: //
return ("a-u-G", "\(googleBase)/coffee.png", "-256")
// 🍺 Beer - bar
case 0x1F37A: // 🍺
return ("a-u-G", "\(googleBase)/bar.png", "-256")
// 🍷 Wine - wine
case 0x1F377: // 🍷
return ("a-u-G", "\(googleBase)/wine.png", "-65281")
// === Default - RED pushpin ===
default:
return ("a-u-G", "\(googleBase)/red-p pushpin.png", "-16776961")
}
}
}

View file

@ -10,6 +10,44 @@ import Network
import OSLog
import Combine
import SwiftUI
import CoreData
import MeshtasticProtobufs
enum TAKServerError: LocalizedError {
case noServerCertificate
case noClientCACertificate
case tlsConfigurationFailed
case listenerFailed(String)
case clientNotFound
case notRunning
case primaryChannelInvalid(String)
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"
case .primaryChannelInvalid(let reason):
return reason
}
}
}
struct PrimaryChannelIssue: Identifiable {
let id = UUID()
let title: String
let description: String
let canAutoFix: Bool
}
/// Manages the TAK Server lifecycle, TLS connections, and client management
/// Runs on MainActor for thread safety, following the AccessoryManager pattern
@ -23,6 +61,14 @@ final class TAKServerManager: ObservableObject {
@Published private(set) var isRunning = false
@Published private(set) var connectedClients: [TAKClientInfo] = []
@Published var lastError: String?
@Published private(set) var primaryChannelIssues: [PrimaryChannelIssue] = []
@Published private(set) var readOnlyMode = false
/// User toggle for read-only mode - locked to true if channel has issues
@AppStorage("takServerReadOnly") var userReadOnlyMode = false
/// Enable Mesh to CoT converter - bridges Meshtastic packets to TAK format
@AppStorage("takServerMeshToCot") var meshToCotEnabled = false
// MARK: - Configuration (persisted via AppStorage)
@ -87,6 +133,100 @@ final class TAKServerManager: ObservableObject {
}
}
// MARK: - Primary Channel Validation
/// Check the primary channel for validity
/// Returns true if the primary channel is valid for TAK server operation
func checkPrimaryChannelValidity() {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = MyInfoEntity.fetchRequest()
var issues: [PrimaryChannelIssue] = []
var isValid = true
do {
let myInfos = try context.fetch(fetchRequest)
guard let myInfo = myInfos.first,
let channels = myInfo.channels?.array as? [ChannelEntity],
let primaryChannel = channels.first(where: { $0.index == 0 || $0.role == 1 }) else {
issues.append(PrimaryChannelIssue(
title: "No Primary Channel",
description: "No primary channel found on device",
canAutoFix: false
))
isValid = false
updateChannelStatus(issues: issues, isValid: isValid)
return
}
let channelName = primaryChannel.name ?? ""
let channelPsk = primaryChannel.psk ?? Data()
let pskBase64 = channelPsk.base64EncodedString()
if channelName.isEmpty {
issues.append(PrimaryChannelIssue(
title: "Unnamed Primary Channel",
description: "TAK Server requires a private channel. Please set up a dedicated TAK channel (name 'TAK' recommended). Tap the button below to auto-configure.",
canAutoFix: true
))
isValid = false
}
let pskLength = pskBase64.count
if pskLength == 0 {
issues.append(PrimaryChannelIssue(
title: "Public Channel Not Supported",
description: "TAK Server requires a private channel with encryption. Public channels expose your location and messages. Tap the button below to set up a private TAK channel.",
canAutoFix: true
))
isValid = false
} else if pskBase64 == "AQ==" {
issues.append(PrimaryChannelIssue(
title: "Default Encryption Key",
description: "TAK Server requires a unique private channel key. The default key is not secure. Tap the button below to set up a proper private TAK channel.",
canAutoFix: true
))
isValid = false
} else if pskLength == 4 {
issues.append(PrimaryChannelIssue(
title: "Weak Encryption Key",
description: "TAK Server requires at least 128-bit encryption for your privacy. Tap the button below to set up a secure private TAK channel.",
canAutoFix: true
))
isValid = false
}
updateChannelStatus(issues: issues, isValid: isValid)
} catch {
Logger.tak.error("Failed to fetch MyInfo for channel validation: \(error.localizedDescription)")
issues.append(PrimaryChannelIssue(
title: "Error Checking Channel",
description: "Could not verify primary channel settings",
canAutoFix: false
))
updateChannelStatus(issues: issues, isValid: false)
}
}
private func updateChannelStatus(issues: [PrimaryChannelIssue], isValid: Bool) {
primaryChannelIssues = issues
readOnlyMode = !isValid
if !isValid {
userReadOnlyMode = true
}
if !isValid && isRunning {
Logger.tak.warning("TAK Server running in read-only mode due to primary channel issues")
}
}
/// Check if TAK client messages should be forwarded to mesh
var shouldForwardTAKToMesh: Bool {
return !userReadOnlyMode
}
// MARK: - Server Lifecycle
/// Start the TAK server (TLS or TCP based on configuration)
@ -96,6 +236,8 @@ final class TAKServerManager: ObservableObject {
return
}
checkPrimaryChannelValidity()
let mode = useTLS ? "TLS" : "TCP"
Logger.tak.info("Starting TAK Server on port \(self.port) (\(mode) mode)")
@ -331,6 +473,11 @@ final class TAKServerManager: ObservableObject {
case .connected(let clientInfo):
connectedClients.append(clientInfo)
Logger.tak.info("TAK client connected: \(clientInfo.displayName)")
// Send all mesh node positions to the newly connected client
if meshToCotEnabled {
await bridge?.broadcastAllNodesToTAK()
}
case .clientInfoUpdated(let clientInfo):
// Update the client info in our list
@ -398,6 +545,110 @@ final class TAKServerManager: ObservableObject {
throw TAKServerError.clientNotFound
}
// MARK: - Auto-fix Primary Channel
/// Automatically fix the primary channel to TAK-compatible settings
/// Sets: Name="TAK", 256-bit AES key, LoRa channel=0
/// Returns true if successful
func autoFixPrimaryChannel() async -> Bool {
let accessoryManager = AccessoryManager.shared
guard accessoryManager.isConnected else {
Logger.tak.error("Cannot fix channel: Not connected to device")
return false
}
Logger.tak.info("Auto-fixing primary channel for TAK compatibility")
let context = PersistenceController.shared.container.viewContext
guard let connectedNodeNum = accessoryManager.activeDeviceNum else {
Logger.tak.error("Cannot fix channel: No active device number")
return false
}
guard let connectedNode = getNodeInfo(id: connectedNodeNum, context: context),
let user = connectedNode.user else {
Logger.tak.error("Cannot fix channel: No connected node or user found")
return false
}
let fetchRequest = MyInfoEntity.fetchRequest()
do {
let myInfos = try context.fetch(fetchRequest)
guard let myInfo = myInfos.first,
let channels = myInfo.channels?.array as? [ChannelEntity],
let primaryChannel = channels.first(where: { $0.index == 0 || $0.role == 1 }) else {
Logger.tak.error("Cannot fix channel: No primary channel found")
return false
}
let newKey = generateChannelKey(size: 32)
let newPsk = Data(base64Encoded: newKey) ?? Data()
primaryChannel.name = "TAK"
primaryChannel.psk = newPsk
primaryChannel.role = 1
primaryChannel.index = 0
if let mutableChannels = myInfo.channels?.mutableCopy() as? NSMutableOrderedSet {
if mutableChannels.contains(primaryChannel) {
mutableChannels.remove(primaryChannel)
mutableChannels.insert(primaryChannel, at: 0)
myInfo.channels = mutableChannels.copy() as? NSOrderedSet
}
}
try context.save()
var channel = Channel()
channel.index = 0
channel.role = .primary
channel.settings.name = "TAK"
channel.settings.psk = newPsk
channel.settings.uplinkEnabled = primaryChannel.uplinkEnabled
channel.settings.downlinkEnabled = primaryChannel.downlinkEnabled
channel.settings.moduleSettings.positionPrecision = UInt32(primaryChannel.positionPrecision)
try await accessoryManager.saveChannel(channel: channel, fromUser: user, toUser: user)
Logger.tak.info("Successfully fixed primary channel: name=TAK, key=256-bit")
// Also set LoRa modem preset to shortFast for optimal TAK performance
var loraConfig = Config.LoRaConfig()
loraConfig.modemPreset = .shortFast
loraConfig.usePreset = true
loraConfig.txEnabled = true
loraConfig.hopLimit = 3
// Get current LoRa config to preserve other settings
if let currentLoRa = connectedNode.loRaConfig {
loraConfig.region = Config.LoRaConfig.RegionCode(rawValue: Int(currentLoRa.regionCode)) ?? .unset
loraConfig.channelNum = UInt32(currentLoRa.channelNum)
loraConfig.txPower = Int32(currentLoRa.txPower)
loraConfig.bandwidth = UInt32(currentLoRa.bandwidth)
loraConfig.codingRate = UInt32(currentLoRa.codingRate)
loraConfig.spreadFactor = UInt32(currentLoRa.spreadFactor)
}
do {
try await accessoryManager.saveLoRaConfig(config: loraConfig, fromUser: user, toUser: user)
Logger.tak.info("Successfully set LoRa modem preset to shortFast")
} catch {
Logger.tak.warning("Failed to set LoRa modem preset: \(error.localizedDescription)")
}
checkPrimaryChannelValidity()
return true
} catch {
Logger.tak.error("Failed to fix primary channel: \(error.localizedDescription)")
return false
}
}
// MARK: - Status
/// Get server status description
@ -412,31 +663,3 @@ final class TAKServerManager: ObservableObject {
}
}
}
// 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

@ -16,6 +16,9 @@ enum CertificateImportType {
struct TAKServerConfig: View {
@StateObject private var takServer = TAKServerManager.shared
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.managedObjectContext) var context
@Environment(\.dismiss) private var dismiss
@State private var showingFileImporter = false
@State private var importType: CertificateImportType = .p12
@State private var p12Password = ""
@ -25,17 +28,40 @@ struct TAKServerConfig: View {
@State private var showingImportError = false
@State private var showingFileExporter = false
@State private var dataPackageURL: URL?
@State private var showingFixWarning = false
@State private var isFixingChannel = false
@State private var showShareChannels = false
@State private var showShareChannelsAlert = false
@State private var connectedNode: NodeInfoEntity?
@State private var isWarningExpanded = true
private let certManager = TAKCertificateManager.shared
var body: some View {
Form {
if !takServer.primaryChannelIssues.isEmpty {
primaryChannelWarningSection
}
serverStatusSection
serverConfigSection
certificatesSection
dataPackageSection
}
.navigationTitle("TAK Server")
.onAppear {
takServer.checkPrimaryChannelValidity()
if let nodeNum = accessoryManager.activeDeviceNum {
connectedNode = getNodeInfo(id: nodeNum, context: context)
}
}
.alert("Fix Primary Channel?", isPresented: $showingFixWarning) {
Button("Cancel", role: .cancel) {}
Button("Fix Channel", role: .destructive) {
fixPrimaryChannel()
}
} message: {
Text("This will change your primary channel to:\n• Name: TAK\n• Encryption: New 256-bit AES key\n• LoRa preset: Short Fast (recommended for TAK)\n\nThis is required for TAK Server to work properly. Any existing channel sharing links will become invalid.")
}
.fileImporter(
isPresented: $showingFileImporter,
allowedContentTypes: importType == .p12 ? [UTType(filenameExtension: "p12") ?? .pkcs12, .pkcs12] : [UTType(filenameExtension: "pem") ?? .plainText],
@ -65,6 +91,14 @@ struct TAKServerConfig: View {
} message: {
Text(importError ?? "Unknown error")
}
.alert("Channel Fixed!", isPresented: $showShareChannelsAlert) {
Button("Share with TAK Buddies") {
showShareChannels = true
}
Button("Later", role: .cancel) {}
} message: {
Text("Your channel has been configured for TAK. To share the QR code: go to Settings > Share QR Code")
}
.fileExporter(
isPresented: $showingFileExporter,
document: dataPackageURL.map { ZipDocument(url: $0) },
@ -84,6 +118,65 @@ struct TAKServerConfig: View {
}
dataPackageURL = nil
}
.navigationDestination(isPresented: $showShareChannels) {
if let node = connectedNode {
ShareChannels(node: node)
}
}
}
// MARK: - Primary Channel Warning Section
private var primaryChannelWarningSection: some View {
Section {
DisclosureGroup(isExpanded: $isWarningExpanded) {
VStack(alignment: .leading, spacing: 12) {
if takServer.readOnlyMode {
Text("Your primary channel is using the default settings (no name or default encryption key). TAK Server is running in read-only mode.")
.font(.subheadline)
.foregroundColor(.secondary)
}
Text("You can fix this yourself by changing your primary channel:")
.font(.subheadline)
VStack(alignment: .leading, spacing: 4) {
Label("Set a channel name", systemImage: "1.circle.fill")
Label("Use a 256-bit encryption key", systemImage: "2.circle.fill")
}
.font(.caption)
.foregroundColor(.secondary)
Divider()
Button {
showingFixWarning = true
} label: {
Label("Auto-Fix Channel", systemImage: "wand.and.stars")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isFixingChannel)
Text("Or fix it yourself in Channels settings, then return here.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 8)
} label: {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("TAK Cannot Be Used on Public Channel")
.font(.headline)
}
}
} header: {
Text("Warning")
}
}
// MARK: - Server Status Section
@ -112,6 +205,19 @@ struct TAKServerConfig: View {
.foregroundColor(.orange)
}
}
if let node = connectedNode,
let role = node.user?.role,
let deviceRole = DeviceRoles(rawValue: Int(role)),
deviceRole != .tak && deviceRole != .takTracker {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("Device role is \"\(deviceRole.name)\". Consider setting to TAK or TAK Tracker for optimal operation.")
.font(.caption)
.foregroundColor(.orange)
}
}
} header: {
Text("Server Status")
}
@ -140,6 +246,27 @@ struct TAKServerConfig: View {
.foregroundColor(.secondary)
}
Toggle(isOn: $takServer.userReadOnlyMode) {
VStack(alignment: .leading, spacing: 2) {
Text("Read-Only Mode")
Text("Meshtastic -> TAK works, TAK -> Meshtastic blocked")
.font(.caption)
.foregroundColor(.secondary)
}
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.disabled(takServer.readOnlyMode)
Toggle(isOn: $takServer.meshToCotEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("Mesh to CoT Converter")
Text("Bridge Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format")
.font(.caption)
.foregroundColor(.secondary)
}
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if takServer.isRunning {
Button {
Task {
@ -351,6 +478,23 @@ struct TAKServerConfig: View {
}
}
private func fixPrimaryChannel() {
isFixingChannel = true
Task {
let success = await takServer.autoFixPrimaryChannel()
await MainActor.run {
isFixingChannel = false
if success {
takServer.userReadOnlyMode = false
showShareChannelsAlert = true
} else {
importError = "Failed to fix primary channel. Make sure you are connected to a device."
showingImportError = true
}
}
}
}
// MARK: - Data Package Generation
private func generateAndShareDataPackage() {