Carplay communications app templates

This commit is contained in:
Garth Vander Houwen 2026-04-16 17:33:18 -07:00
parent 1d6c20925d
commit 36e8f59a02
6 changed files with 389 additions and 3 deletions

View file

@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
AA000201CPLAY000000000002 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */; };
AA000201CPLAY000000000004 /* CarPlayIntentDonation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */; };
102B5EAB2E172F41003D191E /* DatadogCore in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAA2E172F41003D191E /* DatadogCore */; };
102B5EAD2E172F41003D191E /* DatadogCrashReporting in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAC2E172F41003D191E /* DatadogCrashReporting */; };
102B5EAF2E172F41003D191E /* DatadogLogs in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAE2E172F41003D191E /* DatadogLogs */; };
@ -354,6 +356,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayIntentDonation.swift; sourceTree = "<group>"; };
01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = "<group>"; };
0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = "<group>"; };
09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = "<group>"; };
@ -759,6 +763,15 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AA000201CPLAY000000000005 /* CarPlay */ = {
isa = PBXGroup;
children = (
AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */,
AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */,
);
path = CarPlay;
sourceTree = "<group>";
};
231B3F1E2D0879BC0069A07D /* Metrics Visualization */ = {
isa = PBXGroup;
children = (
@ -1251,6 +1264,7 @@
isa = PBXGroup;
children = (
237AEB8D2E1FE120003B7CE3 /* Accessory */,
AA000201CPLAY000000000005 /* CarPlay */,
BCB6137F2C6728E700485544 /* AppIntents */,
D05BD108B673E15AD4B01BC8 /* Intents */,
DD1BD0EC2C603C5B008C0C70 /* Measurement */,
@ -1704,6 +1718,8 @@
buildActionMask = 2147483647;
files = (
43C0CB306098FD005C2D489F /* IntentHandler.swift in Sources */,
AA000201CPLAY000000000002 /* CarPlaySceneDelegate.swift in Sources */,
AA000201CPLAY000000000004 /* CarPlayIntentDonation.swift in Sources */,
7717E5954788B23527BACF65 /* IntentMessageConverters.swift in Sources */,
AB4622DCF4B1D4115ED00312 /* SendMessageIntentHandler.swift in Sources */,
B0E4EEF2D2C41A884A5E949C /* SearchForMessagesIntentHandler.swift in Sources */,

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

@ -0,0 +1,140 @@
// MARK: CarPlayIntentDonation
//
// CarPlayIntentDonation.swift
// Meshtastic
//
// 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 recipientHandle = INPersonHandle(value: String(toUserNum), type: .unknown)
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
private 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
)
}
private static func channelDisplayName(for index: Int32) -> String {
if index == 0 {
return "Primary Channel"
}
return "Channel \(index)"
}
}

View file

@ -0,0 +1,212 @@
// MARK: CarPlaySceneDelegate
//
// CarPlaySceneDelegate.swift
// Meshtastic
//
// CarPlay Communication app scene delegate.
// For communication apps, the system provides the messaging UI.
// This delegate manages the CarPlay scene lifecycle and shows
// favorite contacts and channels for quick messaging via Siri.
//
import CarPlay
import Combine
import CoreData
import Intents
import OSLog
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
var interfaceController: CPInterfaceController?
private var cancellables = Set<AnyCancellable>()
private var context: NSManagedObjectContext {
PersistenceController.shared.container.viewContext
}
// MARK: - CPTemplateApplicationSceneDelegate
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
Logger.services.info("🚗 [CarPlay] Connected")
self.interfaceController = interfaceController
let rootTemplate = buildRootTemplate()
interfaceController.setRootTemplate(rootTemplate, animated: false, completion: nil)
// Observe connection state changes and refresh the template
AccessoryManager.shared.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshRootTemplate()
}
.store(in: &cancellables)
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController
) {
Logger.services.info("🚗 [CarPlay] Disconnected")
cancellables.removeAll()
self.interfaceController = nil
}
// MARK: - Root Template
private func refreshRootTemplate() {
guard let interfaceController else { return }
let rootTemplate = buildRootTemplate()
interfaceController.setRootTemplate(rootTemplate, animated: true, completion: nil)
}
private func buildRootTemplate() -> CPTemplate {
let connected = AccessoryManager.shared.isConnected
var sections = [CPListSection]()
// Status section
let statusItem = CPListItem(
text: connected ? "Connected" : "Not Connected",
detailText: connected
? (AccessoryManager.shared.activeConnection?.device.name ?? "Unknown Device")
: "Open Meshtastic on your phone to connect",
image: UIImage(systemName: connected
? "antenna.radiowaves.left.and.right"
: "antenna.radiowaves.left.and.right.slash")
)
statusItem.isEnabled = false
sections.append(CPListSection(items: [statusItem], header: "Status", sectionIndexTitle: nil))
if connected {
// Favorite contacts section
let favoriteItems = fetchFavoriteContactItems()
if !favoriteItems.isEmpty {
sections.append(CPListSection(items: favoriteItems, header: "Favorites", sectionIndexTitle: nil))
}
// Channels section
let channelItems = fetchChannelItems()
if !channelItems.isEmpty {
sections.append(CPListSection(items: channelItems, header: "Channels", sectionIndexTitle: nil))
}
}
let listTemplate = CPListTemplate(title: "Meshtastic", sections: sections)
listTemplate.tabImage = UIImage(systemName: "antenna.radiowaves.left.and.right")
return listTemplate
}
// MARK: - Data Fetching
private func fetchFavoriteContactItems() -> [CPListItem] {
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
request.predicate = NSPredicate(format: "favorite == YES AND num != %lld", AccessoryManager.shared.activeDeviceNum ?? 0)
request.sortDescriptors = [
NSSortDescriptor(key: "user.longName", ascending: true)
]
request.relationshipKeyPathsForPrefetching = ["user"]
do {
let nodes = try context.fetch(request)
return nodes.compactMap { node -> CPListItem? in
guard let user = node.user else { return nil }
let name = user.longName ?? user.shortName ?? "Unknown"
let shortName = user.shortName ?? "?"
let item = CPListItem(
text: name,
detailText: shortName,
image: UIImage(systemName: "person.circle.fill")
)
item.handler = { [weak self] _, completion in
self?.startMessageIntent(toNodeNum: node.num, name: name)
completion()
}
item.isEnabled = true
return item
}
} catch {
Logger.services.error("🚗 [CarPlay] Failed to fetch favorites: \(error.localizedDescription, privacy: .public)")
return []
}
}
private func fetchChannelItems() -> [CPListItem] {
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 []
}
return channels.compactMap { channel -> CPListItem? in
guard channel.role > 0 else { return nil }
let name = (channel.name?.isEmpty ?? true)
? (channel.index == 0 ? "Primary Channel" : "Channel \(channel.index)")
: channel.name!
let item = CPListItem(
text: name,
detailText: channel.index == 0 ? "Primary" : "Ch \(channel.index)",
image: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right")
)
item.handler = { [weak self] _, completion in
self?.startChannelMessageIntent(channelIndex: Int(channel.index), channelName: name)
completion()
}
item.isEnabled = true
return item
}
}
// MARK: - Siri Message Intents
private func startMessageIntent(toNodeNum: Int64, name: String) {
let person = INPerson(
personHandle: INPersonHandle(value: "\(toNodeNum)", type: .unknown),
nameComponents: nil,
displayName: name,
image: nil,
contactIdentifier: nil,
customIdentifier: "meshtastic-node-\(toNodeNum)"
)
let intent = INSendMessageIntent(
recipients: [person],
outgoingMessageType: .outgoingMessageText,
content: nil,
speakableGroupName: nil,
conversationIdentifier: "dm-\(toNodeNum)",
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)")
}
}
}
private func startChannelMessageIntent(channelIndex: Int, channelName: String) {
let groupName = INSpeakableString(spokenPhrase: channelName)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: nil,
speakableGroupName: groupName,
conversationIdentifier: "channel-\(channelIndex)",
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)")
}
}
}
}

View file

@ -1095,6 +1095,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

@ -131,6 +131,20 @@
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<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/>
@ -243,7 +257,5 @@
</dict>
</dict>
</array>
<key>com.apple.developer.carplay-communication</key>
<true/>
</dict>
</plist>