diff --git a/.github/workflows/bug-report-analyzer.yml b/.github/workflows/bug-report-analyzer.yml
new file mode 100644
index 00000000..12fb495b
--- /dev/null
+++ b/.github/workflows/bug-report-analyzer.yml
@@ -0,0 +1,341 @@
+name: Bug Report Analyzer
+
+on:
+ issues:
+ types: [opened, labeled]
+ workflow_dispatch:
+ inputs:
+ issue_number:
+ description: 'Issue number to analyze'
+ required: true
+ type: number
+
+permissions:
+ issues: write
+ contents: read
+ models: read
+
+jobs:
+ analyze-bug-report:
+ name: Analyze Bug Report
+ runs-on: ubuntu-latest
+ # Run when a bug or triage label is present on the issue, was just applied, or triggered manually
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ contains(github.event.issue.labels.*.name, 'bug') ||
+ contains(github.event.issue.labels.*.name, 'triage') ||
+ github.event.label.name == 'bug' ||
+ github.event.label.name == 'triage'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Analyze bug report and post findings
+ uses: actions/github-script@v7
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ script: |
+ const BOT_COMMENT_MARKER = '';
+ const MODELS_API_URL = 'https://models.inference.ai.azure.com/chat/completions';
+
+ // ── tuneable constants ────────────────────────────────────────────
+ // Minimum character count for a field to be considered non-blank.
+ const MIN_FIELD_LENGTH = 10;
+ // Steps-to-reproduce needs more detail than a one-liner to be useful.
+ const MIN_STEPS_LENGTH = 30;
+ // Cap how many tokens the model may return per response.
+ const MAX_RESPONSE_TOKENS = 1200;
+ // Low temperature → deterministic, factual answers (not creative).
+ const MODEL_TEMPERATURE = 0.2;
+ // How deep to recurse when scanning the repo for Swift files.
+ const MAX_SEARCH_DEPTH = 4;
+ // Max number of file paths sent to the model for relevance ranking.
+ const MAX_FILES_TO_LIST = 300;
+ // Max number of files whose contents are actually read and included.
+ const MAX_FILES_TO_READ = 5;
+ // Ask the model to return a slightly larger set so that if some paths
+ // don't exist we still have MAX_FILES_TO_READ valid candidates to read.
+ const FILE_SELECTION_BUFFER = 3;
+ // Max lines read from each source file to stay within token budget.
+ const MAX_LINES_PER_FILE = 250;
+
+ // ── helpers ──────────────────────────────────────────────────────
+
+ async function callModelsAPI(systemMessage, userMessage) {
+ const response = await fetch(MODELS_API_URL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ model: 'gpt-5.4',
+ messages: [
+ { role: 'system', content: systemMessage },
+ { role: 'user', content: userMessage },
+ ],
+ max_tokens: MAX_RESPONSE_TOKENS,
+ temperature: MODEL_TEMPERATURE,
+ }),
+ });
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Models API ${response.status}: ${text}`);
+ }
+ const data = await response.json();
+ return data.choices[0].message.content.trim();
+ }
+
+ function extractSection(body, heading) {
+ // Matches GitHub issue form sections: ### Heading\ncontent
+ const re = new RegExp(
+ `###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=\\n###|$)`,
+ 'i'
+ );
+ const m = body.match(re);
+ if (!m) return '';
+ const value = m[1].trim();
+ return value === '_No response_' ? '' : value;
+ }
+
+ function isBlank(s) {
+ return !s || s.length < MIN_FIELD_LENGTH;
+ }
+
+ // ── main ─────────────────────────────────────────────────────────
+
+ // Support manual workflow_dispatch by fetching the issue when triggered that way.
+ let issue;
+ if (context.eventName === 'workflow_dispatch') {
+ const issueNumber = parseInt(context.payload.inputs.issue_number, 10);
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
+ core.setFailed(`Invalid issue_number: "${context.payload.inputs.issue_number}". Must be a positive integer.`);
+ return;
+ }
+ try {
+ const { data } = await github.rest.issues.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issueNumber,
+ });
+ issue = data;
+ } catch (err) {
+ core.setFailed(`Could not fetch issue #${issueNumber}: ${err.message}`);
+ return;
+ }
+ } else {
+ issue = context.payload.issue;
+ }
+
+ const body = issue.body || '';
+ const title = issue.title || '';
+
+ // Skip if we have already left an analysis comment on this issue.
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ per_page: 100,
+ });
+ if (comments.some(c => c.body.includes(BOT_COMMENT_MARKER))) {
+ core.info('Already analyzed this issue – skipping.');
+ return;
+ }
+
+ // ── parse template fields ─────────────────────────────────────────
+
+ const firmwareVersion = extractSection(body, 'Firmware Version');
+ const stepsToReproduce = extractSection(body, 'What did you do\\?');
+ const expectedBehavior = extractSection(body, 'Expected Behavior');
+ const currentBehavior = extractSection(body, 'Current Behavior');
+ const additionalComments = extractSection(body, 'Additional comments');
+
+ // ── completeness check ────────────────────────────────────────────
+
+ const missing = [];
+ if (isBlank(firmwareVersion))
+ missing.push(
+ '- **Firmware Version** – please provide the exact version string ' +
+ '(e.g. `2.3.14.abcdef1`). You can find it under *Settings → Firmware* ' +
+ 'in the app or on the node screen.'
+ );
+ if (isBlank(stepsToReproduce) || stepsToReproduce.length < MIN_STEPS_LENGTH)
+ missing.push(
+ '- **Steps to Reproduce** – please list numbered, minimal steps that ' +
+ 'consistently trigger the issue. Include your iOS/iPadOS version and ' +
+ 'device model.'
+ );
+ if (isBlank(expectedBehavior))
+ missing.push(
+ '- **Expected Behavior** – describe what you expected to happen.'
+ );
+ if (isBlank(currentBehavior))
+ missing.push(
+ '- **Current Behavior** – describe what actually happens instead.'
+ );
+
+ if (missing.length > 0) {
+ const commentBody = `${BOT_COMMENT_MARKER}
+ ## 🤖 Additional Information Needed
+
+ Thank you for filing this bug report! To help us isolate the root cause we need a bit more detail:
+
+ ${missing.join('\n')}
+
+ ### Helpful extras (if applicable)
+ - iOS / iPadOS version and device model
+ - Whether this is a **regression** – did it work in an earlier version?
+ - Console logs or a crash report from the app's [Debug Log](https://meshtastic.org/docs/software/apple/ios-debug/) feature
+ - Screenshots or a screen recording if the issue is visual
+
+ Please update the issue with the missing information and we'll take another look. Thank you! 🙏`;
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: commentBody,
+ });
+ core.info('Posted "needs more info" comment.');
+ return;
+ }
+
+ // ── code analysis ─────────────────────────────────────────────────
+
+ const SYSTEM_MESSAGE =
+ 'You are an expert iOS/macOS Swift developer helping to triage bug ' +
+ 'reports for the Meshtastic Apple app – a SwiftUI mesh-radio ' +
+ 'communication app that uses Bluetooth LE and a Core Data stack. ' +
+ 'Be concise, specific, and reference real code paths when possible.';
+
+ try {
+ const fs = require('fs');
+ const path = require('path');
+
+ // Collect all Swift source file paths (max depth 4, skip generated dirs).
+ const SKIP_DIRS = new Set([
+ 'node_modules', '.git', 'DerivedData', 'build',
+ 'MeshtasticProtobufs',
+ ]);
+
+ function collectSwiftFiles(dir, depth) {
+ if (depth > MAX_SEARCH_DEPTH) return [];
+ const results = [];
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
+ catch (_) { return results; }
+ for (const e of entries) {
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ results.push(...collectSwiftFiles(full, depth + 1));
+ } else if (e.name.endsWith('.swift')) {
+ results.push(full);
+ }
+ }
+ return results;
+ }
+
+ const root = process.cwd();
+ const allFiles = collectSwiftFiles(root, 0);
+ const fileList = allFiles
+ .map(f => path.relative(root, f))
+ .slice(0, MAX_FILES_TO_LIST)
+ .join('\n');
+
+ // Ask the model which files are most relevant.
+ const fileSelectionPrompt =
+ `Bug title: ${title}\n` +
+ `Steps to reproduce: ${stepsToReproduce}\n` +
+ `Expected: ${expectedBehavior}\n` +
+ `Current: ${currentBehavior}\n` +
+ (additionalComments ? `Additional: ${additionalComments}\n` : '') +
+ `\nAvailable Swift source files:\n${fileList}\n\n` +
+ 'Return ONLY a JSON array (no markdown, no explanation) of the ' +
+ `${MAX_FILES_TO_READ}–${MAX_FILES_TO_READ + FILE_SELECTION_BUFFER} ` +
+ 'file paths most likely to contain the bug.';
+
+ let relevantFiles = [];
+ try {
+ const raw = await callModelsAPI(SYSTEM_MESSAGE, fileSelectionPrompt);
+ // Strip potential markdown fences before parsing.
+ const cleaned = raw.replace(/```[a-z]*\n?/g, '').trim();
+ relevantFiles = JSON.parse(cleaned);
+ } catch (e) {
+ core.warning(`File selection failed: ${e.message}`);
+ }
+
+ // Read up to MAX_FILES_TO_READ files, capping each at MAX_LINES_PER_FILE lines to stay within token budget.
+ let codeContext = '';
+ for (const relPath of relevantFiles.slice(0, MAX_FILES_TO_READ)) {
+ const absPath = path.join(root, relPath);
+ if (!fs.existsSync(absPath)) continue;
+ try {
+ const content = fs.readFileSync(absPath, 'utf8');
+ const snippet = content.split('\n').slice(0, MAX_LINES_PER_FILE).join('\n');
+ codeContext += `\n\n### ${relPath}\n\`\`\`swift\n${snippet}\n\`\`\``;
+ } catch (_) {}
+ }
+
+ const analysisPrompt =
+ `Bug title: ${title}\n` +
+ `Firmware Version: ${firmwareVersion}\n` +
+ `Steps to reproduce: ${stepsToReproduce}\n` +
+ `Expected: ${expectedBehavior}\n` +
+ `Current: ${currentBehavior}\n` +
+ (additionalComments ? `Additional: ${additionalComments}\n` : '') +
+ (codeContext
+ ? `\nRelevant source code:${codeContext}\n`
+ : '\n(No source files matched – reason from code structure)\n') +
+ '\nPlease provide:\n' +
+ '1. **Likely root cause** – a concise hypothesis with references to ' +
+ 'specific files, types, or functions.\n' +
+ '2. **Relevant code areas** – file paths and line ranges worth ' +
+ 'investigating.\n' +
+ '3. **Clarifying questions** – any details that would confirm or rule ' +
+ 'out the hypothesis.\n' +
+ '4. **Suggested investigation steps** – what a developer should do ' +
+ 'next.\n';
+
+ const analysis = await callModelsAPI(SYSTEM_MESSAGE, analysisPrompt);
+
+ const commentBody = `${BOT_COMMENT_MARKER}
+ ## 🤖 Automated Bug Report Analysis
+
+ Thank you for the detailed report! Here is an automated analysis to help the maintainers investigate:
+
+ ${analysis}
+
+ ---
+ *This analysis was generated automatically from the issue description and the repository source. A human maintainer will review and follow up shortly.*`;
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: commentBody,
+ });
+ core.info('Posted analysis comment.');
+
+ } catch (error) {
+ core.warning(`AI analysis failed (${error.message}). Posting fallback acknowledgement.`);
+
+ const fallback = `${BOT_COMMENT_MARKER}
+ ## 🤖 Bug Report Received
+
+ Thank you for this detailed bug report! A maintainer will review it and investigate the root cause.
+
+ If you can provide any of the following it will speed up the investigation:
+ - Device logs from the Debug Log feature
+ - Whether this is a regression (last known-good firmware version)
+ - A minimal set of steps that consistently reproduce the issue`;
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: fallback,
+ });
+ }
diff --git a/Localizable.xcstrings b/Localizable.xcstrings
index 34a26370..236ae149 100644
--- a/Localizable.xcstrings
+++ b/Localizable.xcstrings
@@ -307,6 +307,22 @@
},
"shouldTranslate" : false
},
+ "\"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
+ },
"(Re)define PIN_GPS_EN for your board." : {
"localizations" : {
"da" : {
@@ -2339,6 +2355,7 @@
}
},
"🦕 End of life Version 🦖 ☄️" : {
+ "extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -4085,6 +4102,7 @@
}
},
"Additional help" : {
+ "extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -4136,6 +4154,10 @@
}
}
},
+ "Additional Help" : {
+ "comment" : "A button that opens a link to the Meshtastic FAQ.",
+ "isCommentAutoGenerated" : true
+ },
"Address" : {
"localizations" : {
"da" : {
@@ -7014,6 +7036,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" : {
@@ -7723,6 +7753,10 @@
}
}
},
+ "Battery Usage" : {
+ "comment" : "A description of the battery usage of enabling background activity.",
+ "isCommentAutoGenerated" : true
+ },
"Baud" : {
"localizations" : {
"da" : {
@@ -9479,6 +9513,10 @@
}
}
},
+ "CarPlay Messaging" : {
+ "comment" : "A description of how to send a message to a mesh channel using CarPlay.",
+ "isCommentAutoGenerated" : true
+ },
"Categories" : {
"localizations" : {
"da" : {
@@ -12465,6 +12503,10 @@
}
}
},
+ "Configure Siri & Shortcuts" : {
+ "comment" : "A button that will open the app's settings to configure Siri and Shortcuts.",
+ "isCommentAutoGenerated" : true
+ },
"Confirm" : {
"localizations" : {
"da" : {
@@ -12671,6 +12713,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" : {
@@ -12753,6 +12803,10 @@
}
}
},
+ "Connected firmware: **%@**" : {
+ "comment" : "A label displaying the firmware version of a device. The argument is the firmware version.",
+ "isCommentAutoGenerated" : true
+ },
"Connected Node %@" : {
"localizations" : {
"da" : {
@@ -13239,6 +13293,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" : {
@@ -14015,6 +14077,7 @@
}
},
"Current Firmware Version: %@, Latest Firmware Version: %@" : {
+ "extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -14072,6 +14135,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" : {
@@ -18853,6 +18926,10 @@
}
}
},
+ "Enable Background Activity" : {
+ "comment" : "A toggle to enable or disable background activity.",
+ "isCommentAutoGenerated" : true
+ },
"Enable broadcasting device metrics to the mesh network. When disabled, metrics are only sent to connected clients." : {
"localizations" : {
"es" : {
@@ -19387,6 +19464,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
+ },
"Enabling Ethernet will disable the bluetooth connection to the app." : {
"localizations" : {
"da" : {
@@ -22158,6 +22239,7 @@
}
},
"Firmware update docs" : {
+ "extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -22209,6 +22291,14 @@
}
}
},
+ "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" : {
@@ -26233,6 +26323,10 @@
}
}
},
+ "How to Update" : {
+ "comment" : "A label displayed above the list of available firmware update options.",
+ "isCommentAutoGenerated" : true
+ },
"How to update Firmware" : {
"localizations" : {
"da" : {
@@ -28134,6 +28228,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" : {
@@ -29054,6 +29152,9 @@
}
}
}
+ },
+ "Loading..." : {
+
},
"Loading TAK config from the node." : {
@@ -31436,7 +31537,12 @@
"comment" : "A description of the read-only mode feature in TAK Server.",
"isCommentAutoGenerated" : true
},
+ "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app." : {
+ "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" : {
@@ -31764,6 +31870,10 @@
}
}
},
+ "Message Notifications" : {
+ "comment" : "A description of the message notifications feature.",
+ "isCommentAutoGenerated" : true
+ },
"Message received from the text message app." : {
"extractionState" : "stale",
"localizations" : {
@@ -32283,6 +32393,10 @@
}
}
},
+ "Minimum required: **%@**" : {
+ "comment" : "A label displaying the minimum required firmware version.",
+ "isCommentAutoGenerated" : true
+ },
"Minimum time between detection broadcasts" : {
"extractionState" : "stale",
"localizations" : {
@@ -37043,6 +37157,10 @@
}
}
},
+ "Open Web Flasher" : {
+ "comment" : "A button that opens the Web Flasher app.",
+ "isCommentAutoGenerated" : true
+ },
"Optimized for 2 color displays" : {
"extractionState" : "stale",
"localizations" : {
@@ -42034,6 +42152,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
@@ -42341,6 +42463,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 a negative acknowledgment" : {
"extractionState" : "stale",
"localizations" : {
@@ -42610,6 +42740,10 @@
}
}
},
+ "Recommended secure version: **%@**" : {
+ "comment" : "A label displaying the recommended secure version of the connected device.",
+ "isCommentAutoGenerated" : true
+ },
"Recording route" : {
"localizations" : {
"da" : {
@@ -46661,6 +46795,10 @@
}
}
},
+ "Security Advisory" : {
+ "comment" : "A title for a security advisory displayed in a card.",
+ "isCommentAutoGenerated" : true
+ },
"Security Config" : {
"localizations" : {
"da" : {
@@ -46777,6 +46915,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" : {
@@ -47730,6 +47872,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
+ },
"Send ASCII bell with alert message. Useful for triggering external notification on bell." : {
"localizations" : {
"da" : {
@@ -50793,6 +50939,10 @@
}
}
},
+ "Shut Down / Restart Node" : {
+ "comment" : "A Siri shortcut to restart or shut down a node.",
+ "isCommentAutoGenerated" : true
+ },
"Shut Down Node?" : {
"localizations" : {
"da" : {
@@ -51149,6 +51299,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" : {
@@ -54140,7 +54298,12 @@
}
}
},
+ "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" : {
@@ -60280,6 +60443,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" : {
@@ -61409,6 +61573,7 @@
}
},
"Welcome to" : {
+ "extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -61436,6 +61601,10 @@
}
}
},
+ "Welcome to Meshtastic" : {
+ "comment" : "The title of the onboarding screen.",
+ "isCommentAutoGenerated" : true
+ },
"What does the lock mean?" : {
"localizations" : {
"da" : {
@@ -62642,6 +62811,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" : {
diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj
index fe26dd7b..0ae0f965 100644
--- a/Meshtastic.xcodeproj/project.pbxproj
+++ b/Meshtastic.xcodeproj/project.pbxproj
@@ -20,6 +20,10 @@
7B1684B5E1AF4137829C03B8 /* TraceRouteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D66A4063FE4E868CD30D3C /* TraceRouteModels.swift */; };
70A5B362F7EC4F14A99A8C39 /* UserEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918722D2C1474B2D99ED01DC /* UserEntity.swift */; };
E340A7CA75194A49AB8763C7 /* WaypointEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98797BFD67F449B9ACCEDA7 /* WaypointEntity.swift */; };
+ AA000401SIRI000000000002 /* AppIntentVocabulary.plist in Resources */ = {isa = PBXBuildFile; fileRef = AA000401SIRI000000000001 /* AppIntentVocabulary.plist */; };
+ AA000301CPTST000000000002 /* CarPlayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000301CPTST000000000001 /* CarPlayTests.swift */; };
+ 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 */; };
@@ -92,6 +96,8 @@
25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; };
25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; };
25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; };
+ AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; };
+ DD9C70102E9F2A0000029299 /* DeviceOnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9C70112E9F2A0000029299 /* DeviceOnboardingTests.swift */; };
2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01028778B8BFD81F7A039593 /* TAKConnection.swift */; };
300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */; };
3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; };
@@ -217,6 +223,7 @@
DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD798B062915928D005217CD /* ChannelMessageList.swift */; };
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169FE272476C700F4AB02 /* LogDocument.swift */; };
DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD836AE626F6B38600ABCC23 /* Connect.swift */; };
+ DD4756AB2E9F1A0000029299 /* SecurityVersionNag.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4756AA2E9F1A0000029299 /* SecurityVersionNag.swift */; };
DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D409287F04F100BAEB7A /* InvalidVersion.swift */; };
DD86D40C287F401000BAEB7A /* SaveChannelQRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D40B287F401000BAEB7A /* SaveChannelQRCode.swift */; };
DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D40E2881BE4C00BAEB7A /* CsvDocument.swift */; };
@@ -377,6 +384,23 @@
D8D66A4063FE4E868CD30D3C /* TraceRouteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceRouteModels.swift; sourceTree = ""; };
918722D2C1474B2D99ED01DC /* UserEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntity.swift; sourceTree = ""; };
E98797BFD67F449B9ACCEDA7 /* WaypointEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointEntity.swift; sourceTree = ""; };
+ AA000401SIRI000000000010 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = en; path = en.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000011 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = de; path = de.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000012 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = es; path = es.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000013 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = fr; path = fr.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000014 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = it; path = it.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000015 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = ja; path = ja.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000016 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = da; path = da.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000017 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = pl; path = pl.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000018 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = ru; path = ru.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000019 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = he; path = he.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000020 /* sr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = sr; path = sr.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000021 /* se */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = se; path = se.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000022 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = zh-Hans; path = zh-Hans.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000401SIRI000000000023 /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = zh-Hant-TW; path = zh-Hant-TW.lproj/AppIntentVocabulary.plist; sourceTree = ""; };
+ AA000301CPTST000000000001 /* CarPlayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayTests.swift; sourceTree = ""; };
+ AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; };
+ AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayIntentDonation.swift; sourceTree = ""; };
01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; };
0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = ""; };
09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = ""; };
@@ -442,6 +466,8 @@
25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; };
25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; };
+ AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = ""; };
+ DD9C70112E9F2A0000029299 /* DeviceOnboardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceOnboardingTests.swift; sourceTree = ""; };
2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = ""; };
3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = ""; };
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; };
@@ -598,6 +624,7 @@
DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 43.xcdatamodel"; sourceTree = ""; };
DD8169FE272476C700F4AB02 /* LogDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDocument.swift; sourceTree = ""; };
DD836AE626F6B38600ABCC23 /* Connect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connect.swift; sourceTree = ""; };
+ DD4756AA2E9F1A0000029299 /* SecurityVersionNag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityVersionNag.swift; sourceTree = ""; };
DD86D409287F04F100BAEB7A /* InvalidVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvalidVersion.swift; sourceTree = ""; };
DD86D40B287F401000BAEB7A /* SaveChannelQRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChannelQRCode.swift; sourceTree = ""; };
DD86D40E2881BE4C00BAEB7A /* CsvDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CsvDocument.swift; sourceTree = ""; };
@@ -779,6 +806,15 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
+ AA000201CPLAY000000000005 /* CarPlay */ = {
+ isa = PBXGroup;
+ children = (
+ AA000201CPLAY000000000001 /* CarPlaySceneDelegate.swift */,
+ AA000201CPLAY000000000003 /* CarPlayIntentDonation.swift */,
+ );
+ path = CarPlay;
+ sourceTree = "";
+ };
231B3F1E2D0879BC0069A07D /* Metrics Visualization */ = {
isa = PBXGroup;
children = (
@@ -932,7 +968,9 @@
isa = PBXGroup;
children = (
AA00010022E2730EC0060000 /* ConnectViewTests.swift */,
+ DD9C70112E9F2A0000029299 /* DeviceOnboardingTests.swift */,
25F5D5D02C4375DF008036E3 /* RouterTests.swift */,
+ AA000301CPTST000000000001 /* CarPlayTests.swift */,
);
path = MeshtasticTests;
sourceTree = "";
@@ -1042,7 +1080,8 @@
isa = PBXGroup;
children = (
DD836AE626F6B38600ABCC23 /* Connect.swift */,
- DD86D409287F04F100BAEB7A /* InvalidVersion.swift */,
+ DD4756AA2E9F1A0000029299 /* SecurityVersionNag.swift */,
+ DD86D409287F04F100BAEB7A /* InvalidVersion.swift */
);
path = Connect;
sourceTree = "";
@@ -1261,6 +1300,8 @@
isa = PBXGroup;
children = (
237AEB8D2E1FE120003B7CE3 /* Accessory */,
+ AA000201CPLAY000000000005 /* CarPlay */,
+ AA000401SIRI000000000001 /* AppIntentVocabulary.plist */,
BCB6137F2C6728E700485544 /* AppIntents */,
D05BD108B673E15AD4B01BC8 /* Intents */,
DD1BD0EC2C603C5B008C0C70 /* Measurement */,
@@ -1622,17 +1663,21 @@
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
- en,
- de,
Base,
- "zh-Hans",
- pl,
- he,
+ da,
+ de,
+ en,
+ es,
fr,
- se,
- sr,
+ he,
it,
ja,
+ pl,
+ ru,
+ se,
+ sr,
+ zh-Hans,
+ zh-Hant-TW,
);
mainGroup = DDC2E14B26CE248E0042C5E4;
packageReferences = (
@@ -1665,6 +1710,7 @@
buildActionMask = 2147483647;
files = (
DDC2E15F26CE248F0042C5E4 /* Preview Assets.xcassets in Resources */,
+ AA000401SIRI000000000002 /* AppIntentVocabulary.plist in Resources */,
DD98EB292E7A42CC0016320A /* AppIcon_Chirpy.icon in Resources */,
25AECD4F2C2F723200862C8E /* Localizable.xcstrings in Resources */,
DDDE5A1329AFEAB900490C6C /* Assets.xcassets in Resources */,
@@ -1717,7 +1763,9 @@
buildActionMask = 2147483647;
files = (
AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */,
+ DD9C70102E9F2A0000029299 /* DeviceOnboardingTests.swift in Sources */,
25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */,
+ AA000301CPTST000000000002 /* CarPlayTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1739,6 +1787,8 @@
70A5B362F7EC4F14A99A8C39 /* UserEntity.swift in Sources */,
E340A7CA75194A49AB8763C7 /* WaypointEntity.swift in Sources */,
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 */,
@@ -1758,6 +1808,7 @@
DDA9515C2BC6631200CEA535 /* TelemetryEnums.swift in Sources */,
DDFEB3BB29900C1200EE7472 /* CurrentConditionsCompact.swift in Sources */,
DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */,
+ DD4756AB2E9F1A0000029299 /* SecurityVersionNag.swift in Sources */,
D93069082B81DF040066FBC8 /* SaveConfigButton.swift in Sources */,
DD5E523F298F5A9E00D21B61 /* AirQualityIndex.swift in Sources */,
DDD5BB182C2F9C36007E03CA /* OSLogEntryLog.swift in Sources */,
@@ -2045,6 +2096,30 @@
};
/* End PBXTargetDependency section */
+/* Begin PBXVariantGroup section */
+ AA000401SIRI000000000001 /* AppIntentVocabulary.plist */ = {
+ isa = PBXVariantGroup;
+ children = (
+ AA000401SIRI000000000010 /* en */,
+ AA000401SIRI000000000011 /* de */,
+ AA000401SIRI000000000012 /* es */,
+ AA000401SIRI000000000013 /* fr */,
+ AA000401SIRI000000000014 /* it */,
+ AA000401SIRI000000000015 /* ja */,
+ AA000401SIRI000000000016 /* da */,
+ AA000401SIRI000000000017 /* pl */,
+ AA000401SIRI000000000018 /* ru */,
+ AA000401SIRI000000000019 /* he */,
+ AA000401SIRI000000000020 /* sr */,
+ AA000401SIRI000000000021 /* se */,
+ AA000401SIRI000000000022 /* zh-Hans */,
+ AA000401SIRI000000000023 /* zh-Hant-TW */,
+ );
+ name = AppIntentVocabulary.plist;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
/* Begin XCBuildConfiguration section */
25F5D5CD2C4375A8008036E3 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -2236,7 +2311,6 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
- ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled";
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Meshtastic/Meshtastic.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
@@ -2275,7 +2349,6 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
- ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled";
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Meshtastic/Meshtastic.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift
index 26443685..edfd8180 100644
--- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift
+++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift
@@ -10,7 +10,7 @@ import OSLog
import MeshtasticProtobufs
import CoreBluetooth
-private let maxRetries = 1
+private let maxRetries = 2
private let retryDelay: Duration = .seconds(2)
extension AccessoryManager {
diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift
index 3c2b7293..dc397200 100644
--- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift
+++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift
@@ -86,7 +86,6 @@ extension AccessoryManager {
func stopDiscovery() {
devices.removeAll()
discoveryTask?.cancel()
- discoveryTask?.cancel()
discoveryTask = nil
}
diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift
index ba1ce44d..7a8f3e95 100644
--- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift
+++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift
@@ -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
diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
index 04dda76b..3bc3723e 100644
--- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
+++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
@@ -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
@@ -288,6 +289,13 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
// Turn off the disconnect buttons
allowDisconnect = false
+
+ // Cancel any existing discovery task so startDiscovery() always creates a fresh one.
+ // Without this, if discovery was still running from before the connection attempt,
+ // startDiscovery() would silently no-op and the device would never reappear in the list.
+ discoveryTask?.cancel()
+ discoveryTask = nil
+
self.startDiscovery()
}
diff --git a/Meshtastic/CarPlay/CarPlayIntentDonation.swift b/Meshtastic/CarPlay/CarPlayIntentDonation.swift
new file mode 100644
index 00000000..fa7aef40
--- /dev/null
+++ b/Meshtastic/CarPlay/CarPlayIntentDonation.swift
@@ -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)"
+ }
+}
diff --git a/Meshtastic/CarPlay/CarPlaySceneDelegate.swift b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift
new file mode 100644
index 00000000..dc1c9d02
--- /dev/null
+++ b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift
@@ -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()
+ // 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()
+
+ 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.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.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.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.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.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.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.activities {
+ await activity.end(nil, dismissalPolicy: .immediate)
+ Logger.services.info("🚗 [CarPlay] Ended Live Activity: \(activity.id)")
+ }
+ }
+ }
+#else
+ private func startLiveActivityIfNeeded() {}
+ private func endLiveActivity() {}
+#endif
+}
diff --git a/Meshtastic/Extensions/View.swift b/Meshtastic/Extensions/View.swift
index b28f5e8e..3009a089 100644
--- a/Meshtastic/Extensions/View.swift
+++ b/Meshtastic/Extensions/View.swift
@@ -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, *) {
diff --git a/Meshtastic/Helpers/GeoJSONOverlayConfig.swift b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift
index b25b8509..e900e4d4 100644
--- a/Meshtastic/Helpers/GeoJSONOverlayConfig.swift
+++ b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift
@@ -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
diff --git a/Meshtastic/Helpers/GeoJSONOverlayManager.swift b/Meshtastic/Helpers/GeoJSONOverlayManager.swift
index 82801db0..c0953668 100644
--- a/Meshtastic/Helpers/GeoJSONOverlayManager.swift
+++ b/Meshtastic/Helpers/GeoJSONOverlayManager.swift
@@ -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, 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) -> [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)
diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift
index 2d0417e7..936f11ad 100644
--- a/Meshtastic/Helpers/MeshPackets.swift
+++ b/Meshtastic/Helpers/MeshPackets.swift
@@ -1028,6 +1028,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
}
diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist
index 02e9ce32..827964be 100644
--- a/Meshtastic/Info.plist
+++ b/Meshtastic/Info.plist
@@ -87,6 +87,12 @@
INSearchForMessagesIntent
INSetMessageAttributeIntent
+ NSUserActivityTypes
+
+ INSendMessageIntent
+ INSearchForMessagesIntent
+ INSetMessageAttributeIntent
+
ITSAppUsesNonExemptEncryption
LSApplicationCategoryType
@@ -113,6 +119,8 @@
We use the camera to share channels using a QR Code
NSLocalNetworkUsageDescription
We use local networking to connect to network-based nodes.
+ NSSiriUsageDescription
+ Siri and Shortcuts let you control Meshtastic hands-free — send messages, disconnect, restart, or shut down your node with your voice.
NSLocationAlwaysAndWhenInUseUsageDescription
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.
NSLocationAlwaysUsageDescription
@@ -131,6 +139,27 @@
UIApplicationSupportsMultipleScenes
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+
+
+ CPTemplateApplicationSceneSessionRoleApplication
+
+
+ UISceneClassName
+ CPTemplateApplicationScene
+ UISceneConfigurationName
+ CarPlay
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate
+
+
+
UIApplicationSupportsIndirectInputEvents
@@ -243,7 +272,5 @@
- com.apple.developer.carplay-communication
-
diff --git a/Meshtastic/Intents/IntentMessageConverters.swift b/Meshtastic/Intents/IntentMessageConverters.swift
index cfd1a724..01b63fd5 100644
--- a/Meshtastic/Intents/IntentMessageConverters.swift
+++ b/Meshtastic/Intents/IntentMessageConverters.swift
@@ -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.fetchRequest()
+ fetchRequest.fetchLimit = 1
+ fetchRequest.predicate = NSPredicate(format: "num == %lld", nodeNum)
+ return (try? context.fetch(fetchRequest)) ?? []
+ }
+
let fetchRequest: NSFetchRequest = 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.fetchRequest()
+ fetchRequest.fetchLimit = 1
+ fetchRequest.predicate = NSPredicate(format: "index == %d", explicitIndex)
+ return (try? context.fetch(fetchRequest)) ?? []
+ }
+
let fetchRequest: NSFetchRequest = 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)"
+ }
}
diff --git a/Meshtastic/Intents/SearchForMessagesIntentHandler.swift b/Meshtastic/Intents/SearchForMessagesIntentHandler.swift
index 42d6208e..9ac87f47 100644
--- a/Meshtastic/Intents/SearchForMessagesIntentHandler.swift
+++ b/Meshtastic/Intents/SearchForMessagesIntentHandler.swift
@@ -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.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)")
diff --git a/Meshtastic/Intents/SendMessageIntentHandler.swift b/Meshtastic/Intents/SendMessageIntentHandler.swift
index 06acb240..7a42937d 100644
--- a/Meshtastic/Intents/SendMessageIntentHandler.swift
+++ b/Meshtastic/Intents/SendMessageIntentHandler.swift
@@ -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,20 +85,20 @@ 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)
}
- #if targetEnvironment(macCatalyst)
return .unsupported()
- #else
- return .unsupported(forReason: .noHandlesForValue)
- #endif
}
// MARK: - Confirmation
@@ -124,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)
}
diff --git a/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift b/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift
index c95529b9..0d424516 100644
--- a/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift
+++ b/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift
@@ -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
diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements
index bc694209..0690bd28 100644
--- a/Meshtastic/Meshtastic.entitlements
+++ b/Meshtastic/Meshtastic.entitlements
@@ -2,6 +2,8 @@
+ com.apple.developer.siri
+
com.apple.developer.associated-domains
applinks:meshtastic.org/e/*
diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift
index ebe1d048..a0565b4d 100644
--- a/Meshtastic/Views/Connect/Connect.swift
+++ b/Meshtastic/Views/Connect/Connect.swift
@@ -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 {
@@ -366,6 +379,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
}
}
}
@@ -378,7 +396,7 @@ struct Connect: View {
let localStats = node?.telemetries.filter { $0.metricsType == 4 }
let mostRecent = localStats?.last
- 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),
diff --git a/Meshtastic/Views/Connect/InvalidVersion.swift b/Meshtastic/Views/Connect/InvalidVersion.swift
index d6030139..2b1227c1 100644
--- a/Meshtastic/Views/Connect/InvalidVersion.swift
+++ b/Meshtastic/Views/Connect/InvalidVersion.swift
@@ -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
}
}
}
diff --git a/Meshtastic/Views/Connect/SecurityVersionNag.swift b/Meshtastic/Views/Connect/SecurityVersionNag.swift
new file mode 100644
index 00000000..1327b674
--- /dev/null
+++ b/Meshtastic/Views/Connect/SecurityVersionNag.swift
@@ -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()
+ }
+ }
+}
diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift
index 540ef0ca..6ce54fef 100644
--- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift
+++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift
@@ -187,10 +187,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 {
@@ -217,8 +220,10 @@ struct MeshMapContent: MapContent {
let allStyledFeatures = GeoJSONOverlayManager.shared.loadStyledFeaturesForConfigs(enabledOverlayConfigs)
return Group {
- ForEach(0.. 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
}
}
diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift
index fd142007..2d04e861 100644
--- a/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift
+++ b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift
@@ -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
diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift
index f0c9f1f1..66eb6e3c 100644
--- a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift
+++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift
@@ -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()
+ }
+ }
+ }
}
diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift
index bdeb208b..b6a74723 100644
--- a/Meshtastic/Views/Settings/Channels.swift
+++ b/Meshtastic/Views/Settings/Channels.swift
@@ -28,7 +28,7 @@ struct Channels: View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.colorScheme) private var colorScheme
- var node: NodeInfoEntity?
+ @ObservedObject var node: NodeInfoEntity
@State var hasChanges = false
@State var hasValidKey = true
@@ -60,8 +60,8 @@ struct Channels: View {
TipView(CreateChannelsTip(), arrowEdge: .bottom)
.tipBackground(colorScheme == .dark ? Color(.systemBackground) : Color(.secondarySystemBackground))
.listRowSeparator(.hidden)
- if node != nil && node?.myInfo != nil {
- ForEach(node?.myInfo?.channels ?? [], id: \.self) { (channel: ChannelEntity) in
+ if node.myInfo != nil {
+ ForEach(node.myInfo?.channels ?? [], id: \.self) { (channel: ChannelEntity) in
Button(action: {
channelIndex = channel.index
channelRole = Int(channel.role)
@@ -172,7 +172,7 @@ struct Channels: View {
selectedChannel!.downlinkEnabled = downlink
selectedChannel!.positionPrecision = Int32(positionPrecision)
- guard var channels = node?.myInfo?.channels else {
+ guard var channels = node.myInfo?.channels else {
return
}
if let idx = channels.firstIndex(where: { $0.psk == selectedChannel?.psk && $0.name == selectedChannel?.name }) {
@@ -180,7 +180,7 @@ struct Channels: View {
} else {
channels.append(selectedChannel!)
}
- node?.myInfo?.channels = channels
+ node.myInfo?.channels = channels
if channel.role != Channel.Role.disabled {
do {
try context.save()
@@ -209,14 +209,14 @@ struct Channels: View {
}
}
Task {
- _ = try await accessoryManager.saveChannel(channel: channel, fromUser: node!.user!, toUser: node!.user!)
+ _ = try await accessoryManager.saveChannel(channel: channel, fromUser: node.user!, toUser: node.user!)
Task { @MainActor in
selectedChannel = nil
channelName = ""
channelRole = 2
hasChanges = false
}
- accessoryManager.mqttManager.connectFromConfigSettings(node: node!)
+ accessoryManager.mqttManager.connectFromConfigSettings(node: node)
}
} label: {
Label("Save", systemImage: "square.and.arrow.down")
@@ -239,6 +239,45 @@ struct Channels: View {
#endif
}
}
+ if (node.myInfo?.channels ?? []).count < 8 {
+
+ Button {
+ let channelIndexes = node.myInfo?.channels?.compactMap { ch -> Int in
+ return Int(ch.index)
+ }
+ let firstChannelIndex = firstMissingChannelIndex(channelIndexes ?? [])
+ channelKeySize = 16
+ let key = generateChannelKey(size: channelKeySize)
+ channelName = ""
+ channelIndex = Int32(firstChannelIndex)
+ channelRole = 2
+ channelKey = key
+ positionsEnabled = false
+ preciseLocation = false
+ positionPrecision = 0
+ uplink = false
+ downlink = false
+
+ let newChannel = ChannelEntity()
+ newChannel.id = channelIndex
+ newChannel.index = channelIndex
+ newChannel.uplinkEnabled = uplink
+ newChannel.downlinkEnabled = downlink
+ newChannel.name = channelName
+ newChannel.role = Int32(channelRole)
+ newChannel.psk = Data(base64Encoded: channelKey) ?? Data()
+ newChannel.positionPrecision = Int32(positionPrecision)
+ selectedChannel = newChannel
+ hasChanges = true
+
+ } label: {
+ Label("Add Channel", systemImage: "plus.square")
+ }
+ .buttonStyle(.bordered)
+ .buttonBorderShape(.capsule)
+ .controlSize(.large)
+ .padding()
+ }
}
.sheet(isPresented: $showingHelp) {
ChannelsHelp()
diff --git a/Meshtastic/Views/Settings/Firmware.swift b/Meshtastic/Views/Settings/Firmware.swift
index 3584cddc..fd0578c3 100644
--- a/Meshtastic/Views/Settings/Firmware.swift
+++ b/Meshtastic/Views/Settings/Firmware.swift
@@ -13,14 +13,13 @@ struct Firmware: View {
@Environment(\.modelContext) private 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)
diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift
index 4852fb93..09e3f50c 100644
--- a/Meshtastic/Views/Settings/Settings.swift
+++ b/Meshtastic/Views/Settings/Settings.swift
@@ -293,6 +293,14 @@ struct Settings: View {
}
}
+ NavigationLink(value: SettingsNavigationState.tak) {
+ Label {
+ Text("TAK Server")
+ } icon: {
+ Image(systemName: "target")
+ }
+ }
+
if isModuleSupported(.telemetryConfig) {
NavigationLink(value: SettingsNavigationState.telemetry) {
Label {
@@ -303,16 +311,6 @@ struct Settings: View {
}
}
- if isTAKModuleSupported() {
- NavigationLink(value: SettingsNavigationState.takConfig) {
- Label {
- Text("TAK")
- } icon: {
- Image(systemName: "shield.checkered")
- }
- }
- }
-
if !showsAnyModuleConfiguration {
Text("This node does not support any configurable modules.")
.foregroundColor(.secondary)
@@ -343,6 +341,15 @@ struct Settings: View {
Image(systemName: "folder")
}
}
+ if #available(iOS 18, *) {
+ NavigationLink(value: SettingsNavigationState.tools) {
+ Label {
+ Text("Tools")
+ } icon: {
+ Image(systemName: "hammer")
+ }
+ }
+ }
}
}
@@ -399,15 +406,6 @@ struct Settings: View {
Image(systemName: "gearshape")
}
}
- if #available(iOS 18, *) {
- NavigationLink(value: SettingsNavigationState.tools) {
- Label {
- Text("Tools")
- } icon: {
- Image(systemName: "hammer")
- }
- }
- }
NavigationLink(value: SettingsNavigationState.routes) {
Label {
Text("Routes")
@@ -511,7 +509,6 @@ struct Settings: View {
developersSection
#endif
firmwareSection
- takSection
}
}
.navigationDestination(for: SettingsNavigationState.self) { destination in
@@ -528,7 +525,11 @@ struct Settings: View {
case .lora:
LoRaConfig(node: nodes.first(where: { $0.num == selectedNode }))
case .channels:
- Channels(node: node)
+ if let node = node {
+ Channels(node: node)
+ } else {
+ Text("Loading...")
+ }
case .shareQRCode:
ShareChannels(node: node)
case .user:
diff --git a/Meshtastic/da.lproj/AppIntentVocabulary.plist b/Meshtastic/da.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..0857f1c7
--- /dev/null
+++ b/Meshtastic/da.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Send en besked på Meshtastic
+ Send en Meshtastic-besked til Lars
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Søg efter Meshtastic-beskeder
+ Find beskeder på Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Markér Meshtastic-besked som læst
+ Markér Meshtastic-beskeder som læst
+
+
+
+
+
diff --git a/Meshtastic/de.lproj/AppIntentVocabulary.plist b/Meshtastic/de.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..93be827c
--- /dev/null
+++ b/Meshtastic/de.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Sende eine Nachricht über Meshtastic
+ Sende eine Meshtastic-Nachricht an Hans
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Suche Meshtastic-Nachrichten
+ Finde Nachrichten auf Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Markiere Meshtastic-Nachricht als gelesen
+ Markiere Meshtastic-Nachrichten als gelesen
+
+
+
+
+
diff --git a/Meshtastic/en.lproj/AppIntentVocabulary.plist b/Meshtastic/en.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..2cc6149d
--- /dev/null
+++ b/Meshtastic/en.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Send a message on Meshtastic
+ Send a Meshtastic message to John
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Search Meshtastic messages
+ Find messages on Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Mark Meshtastic message as read
+ Mark Meshtastic messages as read
+
+
+
+
+
diff --git a/Meshtastic/es.lproj/AppIntentVocabulary.plist b/Meshtastic/es.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..e913fae1
--- /dev/null
+++ b/Meshtastic/es.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Enviar un mensaje en Meshtastic
+ Enviar un mensaje de Meshtastic a Juan
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Buscar mensajes de Meshtastic
+ Encontrar mensajes en Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Marcar mensaje de Meshtastic como leído
+ Marcar mensajes de Meshtastic como leídos
+
+
+
+
+
diff --git a/Meshtastic/fr.lproj/AppIntentVocabulary.plist b/Meshtastic/fr.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..1605dc44
--- /dev/null
+++ b/Meshtastic/fr.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Envoyer un message sur Meshtastic
+ Envoyer un message Meshtastic à Pierre
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Chercher des messages Meshtastic
+ Trouver des messages sur Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Marquer le message Meshtastic comme lu
+ Marquer les messages Meshtastic comme lus
+
+
+
+
+
diff --git a/Meshtastic/he.lproj/AppIntentVocabulary.plist b/Meshtastic/he.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..655291fb
--- /dev/null
+++ b/Meshtastic/he.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ שלח הודעה ב-Meshtastic
+ שלח הודעת Meshtastic לדוד
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ חפש הודעות Meshtastic
+ מצא הודעות ב-Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ סמן הודעת Meshtastic כנקראה
+ סמן הודעות Meshtastic כנקראו
+
+
+
+
+
diff --git a/Meshtastic/it.lproj/AppIntentVocabulary.plist b/Meshtastic/it.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..52a3d8ad
--- /dev/null
+++ b/Meshtastic/it.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Invia un messaggio su Meshtastic
+ Invia un messaggio Meshtastic a Marco
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Cerca messaggi su Meshtastic
+ Trova messaggi su Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Segna il messaggio Meshtastic come letto
+ Segna i messaggi Meshtastic come letti
+
+
+
+
+
diff --git a/Meshtastic/ja.lproj/AppIntentVocabulary.plist b/Meshtastic/ja.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..e8e894eb
--- /dev/null
+++ b/Meshtastic/ja.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Meshtasticでメッセージを送信
+ Meshtasticで太郎にメッセージを送って
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Meshtasticのメッセージを検索
+ Meshtasticでメッセージを探して
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Meshtasticのメッセージを既読にして
+ Meshtasticのメッセージを既読にする
+
+
+
+
+
diff --git a/Meshtastic/pl.lproj/AppIntentVocabulary.plist b/Meshtastic/pl.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..c3429a7c
--- /dev/null
+++ b/Meshtastic/pl.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Wyślij wiadomość przez Meshtastic
+ Wyślij wiadomość Meshtastic do Jana
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Szukaj wiadomości Meshtastic
+ Znajdź wiadomości w Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Oznacz wiadomość Meshtastic jako przeczytaną
+ Oznacz wiadomości Meshtastic jako przeczytane
+
+
+
+
+
diff --git a/Meshtastic/ru.lproj/AppIntentVocabulary.plist b/Meshtastic/ru.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..7f4048dd
--- /dev/null
+++ b/Meshtastic/ru.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Отправить сообщение через Meshtastic
+ Отправить сообщение Meshtastic Ивану
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Найти сообщения в Meshtastic
+ Поиск сообщений Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Отметить сообщение Meshtastic как прочитанное
+ Отметить сообщения Meshtastic как прочитанные
+
+
+
+
+
diff --git a/Meshtastic/se.lproj/AppIntentVocabulary.plist b/Meshtastic/se.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..4bbbb822
--- /dev/null
+++ b/Meshtastic/se.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Skicka ett meddelande på Meshtastic
+ Skicka ett Meshtastic-meddelande till Erik
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Sök Meshtastic-meddelanden
+ Hitta meddelanden på Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Markera Meshtastic-meddelande som läst
+ Markera Meshtastic-meddelanden som lästa
+
+
+
+
+
diff --git a/Meshtastic/sr.lproj/AppIntentVocabulary.plist b/Meshtastic/sr.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..54ff1c6a
--- /dev/null
+++ b/Meshtastic/sr.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ Пошаљи поруку преко Meshtastic
+ Пошаљи Meshtastic поруку Марку
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ Претражи Meshtastic поруке
+ Пронађи поруке на Meshtastic
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ Означи Meshtastic поруку као прочитану
+ Означи Meshtastic поруке као прочитане
+
+
+
+
+
diff --git a/Meshtastic/zh-Hans.lproj/AppIntentVocabulary.plist b/Meshtastic/zh-Hans.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..39a13428
--- /dev/null
+++ b/Meshtastic/zh-Hans.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ 通过Meshtastic发送消息
+ 用Meshtastic给小明发消息
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ 搜索Meshtastic消息
+ 查找Meshtastic消息
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ 将Meshtastic消息标记为已读
+ 标记Meshtastic消息为已读
+
+
+
+
+
diff --git a/Meshtastic/zh-Hant-TW.lproj/AppIntentVocabulary.plist b/Meshtastic/zh-Hant-TW.lproj/AppIntentVocabulary.plist
new file mode 100644
index 00000000..47bfc4e2
--- /dev/null
+++ b/Meshtastic/zh-Hant-TW.lproj/AppIntentVocabulary.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ IntentPhrases
+
+
+ IntentName
+ INSendMessageIntent
+ IntentExamples
+
+ 透過Meshtastic傳送訊息
+ 用Meshtastic傳訊息給小明
+
+
+
+ IntentName
+ INSearchForMessagesIntent
+ IntentExamples
+
+ 搜尋Meshtastic訊息
+ 尋找Meshtastic訊息
+
+
+
+ IntentName
+ INSetMessageAttributeIntent
+ IntentExamples
+
+ 將Meshtastic訊息標記為已讀
+ 標記Meshtastic訊息為已讀
+
+
+
+
+
diff --git a/MeshtasticTests/CarPlayTests.swift b/MeshtasticTests/CarPlayTests.swift
new file mode 100644
index 00000000..786e4136
--- /dev/null
+++ b/MeshtasticTests/CarPlayTests.swift
@@ -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
+ }
+}
diff --git a/MeshtasticTests/ConnectViewTests.swift b/MeshtasticTests/ConnectViewTests.swift
index 450591ad..c2d1d5e1 100644
--- a/MeshtasticTests/ConnectViewTests.swift
+++ b/MeshtasticTests/ConnectViewTests.swift
@@ -364,7 +364,7 @@ struct InvalidVersionTests {
}
@Test func viewCreationWithEmptyVersions() {
- let view = InvalidVersion()
+ let view = InvalidVersion(minimumVersion: "", version: "")
#expect(view.minimumVersion == "")
#expect(view.version == "")
}
diff --git a/MeshtasticTests/DeviceOnboardingTests.swift b/MeshtasticTests/DeviceOnboardingTests.swift
new file mode 100644
index 00000000..4831c87c
--- /dev/null
+++ b/MeshtasticTests/DeviceOnboardingTests.swift
@@ -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()
+ 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])
+ }
+}
diff --git a/README.md b/README.md
index 28a8e2a9..afa87698 100644
--- a/README.md
+++ b/README.md
@@ -116,14 +116,34 @@ Each settings item has an associated deep link. No parameters are supported for
| `meshtastic:///settings/telemetry` | Telemetry |
| **TAK** | |
| `meshtastic:///settings/tak` | TAK Config |
-| **Tools** | |
-| `meshtastic:///settings/tools` | Tools |
| **Logging** | |
| `meshtastic:///settings/debugLogs` | Debug Logs |
| **Developers** | |
| `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)
diff --git a/Widgets/MeshActivityAttributes.swift b/Widgets/MeshActivityAttributes.swift
index 8d7ea9af..7d2bd5a8 100644
--- a/Widgets/MeshActivityAttributes.swift
+++ b/Widgets/MeshActivityAttributes.swift
@@ -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
diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift
index 5f1e6d29..525cf10b 100644
--- a/Widgets/WidgetsLiveActivity.swift
+++ b/Widgets/WidgetsLiveActivity.swift
@@ -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
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
+ // 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
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)
}
}
}