mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
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:
parent
cf137003e9
commit
0898cfed21
25 changed files with 63317 additions and 62576 deletions
116
.github/copilot-instructions.md
vendored
Normal file
116
.github/copilot-instructions.md
vendored
Normal 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.
|
||||
341
.github/workflows/bug-report-analyzer.yml
vendored
Normal file
341
.github/workflows/bug-report-analyzer.yml
vendored
Normal 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,
|
||||
});
|
||||
}
|
||||
124821
Localizable.xcstrings
124821
Localizable.xcstrings
File diff suppressed because it is too large
Load diff
|
|
@ -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)";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ extension AccessoryManager {
|
|||
}
|
||||
|
||||
func stopDiscovery() {
|
||||
devices.removeAll()
|
||||
discoveryTask?.cancel()
|
||||
discoveryTask = nil
|
||||
devices.removeAll()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
26
Meshtastic/Intents/IntentHandler.swift
Normal file
26
Meshtastic/Intents/IntentHandler.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
81
Meshtastic/Intents/IntentMessageConverters.swift
Normal file
81
Meshtastic/Intents/IntentMessageConverters.swift
Normal 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
|
||||
}
|
||||
}
|
||||
106
Meshtastic/Intents/SearchForMessagesIntentHandler.swift
Normal file
106
Meshtastic/Intents/SearchForMessagesIntentHandler.swift
Normal 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
|
||||
}
|
||||
}
|
||||
144
Meshtastic/Intents/SendMessageIntentHandler.swift
Normal file
144
Meshtastic/Intents/SendMessageIntentHandler.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
85
Meshtastic/Intents/SetMessageAttributeIntentHandler.swift
Normal file
85
Meshtastic/Intents/SetMessageAttributeIntentHandler.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ?? [])
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ struct RangeTestConfig: View {
|
|||
return hexLen < 3
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
ConfigHeader(title: "Range", config: \.rangeTestConfig, node: node, onAppear: setRangeTestValues)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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** | |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue