Merge origin/2.7.10 into firmware-updates - resolve conflicts (CarPlay intents, BLE cleanup, channel fix)

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/6141f5ab-6bd5-43f5-9922-a7fde05f0d5d

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-18 04:45:23 +00:00 committed by GitHub
parent cf137003e9
commit 0898cfed21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 63317 additions and 62576 deletions

116
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,116 @@
# GitHub Copilot Instructions for Meshtastic-Apple
## Project Overview
Meshtastic-Apple is a SwiftUI client for iOS, iPadOS, and macOS (via Mac Catalyst) that communicates with Meshtastic LoRa mesh radio devices over BLE, TCP, and serial transports. The app handles mesh networking, messaging, node management, mapping, and radio configuration.
## Architecture
### App Entry Point
- `Meshtastic/MeshtasticApp.swift``@main` `App` struct; initialises `AppState`, `Router`, `AccessoryManager`, `PersistenceController`, and Datadog observability.
- `Meshtastic/MeshtasticAppDelegate.swift``UIApplicationDelegate` for SiriKit intent handling (CarPlay messaging via `INSendMessageIntent` etc.).
### State & Navigation
- `Router` (`Meshtastic/Router/Router.swift`) is a `@MainActor` `ObservableObject` that owns a `NavigationState` struct and drives tab/deep-link routing.
- `NavigationState` and the per-tab enums (`MessagesNavigationState`, `MapNavigationState`, `SettingsNavigationState`) live in `Meshtastic/Router/NavigationState.swift`.
- Deep links use the `meshtastic:///` URL scheme (see README for the full table). `Router.route(url:)` dispatches them.
- `AppState` wraps `Router` and is passed as an `@EnvironmentObject` throughout the view hierarchy.
### Connectivity
- `AccessoryManager` (`Meshtastic/Accessory/Accessory Manager/`) is the central BLE/TCP/serial manager. It is split across extension files:
- `AccessoryManager+Discovery.swift` — device scanning & connection
- `AccessoryManager+Connect.swift` — connection lifecycle
- `AccessoryManager+ToRadio.swift` — packets sent to the radio (including `sendWaypoint`)
- `AccessoryManager+FromRadio.swift` — packets received from the radio
- `AccessoryManager+Position.swift` — GPS position sharing
- `AccessoryManager+MQTT.swift` — MQTT proxy
- `AccessoryManager+TAK.swift` — TAK/CoT integration
- Transport protocols are in `Meshtastic/Accessory/Transports/`.
### Persistence
- Core Data is the sole persistence layer. Use `PersistenceController.shared` for the container; prefer `viewContext` for reads and a background context for writes.
- The model lives in `Meshtastic/Meshtastic.xcdatamodeld` (55+ versioned migrations — always add a new model version for schema changes).
- Query helpers: `QueryCoreData.swift` (`getNodeInfo`, etc.); update helpers: `UpdateCoreData.swift`.
### Protobufs
- The `MeshtasticProtobufs` Swift Package (`MeshtasticProtobufs/Package.swift`) wraps the protobuf-generated Swift sources.
- Regenerate with `./scripts/gen_protos.sh` whenever `protobufs/` submodule changes, then build and commit.
## Code Style
### Language & Frameworks
- **Swift only.** No Objective-C.
- **SwiftUI** for all UI. Do not use UIKit directly unless unavoidable (e.g., `UIApplicationDelegateAdaptor`).
- **SF Symbols** for all icons — never embed image assets for icons.
- **Core Data** for all persistence — do not introduce SQLite, Realm, or other persistence libraries.
- **OSLog / `Logger`** for all logging — never use `print()`. The project's SwiftLint config enforces this with a custom `disable_print` rule. Use the typed loggers defined in `Meshtastic/Extensions/Logger.swift`:
- `Logger.admin`, `Logger.data`, `Logger.mesh`, `Logger.mqtt`, `Logger.radio`, `Logger.services`, `Logger.statistics`, `Logger.transport`, `Logger.tak`
### Platform Support
- Target the **last two major OS versions** of iOS, iPadOS, and macOS (Mac Catalyst).
- Guard iOS-only APIs with `#if !targetEnvironment(macCatalyst)` or `#if canImport(UIKit)`.
### SwiftLint
- SwiftLint is enforced on every commit via `scripts/setup-hooks.sh`. Ensure no new errors or warnings are introduced.
- Key limits (see `.swiftlint.yml`): line length 400, file length warning 3500, type body length warning 400, function body length warning 200, cyclomatic complexity warning 60.
- Disabled rules: `operator_whitespace`, `multiple_closures_with_trailing_closure`, `todo`, `trailing_whitespace`.
### Formatting Conventions
- Indent with **tabs**.
- Opening braces on the same line as the declaration.
- `// MARK: -` comments to separate logical sections within a file.
- `// MARK: FileName` or file-level copyright comment at the top of files.
- Extensions grouped by functionality in separate files (e.g., `AccessoryManager+ToRadio.swift`).
- Prefer `guard` for early exit; avoid deeply nested `if` blocks.
- Use trailing closure syntax; omit argument labels where idiomatic.
### Naming
- Types: `UpperCamelCase`, max 60 chars (warning at 60, error at 70).
- Variables/functions: `lowerCamelCase`, min 1 char, max 60 chars.
- Enum raw values that map to URL path segments use `lowerCamelCase` (e.g., `SettingsNavigationState.appSettings`).
- File names match the primary type they define.
### Concurrency
- `Router` is `@MainActor`; all property reads and method calls must be `await`-ed in async contexts and tests.
- Prefer `async/await` over callback-based APIs for new code.
- Use `Task` for fire-and-forget async work; propagate cancellation via `Task.checkCancellation()`.
## Testing
- Test target: `MeshtasticTests/`.
- Use **Swift Testing** (`import Testing`, `@Suite`, `@Test`, `#expect`, `#require`) for new tests. XCTest is used in some legacy test files.
- Tests are run via Xcode — there is no Makefile or CLI test runner.
- Ensure all existing tests pass before submitting a PR.
- Write tests for new features and bug fixes.
## Git & PR Workflow
- Branch from and target **`main`** (trunk-based development).
- Use **rebase** instead of merge to incorporate upstream changes (`git config pull.rebase true`).
- Keep branches small and focused on a single task.
- Commit messages: imperative mood subject line (e.g., `Fix crash when BLE device disconnects`). Explain *what* and *why* in the body.
- PR description must answer: what changed, why it changed, how it was tested, and include screenshots/videos when UI is affected (see `.github/pull_request_template.md`).
- Self-review code before requesting review; comment complex areas.
## Deep Links
The app registers the `meshtastic:///` URL scheme. Use `Router.route(url:)` to handle incoming URLs. When adding a new deep link:
1. Add a case to the appropriate `*NavigationState` enum in `NavigationState.swift`.
2. Update `Router`'s routing helpers.
3. Document the URL in the README.
## Adding or Updating Protobufs
1. Update the `protobufs/` git submodule.
2. Run `./scripts/gen_protos.sh`.
3. Build, test, and commit the generated changes.
## Core Data Schema Changes
1. Create a new model version in `Meshtastic.xcdatamodeld`.
2. Set it as the current version.
3. Add a migration policy if required (lightweight migration is preferred when possible).
## CI
CI is handled by Xcode Cloud via `ci_scripts/ci_pre_xcodebuild.sh`. Do not modify CI scripts without understanding the Xcode Cloud build environment.

View file

@ -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 <a href="https://meshtastic.org/docs/software/apple/ios-debug/">Debug Log</a> 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,
});
}

File diff suppressed because it is too large Load diff

View file

@ -338,6 +338,11 @@
DDFFA7472B3A7F3C004730DB /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFFA7462B3A7F3C004730DB /* Bundle.swift */; };
E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */; };
FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */; };
43C0CB306098FD005C2D489F /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA63632A10C2DD076FA222BE /* IntentHandler.swift */; };
7717E5954788B23527BACF65 /* IntentMessageConverters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39D80697D5BB72C4566988E0 /* IntentMessageConverters.swift */; };
AB4622DCF4B1D4115ED00312 /* SendMessageIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */; };
B0E4EEF2D2C41A884A5E949C /* SearchForMessagesIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */; };
9BC51D7EF97090D149658843 /* SetMessageAttributeIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -760,6 +765,11 @@
DDF924C926FBB953009FE055 /* ConnectedDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectedDevice.swift; sourceTree = "<group>"; };
DDFEB3BA29900C1200EE7472 /* CurrentConditionsCompact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentConditionsCompact.swift; sourceTree = "<group>"; };
DDFFA7462B3A7F3C004730DB /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; };
CA63632A10C2DD076FA222BE /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = "<group>"; };
39D80697D5BB72C4566988E0 /* IntentMessageConverters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentMessageConverters.swift; sourceTree = "<group>"; };
5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageIntentHandler.swift; sourceTree = "<group>"; };
0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchForMessagesIntentHandler.swift; sourceTree = "<group>"; };
CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetMessageAttributeIntentHandler.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -1169,6 +1179,18 @@
path = AppIntents;
sourceTree = "<group>";
};
D05BD108B673E15AD4B01BC8 /* Intents */ = {
isa = PBXGroup;
children = (
CA63632A10C2DD076FA222BE /* IntentHandler.swift */,
39D80697D5BB72C4566988E0 /* IntentMessageConverters.swift */,
5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */,
0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */,
CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */,
);
path = Intents;
sourceTree = "<group>";
};
C37572859BC745C4284A9B42 /* TAK */ = {
isa = PBXGroup;
children = (
@ -1465,6 +1487,7 @@
237AEB8D2E1FE120003B7CE3 /* Accessory */,
23148E2E2EE1CCB100F0DB2C /* API */,
BCB6137F2C6728E700485544 /* AppIntents */,
D05BD108B673E15AD4B01BC8 /* Intents */,
DD1BD0EC2C603C5B008C0C70 /* Measurement */,
25F5D5BC2C3F6D7B008036E3 /* Router */,
DD7709392AA1ABA1007A8BF0 /* Tips */,
@ -1949,6 +1972,11 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
43C0CB306098FD005C2D489F /* IntentHandler.swift in Sources */,
7717E5954788B23527BACF65 /* IntentMessageConverters.swift in Sources */,
AB4622DCF4B1D4115ED00312 /* SendMessageIntentHandler.swift in Sources */,
B0E4EEF2D2C41A884A5E949C /* SearchForMessagesIntentHandler.swift in Sources */,
9BC51D7EF97090D149658843 /* SetMessageAttributeIntentHandler.swift in Sources */,
230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */,
25F26B1F2C2F611300C9CD9D /* AppData.swift in Sources */,
23825FFF2EF6E79C00C25543 /* ESP32BLEOTASheet.swift in Sources */,
@ -2468,7 +2496,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";
@ -2496,7 +2523,6 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.8;
MARKETING_VERSION = 2.7.10;
OTHER_LDFLAGS = (
"-weak_framework",
@ -2519,7 +2545,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";
@ -2547,7 +2572,6 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.8;
MARKETING_VERSION = 2.7.10;
OTHER_LDFLAGS = (
"-weak_framework",
@ -2584,7 +2608,6 @@
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.8;
MARKETING_VERSION = 2.7.10;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -2620,7 +2643,6 @@
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.8;
MARKETING_VERSION = 2.7.10;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -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 {

View file

@ -85,6 +85,7 @@ extension AccessoryManager {
}
func stopDiscovery() {
devices.removeAll()
discoveryTask?.cancel()
discoveryTask = nil
devices.removeAll()

View file

@ -166,7 +166,6 @@ extension AccessoryManager {
// Update local database with the new node info
// FUTURE: after https://github.com/meshtastic/firmware/pull/8495 is merged, `favorite: true` becomes `favorite: (connectedDeviceRole != DeviceRoles.clientBase)`
upsertNodeInfoPacket(packet: nodeMeshPacket, favorite: true, context: context)
await MeshPackets.shared.upsertNodeInfoPacket(packet: nodeMeshPacket, favorite: true)
}
} catch {

View file

@ -289,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()
}

View file

@ -83,7 +83,9 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>INIntentsSupported</key>
<array>
<string>Intent</string>
<string>INSendMessageIntent</string>
<string>INSearchForMessagesIntent</string>
<string>INSetMessageAttributeIntent</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
@ -119,6 +121,8 @@
<string>We use your location to display it on the mesh map as well as to have GPS coordinates to send to the connected device.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to display it on the mesh map, show and filter by distance as well as to have GPS coordinates to send to the connected device. Route Recording uses location in the background.</string>
<key>NSSiriUsageDescription</key>
<string>Siri is used for messaging to send and receive Meshtastic messages by voice and through CarPlay.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>Privacy Bluetooth Always Usage Description</key>

View file

@ -0,0 +1,26 @@
//
// IntentHandler.swift
// Meshtastic
//
// Routes incoming SiriKit intents to the appropriate handler.
// Used by the app delegate for in-app intent handling to support
// CarPlay messaging and Siri voice commands.
//
import Intents
final class IntentHandler: INExtension {
override func handler(for intent: INIntent) -> Any? {
switch intent {
case is INSendMessageIntent:
return SendMessageIntentHandler()
case is INSearchForMessagesIntent:
return SearchForMessagesIntentHandler()
case is INSetMessageAttributeIntent:
return SetMessageAttributeIntentHandler()
default:
return nil
}
}
}

View file

@ -0,0 +1,81 @@
//
// IntentMessageConverters.swift
// Meshtastic
//
// Helpers for converting Core Data entities to SiriKit intent objects (INPerson, INMessage)
// used by the CarPlay messaging intent handlers.
//
import CoreData
import Intents
enum IntentMessageConverters {
/// Converts a `UserEntity` to an `INPerson` for use with SiriKit intents.
static func inPerson(from user: UserEntity) -> INPerson {
let handle = INPersonHandle(value: String(user.num), type: .unknown)
return INPerson(
personHandle: handle,
nameComponents: nil,
displayName: user.longName ?? user.shortName ?? "Node \(user.num)",
image: nil,
contactIdentifier: String(user.num),
customIdentifier: String(user.num)
)
}
/// Converts a `MessageEntity` to an `INMessage` for use with SiriKit search results.
static func inMessage(from message: MessageEntity) -> INMessage {
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)")
: nil
return INMessage(
identifier: String(message.messageId),
conversationIdentifier: conversationIdentifier(for: message),
content: message.messagePayload,
dateSent: dateSent,
sender: sender,
recipients: recipients,
groupName: groupName,
messageType: .text
)
}
/// Builds a stable conversation identifier from a message.
/// Channel messages use "channel-<N>", direct messages use "dm-<nodeNum>".
static func conversationIdentifier(for message: MessageEntity) -> String {
if let toUser = message.toUser {
return "dm-\(toUser.num)"
}
return "channel-\(message.channel)"
}
/// Searches for `UserEntity` objects whose name matches the given search term.
static func findUsers(matching searchTerm: String, in context: NSManagedObjectContext) -> [UserEntity] {
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@",
searchTerm, searchTerm
)
return (try? context.fetch(fetchRequest)) ?? []
}
/// Looks up a `ChannelEntity` by matching name.
static func findChannels(matching name: String, in context: NSManagedObjectContext) -> [ChannelEntity] {
let fetchRequest: NSFetchRequest<ChannelEntity> = ChannelEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "name != nil AND name != '' AND name CONTAINS[cd] %@", name
)
return (try? context.fetch(fetchRequest)) ?? []
}
/// Resolves a channel index from a spoken group name, defaulting to the primary channel.
static func channelIndex(for name: String, in context: NSManagedObjectContext) -> Int {
let channels = findChannels(matching: name, in: context)
return channels.first.map { Int($0.index) } ?? 0
}
}

View file

@ -0,0 +1,106 @@
//
// SearchForMessagesIntentHandler.swift
// Meshtastic
//
// Handles INSearchForMessagesIntent for CarPlay and Siri.
// Queries Core Data for messages matching the intent criteria
// and returns them as INMessage objects.
//
import CoreData
import Intents
import OSLog
final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentHandling {
/// Maximum number of messages to return in a single search.
private static let maxResults = 20
// MARK: - Handling
func handle(intent: INSearchForMessagesIntent) async -> INSearchForMessagesIntentResponse {
let context = PersistenceController.shared.container.viewContext
let messages: [INMessage] = await MainActor.run {
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
var predicates: [NSPredicate] = []
// Exclude admin and emoji messages
predicates.append(NSPredicate(format: "admin == NO"))
predicates.append(NSPredicate(format: "isEmoji == NO"))
// Filter by identifiers (specific message IDs)
if let identifiers = intent.identifiers, !identifiers.isEmpty {
let messageIds = identifiers.compactMap { Int64($0) }
if !messageIds.isEmpty {
predicates.append(NSPredicate(format: "messageId IN %@", messageIds))
}
}
// Filter by sender
if let senders = intent.senders, !senders.isEmpty {
let senderNums = senders.compactMap { $0.personHandle?.value }.compactMap { Int64($0) }
if !senderNums.isEmpty {
predicates.append(NSPredicate(format: "fromUser.num IN %@", senderNums))
}
}
// Filter by date range.
// INDateComponentsRange exposes DateComponents on all platforms;
// .startDate/.endDate are iOS-only and unavailable on Mac Catalyst.
if let dateRange = intent.dateTimeRange {
let calendar = Calendar.current
if let startComponents = dateRange.startDateComponents,
let startDate = calendar.date(from: startComponents) {
let startTimestamp = Int32(startDate.timeIntervalSince1970)
predicates.append(NSPredicate(format: "messageTimestamp >= %d", startTimestamp))
}
if let endComponents = dateRange.endDateComponents,
let endDate = calendar.date(from: endComponents) {
let endTimestamp = Int32(endDate.timeIntervalSince1970)
predicates.append(NSPredicate(format: "messageTimestamp <= %d", endTimestamp))
}
}
// Filter by group/channel name
if let groupNames = intent.speakableGroupNames, !groupNames.isEmpty {
let channelIndices: [Int32] = groupNames.compactMap { groupName in
let channels = IntentMessageConverters.findChannels(
matching: groupName.spokenPhrase, in: context
)
return channels.first.map { Int32($0.index) }
}
if !channelIndices.isEmpty {
predicates.append(NSPredicate(format: "channel IN %@", channelIndices))
}
}
// Filter by read/unread attribute
let attributes = intent.attributes
if attributes.contains(.read) {
predicates.append(NSPredicate(format: "read == YES"))
} else if attributes.contains(.unread) {
predicates.append(NSPredicate(format: "read == NO"))
}
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "messageTimestamp", ascending: false)
]
fetchRequest.fetchLimit = Self.maxResults
fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser", "toUser"]
do {
let results = try context.fetch(fetchRequest)
return results.map { IntentMessageConverters.inMessage(from: $0) }
} catch {
Logger.services.error("CarPlay/Siri: Failed to search messages: \(error.localizedDescription)")
return []
}
}
let response = INSearchForMessagesIntentResponse(code: .success, userActivity: nil)
response.messages = messages
return response
}
}

View file

@ -0,0 +1,144 @@
//
// SendMessageIntentHandler.swift
// Meshtastic
//
// Handles INSendMessageIntent for CarPlay and Siri messaging.
// Meshtastic supports exactly one destination per message: either a single
// direct-message recipient (a mesh node) or a channel (speakableGroupName).
// Multiple recipients are not supported.
//
import CoreData
import Intents
import OSLog
final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
// MARK: - Resolution
func resolveRecipients(for intent: INSendMessageIntent) async -> [INSendMessageRecipientResolutionResult] {
guard let recipients = intent.recipients, !recipients.isEmpty else {
if intent.speakableGroupName != nil {
return []
}
return [.needsValue()]
}
// Meshtastic only supports a single direct-message recipient.
if recipients.count > 1 {
return [.unsupported(forReason: .noAccount)]
}
let context = PersistenceController.shared.container.viewContext
let searchTerm = recipients[0].displayName
let matchingUsers = await MainActor.run {
IntentMessageConverters.findUsers(matching: searchTerm, in: context)
}
if matchingUsers.isEmpty {
return [.unsupported(forReason: .noAccount)]
} else if matchingUsers.count == 1, let user = matchingUsers.first {
return [.success(with: IntentMessageConverters.inPerson(from: user))]
} else {
let persons = matchingUsers.map { IntentMessageConverters.inPerson(from: $0) }
return [.disambiguation(with: persons)]
}
}
func resolveContent(for intent: INSendMessageIntent) async -> INStringResolutionResult {
guard let content = intent.content, !content.isEmpty else {
return .needsValue()
}
guard let data = content.data(using: .utf8), data.count <= 200 else {
return .unsupported()
}
return .success(with: content)
}
func resolveSpeakableGroupName(for intent: INSendMessageIntent) async -> INSpeakableStringResolutionResult {
guard let groupName = intent.speakableGroupName else {
if let recipients = intent.recipients, !recipients.isEmpty {
return .notRequired()
}
return .needsValue()
}
let context = PersistenceController.shared.container.viewContext
let matchingChannels = await MainActor.run {
IntentMessageConverters.findChannels(matching: groupName.spokenPhrase, in: context)
}
if matchingChannels.count == 1, let channel = matchingChannels.first {
let speakable = INSpeakableString(spokenPhrase: channel.name ?? "Channel \(channel.index)")
return .success(with: speakable)
} else if matchingChannels.count > 1 {
let speakables = matchingChannels.map {
INSpeakableString(spokenPhrase: $0.name ?? "Channel \($0.index)")
}
return .disambiguation(with: speakables)
}
return .unsupported()
}
// MARK: - Confirmation
func confirm(intent: INSendMessageIntent) async -> INSendMessageIntentResponse {
let connected = await AccessoryManager.shared.isConnected
guard connected else {
return INSendMessageIntentResponse(code: .failureRequiringAppLaunch, userActivity: nil)
}
return INSendMessageIntentResponse(code: .ready, userActivity: nil)
}
// MARK: - Handling
func handle(intent: INSendMessageIntent) async -> INSendMessageIntentResponse {
let connected = await AccessoryManager.shared.isConnected
guard connected else {
return INSendMessageIntentResponse(code: .failureRequiringAppLaunch, userActivity: nil)
}
guard let content = intent.content, !content.isEmpty else {
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
}
do {
if let groupName = intent.speakableGroupName {
// Channel message
let context = PersistenceController.shared.container.viewContext
let channelIndex = await MainActor.run {
IntentMessageConverters.channelIndex(for: groupName.spokenPhrase, in: context)
}
try await AccessoryManager.shared.sendMessage(
message: content,
toUserNum: 0,
channel: Int32(channelIndex),
isEmoji: false,
replyID: 0
)
} else if let recipient = intent.recipients?.first,
let handleValue = recipient.personHandle?.value,
let nodeNum = Int64(handleValue) {
// Direct message to a single node
try await AccessoryManager.shared.sendMessage(
message: content,
toUserNum: nodeNum,
channel: 0,
isEmoji: false,
replyID: 0
)
} else {
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
}
Logger.services.info("CarPlay/Siri: Message sent successfully")
return INSendMessageIntentResponse(code: .success, userActivity: nil)
} catch {
Logger.services.error("CarPlay/Siri: Failed to send message: \(error.localizedDescription)")
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
}
}
}

View file

@ -0,0 +1,85 @@
//
// SetMessageAttributeIntentHandler.swift
// Meshtastic
//
// Handles INSetMessageAttributeIntent for CarPlay and Siri.
// Marks messages as read or unread in Core Data.
//
import CoreData
import Intents
import OSLog
final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeIntentHandling {
// MARK: - Resolution
func resolveAttribute(for intent: INSetMessageAttributeIntent) async -> INMessageAttributeResolutionResult {
let attribute = intent.attribute
guard attribute != .unknown else {
return .needsValue()
}
return .success(with: attribute)
}
// MARK: - Confirmation
func confirm(intent: INSetMessageAttributeIntent) async -> INSetMessageAttributeIntentResponse {
guard let identifiers = intent.identifiers, !identifiers.isEmpty else {
return INSetMessageAttributeIntentResponse(code: .failure, userActivity: nil)
}
return INSetMessageAttributeIntentResponse(code: .ready, userActivity: nil)
}
// MARK: - Handling
func handle(intent: INSetMessageAttributeIntent) async -> INSetMessageAttributeIntentResponse {
guard let identifiers = intent.identifiers, !identifiers.isEmpty else {
return INSetMessageAttributeIntentResponse(code: .failure, userActivity: nil)
}
let attribute = intent.attribute
let context = PersistenceController.shared.container.viewContext
let success: Bool = await MainActor.run {
let messageIds = identifiers.compactMap { Int64($0) }
guard !messageIds.isEmpty else { return false }
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "messageId IN %@", messageIds)
do {
let messages = try context.fetch(fetchRequest)
guard !messages.isEmpty else { return false }
for message in messages {
switch attribute {
case .read:
message.read = true
case .unread:
message.read = false
case .flagged, .unflagged:
// Meshtastic does not support message flagging
break
default:
break
}
}
if context.hasChanges {
try context.save()
}
Logger.services.info("CarPlay/Siri: Updated \(messages.count) message(s) to \(String(describing: attribute))")
return true
} catch {
Logger.services.error("CarPlay/Siri: Failed to update message attributes: \(error.localizedDescription)")
return false
}
}
return INSetMessageAttributeIntentResponse(
code: success ? .success : .failure,
userActivity: nil
)
}
}

View file

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:meshtastic.org/e/*</string>

View file

@ -5,6 +5,7 @@
// Created by Ben on 8/20/23.
//
import Intents
import SwiftUI
import OSLog
@ -40,8 +41,22 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
Task { @MainActor in
TAKServerManager.shared.initializeOnStartup()
}
// Request Siri authorization so intent donations work and CarPlay messaging is available.
#if !targetEnvironment(macCatalyst)
INPreferences.requestSiriAuthorization { status in
Logger.services.info("Siri authorization status: \(String(describing: status))")
}
#endif
return true
}
// MARK: - SiriKit Intent Handling
/// Routes incoming SiriKit intents to the appropriate handler for CarPlay and Siri messaging support.
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? {
IntentHandler().handler(for: intent)
}
// Lets us show the notification in the app in the foreground
func userNotificationCenter(
_ center: UNUserNotificationCenter,

View file

@ -60,7 +60,6 @@ struct ChannelMessageList: View {
}
private func routerIsShowingThisChannel() -> Bool {
guard appState.router.navigationState.selectedTab == .messages else { return false }
guard appState.router.selectedTab == .messages else { return false }
return scenePhase == .active
}

View file

@ -12,7 +12,6 @@ struct MessageContextMenuItems: View {
@Binding var isShowingDeleteConfirmation: Bool
@Binding var isShowingTapbackInput: Bool
let onReply: () -> Void
@State var relayDisplay: String? = nil
let canTranslate: Bool
let hasTranslatedText: Bool
let isShowingTranslatedText: Bool
@ -37,32 +36,6 @@ struct MessageContextMenuItems: View {
}
}
Menu("Tapback") {
ForEach(Tapbacks.allCases) { tb in
Button {
Task {
do {
try await accessoryManager.sendMessage(
message: tb.emojiString,
toUserNum: tapBackDestination.userNum,
channel: tapBackDestination.channelNum,
isEmoji: true,
replyID: message.messageId
)
Task { @MainActor in
self.context.refresh(tapBackDestination.managedObject, mergeChanges: true)
}
} catch {
Logger.services.warning("Failed to send tapback.")
}
}
} label: {
Text(tb.description)
Image(uiImage: tb.emojiString.image()!)
}
}
}
Button("Tapback") {
// The context menu needs a moment to dismiss before the focus state can be changed.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
@ -108,7 +81,6 @@ struct MessageContextMenuItems: View {
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
// Compute a relay display string if relayNode is present
VStack {
Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))")

View file

@ -38,35 +38,8 @@ struct MessageText: View {
SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) {
messageContent
.environment(\.openURL, OpenURLAction { url in
saveChannelLink = nil
var addChannels = false
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
// Handle contact URL
ContactURLHandler.handleContactUrl(url: url, accessoryManager: AccessoryManager.shared)
return .handled // Prevent default browser opening
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/") {
// Handle channel URL
let components = url.absoluteString.components(separatedBy: "#")
guard !components.isEmpty, let lastComponent = components.last else {
Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)")
return .discarded
}
addChannels = Bool(url.query?.contains("add=true") ?? false)
guard let lastComponent = components.last else {
Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)")
self.saveChannelLink = nil
return .discarded
}
let cs = lastComponent.components(separatedBy: "?").first ?? ""
self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels)
Logger.services.debug("Add Channel: \(addChannels, privacy: .public)")
Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)")
return .handled // Prevent default browser opening
}
return .systemAction // Open other URLs in browser
})
// Display sheet for channel settings
handleURL(url)
})
.sheet(item: $saveChannelLink) { link in
SaveChannelQRCode(
channelSetLink: link.data,

View file

@ -57,7 +57,6 @@ struct UserMessageList: View {
}
private func routerIsShowingThisUser() -> Bool {
guard appState.router.navigationState.selectedTab == .messages else { return false }
guard appState.router.selectedTab == .messages else { return false }
return scenePhase == .active
}

View file

@ -208,8 +208,6 @@ fileprivate struct FilteredNodeList: View {
// The body of the view
var body: some View {
// If the connected node passes filters, always show it first
let nodesWithConnectedFirst = nodes.filter { $0.num == accessoryManager.activeDeviceNum } + nodes.filter { $0.num != accessoryManager.activeDeviceNum }
// If the connected node passes filters, always show it first (single-pass)
let nodesWithConnectedFirst: [NodeInfoEntity] = {
let activeNum = accessoryManager.activeDeviceNum

View file

@ -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
@ -65,8 +65,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?.array as? [ChannelEntity] ?? [], id: \.self) { (channel: ChannelEntity) in
if node.myInfo != nil {
ForEach(node.myInfo?.channels?.array as? [ChannelEntity] ?? [], id: \.self) { (channel: ChannelEntity) in
Button(action: {
channelIndex = channel.index
channelRole = Int(channel.role)
@ -177,7 +177,7 @@ struct Channels: View {
selectedChannel!.downlinkEnabled = downlink
selectedChannel!.positionPrecision = Int32(positionPrecision)
guard let mutableChannels = node?.myInfo?.channels?.mutableCopy() as? NSMutableOrderedSet else {
guard let mutableChannels = node.myInfo?.channels?.mutableCopy() as? NSMutableOrderedSet else {
return
}
if mutableChannels.contains(selectedChannel as Any) {
@ -186,7 +186,7 @@ struct Channels: View {
} else {
mutableChannels.add(selectedChannel as Any)
}
node?.myInfo?.channels = mutableChannels.copy() as? NSOrderedSet
node.myInfo?.channels = mutableChannels.copy() as? NSOrderedSet
context.refresh(selectedChannel!, mergeChanges: true)
if channel.role != Channel.Role.disabled {
do {
@ -216,14 +216,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")
@ -246,10 +246,10 @@ struct Channels: View {
#endif
}
}
if node?.myInfo?.channels?.array.count ?? 0 < 8 && node != nil {
if node.myInfo?.channels?.array.count ?? 0 < 8 {
Button {
let channelIndexes = node?.myInfo?.channels?.compactMap({(ch) -> Int in
let channelIndexes = node.myInfo?.channels?.compactMap({(ch) -> Int in
return (ch as AnyObject).index
})
let firstChannelIndex = firstMissingChannelIndex(channelIndexes ?? [])

View file

@ -35,7 +35,6 @@ struct RangeTestConfig: View {
return hexLen < 3
}
var body: some View {
Form {
ConfigHeader(title: "Range", config: \.rangeTestConfig, node: node, onAppear: setRangeTestValues)

View file

@ -301,6 +301,14 @@ struct Settings: View {
}
}
NavigationLink(value: SettingsNavigationState.tak) {
Label {
Text("TAK Server")
} icon: {
Image(systemName: "target")
}
}
if isModuleSupported(.telemetryConfig) {
NavigationLink(value: SettingsNavigationState.telemetry) {
Label {
@ -351,6 +359,15 @@ struct Settings: View {
Image(systemName: "folder")
}
}
if #available(iOS 18, *) {
NavigationLink(value: SettingsNavigationState.tools) {
Label {
Text("Tools")
} icon: {
Image(systemName: "hammer")
}
}
}
}
}
@ -536,7 +553,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:

View file

@ -116,8 +116,6 @@ 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** | |