Merge origin/2.7.10 into firmware-updates - resolve conflicts (CarPlay, onboarding, map cache, DM crash fix)

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-18 18:02:41 +00:00 committed by GitHub
commit 7718132c6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 6415 additions and 427 deletions

View file

@ -229,6 +229,22 @@
"comment": "A button that initiates",
"isCommentAutoGenerated": true
},
"\"Disconnect Meshtastic\" — disconnect from the connected BLE node.": {
"comment": "A description of how to use the \"Disconnect Node\" Siri shortcut.",
"isCommentAutoGenerated": true
},
"\"Send a Meshtastic direct message\" — send a private message to a node.": {
"comment": "A description of how to send a direct message to a node using Siri.",
"isCommentAutoGenerated": true
},
"\"Send a Meshtastic group message\" — send a message to a mesh channel.": {
"comment": "A description of how to send a group message using Siri.",
"isCommentAutoGenerated": true
},
"\"Shut down my Meshtastic node\" or \"Restart my Meshtastic node\".": {
"comment": "A description of how to use Siri to restart or shut down a node.",
"isCommentAutoGenerated": true
},
"%@": {
"localizations": {
"da": {
@ -4031,7 +4047,12 @@
}
}
},
"Additional Help": {
"comment": "A button that opens a link to the Meshtastic FAQ.",
"isCommentAutoGenerated": true
},
"Additional help": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -7105,6 +7126,14 @@
}
}
},
"Background Activity": {
"comment": "A title for a screen that describes the benefits of enabling background location tracking.",
"isCommentAutoGenerated": true
},
"Background Mesh Tracking": {
"comment": "A description of the background mesh tracking feature.",
"isCommentAutoGenerated": true
},
"Backup": {
"localizations": {
"ja": {
@ -7814,6 +7843,10 @@
}
}
},
"Battery Usage": {
"comment": "A description of the battery usage of enabling background activity.",
"isCommentAutoGenerated": true
},
"Baud": {
"localizations": {
"da": {
@ -9450,6 +9483,10 @@
}
}
},
"CarPlay Messaging": {
"comment": "A description of how to send a message to a mesh channel using CarPlay.",
"isCommentAutoGenerated": true
},
"Carousel Interval": {
"localizations": {
"da": {
@ -12060,7 +12097,6 @@
}
},
"Community Support": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -12407,6 +12443,10 @@
}
}
},
"Configure Siri & Shortcuts": {
"comment": "A button that will open the app's settings to configure Siri and Shortcuts.",
"isCommentAutoGenerated": true
},
"Configure notification permissions": {
"localizations": {
"de": {
@ -12641,6 +12681,14 @@
}
}
},
"Connect to nodes on your local Wi-Fi network.": {
"comment": "A description of how to connect to nodes on your local Wi-Fi network.",
"isCommentAutoGenerated": true
},
"Connect to your Meshtastic node via Bluetooth Low Energy for the best messaging experience.": {
"comment": "A description of the Bluetooth connectivity feature.",
"isCommentAutoGenerated": true
},
"Connected": {
"localizations": {
"da": {
@ -12815,6 +12863,10 @@
}
}
},
"Connected firmware: **%@**": {
"comment": "A label displaying the firmware version of a device. The argument is the firmware version.",
"isCommentAutoGenerated": true
},
"Connecting . .": {
"localizations": {
"da": {
@ -13001,6 +13053,64 @@
}
}
},
"Connection Attempt %lld of 10": {
"localizations": {
"da": {
"stringUnit": {
"state": "translated",
"value": "Tilslutningsforsøg %lld af 10"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Verbindungsversuch %lld von 10"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Intento de conexión %lld de 10"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Tentativo di connessione %lld di 10"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "接続試行 %lld / 10"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Количество попыток подключения, %lld из 10"
}
},
"sr": {
"stringUnit": {
"state": "translated",
"value": "Покушај повезивања %lld од 10"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "连接尝试 %lld共 10 次"
}
},
"zh-Hant-TW": {
"stringUnit": {
"state": "translated",
"value": "嘗試連接 %lld / 10"
}
}
}
},
"Connection Name": {
"localizations": {
"es": {
@ -13209,6 +13319,14 @@
}
}
},
"Continue": {
"comment": "A button that will continue to the next step in the onboarding process.",
"isCommentAutoGenerated": true
},
"Continuous Location Updates": {
"comment": "A description of the continuous location updates feature.",
"isCommentAutoGenerated": true
},
"Control Type": {
"localizations": {
"da": {
@ -13926,7 +14044,6 @@
},
"Current Firmware Version": {},
"Current Firmware Version: %@": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -14043,6 +14160,16 @@
}
}
},
"Current Firmware Version: %@, Minimum Required Version: %@": {
"localizations": {
"en": {
"stringUnit": {
"state": "new",
"value": "Current Firmware Version: %1$@, Minimum Required Version: %2$@"
}
}
}
},
"Current: %lld": {
"localizations": {
"da": {
@ -14096,7 +14223,6 @@
}
},
"Currently the recommended way to update ESP32 devices is using the web flasher on a desktop computer from a chrome based browser. It does not work on mobile devices or over BLE.": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -18194,7 +18320,6 @@
},
"Download TAK Server Data Package": {},
"Drag & Drop Firmware Update": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -18247,7 +18372,6 @@
}
},
"Drag & Drop Firmware Update Documentation": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -18300,7 +18424,6 @@
}
},
"Drag & Drop is the recommended way to update firmware for NRF devices. If your iPhone or iPad is USB-C it will work with your regular USB-C charging cable, for lightning devices you need the Apple Lightning to USB camera adaptor.": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -18458,7 +18581,6 @@
}
},
"ESP 32 OTA update is a work in progress, click the button below to send your device a reboot into ota admin message.": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -18515,7 +18637,6 @@
"isCommentAutoGenerated": true
},
"ESP32 Device Firmware Update": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -18958,6 +19079,10 @@
}
}
},
"Enable Background Activity": {
"comment": "A toggle to enable or disable background activity.",
"isCommentAutoGenerated": true
},
"Enable Location Sharing": {
"localizations": {
"de": {
@ -19558,6 +19683,10 @@
}
}
},
"Enabling background activity may increase battery usage. You can toggle this at any time in the app settings.": {
"comment": "A description of the battery usage of enabling background activity.",
"isCommentAutoGenerated": true
},
"Encoder Press Event": {
"localizations": {
"da": {
@ -19786,7 +19915,6 @@
}
},
"Enter DFU Mode": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -22107,6 +22235,14 @@
"comment": "A section header that lists available firmware releases.",
"isCommentAutoGenerated": true
},
"Firmware Update Docs": {
"comment": "A link to the firmware update documentation.",
"isCommentAutoGenerated": true
},
"Firmware Update Required": {
"comment": "A title for a screen that displays a firmware update is required message.",
"isCommentAutoGenerated": true
},
"Firmware Updates": {
"localizations": {
"da": {
@ -22252,6 +22388,7 @@
"isCommentAutoGenerated": true
},
"Firmware update docs": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -23724,7 +23861,6 @@
}
},
"Full Support": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -24320,7 +24456,6 @@
}
},
"Get NRF DFU from the App Store": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -24505,7 +24640,6 @@
}
},
"Get the latest stable firmware": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -26192,8 +26326,11 @@
}
}
},
"How to Update": {
"comment": "A label displayed above the list of available firmware update options.",
"isCommentAutoGenerated": true
},
"How to update Firmware": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -26893,7 +27030,6 @@
}
},
"If it is hard to access your device's reset button enter DFU mode here.": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -28101,6 +28237,10 @@
}
}
},
"Keep the mesh map updated and send your position to the mesh even while using other apps.": {
"comment": "A description of the benefits of continuous location updates.",
"isCommentAutoGenerated": true
},
"Key": {
"localizations": {
"da": {
@ -31760,7 +31900,12 @@
}
}
},
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app.": {
"comment": "A description of how user data is used by Meshtastic.",
"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.": {
"extractionState": "stale",
"localizations": {
"es": {
"stringUnit": {
@ -31984,6 +32129,10 @@
}
}
},
"Message Notifications": {
"comment": "A description of the message notifications feature.",
"isCommentAutoGenerated": true
},
"Message Size": {
"comment": "VoiceOver label for message size",
"localizations": {
@ -32559,6 +32708,10 @@
}
}
},
"Minimum required: **%@**": {
"comment": "A label displaying the minimum required firmware version.",
"isCommentAutoGenerated": true
},
"Minimum time between detection broadcasts": {
"extractionState": "stale",
"localizations": {
@ -34109,7 +34262,6 @@
}
},
"Newer firmware is available": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -36415,7 +36567,6 @@
}
},
"OTA Updates are not supported on this NRF Device.": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -36468,7 +36619,6 @@
}
},
"OTA Updates are not supported on your platform.": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -37193,7 +37343,7 @@
}
},
"Open Web Flasher": {
"comment": "A link label that says \"Open Web Flasher\" and has an arrow icon pointing to it.",
"comment": "A button that opens the Web Flasher app.",
"isCommentAutoGenerated": true
},
"Optimized for 2 color displays": {
@ -42276,6 +42426,10 @@
}
}
},
"Read and reply to Meshtastic channel and direct messages directly from your car's display using CarPlay.": {
"comment": "A description of how to use CarPlay with Meshtastic.",
"isCommentAutoGenerated": true
},
"Read-Only Mode": {
"comment": "A toggle that allows the user to enable or disable read-only mode for the TAK server.",
"isCommentAutoGenerated": true
@ -42587,6 +42741,14 @@
}
}
},
"Receive notifications for incoming messages and critical alerts even when the app is in the background.": {
"comment": "A description of the notification feature.",
"isCommentAutoGenerated": true
},
"Receive position updates from other nodes and maintain an accurate picture of the mesh while in the background.": {
"comment": "A description of the benefits of enabling background mesh tracking.",
"isCommentAutoGenerated": true
},
"Received Ack": {
"extractionState": "stale",
"localizations": {
@ -42856,6 +43018,10 @@
}
}
},
"Recommended secure version: **%@**": {
"comment": "A label displaying the recommended secure version of the connected device.",
"isCommentAutoGenerated": true
},
"Recording route": {
"localizations": {
"da": {
@ -46863,6 +47029,10 @@
}
}
},
"Security Advisory": {
"comment": "A title for a security advisory displayed in a card.",
"isCommentAutoGenerated": true
},
"Security Config": {
"localizations": {
"da": {
@ -46979,6 +47149,10 @@
}
}
},
"Security Update Recommended": {
"comment": "A title for a view that warns the user that their device is running an outdated firmware version.",
"isCommentAutoGenerated": true
},
"Select": {
"extractionState": "stale",
"localizations": {
@ -47797,7 +47971,6 @@
}
},
"Send Reboot OTA": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -48205,6 +48378,10 @@
}
}
},
"Send and receive Meshtastic messages hands-free using Siri and CarPlay.": {
"comment": "A description of how to use Siri and CarPlay with Meshtastic.",
"isCommentAutoGenerated": true
},
"Sender Interval": {
"extractionState": "stale",
"localizations": {
@ -50960,6 +51137,10 @@
}
}
},
"Shut Down / Restart Node": {
"comment": "A Siri shortcut to restart or shut down a node.",
"isCommentAutoGenerated": true
},
"Shut Down Node?": {
"localizations": {
"da": {
@ -51316,6 +51497,14 @@
}
}
},
"Siri & CarPlay": {
"comment": "A description of how to use Siri and CarPlay with Meshtastic.",
"isCommentAutoGenerated": true
},
"Siri, Shortcuts & CarPlay": {
"comment": "A label displayed above the Siri, Shortcuts & CarPlay onboarding view.",
"isCommentAutoGenerated": true
},
"Six Hours": {
"extractionState": "stale",
"localizations": {
@ -53867,7 +54056,12 @@
"comment": "A footnote explaining that the Web Flasher does not support updating on this device or over USB or BLE.",
"isCommentAutoGenerated": true
},
"The Meshtastic Apple app requires firmware version %@ or later. Older firmware versions are no longer supported and may have compatibility issues or missing features.": {
"comment": "A body text that explains that the app requires a certain version of the firmware.",
"isCommentAutoGenerated": true
},
"The Meshtastic Apple apps support firmware version %@ and above.": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -60229,6 +60423,7 @@
}
},
"Version %@ includes substantial network optimizations and extensive changes to devices and client apps. Only nodes version %@ and above are supported.": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -61214,7 +61409,6 @@
}
},
"Web Flasher": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -61359,6 +61553,7 @@
}
},
"Welcome to": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
@ -61386,6 +61581,10 @@
}
}
},
"Welcome to Meshtastic": {
"comment": "The title of the onboarding screen.",
"isCommentAutoGenerated": true
},
"What does the lock mean?": {
"localizations": {
"da": {
@ -62354,7 +62553,6 @@
}
},
"You can also update your Meshtastic device over bluetooth using the Nordic DFU app.": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -62493,7 +62691,6 @@
}
},
"Your Firmware is up to date": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {
@ -62601,6 +62798,10 @@
"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 connected device is running firmware older than **%@**, which contains known security vulnerabilities. Updating your firmware is strongly recommended to protect your device and mesh network.": {
"comment": "A body text that describes the security advisory.",
"isCommentAutoGenerated": true
},
"Your current location will be set as the fixed position and broadcast over the mesh on the position interval.": {
"localizations": {
"da": {
@ -63284,6 +63485,7 @@
}
},
"🦕 End of life Version 🦖 ☄️": {
"extractionState": "stale",
"localizations": {
"da": {
"stringUnit": {

View file

@ -1,5 +1,5 @@
{
"originHash" : "943f1047f8d99b0600f1c91f14d7bf4808ab1caf172ae4d7f3ebea325c27437f",
"originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4",
"pins" : [
{
"identity" : "cocoamqtt",
@ -19,15 +19,6 @@
"version" : "3.4.0"
}
},
{
"identity" : "ios-dfu-library",
"kind" : "remoteSourceControl",
"location" : "https://github.com/NordicSemiconductor/IOS-DFU-Library",
"state" : {
"revision" : "4773d7eed944684dfdd177a4a91af8a89ebbaac8",
"version" : "4.16.0"
}
},
{
"identity" : "mqttcocoaasyncsocket",
"kind" : "remoteSourceControl",
@ -72,24 +63,6 @@
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
},
{
"identity" : "swiftdraw",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swhitty/SwiftDraw",
"state" : {
"revision" : "17d55c17540f3eb10685058e803d7ae73d9bf9d3",
"version" : "0.25.3"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation",
"state" : {
"revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0",
"version" : "0.9.19"
}
}
],
"version" : 3

View file

@ -1,5 +1,5 @@
{
"originHash" : "2ef98d89234a9436246b308320a5af24fe6a09930ba8db91f4e0a48cc09dcd60",
"originHash" : "25240dd07109fa832be10093f5d97529f872f18e8d9df6468e5e4212bc0b487e",
"pins" : [
{
"identity" : "cocoamqtt",
@ -19,15 +19,6 @@
"version" : "3.3.0"
}
},
{
"identity" : "ios-dfu-library",
"kind" : "remoteSourceControl",
"location" : "https://github.com/NordicSemiconductor/IOS-DFU-Library",
"state" : {
"revision" : "4773d7eed944684dfdd177a4a91af8a89ebbaac8",
"version" : "4.16.0"
}
},
{
"identity" : "mqttcocoaasyncsocket",
"kind" : "remoteSourceControl",
@ -72,24 +63,6 @@
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
},
{
"identity" : "swiftdraw",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swhitty/SwiftDraw",
"state" : {
"revision" : "17d55c17540f3eb10685058e803d7ae73d9bf9d3",
"version" : "0.25.3"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation",
"state" : {
"revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0",
"version" : "0.9.19"
}
}
],
"version" : 3

View file

@ -376,7 +376,10 @@ extension AccessoryManager {
do {
try context.save()
Logger.data.info("💾 Saved a new sent message from \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)")
// Donate outgoing message to SiriKit for CarPlay
if !isEmoji {
CarPlayIntentDonation.donateOutgoingMessage(content: message, toUserNum: toUserNum, channel: channel)
}
} catch {
context.rollback()
let nsError = error as NSError

View file

@ -116,7 +116,8 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
// Constants
let NONCE_ONLY_CONFIG = 69420
let NONCE_ONLY_DB = 69421
let minimumVersion = "2.3.15"
let minimumVersion = "2.5.18"
let securityVersion = "2.6.0"
// Global Objects
// Chicken/Egg problem. Set in the App object immediately after

View file

@ -0,0 +1,142 @@
//
// CarPlayIntentDonation.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 4/16/26.
//
// Donates SiriKit interactions when messages are received so that
// conversations appear in CarPlay's messaging interface and Siri
// can read them aloud.
//
import CoreData
import Intents
import OSLog
enum CarPlayIntentDonation {
/// Donates an incoming message interaction so it appears in CarPlay Messages.
/// Call this after saving a new `MessageEntity` to Core Data.
static func donateReceivedMessage(_ message: MessageEntity) {
guard let fromUser = message.fromUser else { return }
guard !message.isEmoji, !message.admin else { return }
let sender = IntentMessageConverters.inPerson(from: fromUser)
let me = mePerson()
let intent: INSendMessageIntent
if message.toUser != nil {
// Direct message
intent = INSendMessageIntent(
recipients: [me],
outgoingMessageType: .outgoingMessageText,
content: message.messagePayload,
speakableGroupName: nil,
conversationIdentifier: "dm-\(fromUser.num)",
serviceName: "Meshtastic",
sender: sender,
attachments: nil
)
} else {
// Channel message
let channelName = channelDisplayName(for: message.channel)
let groupName = INSpeakableString(spokenPhrase: channelName)
intent = INSendMessageIntent(
recipients: [me],
outgoingMessageType: .outgoingMessageText,
content: message.messagePayload,
speakableGroupName: groupName,
conversationIdentifier: "channel-\(message.channel)",
serviceName: "Meshtastic",
sender: sender,
attachments: nil
)
intent.setImage(
INImage(named: "antenna.radiowaves.left.and.right"),
forParameterNamed: \.speakableGroupName
)
}
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate { error in
if let error {
Logger.services.error("🚗 [CarPlay] Failed to donate interaction: \(error.localizedDescription, privacy: .public)")
} else {
Logger.services.debug("🚗 [CarPlay] Donated incoming message from \(fromUser.longName ?? "unknown", privacy: .public)")
}
}
}
/// Donates an outgoing message interaction after the user sends a message.
static func donateOutgoingMessage(content: String, toUserNum: Int64, channel: Int32) {
let me = mePerson()
let intent: INSendMessageIntent
if toUserNum != 0 {
let handleValue = "\(toUserNum)@meshtastic.local"
let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress)
let recipient = INPerson(
personHandle: recipientHandle,
nameComponents: nil,
displayName: "Node \(toUserNum.toHex())",
image: nil,
contactIdentifier: String(toUserNum),
customIdentifier: String(toUserNum)
)
intent = INSendMessageIntent(
recipients: [recipient],
outgoingMessageType: .outgoingMessageText,
content: content,
speakableGroupName: nil,
conversationIdentifier: "dm-\(toUserNum)",
serviceName: "Meshtastic",
sender: me,
attachments: nil
)
} else {
let channelName = channelDisplayName(for: channel)
let groupName = INSpeakableString(spokenPhrase: channelName)
intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content,
speakableGroupName: groupName,
conversationIdentifier: "channel-\(channel)",
serviceName: "Meshtastic",
sender: me,
attachments: nil
)
}
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .outgoing
interaction.donate { error in
if let error {
Logger.services.error("🚗 [CarPlay] Failed to donate outgoing interaction: \(error.localizedDescription, privacy: .public)")
}
}
}
// MARK: - Helpers
static func mePerson() -> INPerson {
let meHandle = INPersonHandle(value: "me", type: .unknown)
return INPerson(
personHandle: meHandle,
nameComponents: nil,
displayName: "Me",
image: nil,
contactIdentifier: "me",
customIdentifier: "me",
isMe: true
)
}
static func channelDisplayName(for index: Int32) -> String {
if index == 0 {
return "Primary Channel"
}
return "Channel \(index)"
}
}

View file

@ -0,0 +1,510 @@
//
// CarPlaySceneDelegate.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 4/16/26.
//
// CarPlay Communication app scene delegate.
// Uses a tab bar with Channels and Direct Messages tabs,
// matching the main app's Messages navigation structure.
//
import CarPlay
import Combine
import CoreData
import Intents
import OSLog
#if canImport(ActivityKit)
import ActivityKit
#endif
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPInterfaceControllerDelegate {
var interfaceController: CPInterfaceController?
private var cancellables = Set<AnyCancellable>()
// Retained template references so we can call updateSections rather than replacing the whole tree.
private var channelsTemplate: CPListTemplate?
private var directMessagesTemplate: CPListTemplate?
// Tracks which conversation identifiers have already had a contact intent donated
// during this CarPlay session so we don't re-donate on every refresh.
private var donatedConversationIds = Set<String>()
private lazy var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
/// Returns a human-readable "last heard" string.
/// `now` is passed in so all rows in a single render share one `Date()` allocation.
private func lastHeardText(_ date: Date?, now: Date) -> String {
guard let date else { return "Never heard" }
let interval = now.timeIntervalSince(date)
if interval < 60 { return "Just now" }
if interval < 3600 { return "\(Int(interval / 60))m ago" }
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
return "\(Int(interval / 86400))d ago"
}
// MARK: - CPTemplateApplicationSceneDelegate
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
Logger.services.info("🚗 [CarPlay] Connected")
self.interfaceController = interfaceController
interfaceController.delegate = self
buildAndSetRootTemplate(animated: false)
// Observe connection state changes and refresh sections (not the whole template tree).
// Debounce absorbs reconnect spikes that would otherwise fire multiple expensive refreshes.
AccessoryManager.shared.$isConnected
.removeDuplicates()
.dropFirst() // Skip initial value we already built sections above
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] isConnected in
self?.refreshSections()
if isConnected {
self?.startLiveActivityIfNeeded()
}
}
.store(in: &cancellables)
// Start Live Activity immediately if already connected
if AccessoryManager.shared.isConnected {
startLiveActivityIfNeeded()
}
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController
) {
Logger.services.info("🚗 [CarPlay] Disconnected")
endLiveActivity()
cancellables.removeAll()
donatedConversationIds.removeAll()
channelsTemplate = nil
directMessagesTemplate = nil
self.interfaceController = nil
}
// MARK: - CPInterfaceControllerDelegate
func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) {}
func templateDidAppear(_ aTemplate: CPTemplate, animated: Bool) {}
func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) {}
func templateDidDisappear(_ aTemplate: CPTemplate, animated: Bool) {}
// MARK: - Root Template
/// Called once at connection time. Builds and caches the two `CPListTemplate` tabs.
private func buildAndSetRootTemplate(animated: Bool) {
let connected = AccessoryManager.shared.isConnected
let chTemplate = CPListTemplate(title: "Channels", sections: buildChannelSections(connected: connected))
chTemplate.tabImage = UIImage(systemName: "bubble.left.and.bubble.right")
channelsTemplate = chTemplate
let dmTemplate = CPListTemplate(title: "Direct Messages", sections: buildDirectMessageSections(connected: connected))
dmTemplate.tabImage = UIImage(systemName: "bubble.left.and.text.bubble.right")
directMessagesTemplate = dmTemplate
let tabBar = CPTabBarTemplate(templates: [chTemplate, dmTemplate])
interfaceController?.setRootTemplate(tabBar, animated: animated, completion: nil)
}
/// Called on subsequent connection-state changes updates sections in-place
/// instead of tearing down and rebuilding the entire template hierarchy.
private func refreshSections() {
let connected = AccessoryManager.shared.isConnected
channelsTemplate?.updateSections(buildChannelSections(connected: connected))
directMessagesTemplate?.updateSections(buildDirectMessageSections(connected: connected))
}
// MARK: - Section Builders
private func buildChannelSections(connected: Bool) -> [CPListSection] {
guard connected else {
let statusItem = CPListItem(
text: "Not Connected",
detailText: "Open Meshtastic to connect",
image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash")
)
statusItem.isEnabled = false
return [CPListSection(items: [statusItem])]
}
let channelItems = fetchChannelItems()
if channelItems.isEmpty {
let emptyItem = CPListItem(text: "No Channels", detailText: nil)
emptyItem.isEnabled = false
return [CPListSection(items: [emptyItem])]
}
return [CPListSection(items: channelItems)]
}
private func buildDirectMessageSections(connected: Bool) -> [CPListSection] {
guard connected else {
let statusItem = CPListItem(
text: "Not Connected",
detailText: "Open Meshtastic to connect",
image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash")
)
statusItem.isEnabled = false
return [CPListSection(items: [statusItem])]
}
var sections = [CPListSection]()
let favoriteItems = fetchFavoriteContactItems()
if !favoriteItems.isEmpty {
sections.append(CPListSection(items: favoriteItems, header: "Favorites", sectionIndexTitle: nil))
}
let dmItems = fetchDirectMessageItems()
if !dmItems.isEmpty {
sections.append(CPListSection(items: dmItems, header: "Recent", sectionIndexTitle: nil))
}
if favoriteItems.isEmpty && dmItems.isEmpty {
let emptyItem = CPListItem(text: "No Messages", detailText: "No direct message history")
emptyItem.isEnabled = false
sections.append(CPListSection(items: [emptyItem]))
}
return sections
}
// MARK: - Data Fetching
private func fetchFavoriteContactItems() -> [CPMessageListItem] {
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
request.predicate = NSPredicate(format: "favorite == YES AND num != %lld", AccessoryManager.shared.activeDeviceNum ?? 0)
request.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)]
request.relationshipKeyPathsForPrefetching = ["user"]
do {
let nodes = try context.fetch(request)
let nodeNums = nodes.compactMap { $0.user != nil ? $0.num : nil as Int64? }
let unreadCounts = fetchUnreadCountsForDMs(nodeNums: nodeNums)
let now = Date()
return nodes.compactMap { node -> CPMessageListItem? in
guard let user = node.user else { return nil }
let name = user.longName ?? user.shortName ?? "Unknown"
let unreadCount = unreadCounts[node.num] ?? 0
let hasUnread = unreadCount > 0
let convId = "dm-\(node.num)"
let leadingConfig = CPMessageListItemLeadingConfiguration(
leadingItem: .star,
leadingImage: UIImage(systemName: "person.circle.fill"),
unread: hasUnread
)
let item = CPMessageListItem(
fullName: name,
phoneOrEmailAddress: "\(node.num)@meshtastic.local",
leadingConfiguration: leadingConfig,
trailingConfiguration: nil,
detailText: hasUnread ? "\(unreadCount) unread" : nil,
trailingText: lastHeardText(node.lastHeard, now: now)
)
item.conversationIdentifier = convId
item.userInfo = node.num
donateMessageIntentIfNeeded(conversationId: convId, toNodeNum: node.num, name: name)
return item
}
} catch {
Logger.services.error("🚗 [CarPlay] Failed to fetch favorites: \(error.localizedDescription, privacy: .public)")
return []
}
}
private func fetchChannelItems() -> [CPMessageListItem] {
guard let connectedNum = AccessoryManager.shared.activeDeviceNum,
let connectedNode = getNodeInfo(id: connectedNum, context: context),
let myInfo = connectedNode.myInfo,
let channels = myInfo.channels?.array as? [ChannelEntity] else {
return []
}
let activeChannels = channels.filter { $0.role > 0 }
let channelIndices = activeChannels.map { $0.index }
let unreadCounts = fetchUnreadCountsForChannels(channelIndices: channelIndices)
return activeChannels.compactMap { channel -> CPMessageListItem? in
let name = (channel.name?.isEmpty ?? true)
? (channel.index == 0 ? "Primary Channel" : "Channel \(channel.index)")
: channel.name!
let channelIndex = Int(channel.index)
let unreadCount = unreadCounts[channel.index] ?? 0
let hasUnread = unreadCount > 0
let convId = "channel-\(channelIndex)"
let leadingConfig = CPMessageListItemLeadingConfiguration(
leadingItem: .none,
leadingImage: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right"),
unread: hasUnread
)
let item = CPMessageListItem(
conversationIdentifier: convId,
text: name,
leadingConfiguration: leadingConfig,
trailingConfiguration: nil,
detailText: hasUnread ? "\(unreadCount) unread" : (channel.index == 0 ? "Primary" : "Ch \(channel.index)"),
trailingText: nil
)
item.phoneOrEmailAddress = "\(convId)@meshtastic.local"
item.userInfo = channelIndex
donateChannelIntentIfNeeded(conversationId: convId, channelIndex: channelIndex, channelName: name)
return item
}
}
private func fetchDirectMessageItems() -> [CPMessageListItem] {
let request: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
let connectedNum = AccessoryManager.shared.activeDeviceNum ?? 0
// Match the app's UserList: exclude self, ignored, favorites (shown above).
// Use `lastMessage != nil` instead of the expensive `@count` aggregate predicate
// to find nodes that have exchanged at least one message.
let notSelf = NSPredicate(format: "userNode.num != %lld", connectedNum)
let notIgnored = NSPredicate(format: "userNode.ignored == NO")
let notFavorite = NSPredicate(format: "userNode.favorite == NO")
let hasMessagesOrMessagable = NSCompoundPredicate(type: .or, subpredicates: [
NSPredicate(format: "unmessagable == NO"),
NSPredicate(format: "lastMessage != nil")
])
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [notSelf, notIgnored, notFavorite, hasMessagesOrMessagable])
request.sortDescriptors = [
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
NSSortDescriptor(key: "lastMessage", ascending: false),
NSSortDescriptor(key: "longName", ascending: true)
]
request.fetchLimit = 24 // CarPlay limits list items
request.relationshipKeyPathsForPrefetching = ["userNode"]
do {
let users = try context.fetch(request)
let nodeNums = users.compactMap { $0.userNode?.num }
let unreadCounts = fetchUnreadCountsForDMs(nodeNums: nodeNums)
let now = Date()
return users.compactMap { user -> CPMessageListItem? in
guard let node = user.userNode else { return nil }
let name = user.longName ?? user.shortName ?? "Unknown"
let nodeNum = node.num
let unreadCount = unreadCounts[nodeNum] ?? 0
let hasUnread = unreadCount > 0
let convId = "dm-\(nodeNum)"
let leadingConfig = CPMessageListItemLeadingConfiguration(
leadingItem: .none,
leadingImage: UIImage(systemName: "person.circle.fill"),
unread: hasUnread
)
let item = CPMessageListItem(
fullName: name,
phoneOrEmailAddress: "\(nodeNum)@meshtastic.local",
leadingConfiguration: leadingConfig,
trailingConfiguration: nil,
detailText: hasUnread ? "\(unreadCount) unread" : nil,
trailingText: lastHeardText(node.lastHeard, now: now)
)
item.conversationIdentifier = convId
item.userInfo = nodeNum
donateMessageIntentIfNeeded(conversationId: convId, toNodeNum: nodeNum, name: name)
return item
}
} catch {
Logger.services.error("🚗 [CarPlay] Failed to fetch DM users: \(error.localizedDescription, privacy: .public)")
return []
}
}
// MARK: - Unread Count Batch Fetching
/// Fetches unread message counts for multiple DM node numbers in a single query,
/// then groups the results in-memory. This avoids the N+1 count-per-row pattern
/// while staying compatible with Core Data's relationship keypath restrictions.
private func fetchUnreadCountsForDMs(nodeNums: [Int64]) -> [Int64: Int] {
guard !nodeNums.isEmpty else { return [:] }
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "read == NO"),
NSPredicate(format: "fromUser.num IN %@", nodeNums)
])
fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser"]
let results = (try? context.fetch(fetchRequest)) ?? []
var counts = [Int64: Int]()
for message in results {
if let num = message.fromUser?.num {
counts[num, default: 0] += 1
}
}
return counts
}
/// Fetches unread message counts for multiple channel indices in a single query,
/// then groups the results in-memory.
private func fetchUnreadCountsForChannels(channelIndices: [Int32]) -> [Int32: Int] {
guard !channelIndices.isEmpty else { return [:] }
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "read == NO"),
NSPredicate(format: "toUser == nil"),
NSPredicate(format: "channel IN %@", channelIndices)
])
let results = (try? context.fetch(fetchRequest)) ?? []
var counts = [Int32: Int]()
for message in results {
counts[message.channel, default: 0] += 1
}
return counts
}
// MARK: - Intent Donation
/// Donates a contact intent for a DM conversation the first time it is seen this session.
/// Subsequent renders are no-ops, avoiding repeated IPC calls to the intents daemon.
private func donateMessageIntentIfNeeded(conversationId: String, toNodeNum: Int64, name: String) {
guard donatedConversationIds.insert(conversationId).inserted else { return }
let handleValue = "\(toNodeNum)@meshtastic.local"
let person = INPerson(
personHandle: INPersonHandle(value: handleValue, type: .emailAddress),
nameComponents: nil,
displayName: name,
image: nil,
contactIdentifier: "\(toNodeNum)",
customIdentifier: "\(toNodeNum)"
)
let intent = INSendMessageIntent(
recipients: [person],
outgoingMessageType: .outgoingMessageText,
content: nil,
speakableGroupName: nil,
conversationIdentifier: conversationId,
serviceName: "Meshtastic",
sender: nil,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .outgoing
interaction.donate { error in
if let error {
Logger.services.error("🚗 [CarPlay] DM intent donation error: \(error.localizedDescription, privacy: .public)")
}
}
}
/// Donates a contact intent for a channel conversation the first time it is seen this session.
private func donateChannelIntentIfNeeded(conversationId: String, channelIndex: Int, channelName: String) {
guard donatedConversationIds.insert(conversationId).inserted else { return }
let channelHandle = "channel-\(channelIndex)@meshtastic.local"
let recipient = INPerson(
personHandle: INPersonHandle(value: channelHandle, type: .emailAddress),
nameComponents: nil,
displayName: channelName,
image: nil,
contactIdentifier: channelHandle,
customIdentifier: channelHandle
)
let groupName = INSpeakableString(spokenPhrase: channelName)
let intent = INSendMessageIntent(
recipients: [recipient],
outgoingMessageType: .outgoingMessageText,
content: nil,
speakableGroupName: groupName,
conversationIdentifier: conversationId,
serviceName: "Meshtastic",
sender: nil,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .outgoing
interaction.donate { error in
if let error {
Logger.services.error("🚗 [CarPlay] Channel intent donation error: \(error.localizedDescription, privacy: .public)")
}
}
}
// MARK: - Live Activity
#if canImport(ActivityKit) && !targetEnvironment(macCatalyst)
private func startLiveActivityIfNeeded() {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
Logger.services.info("🚗 [CarPlay] Live Activities not enabled")
return
}
// Don't start another if one is already running
guard Activity<MeshActivityAttributes>.activities.isEmpty else {
Logger.services.info("🚗 [CarPlay] Live Activity already active")
return
}
guard let connectedNum = AccessoryManager.shared.activeDeviceNum else { return }
let connectedNode = getNodeInfo(id: connectedNum, context: context)
let nodeName = connectedNode?.user?.longName ?? "Meshtastic"
let nodeShortName = connectedNode?.user?.shortName ?? "?"
// Fetch latest local stats telemetry
let localStats = connectedNode?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
let mostRecent = localStats?.lastObject as? TelemetryEntity
let timerSeconds = 900 // 15 minute local stats interval
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
let initialState = MeshActivityAttributes.ContentState(
uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0),
channelUtilization: mostRecent?.channelUtilization ?? 0.0,
airtime: mostRecent?.airUtilTx ?? 0.0,
sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0),
receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0),
badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0),
dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0),
packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0),
packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0),
nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0),
totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0),
timerRange: Date.now...future
)
let attributes = MeshActivityAttributes(nodeNum: Int(connectedNum), name: nodeName, shortName: nodeShortName)
let content = ActivityContent(state: initialState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!)
do {
let activity = try Activity<MeshActivityAttributes>.request(attributes: attributes, content: content, pushType: nil)
Logger.services.info("🚗 [CarPlay] Started Live Activity: \(activity.id)")
} catch {
Logger.services.error("🚗 [CarPlay] Failed to start Live Activity: \(error.localizedDescription, privacy: .public)")
}
}
private func endLiveActivity() {
Task {
for activity in Activity<MeshActivityAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
Logger.services.info("🚗 [CarPlay] Ended Live Activity: \(activity.id)")
}
}
}
#else
private func startLiveActivityIfNeeded() {}
private func endLiveActivity() {}
#endif
}

View file

@ -218,9 +218,10 @@ extension UserDefaults {
}
@UserDefault(.lastDeviceAPIUpdate, defaultValue: .distantPast)
static var lastDeviceAPIUpdate: Date
@UserDefault(.lastFirmwareAPIUpdate, defaultValue: .distantPast)
static var lastFirmwareAPIUpdate: Date
}
enum TestIntEnum: Int, Decodable {
case one = 1

View file

@ -47,6 +47,26 @@ extension View {
}
}
/// Standard capsule-shaped prominent button styling.
/// On iOS 26+ the button also receives a glass background effect.
@ViewBuilder
func capsuleButtonStyle() -> some View {
if #available(iOS 26.0, macOS 26.0, *) {
self
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
.buttonStyle(.borderedProminent)
.glassEffect(in: .capsule)
} else {
self
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
.buttonStyle(.borderedProminent)
}
}
@ViewBuilder
func glassButtonStyle() -> some View {
if #available(iOS 26.0, macOS 26.0, *) {

View file

@ -174,10 +174,20 @@ struct GeoJSONStyledFeature: Identifiable {
let id = UUID()
let feature: GeoJSONFeature
let overlayId: String
/// MKOverlay pre-computed once at init avoids repeated JSONSerialization + MKGeoJSONDecoder
/// calls on every map render pass.
let precomputedOverlay: MKOverlay?
/// Create MKOverlay from this styled feature
func createOverlay() -> MKOverlay? {
// Convert feature to standard GeoJSON format for MKGeoJSONDecoder
init(feature: GeoJSONFeature, overlayId: String) {
self.feature = feature
self.overlayId = overlayId
// Call the static helper after all stored properties are assigned so `self` is available
// for the instance but we don't actually need self here, so this is safe.
self.precomputedOverlay = GeoJSONStyledFeature.makeOverlay(for: feature)
}
/// Builds an MKOverlay from a GeoJSON feature. Static so it can be called from init.
private static func makeOverlay(for feature: GeoJSONFeature) -> MKOverlay? {
let featureDict: [String: Any] = [
"type": feature.type,
"geometry": [
@ -188,31 +198,23 @@ struct GeoJSONStyledFeature: Identifiable {
]
do {
// Serialize feature dictionary to JSON data
let geojsonData = try JSONSerialization.data(withJSONObject: featureDict)
do {
// Decode GeoJSON data into MKGeoJSONFeature objects
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature {
// Extract geometry and create overlay
if let geometry = mkFeature.geometry.first as? MKOverlay {
// Successfully created overlay
return geometry
} else {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - Geometry is not an MKOverlay.")
}
} else {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON - No valid MKGeoJSONFeature found.")
}
} catch {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON data: \(error.localizedDescription)")
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature,
let geometry = mkFeature.geometry.first as? MKOverlay {
return geometry
} else {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - no valid MKOverlay geometry.")
}
} catch {
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to serialize feature dictionary to JSON: \(error.localizedDescription)")
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to build overlay: \(error.localizedDescription)")
}
return nil
}
/// Returns the pre-computed overlay. Retained for API compatibility.
func createOverlay() -> MKOverlay? { precomputedOverlay }
/// Get stroke style for this feature
var strokeStyle: StrokeStyle {
let dashArray = feature.lineDashArray

View file

@ -8,6 +8,10 @@ class GeoJSONOverlayManager {
private init() {}
private var featureCollection: GeoJSONFeatureCollection?
// Cache the last styled-features result keyed by the enabled-configs set.
// GeoJSONStyledFeature instances have stable UUIDs once created, so SwiftUI's
// ForEach diffing correctly skips unchanged overlays between renders.
private var styledFeaturesCache: (configs: Set<UUID>, features: [GeoJSONStyledFeature])?
/// Load raw GeoJSON feature collection from user uploads
func loadFeatureCollection() -> GeoJSONFeatureCollection? {
@ -24,36 +28,35 @@ class GeoJSONOverlayManager {
return nil
}
/// Load styled features for specific enabled configs
/// Load styled features for specific enabled configs.
/// Results are cached per unique `enabledConfigs` set file I/O and JSON decoding
/// only happen when the set changes, not on every map render.
func loadStyledFeaturesForConfigs(_ enabledConfigs: Set<UUID>) -> [GeoJSONStyledFeature] {
// Get files that match the enabled configs
let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) }
if let cache = styledFeaturesCache, cache.configs == enabledConfigs {
return cache.features
}
let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) }
guard !enabledFiles.isEmpty else {
styledFeaturesCache = (configs: enabledConfigs, features: [])
return []
}
// Load feature collection from enabled files only
guard let collection = MapDataManager.shared.loadFeatureCollectionForFiles(enabledFiles) else {
styledFeaturesCache = (configs: enabledConfigs, features: [])
return []
}
var styledFeatures: [GeoJSONStyledFeature] = []
for feature in collection.features {
// Skip invisible features
guard feature.isVisible else {
continue
}
let layerId = feature.layerId ?? "default"
let styledFeature = GeoJSONStyledFeature(
guard feature.isVisible else { continue }
styledFeatures.append(GeoJSONStyledFeature(
feature: feature,
overlayId: layerId
)
styledFeatures.append(styledFeature)
overlayId: feature.layerId ?? "default"
))
}
styledFeaturesCache = (configs: enabledConfigs, features: styledFeatures)
return styledFeatures
}
@ -106,9 +109,10 @@ class GeoJSONOverlayManager {
return Array(layerIds).sorted()
}
/// Clear cached data (useful for testing or memory management)
/// Clear cached data (called when files are added, deleted, or toggled).
func clearCache() {
featureCollection = nil
styledFeaturesCache = nil
}
/// Check if user-uploaded data is available (regardless of active state)

View file

@ -142,7 +142,7 @@ actor MeshPackets {
myInfoEntity.rebootCount = Int32(myInfo.rebootCount)
myInfoEntity.deviceId = myInfo.deviceID
myInfoEntity.pioEnv = myInfo.pioEnv
do {
try context.save()
Logger.data.info("💾 Saved a new myInfo for node: \(myInfo.myNodeNum.toHex(), privacy: .public)")
@ -1088,6 +1088,9 @@ actor MeshPackets {
}
// Send notifications if the message saved properly to core data
if messageSaved {
// Donate to SiriKit so the message appears in CarPlay Messages
CarPlayIntentDonation.donateReceivedMessage(newMessage)
if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications {
return
}

View file

@ -87,6 +87,12 @@
<string>INSearchForMessagesIntent</string>
<string>INSetMessageAttributeIntent</string>
</array>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
<string>INSearchForMessagesIntent</string>
<string>INSetMessageAttributeIntent</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
@ -113,6 +119,8 @@
<string>We use the camera to share channels using a QR Code</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We use local networking to connect to network-based nodes.</string>
<key>NSSiriUsageDescription</key>
<string>Siri and Shortcuts let you control Meshtastic hands-free — send messages, disconnect, restart, or shut down your node with your voice.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We use your location to display it on the mesh map, show and filter by distance as well as to have GPS coordinates to send to the connected device. Route Recording uses location in the background.</string>
<key>NSLocationAlwaysUsageDescription</key>
@ -131,6 +139,27 @@
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
</dict>
</array>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>CPTemplateApplicationScene</string>
<key>UISceneConfigurationName</key>
<string>CarPlay</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
@ -324,7 +353,5 @@
</dict>
</dict>
</array>
<key>com.apple.developer.carplay-communication</key>
<true/>
</dict>
</plist>

View file

@ -10,10 +10,13 @@ import CoreData
import Intents
enum IntentMessageConverters {
static let meshtasticDomain = "@meshtastic.local"
/// Converts a `UserEntity` to an `INPerson` for use with SiriKit intents.
/// Uses the `@meshtastic.local` email format so the handle matches `CPContactMessageButton` identifiers.
static func inPerson(from user: UserEntity) -> INPerson {
let handle = INPersonHandle(value: String(user.num), type: .unknown)
let handleValue = "\(user.num)\(meshtasticDomain)"
let handle = INPersonHandle(value: handleValue, type: .emailAddress)
return INPerson(
personHandle: handle,
nameComponents: nil,
@ -29,8 +32,8 @@ enum IntentMessageConverters {
let sender: INPerson? = message.fromUser.map { inPerson(from: $0) }
let recipients: [INPerson]? = message.toUser.map { [inPerson(from: $0)] }
let dateSent = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
let groupName: INSpeakableString? = message.channel > 0
? INSpeakableString(spokenPhrase: "Channel \(message.channel)")
let groupName: INSpeakableString? = message.toUser == nil
? INSpeakableString(spokenPhrase: channelDisplayName(for: message.channel, named: nil))
: nil
return INMessage(
@ -56,16 +59,30 @@ enum IntentMessageConverters {
/// Searches for `UserEntity` objects whose name matches the given search term.
static func findUsers(matching searchTerm: String, in context: NSManagedObjectContext) -> [UserEntity] {
if let nodeNum = directMessageNodeNum(from: searchTerm) {
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
fetchRequest.fetchLimit = 1
fetchRequest.predicate = NSPredicate(format: "num == %lld", nodeNum)
return (try? context.fetch(fetchRequest)) ?? []
}
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@",
searchTerm, searchTerm
format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@ OR userId CONTAINS[cd] %@",
searchTerm, searchTerm, searchTerm
)
return (try? context.fetch(fetchRequest)) ?? []
}
/// Looks up a `ChannelEntity` by matching name.
static func findChannels(matching name: String, in context: NSManagedObjectContext) -> [ChannelEntity] {
if let explicitIndex = channelIndex(fromHandleOrName: name) {
let fetchRequest: NSFetchRequest<ChannelEntity> = ChannelEntity.fetchRequest()
fetchRequest.fetchLimit = 1
fetchRequest.predicate = NSPredicate(format: "index == %d", explicitIndex)
return (try? context.fetch(fetchRequest)) ?? []
}
let fetchRequest: NSFetchRequest<ChannelEntity> = ChannelEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "name != nil AND name != '' AND name CONTAINS[cd] %@", name
@ -75,7 +92,57 @@ enum IntentMessageConverters {
/// Resolves a channel index from a spoken group name, defaulting to the primary channel.
static func channelIndex(for name: String, in context: NSManagedObjectContext) -> Int {
if let explicitIndex = channelIndex(fromHandleOrName: name) {
return explicitIndex
}
let channels = findChannels(matching: name, in: context)
return channels.first.map { Int($0.index) } ?? 0
}
static func directMessageNodeNum(from value: String) -> Int64? {
if let nodeNum = Int64(value) {
return nodeNum
}
if value.hasSuffix(meshtasticDomain) {
let rawValue = String(value.dropLast(meshtasticDomain.count))
return Int64(rawValue)
}
return nil
}
static func channelIndex(fromHandleOrName value: String) -> Int? {
if value.caseInsensitiveCompare("Primary Channel") == .orderedSame {
return 0
}
if value.hasPrefix("Channel "), let index = Int(value.dropFirst("Channel ".count)) {
return index
}
let channelPrefix = "channel-"
if value.hasPrefix(channelPrefix) {
let remainder = String(value.dropFirst(channelPrefix.count))
let rawIndex = remainder.hasSuffix(meshtasticDomain)
? String(remainder.dropLast(meshtasticDomain.count))
: remainder
return Int(rawIndex)
}
return nil
}
static func channelDisplayName(for index: Int32, named name: String?) -> String {
if let name, !name.isEmpty {
return name
}
if index == 0 {
return "Primary Channel"
}
return "Channel \(index)"
}
}

View file

@ -19,9 +19,11 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
// MARK: - Handling
func handle(intent: INSearchForMessagesIntent) async -> INSearchForMessagesIntentResponse {
let context = PersistenceController.shared.container.viewContext
// Use a private background context so the fetch does not block the main thread.
let bgContext = PersistenceController.shared.container.newBackgroundContext()
bgContext.automaticallyMergesChangesFromParent = true
let messages: [INMessage] = await MainActor.run {
let messages: [INMessage] = await bgContext.perform {
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
var predicates: [NSPredicate] = []
@ -29,6 +31,22 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
predicates.append(NSPredicate(format: "admin == NO"))
predicates.append(NSPredicate(format: "isEmoji == NO"))
// Filter by conversation identifiers (e.g., "dm-123456" or "channel-0")
// This is the primary filter when Siri reads messages for a CarPlay contact.
if let conversationIds = intent.conversationIdentifiers, !conversationIds.isEmpty {
var conversationPredicates: [NSPredicate] = []
for convId in conversationIds {
if convId.hasPrefix("dm-"), let nodeNum = Int64(convId.dropFirst("dm-".count)) {
conversationPredicates.append(NSPredicate(format: "fromUser.num == %lld", nodeNum))
} else if convId.hasPrefix("channel-"), let channelIndex = Int32(convId.dropFirst("channel-".count)) {
conversationPredicates.append(NSPredicate(format: "channel == %d AND toUser == nil", channelIndex))
}
}
if !conversationPredicates.isEmpty {
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: conversationPredicates))
}
}
// Filter by identifiers (specific message IDs)
if let identifiers = intent.identifiers, !identifiers.isEmpty {
let messageIds = identifiers.compactMap { Int64($0) }
@ -37,9 +55,12 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
}
}
// Filter by sender
// Filter by sender parse @meshtastic.local email-format handles
if let senders = intent.senders, !senders.isEmpty {
let senderNums = senders.compactMap { $0.personHandle?.value }.compactMap { Int64($0) }
let senderNums = senders.compactMap { sender -> Int64? in
guard let handleValue = sender.personHandle?.value else { return nil }
return IntentMessageConverters.directMessageNodeNum(from: handleValue)
}
if !senderNums.isEmpty {
predicates.append(NSPredicate(format: "fromUser.num IN %@", senderNums))
}
@ -62,16 +83,19 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
}
}
// Filter by group/channel name
// Filter by group/channel name or handle
if let groupNames = intent.speakableGroupNames, !groupNames.isEmpty {
let channelIndices: [Int32] = groupNames.compactMap { groupName in
if let idx = IntentMessageConverters.channelIndex(fromHandleOrName: groupName.spokenPhrase) {
return Int32(idx)
}
let channels = IntentMessageConverters.findChannels(
matching: groupName.spokenPhrase, in: context
matching: groupName.spokenPhrase, in: bgContext
)
return channels.first.map { Int32($0.index) }
}
if !channelIndices.isEmpty {
predicates.append(NSPredicate(format: "channel IN %@", channelIndices))
predicates.append(NSPredicate(format: "channel IN %@ AND toUser == nil", channelIndices))
}
}
@ -91,7 +115,7 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser", "toUser"]
do {
let results = try context.fetch(fetchRequest)
let results = try bgContext.fetch(fetchRequest)
return results.map { IntentMessageConverters.inMessage(from: $0) }
} catch {
Logger.services.error("CarPlay/Siri: Failed to search messages: \(error.localizedDescription)")

View file

@ -30,7 +30,21 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
}
let context = PersistenceController.shared.container.viewContext
let searchTerm = recipients[0].displayName
let recipient = recipients[0]
let handleValue = recipient.personHandle?.value ?? ""
// If this is a channel handle, accept it directly
if IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) != nil {
return [.success(with: recipient)]
}
// If the handle resolves to a node number, accept it directly
if IntentMessageConverters.directMessageNodeNum(from: handleValue) != nil {
return [.success(with: recipient)]
}
// Otherwise search by display name
let searchTerm = recipient.displayName ?? handleValue
let matchingUsers = await MainActor.run {
IntentMessageConverters.findUsers(matching: searchTerm, in: context)
}
@ -71,11 +85,15 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
}
if matchingChannels.count == 1, let channel = matchingChannels.first {
let speakable = INSpeakableString(spokenPhrase: channel.name ?? "Channel \(channel.index)")
let speakable = INSpeakableString(
spokenPhrase: IntentMessageConverters.channelDisplayName(for: channel.index, named: channel.name)
)
return .success(with: speakable)
} else if matchingChannels.count > 1 {
let speakables = matchingChannels.map {
INSpeakableString(spokenPhrase: $0.name ?? "Channel \($0.index)")
INSpeakableString(
spokenPhrase: IntentMessageConverters.channelDisplayName(for: $0.index, named: $0.name)
)
}
return .disambiguation(with: speakables)
}
@ -120,16 +138,27 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
replyID: 0
)
} else if let recipient = intent.recipients?.first,
let handleValue = recipient.personHandle?.value,
let nodeNum = Int64(handleValue) {
let handleValue = recipient.personHandle?.value {
if let channelIndex = IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) {
try await AccessoryManager.shared.sendMessage(
message: content,
toUserNum: 0,
channel: Int32(channelIndex),
isEmoji: false,
replyID: 0
)
} else if let nodeNum = IntentMessageConverters.directMessageNodeNum(from: handleValue) {
// Direct message to a single node
try await AccessoryManager.shared.sendMessage(
message: content,
toUserNum: nodeNum,
channel: 0,
isEmoji: false,
replyID: 0
)
try await AccessoryManager.shared.sendMessage(
message: content,
toUserNum: nodeNum,
channel: 0,
isEmoji: false,
replyID: 0
)
} else {
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
}
} else {
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
}

View file

@ -39,9 +39,11 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt
}
let attribute = intent.attribute
let context = PersistenceController.shared.container.viewContext
// Use a private background context so Core Data work does not block the main thread.
let bgContext = PersistenceController.shared.container.newBackgroundContext()
bgContext.automaticallyMergesChangesFromParent = true
let success: Bool = await MainActor.run {
let success: Bool = await bgContext.perform {
let messageIds = identifiers.compactMap { Int64($0) }
guard !messageIds.isEmpty else { return false }
@ -49,7 +51,7 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt
fetchRequest.predicate = NSPredicate(format: "messageId IN %@", messageIds)
do {
let messages = try context.fetch(fetchRequest)
let messages = try bgContext.fetch(fetchRequest)
guard !messages.isEmpty else { return false }
for message in messages {
@ -66,8 +68,8 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt
}
}
if context.hasChanges {
try context.save()
if bgContext.hasChanges {
try bgContext.save()
}
Logger.services.info("CarPlay/Siri: Updated \(messages.count) message(s) to \(String(describing: attribute))")
return true

View file

@ -2,44 +2,44 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:meshtastic.org/e/*</string>
<string>applinks:meshtastic.org/v/*</string>
</array>
<key>com.apple.developer.carplay-communication</key>
<true/>
<key>com.apple.developer.networking.custom-protocol</key>
<true/>
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>TAG</string>
</array>
<key>com.apple.developer.usernotifications.critical-alerts</key>
<true/>
<key>com.apple.developer.weatherkit</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.serial</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<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>
<array>
<string>$(AppIdentifierPrefix)gvh.MeshtasticClient</string>
</array>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:meshtastic.org/e/*</string>
<string>applinks:meshtastic.org/v/*</string>
</array>
<key>com.apple.developer.carplay-communication</key>
<true/>
<key>com.apple.developer.networking.custom-protocol</key>
<true/>
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>TAG</string>
</array>
<key>com.apple.developer.usernotifications.critical-alerts</key>
<true/>
<key>com.apple.developer.weatherkit</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.serial</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<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>
<array>
<string>$(AppIdentifierPrefix)gvh.MeshtasticClient</string>
</array>
</dict>
</plist>

View file

@ -36,7 +36,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
} else {
Logger.services.info("📋 Device list API data update is not needed...")
}
// Initialize TAK Server if enabled
Task { @MainActor in
TAKServerManager.shared.initializeOnStartup()

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,10 @@ struct Connect: View {
@State var node: NodeInfoEntity?
@State var isUnsetRegion = false
@State var invalidFirmwareVersion = false
@State var showSecurityVersionNag = false
#if !targetEnvironment(macCatalyst)
@State var liveActivityStarted = false
#endif
@ObservedObject var manualConnections = ManualConnectionList.shared
var body: some View {
@ -347,6 +350,16 @@ struct Connect: View {
// .onChange(of: accessoryManager) {
// invalidFirmwareVersion = self.bleManager.invalidVersion
// }
.sheet(isPresented: $invalidFirmwareVersion) {
InvalidVersion(minimumVersion: accessoryManager.minimumVersion, version: accessoryManager.activeConnection?.device.firmwareVersion ?? "?.?.?")
.presentationDetents([.large])
.presentationDragIndicator(.automatic)
}
.sheet(isPresented: $showSecurityVersionNag) {
SecurityVersionNag(minimumSecureVersion: accessoryManager.securityVersion, version: accessoryManager.activeConnection?.device.firmwareVersion ?? "?.?.?")
.presentationDetents([.large])
.presentationDragIndicator(.automatic)
}
.onChange(of: self.accessoryManager.state) { _, state in
if let deviceNum = accessoryManager.activeDeviceNum, UserDefaults.preferredPeripheralId.count > 0 && state == .subscribed {
@ -364,6 +377,11 @@ struct Connect: View {
} catch {
Logger.data.error("💥 Error fetching node info: \(error.localizedDescription, privacy: .public)")
}
// Check firmware version on connection
let meetsMinimumVersion = accessoryManager.checkIsVersionSupported(forVersion: accessoryManager.minimumVersion)
let meetsSecurityVersion = accessoryManager.checkIsVersionSupported(forVersion: accessoryManager.securityVersion)
invalidFirmwareVersion = !meetsMinimumVersion
showSecurityVersionNag = meetsMinimumVersion && !meetsSecurityVersion
}
}
}
@ -376,7 +394,7 @@ struct Connect: View {
let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
let mostRecent = localStats?.lastObject as? TelemetryEntity
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown")
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown", shortName: node?.user?.shortName ?? "?")
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0),

View file

@ -10,55 +10,97 @@ struct InvalidVersion: View {
@Environment(\.dismiss) private var dismiss
@State var minimumVersion = ""
@State var version = ""
let minimumVersion: String
let version: String
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 60))
.foregroundColor(.orange)
.padding(.top, 40)
VStack {
Text("Update Your Firmware")
.font(.largeTitle)
.foregroundColor(.orange)
Divider()
VStack {
Text("The Meshtastic Apple apps support firmware version \(minimumVersion) and above.")
.font(.title2)
.padding(.bottom)
Link("Firmware update docs", destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/")!)
.font(.title)
.padding()
Link("Additional help", destination: URL(string: "https://meshtastic.org/docs/faq")!)
.font(.title)
.padding()
}
.padding()
Divider()
.padding(.top)
VStack {
Text("🦕 End of life Version 🦖 ☄️")
.font(.title3)
.foregroundColor(.orange)
.padding(.bottom)
Text("Version \(minimumVersion) includes substantial network optimizations and extensive changes to devices and client apps. Only nodes version \(minimumVersion) and above are supported.")
.font(.callout)
.padding([.leading, .trailing, .bottom])
#if targetEnvironment(macCatalyst)
Button {
dismiss()
} label: {
Label("Close", systemImage: "xmark")
Text("Firmware Update Required")
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 8) {
if !version.isEmpty {
Label {
Text("Connected firmware: **\(version)**")
} icon: {
Image(systemName: "wifi.slash")
.foregroundColor(.red)
}
.font(.body)
}
Label {
Text("Minimum required: **\(minimumVersion)**")
} icon: {
Image(systemName: "checkmark.shield.fill")
.foregroundColor(.green)
}
.font(.body)
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
#endif
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}.padding()
Text("The Meshtastic Apple app requires firmware version \(minimumVersion) or later. Older firmware versions are no longer supported and may have compatibility issues or missing features.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal)
VStack(alignment: .leading, spacing: 12) {
Text("How to Update")
.font(.headline)
Link(destination: URL(string: "https://flasher.meshtastic.org")!) {
Label("Open Web Flasher", systemImage: "bolt.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
.buttonBorderShape(.capsule)
Link(destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/")!) {
Label("Firmware Update Docs", systemImage: "book.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.regular)
.buttonBorderShape(.capsule)
Link(destination: URL(string: "https://meshtastic.org/docs/faq")!) {
Label("Additional Help", systemImage: "questionmark.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.regular)
.buttonBorderShape(.capsule)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.padding(.horizontal)
}
.padding(.bottom, 20)
}
#if targetEnvironment(macCatalyst)
Button {
dismiss()
} label: {
Label("Close", systemImage: "xmark")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
#endif
}
}
}

View file

@ -0,0 +1,103 @@
//
// SecurityVersionNag.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 2024.
//
import SwiftUI
struct SecurityVersionNag: View {
@Environment(\.dismiss) private var dismiss
let minimumSecureVersion: String
let version: String
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 20) {
Image(systemName: "shield.slash.fill")
.font(.system(size: 60))
.foregroundColor(.red)
.padding(.top, 40)
Text("Security Update Recommended")
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 8) {
if !version.isEmpty {
Label {
Text("Connected firmware: **\(version)**")
} icon: {
Image(systemName: "wifi.exclamationmark")
.foregroundColor(.orange)
}
.font(.body)
}
Label {
Text("Recommended secure version: **\(minimumSecureVersion)**")
} icon: {
Image(systemName: "checkmark.shield.fill")
.foregroundColor(.green)
}
.font(.body)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
VStack(alignment: .leading, spacing: 12) {
Text("Security Advisory")
.font(.headline)
Text("Your connected device is running firmware older than **\(minimumSecureVersion)**, which contains known security vulnerabilities. Updating your firmware is strongly recommended to protect your device and mesh network.")
.font(.body)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.padding(.horizontal)
VStack(alignment: .leading, spacing: 12) {
Text("How to Update")
.font(.headline)
Link(destination: URL(string: "https://flasher.meshtastic.org")!) {
Label("Open Web Flasher", systemImage: "bolt.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
.buttonBorderShape(.capsule)
Link(destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/")!) {
Label("Firmware Update Docs", systemImage: "book.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.regular)
.buttonBorderShape(.capsule)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.padding(.horizontal)
}
.padding(.bottom, 20)
}
Button {
dismiss()
} label: {
Text("Dismiss")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
}
}
}

View file

@ -15,7 +15,6 @@ struct CompassView: View {
let waypointLocation: CLLocationCoordinate2D?
let waypointLongName: String?
let waypointShortName: String?
var waypointName: String? { waypointLongName }
let color: Color
@ObservedObject private var locationsHandler = LocationsHandler.shared
@ -83,7 +82,7 @@ struct CompassView: View {
if waypointLongName != nil || waypointLocation != nil {
Spacer()
VStack(spacing: 4) {
Text(waypointLongName ?? waypointName ?? "Waypoint")
Text(waypointLongName ?? "Waypoint")
.font(.largeTitle)
if let bearing = bearingToWaypoint() {

View file

@ -40,7 +40,7 @@ struct MeshMapContent: MapContent {
@AppStorage("mapOverlaysEnabled") private var showMapOverlays = false
@Binding var enabledOverlayConfigs: Set<UUID>
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn)
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .none)
var positions: FetchedResults<PositionEntity>
@FetchRequest(fetchRequest: WaypointEntity.allWaypointssFetchRequest(), animation: .none)
@ -184,10 +184,13 @@ struct MeshMapContent: MapContent {
@MapContentBuilder
var meshMap: some MapContent {
let loraNodes = positions.filter { $0.nodePosition?.viaMqtt ?? true == false }
let loraCoords = Array(loraNodes).compactMap({(position) -> CLLocationCoordinate2D in
return position.nodeCoordinate ?? LocationsHandler.DefaultLocation
})
// Only compute LoRa node coordinates when the convex hull is actually displayed.
// The filter scans the entire positions array on every render, so guard it.
let loraCoords: [CLLocationCoordinate2D] = showConvexHull
? positions
.filter { !($0.nodePosition?.viaMqtt ?? true) }
.compactMap { $0.nodeCoordinate ?? LocationsHandler.DefaultLocation }
: []
/// Convex Hull
if showConvexHull {
if loraCoords.count > 0 {
@ -214,8 +217,10 @@ struct MeshMapContent: MapContent {
let allStyledFeatures = GeoJSONOverlayManager.shared.loadStyledFeaturesForConfigs(enabledOverlayConfigs)
return Group {
ForEach(0..<allStyledFeatures.count, id: \.self) { index in
let styledFeature = allStyledFeatures[index]
// GeoJSONStyledFeature is Identifiable with a stable UUID assigned at creation.
// Using ForEach with Identifiable gives SwiftUI stable identity for diffing,
// avoiding full teardown/rebuild of overlay views on each render.
ForEach(allStyledFeatures) { styledFeature in
let feature = styledFeature.feature
let geometryType = feature.geometry.type

View file

@ -20,6 +20,12 @@ struct NodeMapContent: MapContent {
@Namespace var mapScope
@State var selectedPosition: PositionEntity?
// Static UIImage caches keyed by node.num.
// Node colors are deterministic from node.num (via UIColor(hex:)), so caching by num is correct.
// nonisolated(unsafe) is required for static mutable state in Swift 6.
private nonisolated(unsafe) static var circleImageCache: [Int64: UIImage] = [:]
private nonisolated(unsafe) static var arrowImageCache: [Int64: UIImage] = [:]
@MapContentBuilder
var nodeMap: some MapContent {
let positionArray = node.positions?.array as? [PositionEntity] ?? []
@ -160,18 +166,20 @@ struct NodeMapContent: MapContent {
}
private func prerenderHistoryPointCircle(fill: Color, stroke: Color) -> UIImage {
// Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points.
if let cached = NodeMapContent.circleImageCache[node.num] { return cached }
let content = Circle()
.fill(fill)
.strokeBorder(stroke, lineWidth: 2)
.frame(width: 12, height: 12)
let renderer = ImageRenderer(content: content)
renderer.scale = UIScreen.main.scale
return renderer.uiImage!
let image = renderer.uiImage!
NodeMapContent.circleImageCache[node.num] = image
return image
}
private func prerenderHistoryPointArrow(fill: Color, stroke: Color) -> UIImage {
// Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points.
if let cached = NodeMapContent.arrowImageCache[node.num] { return cached }
let content = Image(systemName: "location.north.circle")
.resizable()
.scaledToFit()
@ -181,6 +189,8 @@ struct NodeMapContent: MapContent {
.frame(width: 16, height: 16)
let renderer = ImageRenderer(content: content)
renderer.scale = UIScreen.main.scale
return renderer.uiImage!
let image = renderer.uiImage!
NodeMapContent.arrowImageCache[node.num] = image
return image
}
}

View file

@ -61,6 +61,18 @@ struct MapLegend: View {
.navigationTitle("Map Legend")
.navigationBarTitleDisplayMode(.inline)
}
#if targetEnvironment(macCatalyst)
Spacer()
Button {
dismiss()
} label: {
Label("Close", systemImage: "xmark")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding(.bottom)
#endif
}
// MARK: - Sections

View file

@ -1,4 +1,5 @@
import CoreBluetooth
import Intents
import OSLog
import SwiftUI
import Foundation
@ -8,11 +9,14 @@ struct DeviceOnboarding: View {
enum SetupGuide: Hashable {
case notifications
case location
case backgroundActivity
case localNetwork
case bluetooth
case siri
}
@EnvironmentObject var accessoryManager: AccessoryManager
@ObservedObject private var locationsHandler: LocationsHandler = .shared
@State var navigationPath: [SetupGuide] = []
@State var locationStatus = LocationsHandler.shared.manager.authorizationStatus
@AppStorage("provideLocation") private var provideLocation: Bool = false
@ -21,25 +25,20 @@ struct DeviceOnboarding: View {
/// The Title View
var title: some View {
VStack {
Text("Welcome to")
.font(.title2.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text("Meshtastic")
.font(.largeTitle.bold())
Text("Welcome to Meshtastic")
.font(.title.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
}
var welcomeView: some View {
VStack {
VStack(spacing: 0) {
ScrollView(.vertical) {
VStack {
// Title
title
.padding(.top)
// Onboarding
VStack(alignment: .leading, spacing: 16) {
makeRow(
icon: "antenna.radiowaves.left.and.right",
@ -59,14 +58,34 @@ struct DeviceOnboarding: View {
makeRow(
icon: "person.2.shield",
title: String(localized: "User Privacy"),
subtitle: String(localized: "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.")
subtitle: String(localized: "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app.")
)
makeRow(
icon: "bell.badge",
title: String(localized: "Message Notifications"),
subtitle: String(localized: "Receive notifications for incoming messages and critical alerts even when the app is in the background.")
)
makeRow(
icon: "custom.bluetooth",
title: String(localized: "Bluetooth Connectivity"),
subtitle: String(localized: "Connect to your Meshtastic node via Bluetooth Low Energy for the best messaging experience.")
)
makeRow(
icon: "network",
title: String(localized: "Local Network Access"),
subtitle: String(localized: "Connect to nodes on your local Wi-Fi network.")
)
makeRow(
icon: "car.fill",
title: String(localized: "Siri & CarPlay"),
subtitle: String(localized: "Send and receive Meshtastic messages hands-free using Siri and CarPlay.")
)
}
.padding()
.padding(.horizontal)
.padding(.bottom)
}
.interactiveDismissDisabled()
}
Spacer()
.interactiveDismissDisabled()
Button {
Task {
await goToNextStep(after: nil)
@ -75,10 +94,7 @@ struct DeviceOnboarding: View {
Text("Get started")
.frame(maxWidth: .infinity)
}
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
.buttonStyle(.borderedProminent)
.capsuleButtonStyle()
}
}
@ -133,10 +149,7 @@ struct DeviceOnboarding: View {
Text("Configure notification permissions")
.frame(maxWidth: .infinity)
}
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
.buttonStyle(.borderedProminent)
.capsuleButtonStyle()
}
}
@ -202,10 +215,64 @@ struct DeviceOnboarding: View {
.frame(maxWidth: .infinity)
}
.padding()
.buttonBorderShape(.capsule)
.controlSize(.large)
.capsuleButtonStyle()
}
}
var backgroundActivityView: some View {
VStack {
ScrollView(.vertical) {
VStack {
Text("Background Activity")
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 16) {
Text(createBackgroundActivityString())
.font(.body.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
makeRow(
icon: "location.fill",
title: String(localized: "Continuous Location Updates"),
subtitle: String(localized: "Keep the mesh map updated and send your position to the mesh even while using other apps.")
)
makeRow(
icon: "antenna.radiowaves.left.and.right",
title: String(localized: "Background Mesh Tracking"),
subtitle: String(localized: "Receive position updates from other nodes and maintain an accurate picture of the mesh while in the background.")
)
makeRow(
icon: "battery.100.bolt",
title: String(localized: "Battery Usage"),
subtitle: String(localized: "Enabling background activity may increase battery usage. You can toggle this at any time in the app settings.")
)
Toggle(isOn: $locationsHandler.backgroundActivity) {
Label {
Text("Enable Background Activity")
} icon: {
Image(systemName: "location.circle")
}
}
.fixedSize()
.scaleEffect(0.85)
.padding(.leading, 52)
.tint(.accentColor)
}
.padding()
}
Spacer()
Button {
Task {
await goToNextStep(after: .backgroundActivity)
}
} label: {
Text("Continue")
.frame(maxWidth: .infinity)
}
.padding()
.buttonStyle(.borderedProminent)
.capsuleButtonStyle()
}
}
@ -252,10 +319,7 @@ struct DeviceOnboarding: View {
.frame(maxWidth: .infinity)
}
.padding()
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
.buttonStyle(.borderedProminent)
.capsuleButtonStyle()
}
}
@ -297,10 +361,64 @@ struct DeviceOnboarding: View {
.frame(maxWidth: .infinity)
}
.padding()
.buttonBorderShape(.capsule)
.controlSize(.large)
.capsuleButtonStyle()
}
}
var siriView: some View {
VStack {
ScrollView(.vertical) {
VStack {
Text("Siri, Shortcuts & CarPlay")
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 16) {
Text(createSiriString())
.font(.body.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
makeRow(
icon: "car.fill",
title: String(localized: "CarPlay Messaging"),
subtitle: String(localized: "Read and reply to Meshtastic channel and direct messages directly from your car's display using CarPlay.")
)
makeRow(
icon: "message",
title: String(localized: "Send a Group Message"),
subtitle: String(localized: "\"Send a Meshtastic group message\" — send a message to a mesh channel.")
)
makeRow(
icon: "bubble",
title: String(localized: "Send a Direct Message"),
subtitle: String(localized: "\"Send a Meshtastic direct message\" — send a private message to a node.")
)
makeRow(
icon: "power",
title: String(localized: "Shut Down / Restart Node"),
subtitle: String(localized: "\"Shut down my Meshtastic node\" or \"Restart my Meshtastic node\".")
)
makeRow(
icon: "antenna.radiowaves.left.and.right.slash",
title: String(localized: "Disconnect Node"),
subtitle: String(localized: "\"Disconnect Meshtastic\" — disconnect from the connected BLE node.")
)
}
.padding()
}
Spacer()
Button {
Task {
await requestSiriPermissions()
await goToNextStep(after: .siri)
}
} label: {
Text("Configure Siri & Shortcuts")
.frame(maxWidth: .infinity)
}
.padding()
.buttonStyle(.borderedProminent)
.capsuleButtonStyle()
}
}
@ -313,16 +431,50 @@ struct DeviceOnboarding: View {
notificationView
case .location:
locationView
case .backgroundActivity:
backgroundActivityView
case .bluetooth:
bluetoothView
case .localNetwork:
localNetworkView
case .siri:
siriView
}
}
}
.toolbar(.hidden)
}
@ViewBuilder
func makeCompactRow(icon: String, title: String, subtitle: String) -> some View {
HStack(alignment: .center, spacing: 12) {
Group {
if icon.starts(with: "custom.") {
Image(icon)
.resizable()
.symbolRenderingMode(.multicolor)
} else {
Image(systemName: icon)
.resizable()
.symbolRenderingMode(.multicolor)
}
}
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.padding(.leading, 4)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.footnote.weight(.semibold))
.foregroundColor(.primary)
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.accessibilityElement(children: .combine)
}
@ViewBuilder
func makeRow(
icon: String,
@ -351,11 +503,11 @@ struct DeviceOnboarding: View {
}
VStack(alignment: .leading) {
Text(title)
.font(.subheadline.weight(.semibold))
.font(.footnote.weight(.semibold))
.foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true)
Text(subtitle)
.font(.subheadline)
.font(.footnote)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}.multilineTextAlignment(.leading)
@ -381,18 +533,31 @@ struct DeviceOnboarding: View {
}
case .location:
locationStatus = LocationsHandler.shared.manager.authorizationStatus
if locationStatus != .notDetermined && locationStatus != .restricted {
navigationPath.append(.localNetwork)
if locationStatus == .authorizedWhenInUse || locationStatus == .authorizedAlways {
navigationPath.append(.backgroundActivity)
}
case .backgroundActivity:
navigationPath.append(.localNetwork)
case .localNetwork:
navigationPath.append(.bluetooth)
case .bluetooth:
navigationPath.append(.siri)
case .siri:
dismiss()
}
}
// MARK: Formatting
func createBackgroundActivityString() -> AttributedString {
var fullText = AttributedString("Meshtastic can track your location in the background to keep the mesh map updated and send your position to the mesh even when the app is not in the foreground. You can update this setting at any time from settings.")
if let range = fullText.range(of: "settings") {
fullText[range].link = URL(string: UIApplication.openSettingsURLString)!
fullText[range].foregroundColor = .blue
}
return fullText
}
func createLocationString() -> AttributedString {
var fullText = AttributedString(localized: "Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from settings.")
if let range = fullText.range(of: String(localized: "settings")) {
@ -420,6 +585,15 @@ struct DeviceOnboarding: View {
return fullText
}
func createSiriString() -> AttributedString {
var fullText = AttributedString("Meshtastic supports Siri, Shortcuts, and CarPlay so you can send and receive messages hands-free. You can update Siri permissions at any time from settings.")
if let range = fullText.range(of: "settings") {
fullText[range].link = URL(string: UIApplication.openSettingsURLString)!
fullText[range].foregroundColor = .blue
}
return fullText
}
// MARK: Permission Checks
func requestNotificationsPermissions() async {
let center = UNUserNotificationCenter.current()
@ -452,6 +626,22 @@ struct DeviceOnboarding: View {
func requestBluetoothPermissions() async {
_ = await BluetoothAuthorizationHelper.requestBluetoothAuthorization()
}
func requestSiriPermissions() async {
await withCheckedContinuation { continuation in
INPreferences.requestSiriAuthorization { status in
switch status {
case .authorized:
Logger.services.info("Siri permissions are enabled")
case .denied:
Logger.services.info("Siri permissions denied")
default:
Logger.services.info("Siri permissions status: \(status.rawValue)")
}
continuation.resume()
}
}
}
}

View file

@ -13,14 +13,13 @@ struct Firmware: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var accessoryManager: AccessoryManager
var node: NodeInfoEntity?
@State var minimumVersion = "2.5.4"
@State var version = ""
@State private var currentDevice: DeviceHardware?
@State private var latestStable: FirmwareRelease?
@State private var latestAlpha: FirmwareRelease?
var body: some View {
let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion)
let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: accessoryManager.minimumVersion)
let connectedVersion = accessoryManager.activeConnection?.device.firmwareVersion ?? "Unknown"
ScrollView {
VStack(alignment: .leading) {
@ -63,7 +62,7 @@ struct Firmware: View {
.foregroundStyle(.red)
.font(.title2)
.padding(.bottom)
Text("Current Firmware Version: \(connectedVersion), Latest Firmware Version: \(minimumVersion)")
Text("Current Firmware Version: \(connectedVersion), Minimum Required Version: \(accessoryManager.minimumVersion)")
.fixedSize(horizontal: false, vertical: true)
.font(.title3)
.padding(.bottom)

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Send en besked på Meshtastic</string>
<string>Send en Meshtastic-besked til Lars</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Søg efter Meshtastic-beskeder</string>
<string>Find beskeder på Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Markér Meshtastic-besked som læst</string>
<string>Markér Meshtastic-beskeder som læst</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Sende eine Nachricht über Meshtastic</string>
<string>Sende eine Meshtastic-Nachricht an Hans</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Suche Meshtastic-Nachrichten</string>
<string>Finde Nachrichten auf Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Markiere Meshtastic-Nachricht als gelesen</string>
<string>Markiere Meshtastic-Nachrichten als gelesen</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Send a message on Meshtastic</string>
<string>Send a Meshtastic message to John</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Search Meshtastic messages</string>
<string>Find messages on Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Mark Meshtastic message as read</string>
<string>Mark Meshtastic messages as read</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Enviar un mensaje en Meshtastic</string>
<string>Enviar un mensaje de Meshtastic a Juan</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Buscar mensajes de Meshtastic</string>
<string>Encontrar mensajes en Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Marcar mensaje de Meshtastic como leído</string>
<string>Marcar mensajes de Meshtastic como leídos</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Envoyer un message sur Meshtastic</string>
<string>Envoyer un message Meshtastic à Pierre</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Chercher des messages Meshtastic</string>
<string>Trouver des messages sur Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Marquer le message Meshtastic comme lu</string>
<string>Marquer les messages Meshtastic comme lus</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>שלח הודעה ב-Meshtastic</string>
<string>שלח הודעת Meshtastic לדוד</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>חפש הודעות Meshtastic</string>
<string>מצא הודעות ב-Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>סמן הודעת Meshtastic כנקראה</string>
<string>סמן הודעות Meshtastic כנקראו</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Invia un messaggio su Meshtastic</string>
<string>Invia un messaggio Meshtastic a Marco</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Cerca messaggi su Meshtastic</string>
<string>Trova messaggi su Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Segna il messaggio Meshtastic come letto</string>
<string>Segna i messaggi Meshtastic come letti</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Meshtasticでメッセージを送信</string>
<string>Meshtasticで太郎にメッセージを送って</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Meshtasticのメッセージを検索</string>
<string>Meshtasticでメッセージを探して</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Meshtasticのメッセージを既読にして</string>
<string>Meshtasticのメッセージを既読にする</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Wyślij wiadomość przez Meshtastic</string>
<string>Wyślij wiadomość Meshtastic do Jana</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Szukaj wiadomości Meshtastic</string>
<string>Znajdź wiadomości w Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Oznacz wiadomość Meshtastic jako przeczytaną</string>
<string>Oznacz wiadomości Meshtastic jako przeczytane</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Отправить сообщение через Meshtastic</string>
<string>Отправить сообщение Meshtastic Ивану</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Найти сообщения в Meshtastic</string>
<string>Поиск сообщений Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Отметить сообщение Meshtastic как прочитанное</string>
<string>Отметить сообщения Meshtastic как прочитанные</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Skicka ett meddelande på Meshtastic</string>
<string>Skicka ett Meshtastic-meddelande till Erik</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Sök Meshtastic-meddelanden</string>
<string>Hitta meddelanden på Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Markera Meshtastic-meddelande som läst</string>
<string>Markera Meshtastic-meddelanden som lästa</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>Пошаљи поруку преко Meshtastic</string>
<string>Пошаљи Meshtastic поруку Марку</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>Претражи Meshtastic поруке</string>
<string>Пронађи поруке на Meshtastic</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>Означи Meshtastic поруку као прочитану</string>
<string>Означи Meshtastic поруке као прочитане</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>通过Meshtastic发送消息</string>
<string>用Meshtastic给小明发消息</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>搜索Meshtastic消息</string>
<string>查找Meshtastic消息</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>将Meshtastic消息标记为已读</string>
<string>标记Meshtastic消息为已读</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IntentPhrases</key>
<array>
<dict>
<key>IntentName</key>
<string>INSendMessageIntent</string>
<key>IntentExamples</key>
<array>
<string>透過Meshtastic傳送訊息</string>
<string>用Meshtastic傳訊息給小明</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSearchForMessagesIntent</string>
<key>IntentExamples</key>
<array>
<string>搜尋Meshtastic訊息</string>
<string>尋找Meshtastic訊息</string>
</array>
</dict>
<dict>
<key>IntentName</key>
<string>INSetMessageAttributeIntent</string>
<key>IntentExamples</key>
<array>
<string>將Meshtastic訊息標記為已讀</string>
<string>標記Meshtastic訊息為已讀</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,176 @@
//
// CarPlayTests.swift
// MeshtasticTests
//
// Copyright(c) Garth Vander Houwen 4/16/26.
//
import CarPlay
import CoreData
import Foundation
import Intents
import Testing
@testable import Meshtastic
// MARK: - CarPlaySceneDelegate Tests
@Suite("CarPlaySceneDelegate")
struct CarPlaySceneDelegateTests {
@Test func initialState() {
let delegate = CarPlaySceneDelegate()
#expect(delegate.interfaceController == nil)
}
@Test func disconnectClearsInterfaceController() {
let delegate = CarPlaySceneDelegate()
// Simulate that interface controller was set during connect
delegate.interfaceController = nil
#expect(delegate.interfaceController == nil)
}
}
// MARK: - CarPlayIntentDonation Tests
@Suite("CarPlayIntentDonation")
struct CarPlayIntentDonationTests {
// MARK: - channelDisplayName
@Test func channelDisplayNamePrimary() {
let name = CarPlayIntentDonation.testChannelDisplayName(for: 0)
#expect(name == "Primary Channel")
}
@Test func channelDisplayNameSecondary() {
let name = CarPlayIntentDonation.testChannelDisplayName(for: 1)
#expect(name == "Channel 1")
}
@Test func channelDisplayNameHighIndex() {
let name = CarPlayIntentDonation.testChannelDisplayName(for: 7)
#expect(name == "Channel 7")
}
// MARK: - mePerson
@Test func mePersonIsMe() {
let me = CarPlayIntentDonation.testMePerson()
#expect(me.isMe)
#expect(me.displayName == "Me")
#expect(me.personHandle?.value == "me")
}
// MARK: - Outgoing DM Intent Structure
@Test func outgoingDMIntentHasCorrectConversationId() {
let intent = CarPlayIntentDonation.testBuildOutgoingIntent(
content: "Hello mesh",
toUserNum: 1234567890,
channel: 0
)
#expect(intent.conversationIdentifier == "dm-1234567890")
#expect(intent.serviceName == "Meshtastic")
#expect(intent.content == "Hello mesh")
#expect(intent.recipients?.count == 1)
#expect(intent.speakableGroupName == nil)
}
@Test func outgoingChannelIntentHasCorrectConversationId() {
let intent = CarPlayIntentDonation.testBuildOutgoingIntent(
content: "Channel message",
toUserNum: 0,
channel: 2
)
#expect(intent.conversationIdentifier == "channel-2")
#expect(intent.serviceName == "Meshtastic")
#expect(intent.content == "Channel message")
#expect(intent.recipients == nil)
#expect(intent.speakableGroupName?.spokenPhrase == "Channel 2")
}
@Test func outgoingPrimaryChannelIntentName() {
let intent = CarPlayIntentDonation.testBuildOutgoingIntent(
content: "Test",
toUserNum: 0,
channel: 0
)
#expect(intent.speakableGroupName?.spokenPhrase == "Primary Channel")
}
// MARK: - Interaction Direction
@Test func outgoingInteractionDirection() {
let interaction = CarPlayIntentDonation.testBuildOutgoingInteraction(
content: "Test",
toUserNum: 999,
channel: 0
)
#expect(interaction.direction == .outgoing)
}
}
// MARK: - Test Helpers Extension
extension CarPlayIntentDonation {
/// Exposes channelDisplayName for testing
static func testChannelDisplayName(for index: Int32) -> String {
channelDisplayName(for: index)
}
/// Exposes mePerson for testing
static func testMePerson() -> INPerson {
mePerson()
}
/// Builds an outgoing INSendMessageIntent without donating
static func testBuildOutgoingIntent(content: String, toUserNum: Int64, channel: Int32) -> INSendMessageIntent {
let me = mePerson()
if toUserNum != 0 {
let handleValue = "\(toUserNum)@meshtastic.local"
let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress)
let recipient = INPerson(
personHandle: recipientHandle,
nameComponents: nil,
displayName: "Node \(toUserNum.toHex())",
image: nil,
contactIdentifier: String(toUserNum),
customIdentifier: String(toUserNum)
)
return INSendMessageIntent(
recipients: [recipient],
outgoingMessageType: .outgoingMessageText,
content: content,
speakableGroupName: nil,
conversationIdentifier: "dm-\(toUserNum)",
serviceName: "Meshtastic",
sender: me,
attachments: nil
)
} else {
let channelName = channelDisplayName(for: channel)
let groupName = INSpeakableString(spokenPhrase: channelName)
return INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content,
speakableGroupName: groupName,
conversationIdentifier: "channel-\(channel)",
serviceName: "Meshtastic",
sender: me,
attachments: nil
)
}
}
/// Builds an outgoing INInteraction without donating
static func testBuildOutgoingInteraction(content: String, toUserNum: Int64, channel: Int32) -> INInteraction {
let intent = testBuildOutgoingIntent(content: content, toUserNum: toUserNum, channel: channel)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .outgoing
return interaction
}
}

View file

@ -364,7 +364,7 @@ struct InvalidVersionTests {
}
@Test func viewCreationWithEmptyVersions() {
let view = InvalidVersion()
let view = InvalidVersion(minimumVersion: "", version: "")
#expect(view.minimumVersion == "")
#expect(view.version == "")
}

View file

@ -0,0 +1,181 @@
//
// DeviceOnboardingTests.swift
// MeshtasticTests
//
// Copyright(c) Garth Vander Houwen 2026.
//
import Foundation
import Testing
@testable import Meshtastic
// MARK: - SetupGuide Enum
@Suite("DeviceOnboarding.SetupGuide")
struct SetupGuideTests {
@Test func allCasesExist() {
let cases: [DeviceOnboarding.SetupGuide] = [
.notifications, .location, .backgroundActivity,
.localNetwork, .bluetooth, .siri
]
#expect(cases.count == 6)
}
@Test func isHashable() {
var seen = Set<DeviceOnboarding.SetupGuide>()
seen.insert(.notifications)
seen.insert(.notifications) // duplicate should not grow set
seen.insert(.siri)
#expect(seen.count == 2)
}
@Test func equality() {
#expect(DeviceOnboarding.SetupGuide.bluetooth == .bluetooth)
#expect(DeviceOnboarding.SetupGuide.notifications != .siri)
#expect(DeviceOnboarding.SetupGuide.location != .backgroundActivity)
}
@Test func allCasesAreUnique() {
let cases: [DeviceOnboarding.SetupGuide] = [
.notifications, .location, .backgroundActivity,
.localNetwork, .bluetooth, .siri
]
let unique = Set(cases)
#expect(unique.count == cases.count)
}
}
// MARK: - Attributed String Formatters
@Suite("DeviceOnboarding string formatters")
struct OnboardingStringFormatterTests {
let view = DeviceOnboarding()
// Helpers
private func hasSettingsLink(_ string: AttributedString) -> Bool {
guard let range = string.range(of: "settings") else { return false }
return string[range].link != nil
}
private func settingsLinkURL(_ string: AttributedString) -> URL? {
guard let range = string.range(of: "settings") else { return nil }
return string[range].link
}
@Test func backgroundActivityStringContainsText() {
let string = view.createBackgroundActivityString()
#expect(string.description.contains("background"))
#expect(string.description.contains("settings"))
}
@Test func backgroundActivityStringHasSettingsLink() {
let string = view.createBackgroundActivityString()
#expect(hasSettingsLink(string))
}
@Test func backgroundActivitySettingsLinkIsAppSettings() {
let string = view.createBackgroundActivityString()
let url = settingsLinkURL(string)
#expect(url?.scheme == "app-settings" || url?.absoluteString.contains("settings") == true)
}
@Test func locationStringContainsText() {
let string = view.createLocationString()
#expect(string.description.contains("location"))
#expect(string.description.contains("settings"))
}
@Test func locationStringHasSettingsLink() {
let string = view.createLocationString()
#expect(hasSettingsLink(string))
}
@Test func localNetworkStringContainsText() {
let string = view.createLocalNetworkString()
#expect(string.description.contains("local network") || string.description.contains("TCP"))
#expect(string.description.contains("settings"))
}
@Test func localNetworkStringHasSettingsLink() {
let string = view.createLocalNetworkString()
#expect(hasSettingsLink(string))
}
@Test func bluetoothStringContainsText() {
let string = view.createBluetoothString()
#expect(string.description.contains("Bluetooth") || string.description.contains("BLE"))
#expect(string.description.contains("settings"))
}
@Test func bluetoothStringHasSettingsLink() {
let string = view.createBluetoothString()
#expect(hasSettingsLink(string))
}
@Test func siriStringContainsCarPlay() {
let string = view.createSiriString()
#expect(string.description.contains("CarPlay"))
}
@Test func siriStringContainsSiri() {
let string = view.createSiriString()
#expect(string.description.contains("Siri"))
}
@Test func siriStringHasSettingsLink() {
let string = view.createSiriString()
#expect(hasSettingsLink(string))
}
@Test func allStringsHaveSettingsLinks() {
let strings = [
view.createBackgroundActivityString(),
view.createLocationString(),
view.createLocalNetworkString(),
view.createBluetoothString(),
view.createSiriString()
]
for string in strings {
#expect(hasSettingsLink(string), "Expected 'settings' link in: \(string)")
}
}
}
// MARK: - Navigation Flow
@Suite("DeviceOnboarding navigation")
struct OnboardingNavigationTests {
@Test func backgroundActivityAlwaysGoesToLocalNetwork() async {
let view = DeviceOnboarding()
await view.goToNextStep(after: .backgroundActivity)
#expect(view.navigationPath == [.localNetwork])
}
@Test func localNetworkAlwaysGoesToBluetooth() async {
let view = DeviceOnboarding()
await view.goToNextStep(after: .localNetwork)
#expect(view.navigationPath == [.bluetooth])
}
@Test func bluetoothAlwaysGoesToSiri() async {
let view = DeviceOnboarding()
await view.goToNextStep(after: .bluetooth)
#expect(view.navigationPath == [.siri])
}
@Test func navigationPathStartsEmpty() {
let view = DeviceOnboarding()
#expect(view.navigationPath.isEmpty)
}
@Test func deterministicStepsAppendInOrder() async {
let view = DeviceOnboarding()
await view.goToNextStep(after: .backgroundActivity)
await view.goToNextStep(after: .localNetwork)
await view.goToNextStep(after: .bluetooth)
#expect(view.navigationPath == [.localNetwork, .bluetooth, .siri])
}
}

View file

@ -122,6 +122,28 @@ Each settings item has an associated deep link. No parameters are supported for
| `meshtastic:///settings/appFiles` | App Files |
| `meshtastic:///settings/firmwareUpdates` | Firmware Updates |
## CarPlay
The app supports CarPlay for hands-free mesh messaging while driving. The CarPlay interface shows connection status, favorite contacts, and channels.
### Siri Voice Commands
Use these Siri voice commands on CarPlay to interact with Meshtastic:
| Intent | Example Phrase |
| --- | --- |
| `INSendMessageIntent` | "Send a message on Meshtastic" |
| `INSearchForMessagesIntent` | "Search Meshtastic messages" |
| `INSetMessageAttributeIntent` | "Mark Meshtastic message as read" |
### Features
- **Connection Status** — Shows whether a Meshtastic device is connected and the device name
- **Favorite Contacts** — Lists nodes marked as favorites with unread message counts; tap to view contact detail with a native Siri compose button
- **Channels** — Lists configured channels with unread counts; tap to start a channel message via Siri
- **Incoming Message Notifications** — Siri announces incoming Meshtastic messages when Announce Notifications is enabled
- **Conversation History** — Sent and received messages appear in CarPlay Messages for quick access
## Release Process
For more information on how a new release of Meshtastic is managed, please refer to [RELEASING.md](./RELEASING.md)

View file

@ -34,6 +34,7 @@ struct MeshActivityAttributes: ActivityAttributes {
// Fixed non-changing properties about your activity go here!
var nodeNum: Int
var name: String
var shortName: String
}
#endif
#endif

View file

@ -14,7 +14,7 @@ struct WidgetsLiveActivity: Widget {
ActivityConfiguration(for: MeshActivityAttributes.self) { context in
LiveActivityView(nodeName: context.attributes.name,
uptimeSeconds: 0, // context.attributes.uptimeSeconds,
uptimeSeconds: context.state.uptimeSeconds,
channelUtilization: context.state.channelUtilization,
airtime: context.state.airtime,
sentPackets: context.state.sentPackets,
@ -31,18 +31,16 @@ struct WidgetsLiveActivity: Widget {
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
if context.state.totalNodes > 0 {
Text(" \(context.state.nodesOnline) online")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize()
} else {
Text(" ")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize()
}
Text("Ch. Util: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%")
Text(context.attributes.shortName)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.fixedSize()
Text("Sent: \(context.state.sentPackets)")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize()
Text("ChUtil: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize()
@ -50,10 +48,6 @@ struct WidgetsLiveActivity: Widget {
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize()
Text("Sent: \(context.state.sentPackets)")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize()
Text("Received: \(context.state.receivedPackets)")
.font(.caption2)
.foregroundStyle(.secondary)
@ -64,52 +58,95 @@ struct WidgetsLiveActivity: Widget {
.tint(Color("LightIndigo"))
}
DynamicIslandExpandedRegion(.trailing, priority: 1) {
Spacer()
if context.state.totalNodes > 0 {
HStack(spacing: 3) {
Image(systemName: "person.2.fill")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(context.state.nodesOnline)/\(context.state.totalNodes)")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.primary)
}
.fixedSize()
}
Text("Bad: \(context.state.badReceivedPackets)")
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize()
Text("Dupe: \(context.state.dupeReceivedPackets)")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize()
Text("Relayed: \(context.state.packetsSentRelay)")
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize()
Text("Relay Cancel: \(context.state.packetsCanceledRelay)")
.font(.caption)
Text("Relayed: \(context.state.packetsSentRelay)")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize()
Text("Rly Cancel: \(context.state.packetsCanceledRelay)")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize()
}
DynamicIslandExpandedRegion(.bottom) {
Text("Last Heard: \(Date().formatted())")
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.tint)
.fixedSize()
HStack(spacing: 4) {
if let uptime = context.state.uptimeSeconds, uptime > 0 {
Text("UPTIME:")
.font(.caption2)
.foregroundStyle(.tint)
Text(uptime >= 3600 ? "\(uptime / 3600)h \((uptime % 3600) / 60)m" : "\((uptime % 3600) / 60)m")
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.tint)
Text("")
.font(.caption2)
.foregroundStyle(.tint)
}
Text("UPDATED:")
.font(.caption2)
.foregroundStyle(.tint)
Text("\(Date().formatted(date: .omitted, time: .shortened))")
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.tint)
}
}
} compactLeading: {
Image("m-logo-black")
.resizable()
.frame(width: 25)
.padding(4)
.background(.green.gradient, in: ContainerRelativeShape())
HStack(spacing: 2) {
Image(systemName: "person.2.fill")
.font(.system(size: 9))
.foregroundStyle(.green)
if context.state.totalNodes > 0 {
Text("\(context.state.nodesOnline)")
.font(.caption2)
.fontWeight(.semibold)
.foregroundStyle(.primary)
}
}
.fixedSize()
} compactTrailing: {
Text(timerInterval: context.state.timerRange, countsDown: true)
.monospacedDigit()
.foregroundColor(Color("LightIndigo"))
.frame(width: 40)
Text("\(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%")
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.primary)
.fixedSize()
} minimal: {
Image("m-logo-black")
.resizable()
.frame(width: 24.0)
.padding(4)
.background(.green.gradient, in: ContainerRelativeShape())
ZStack {
Image(systemName: "person.2.fill")
.font(.system(size: 10))
.foregroundStyle(.green)
if context.state.totalNodes > 0 {
Text("\(context.state.nodesOnline)")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.white)
.offset(y: 6)
}
}
}
.contentMargins(.trailing, 32, for: .expanded)
.contentMargins([.leading, .top, .bottom], 6, for: .compactLeading)
.contentMargins(.leading, 16, for: .expanded)
.contentMargins(.trailing, 16, for: .expanded)
.contentMargins(.all, 6, for: .compactLeading)
.contentMargins(.all, 6, for: .compactTrailing)
.contentMargins(.all, 6, for: .minimal)
.widgetURL(URL(string: "meshtastic:///connect"))
}
@ -117,7 +154,7 @@ struct WidgetsLiveActivity: Widget {
}
struct WidgetsLiveActivity_Previews: PreviewProvider {
static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G")
static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G", shortName: "8E6G")
static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100, packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300))
static var previews: some View {
@ -154,108 +191,122 @@ struct LiveActivityView: View {
var timerRange: ClosedRange<Date>
var body: some View {
HStack {
Spacer()
Image(colorScheme == .light ? "m-logo-black" : "m-logo-white")
.resizable()
.clipShape(ContainerRelativeShape())
.opacity(isLuminanceReduced ? 0.5 : 1.0)
.aspectRatio(contentMode: .fit)
.frame(minWidth: 25, idealWidth: 45, maxWidth: 55)
Spacer()
NodeInfoView(isLuminanceReduced: _isLuminanceReduced, nodeName: nodeName, uptimeSeconds: uptimeSeconds, channelUtilization: channelUtilization, airtime: airtime, sentPackets: sentPackets, receivedPackets: receivedPackets, badReceivedPackets: badReceivedPackets,
dupeReceivedPackets: dupeReceivedPackets, packetsSentRelay: packetsSentRelay, packetsCanceledRelay: packetsCanceledRelay, nodesOnline: nodesOnline, timerRange: timerRange)
Spacer()
}
.tint(.primary)
.padding([.leading, .top, .bottom])
.padding(.trailing, 25)
.activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed"))
.activitySystemActionForegroundColor(.primary)
}
}
let errorRate = receivedPackets > 0
? (Double(badReceivedPackets) / Double(receivedPackets)) * 100
: 0.0
let now = Date()
struct NodeInfoView: View {
@Environment(\.isLuminanceReduced) var isLuminanceReduced
VStack(alignment: .leading, spacing: 4) {
// Header row: logo + node name + nodes online
HStack(spacing: 6) {
Image("m-logo-white")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.clipShape(RoundedRectangle(cornerRadius: 6))
Text(nodeName)
.font(.callout)
.fontWeight(.semibold)
.foregroundStyle(.tint)
.lineLimit(1)
Spacer()
if totalNodes > 0 {
HStack(spacing: 3) {
Image(systemName: "person.2.fill")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(nodesOnline)/\(totalNodes)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.fixedSize()
}
}
var nodeName: String
var uptimeSeconds: UInt32?
var channelUtilization: Float?
var airtime: Float?
var sentPackets: UInt32
var receivedPackets: UInt32
var badReceivedPackets: UInt32
var dupeReceivedPackets: UInt32
var packetsSentRelay: UInt32
var packetsCanceledRelay: UInt32
var nodesOnline: UInt32
var timerRange: ClosedRange<Date>
// Stats grid two columns
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
StatRow(label: "Ch. Utilization", value: "\(channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%")
StatRow(label: "Airtime", value: "\(airtime?.formatted(.number.precision(.fractionLength(1))) ?? "--")%")
StatRow(label: "Sent", value: "\(sentPackets)")
StatRow(label: "Received", value: "\(receivedPackets)")
}
VStack(alignment: .leading, spacing: 2) {
StatRow(label: "Error Rate", value: "\(errorRate.formatted(.number.precision(.fractionLength(1))))%")
StatRow(label: "Relayed", value: "\(packetsSentRelay)")
StatRow(label: "Relay Canceled", value: "\(packetsCanceledRelay)")
StatRow(label: "Duplicate", value: "\(dupeReceivedPackets)")
}
}
.fixedSize(horizontal: true, vertical: false)
.opacity(isLuminanceReduced ? 0.8 : 1.0)
var body: some View {
let errorRate = (Double(badReceivedPackets) / Double(receivedPackets)) * 100
VStack(alignment: .leading, spacing: 0) {
Text(nodeName)
.font(nodeName.count > 14 ? .callout : .title3)
.fontWeight(.semibold)
.foregroundStyle(.tint)
// Text("\(channelUtilization.map { String(format: "Ch. Util: %.2f", $0 ) } ?? "--")% \(airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%")
Text("Ch. Util: \(channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.opacity(isLuminanceReduced ? 0.8 : 1.0)
.fixedSize()
Text("Packets: Sent \(sentPackets) Rec. \(receivedPackets)")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.opacity(isLuminanceReduced ? 0.8 : 1.0)
.fixedSize()
Text("Bad: \(badReceivedPackets) Error Rate: \(errorRate.formatted(.number.precision(.fractionLength(2))))%")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.opacity(isLuminanceReduced ? 0.8 : 1.0)
.fixedSize()
Text("Connected: \(nodesOnline) nodes online")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.opacity(isLuminanceReduced ? 0.8 : 1.0)
.fixedSize()
let now = Date()
Text("Last Heard: \(now.formatted())")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.opacity(isLuminanceReduced ? 0.8 : 1.0)
.fixedSize()
// Footer: uptime + timer
HStack {
if timerRange.upperBound >= now {
Text("Next Update:")
.font(.caption)
.fontWeight(.medium)
Spacer(minLength: 0)
if let uptimeSeconds, uptimeSeconds > 0 {
Text("Uptime:")
.font(.caption2)
.foregroundStyle(.secondary)
Text(uptimeText(uptimeSeconds))
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.tint)
Text("")
.font(.caption2)
.foregroundStyle(.secondary)
}
if timerRange.upperBound >= now {
Text("Update in:")
.font(.caption2)
.foregroundStyle(.secondary)
.opacity(isLuminanceReduced ? 0.8 : 1.0)
.fixedSize()
Text(timerInterval: timerRange, countsDown: true)
.monospacedDigit()
.multilineTextAlignment(.leading)
.font(.caption)
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.tint)
} else {
Text("Not Connected")
.multilineTextAlignment(.leading)
.font(.caption)
.font(.caption2)
.fontWeight(.semibold)
.foregroundStyle(.tint)
}
Spacer(minLength: 0)
}
.fixedSize(horizontal: false, vertical: true)
}
.tint(.primary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed"))
.activitySystemActionForegroundColor(.primary)
}
private func uptimeText(_ seconds: UInt32) -> String {
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
if hours > 0 {
return "\(hours)h \(minutes)m"
}
return "\(minutes)m"
}
}
struct StatRow: View {
var label: String
var value: String
var body: some View {
HStack(spacing: 4) {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.primary)
}
.fixedSize()
}
}
@ -265,27 +316,26 @@ struct TimerView: View {
var timerRange: ClosedRange<Date>
var body: some View {
VStack(alignment: .center) {
VStack(alignment: .center, spacing: 2) {
Text("UPDATE IN")
.font(.caption2)
.allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/)
.allowsTightening(true)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.opacity(isLuminanceReduced ? 0.5 : 1.0)
Text(timerInterval: timerRange, countsDown: true)
.monospacedDigit()
.multilineTextAlignment(.center)
.frame(width: 80)
.font(.callout)
.frame(width: 60)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.tint)
Image(systemName: "timer")
.symbolRenderingMode(.multicolor)
.resizable()
.foregroundStyle(.secondary)
.frame(width: 30, height: 30)
.frame(width: 20, height: 20)
.opacity(isLuminanceReduced ? 0.5 : 1.0)
.offset(y: -5)
}
}
}