feat: lockdown mode — spec + protobufs bump + coordinator foundation#1779
Open
niccellular wants to merge 17 commits into
Open
feat: lockdown mode — spec + protobufs bump + coordinator foundation#1779niccellular wants to merge 17 commits into
niccellular wants to merge 17 commits into
Conversation
Adds the GitHub Spec Kit (https://github.com/github/spec-kit) framework to drive spec-driven development for future features. Lays down: - .specify/ framework templates, scripts, hooks - .github/prompts/ Copilot Chat slash-command prompts (/speckit.specify, /speckit.clarify, ...) - .github/agents/ Copilot agent definitions - .github/copilot-instructions.md Copilot project context - .claude/skills/ Claude Code skill manifests for the same workflow - CLAUDE.md project context for Claude Code sessions - .vscode/ workspace settings recommended by spec-kit No source changes. Feature specs live under specs/<NNN-name>/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First feature spec under the new spec-kit framework. Ports the Meshtastic-Android lockdown spec to Apple, with Apple-specific divergences in storage (Keychain, not EncryptedSharedPreferences), state ownership (single ObservableObject coordinator injected via Environment — no KMP layer), and UI (SwiftUI .fullScreenCover gate, SecureField passphrase entry, "Lock Now" in Settings → Security). Wire format identical to Android: AdminMessage.lockdown_auth out, FromRadio.lockdown_status in, per meshtastic/protobufs PR #911. Artifacts: - spec.md the feature spec - plan.md implementation plan - research.md resolutions for the three R-items (Keychain API, ObservableObject vs Observable, verified SwiftProtobuf type names) - data-model.md LockdownState enum + StoredPassphrase struct - contracts/ LockdownCoordinator + LockdownSender contracts - quickstart.md end-to-end manual test plan - tasks.md T001-T028 broken out by user story - checklists/ requirements quality + security audit - analysis.md cross-artifact consistency review Output of /speckit.specify + /speckit.plan + /speckit.tasks + /speckit.checklist + /speckit.analyze in this order. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls the full upstream proto set from meshtastic/protobufs master at commit 1c62540 (the merge of PR #911 — LockdownAuth + LockdownStatus). Regenerates Swift bindings under MeshtasticProtobufs/Sources/meshtastic/ via the existing scripts/gen_protos.sh invocation of protoc. Adds LockdownAuth (admin.proto) and LockdownStatus (mesh.proto). The new oneof cases AdminMessage.OneOf_PayloadVariant.lockdownAuth and FromRadio.OneOf_PayloadVariant.lockdownStatus are now available. Note that the generated Swift types are UNPREFIXED (e.g. LockdownAuth, not Meshtastic_LockdownAuth) because the .proto files lack a swift_prefix option. Also picks up other upstream deltas accumulated since c8d5047, including a new serial_hal.proto. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the foundational layer of MESHTASTIC_LOCKDOWN client support
per specs/007-lockdown-mode/. NO USER-FACING UI YET — coordinator stays
at .none on non-lockdown firmware, so this change is functionally inert
for existing devices. UI (LockdownSheet, SecurityConfig Lock Now row),
tests, and banner suppression follow in subsequent commits.
What lands:
- Meshtastic/Model/LockdownState.swift (new)
7-case enum mirroring the Android sealed class. Equatable for
SwiftUI diffing.
- Meshtastic/Helpers/LockdownPassphraseStore.swift (new)
Per-peripheral JSON-in-Keychain cache. Keyed by CBPeripheral
identifier (Device.id, a UUID). Uses
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly so silent
auto-replay can fire on first foreground after device reboot.
synchronizable=false — lockdown is a per-device pairing, not
iCloud-synced.
- Meshtastic/Helpers/LockdownCoordinator.swift (new)
@mainactor ObservableObject driving the state machine. Owns
auto-replay on LOCKED (silent send when cache hits), cache delete
on auto-replay UNLOCK_FAILED with backoff=0, pendingLockNow flag
resolved by the next inbound LOCKED status OR the BLE disconnect
(whichever comes first). LockdownSender protocol defines the
outbound dependency.
- Meshtastic/Helpers/KeychainHelper.swift (modified)
Added optional accessibility: CFString and synchronizable: Bool
params to save/read/delete. Defaults preserve every existing
callsite. Required so LockdownPassphraseStore can opt into
stricter accessibility + sync=false without forking the helper.
- Meshtastic/Accessory/Accessory Manager/AccessoryManager+Lockdown.swift (new)
Conforms AccessoryManager to LockdownSender. Builds the
AdminMessage.lockdown_auth ToRadio packet with the firmware-required
MeshPacket invariants:
to = myNodeNum, from unset, channel=0, wantAck=true,
hopLimit=hopStart=7, priority=.reliable,
decoded.portnum=.adminApp, NO pkiEncrypted.
Sends via the existing send(toRadio, debugDescription:) path so
the BLE serial queue handles it like any other admin write.
- Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift (modified)
- new `var lockdownCoordinator: LockdownCoordinator?` (set from MeshtasticApp)
- processFromRadio: new `case .lockdownStatus(let status):` branch
before the default arm; forwards to coordinator.handle(status)
- closeConnection: calls coordinator.onDisconnect() so a pending
Lock Now resolves to .lockNowAcknowledged on the BLE drop, and
per-connection state is cleared
- Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift (modified)
Step 0 of the connect sequence now calls
coordinator.onConnect(peripheralID: device.id) — firmware
requires re-auth on every new connection regardless of storage state.
- Meshtastic/MeshtasticApp.swift (modified)
Instantiates LockdownCoordinator() in init(), wires
accessoryManager as its LockdownSender (deferred call to avoid an
init-time cycle), assigns accessoryManager.lockdownCoordinator,
and exposes it as @StateObject + .environmentObject so the UI
work that follows can observe it.
Architectural deviation from the plan: the plan said "extend
BluetoothManager", but in this repo BluetoothManager is a 27-line
scan/connect stub — actual ToRadio sends go through AccessoryManager
(@mainactor, ObservableObject, singleton-ish). The implementation
pivoted accordingly; specs/007-lockdown-mode/tasks.md T008 has been
amended to reflect reality.
Manual step required after merging: add the four new .swift files to
the Meshtastic target via Xcode's "Add Files to Project" — the
xcodeproj uses explicit group membership (not
fileSystemSynchronizedGroups), so file drops don't auto-join the target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3e60c4f to
cb33b81
Compare
Full-screen sheet rendered when LockdownCoordinator.state requires the
user to act. Three content variants driven by a switch on the state:
- PassphraseEntryContent(mode: .provision) (.needsProvision)
- PassphraseEntryContent(mode: .unlock(reason:)) (.locked, .unlockFailed)
- BackoffCountdownContent(deadline:) (.unlockBackoff)
PassphraseEntryContent renders a Form with three sections:
1. SecureField passphrase entry (.textContentType(.password),
autocorrection + autocapitalisation disabled) plus a live N/32 byte
counter that turns red outside the 1..32 range. An optional inline
error banner is shown for .unlockFailed.
2. DisclosureGroup (collapsed by default) with optional Boots remaining
and Hours valid TextFields. Empty fields submit 0 so the firmware
uses its defaults; non-numeric input disables Submit.
3. Submit button gated on (passphrase valid) AND (TTL fields parseable)
AND (AccessoryManager has a device.num). A "Connecting to device..."
caption surfaces when the coordinator is not yet ready.
Hint copy for the unlock variant is selected from the firmware's
lock_reason string per the table in specs/007-lockdown-mode/spec.md
(needs_auth, token_*, auto_replay_wrong_passphrase, unknown). The
component falls back to the needs_auth string for forward-compat.
BackoffCountdownContent uses TimelineView(.periodic) to render a
deadline-relative countdown each second. It is read-only and has no
Submit. When the deadline elapses the view stays mounted until the
next inbound LockdownStatus changes the coordinator state; this
matches the strict reading of data-model.md (the coordinator owns
all state transitions; views do not call back into it on a timer).
NFR-002: passphrase wiped from local @State on submit and on
onDisappear; never logged.
FR-012: .interactiveDismissDisabled(true) on the NavigationStack so
the sheet cannot be swipe-dismissed.
Strings are LocalizedStringKey references; the corresponding entries
in Localizable.xcstrings land in T016. Until then they fall back to
the keys themselves at runtime.
Presentation of this view as a .fullScreenCover (with the dismiss
binding tied to coordinator state) is T012 in ContentView.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the 5 lockdown Swift files to Meshtastic.xcodeproj target membership (PBXBuildFile + PBXFileReference + group children + Sources phase). New Lockdown PBXGroup created under Views for LockdownSheet.swift. New IDs namespaced under DD7E574/575/576 prefixes (zero collisions with existing). Also adds switch cases for four new PortNum values pulled in by the protobufs bump in commit 4c5e637 (remoteShellApp, lorawanBridge, atakPluginV2, groupalarmApp). All four are no-op handlers consistent with how other unhandled apps are logged in this switch; no behavior change for existing functionality. xcodebuild now compiles cleanly: xcodebuild -workspace Meshtastic.xcworkspace -scheme Meshtastic \ -destination 'generic/platform=iOS' build \ CODE_SIGNING_ALLOWED=NO ** BUILD SUCCEEDED ** Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two build issues surfaced after pulling lockdown files into the target:
1. AccessoryManager+Lockdown.swift triggered a Swift-6 cross-actor
warning ("conformance crosses into main actor-isolated code"). Root
cause: LockdownSender was actor-agnostic but AccessoryManager is
@mainactor. Fix: mark the LockdownSender protocol @mainactor since
its only caller (LockdownCoordinator) is already @mainactor. Drops
the Task { @mainactor in ... } wrapper inside sendLockdownAuth(...);
only the actual `try await send(...)` call now needs a Task because
that method is async.
2. LockdownSheet.swift line 151:
.foregroundStyle(isPassphraseValid ? .secondary : .red)
.secondary is HierarchicalShapeStyle, .red is Color. The ternary
couldn't infer a common type. Fix: explicit Color.secondary /
Color.red.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Presents LockdownSheet as a non-dismissable .fullScreenCover when the LockdownCoordinator reports any state that demands user action: - .needsProvision - .locked(reason:) - .unlockFailed - .unlockBackoff(deadline:) The cover dismisses automatically when the coordinator transitions to .none, .unlocked, or .lockNowAcknowledged. The isPresented binding's setter is intentionally a no-op so users cannot dismiss the cover via gesture; visibility is fully driven by coordinator.state per FR-012. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…T020, T022)
16 unit tests covering every transition in data-model.md using fake
collaborators (FakeLockdownSender, FakePassphraseStore). No BLE,
no Keychain.
Coverage:
- Initial state is .none
- NEEDS_PROVISION transition (T015 US-2)
- Submit-from-NEEDS_PROVISION caches passphrase on UNLOCKED (T015 US-2)
- LOCKED without cache transitions to .locked(reason:) (T013 US-1)
- LOCKED with cache auto-replays silently (T013 US-4)
- UNLOCK_FAILED user-submit, backoff=0 -> .unlockFailed (T013 US-1)
- UNLOCK_FAILED user-submit, backoff>0 -> .unlockBackoff with deadline (T013 US-1)
- UNLOCK_FAILED auto-replay, backoff=0 -> clears cache, .locked (T022 US-4)
- UNLOCK_FAILED auto-replay, backoff>0 -> preserves cache, .unlockBackoff (T022 US-4)
- Lock Now sets pending flag, sends empty-passphrase auth (T020 US-3)
- LOCKED with pendingLockNow -> .lockNowAcknowledged (T020 US-3)
- onDisconnect with pendingLockNow -> .lockNowAcknowledged (T020 US-3)
- onDisconnect without pendingLockNow -> .none
- STATE_UNSPECIFIED ignored (analysis.md G3 forward-compat)
- forgetCachedPassphrase deletes cache for connected peripheral
- forgetCachedPassphrase no-op when no peripheral connected
Required test-seam plumbing:
- LockdownPassphraseStoring protocol added so tests can inject a fake.
LockdownPassphraseStore conforms. LockdownCoordinator's `store`
dependency switched to the protocol type. No effect on production
behavior; this is below the level of the publicly documented contract
(LockdownSender protocol + coordinator public methods are unchanged).
- LockdownCoordinatorTests.swift added to Meshtastic.xcodeproj test
target via direct pbxproj edits.
Run:
xcodebuild test -workspace Meshtastic.xcworkspace -scheme Meshtastic \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:MeshtasticTests/LockdownCoordinatorTests
** TEST SUCCEEDED **
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 unit tests against the real iOS Keychain:
- read of unknown peripheral returns nil
- save then read round-trips passphrase + TTL fields
- save overwrites previous entry
- delete removes entry
- delete of unknown peripheral returns true (treated as no-op success)
- entries are isolated per peripheral UUID
Each test uses a fresh UUID and cleans its key in tearDown so no
cross-test interference and no production keychain entries touched.
setUpWithError probes the Keychain via KeychainHelper.standard.save
and throws XCTSkip if SecItemAdd returns errSecMissingEntitlement
(-34018), which happens in unsigned test harnesses like the project's
default `xcodebuild ... CODE_SIGNING_ALLOWED=NO` invocation. The tests
exercise the real Keychain when the test target is signed.
xcodebuild test -workspace Meshtastic.xcworkspace -scheme Meshtastic \
-destination 'platform=iOS Simulator,name=iPhone 15'
** TEST SUCCEEDED **
(35 tests, 6 skipped, 0 failures)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…017, T019, T021, T024, T025)
Settings → Security → Lockdown (LockdownSection):
- Renders only when lockdown.state == .unlocked (T024 US-5).
- Status row with green lock.open.fill glyph.
- "Boots remaining: N" row when bootsRemaining > 0.
- "No time limit" or "Expires <localized date/time>" row driven by
validUntilEpoch (T024 US-5).
- Destructive "Lock Now" button (T017 US-3) with a confirmation alert
that explains the reboot side effect.
- "Forget Stored Passphrase" button (T021 US-4) calls
coordinator.forgetCachedPassphrase().
- All other coordinator states show EmptyView so the section disappears
cleanly; .needsProvision / .locked / .unlockFailed / .unlockBackoff
surface via the full-screen sheet, not in Settings.
MeshtasticApp:
- .onChange(of: lockdownCoordinator.state) tears down the BLE connection
when state resolves to .lockNowAcknowledged (T019 US-3). This is the
one-way exit from the Lock Now flow; ContentView's cover binding also
dismisses since .lockNowAcknowledged is non-blocking.
Banner suppression (T025 FR-013):
- Connect.swift's region-unset banner now also checks
!lockdown.isBlockingSession. Non-lockdown firmware leaves
coordinator state at .none which returns false, so existing
behavior for non-hardened devices is preserved. Hardened-but-locked
devices stop nagging the user with banners they can't act on.
LockdownCoordinator helper:
- New `isBlockingSession: Bool` flag the views use; ContentView's
gate now defers to it (single source of truth).
Build:
xcodebuild ... -destination 'generic/platform=iOS' build
** BUILD SUCCEEDED **
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
22 new "lockdown.*" keys for LockdownSheet (passphrase entry, provisioning,
backoff countdown, lock_reason hint copy) and the Settings → Lockdown
section. English source values only; entries are state=new so the next
crowdin sync will pick them up for translation.
Keys cover:
- lockdown.set_passphrase.* (provisioning title/hint/submit)
- lockdown.unlock.* (unlock title/submit)
- lockdown.passphrase.field, lockdown.passphrase.wrong
- lockdown.session.* (TTL fields under disclosure group)
- lockdown.connecting (Submit gating caption)
- lockdown.backoff.title, lockdown.backoff.body %lld, lockdown.backoff.explanation
- lockdown.locked.{needs_auth, token_expired, token_rtc, token_tamper, auto_replay_failed}
(hint copy switched by firmware-supplied lock_reason)
The backoff key uses the %lld placeholder convention that matches the
LocalizedStringKey interpolation `"lockdown.backoff.body \(remaining)"`
in LockdownSheet.swift; SwiftUI's catalog lookup synthesizes the same key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LockdownSheet.PassphraseEntryContent:
- byte-counter Text now exposes an explicit accessibilityLabel
"Passphrase length N of 32 bytes" so VoiceOver reads it as English
instead of pronouncing "N/32" as "N slash 32".
- Submit button accessibilityHint explains the disabled state
("Enter a passphrase between 1 and 32 bytes.") so VoiceOver users
know why the button is inactive.
- dynamicTypeSize cap at ...accessibility3. The Form's three-section
layout with passphrase + DisclosureGroup + Submit breaks visually
beyond accessibility3 in iOS 16.4. Cap matches what the rest of the
Settings forms do implicitly; users who need larger sizes still get
a usable layout at .accessibility3 (roughly 2x default).
BackoffCountdownContent already had an accessibilityLabel on the
TimelineView countdown ("Retry available in N seconds"); no further
change there.
SF Symbols already in use across the sheet: lock.trianglebadge.exclamationmark
(backoff), exclamationmark.triangle.fill (inline error), clock.badge
(disclosure label). Matches the SF Symbols set called out in
specs/007-lockdown-mode/spec.md "Design Standards Compliance".
Build:
xcodebuild ... build CODE_SIGNING_ALLOWED=NO
** BUILD SUCCEEDED **
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T028 (manual quickstart on hardware) remains open; requires a MESHTASTIC_LOCKDOWN firmware-flashed device which is out of scope for code-level work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the lockdown branch up to date with origin/main (124+ commits
ahead at merge time). Resolution summary by file:
- MeshtasticProtobufs/Sources/meshtastic/*.pb.swift (9 files):
Both branches independently regenerated these from the same
protobufs revision (1c62540). Took main's regenerated output;
LockdownAuth + LockdownStatus types still present.
- .specify/**, .github/{copilot-instructions.md,prompts,agents},
.claude/skills/**, .vscode/settings.json, CLAUDE.md:
Both branches added spec-kit scaffold. Took main's versions
(scaffold is byte-identical or main has minor edits).
- Localizable.xcstrings: took main's, re-injected the 22 lockdown.*
keys via the same python script used in commit 1dd4abc.
- Meshtastic.xcodeproj/project.pbxproj: took main's (major structure
changes for the new Watch app target etc.), then re-added the 7
lockdown PBXFileReference + PBXBuildFile + group + Sources phase
entries (5 app sources + 2 test sources + Lockdown view group)
with the same DD7E574/575/576 IDs. plutil -lint clean.
- Meshtastic/Views/ContentView.swift: main rewrote body to dispatch
via @ViewBuilder var tabContent (iOS 18+ TabView vs legacy). Moved
the .fullScreenCover(LockdownSheet) modifier onto the new body
structure.
- Meshtastic/Views/Settings/Config/SecurityConfig.swift: main added
a #Preview block; kept both my LockdownSection extension and the
Preview. Augmented the Preview with .environmentObject(LockdownCoordinator())
so SecurityConfig's new @EnvironmentObject dependency resolves.
- Meshtastic/MeshtasticApp.swift: main changed persistenceController
type to optional (test-mode support) and added a Mesh Map secondary
WindowGroup. Kept both: lockdown coordinator construction + the new
Watch/optional-persistence/MapWindow plumbing. Injected
lockdownCoordinator into both WindowGroups' environment chains.
- Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift:
Main added `self.activeDeviceNum = nil` in closeConnection AND four
new portnum cases (.groupalarmApp, .lorawanBridge, .remoteShellApp,
.unknownApp) using its own copy-text. Kept main's variants;
re-added .atakPluginV2 (which main missed) alongside. Kept my
lockdownCoordinator?.onDisconnect() call after activeDeviceNum.
- Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift:
auto-merged; main's `maxAttempts:` retry refactor preserved my
onConnect(peripheralID: device.id) call at step 0.
Build / test: cannot verify locally (this Xcode lacks watchOS 26.5 SDK
required by main's new Watch App target). CI will exercise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
📄 Docs staleness warningThis PR modifies user-facing Swift source files but does not update any page under Changed source files: What to check:
If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the After updating |
Both branches added strings since the last merge; took main's version and re-injected the 21 lockdown.* keys via the same Python script used in commit 1dd4abc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) Bumps protobufs submodule from master (1c62540) to develop tip (7ffb4bb) which includes PR #916: a new `uint32 max_session_seconds = 5` on `LockdownAuth`. Per-boot uptime cap, in seconds. 0 = unlimited (current behavior). Non-zero arms a firmware uptime timer at unlock; on expiry the device revokes per-connection auth and reboots without deleting the token so the next boot auto-unlocks via the boot-count TTL and arms a fresh session window. Total exposure ceiling = boots_remaining * max_session_seconds. Wiring across the app: Meshtastic/Helpers/LockdownPassphraseStore.swift StoredPassphrase gains `maxSessionSeconds: UInt32` with default 0. Custom Decodable init via decodeIfPresent so cached entries written before this commit still load cleanly (treated as unlimited = 0). Meshtastic/Helpers/LockdownCoordinator.swift LockdownSender protocol method gains `maxSessionSeconds: UInt32`. submitPassphrase(_:bootsRemaining:validUntilEpoch:maxSessionSeconds:) gains the param with default 0 for source compat with existing call sites. Auto-replay reuses the cached value; lockNow sends 0. pendingMaxSessionSeconds tracks the in-flight submit through to UNLOCKED so the value persists in the cache. Meshtastic/Accessory/Accessory Manager/AccessoryManager+Lockdown.swift sendLockdownAuth impl threads the value into LockdownAuth.maxSessionSeconds before serializing. No change to the MeshPacket envelope. Meshtastic/Views/Lockdown/LockdownSheet.swift New optional "Session cap (minutes)" TextField in the DisclosureGroup, alongside Boots remaining / Hours valid. Converted minutes -> seconds on submit; empty -> 0. areTTLFieldsValid now also checks the new parse so non-numeric input keeps Submit disabled. Localizable.xcstrings Two new keys: lockdown.session.cap_minutes, lockdown.session.cap_caption. MeshtasticTests/LockdownCoordinatorTests.swift FakeLockdownSender.Call records maxSessionSeconds. Two new tests: - testSubmitPassphrase_threadsMaxSessionSecondsToSenderAndCache - testHandle_locked_autoReplay_passesCachedMaxSessionSeconds All 18 existing tests continue to pass via the default param value. protobufs (submodule) 1c62540 -> 7ffb4bb. Develop has 9 other commits since master (NodeDB split, XEdDSA, PositionLite precision_bits, etc.); none touch portnums so no exhaustive-switch impact in AccessoryManager. Cannot test locally (Xcode here lacks watchOS 26.5 SDK required by main's new Watch App target in the scheme). CI will build + run tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements MESHTASTIC_LOCKDOWN client support for Meshtastic-Apple using the typed proto wire format from meshtastic/protobufs PR #911.
Status
xcodebuild test ... -destination 'platform=iOS Simulator,name=iPhone 15' CODE_SIGNING_ALLOWED=NO35 tests, 6 skipped (Keychain entitlement), 0 failures.
What lands
Foundation
protobufssubmodule bumped to1c62540(PR Waypointform drop maps pin #911 merge); Swift bindings regenerated. Includes the 4 unrelated portnums upstream added since the prior pin (remoteShellApp,lorawanBridge,atakPluginV2,groupalarmApp) handled as no-ops inprocessFromRadio.LockdownState.swift— 7-case enum.LockdownPassphraseStore.swift— per-peripheral Keychain cache,kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,synchronizable=false.LockdownPassphraseStoringprotocol so tests can inject a fake.LockdownCoordinator.swift—@MainActor ObservableObject. Auto-replay onLOCKEDwith cache hit, cache clear on auto-replay reject (backoff=0),pendingLockNowflag resolved by nextLOCKEDor BLE disconnect.KeychainHelper.swift— extended with optionalaccessibility:andsynchronizable:params (defaults preserve every existing callsite).AccessoryManager+Lockdown.swift—LockdownSenderconformance; builds the strict-gate MeshPacket (to=myNodeNum,fromunset,wantAck=true,hopLimit=hopStart=7,priority=.reliable, nopkiEncrypted).AccessoryManager.swift—lockdownStatusdispatch branch,lockdownCoordinatorstored property,onConnect/onDisconnectlifecycle hooks.MeshtasticApp.swift— instantiates coordinator, wires sender, injects into environment, auto-disconnects on.lockNowAcknowledged.Architectural deviation from plan: the plan said "extend
BluetoothManager", but in this repo that file is a 27-line scan/connect stub. Real ToRadio sends go throughAccessoryManager.tasks.mdT008 amended.UI
LockdownSheet.swift(T011) — full-screen sheet with three content variants driven byLockdownState: passphrase entry/provisioning, unlock-failed with inline error, backoff countdown withTimelineView. Optional Boots / Hours TTL fields behind aDisclosureGroup.ContentView.swift(T012) —.fullScreenCovergate with non-dismissable binding (FR-012).SecurityConfig.swift(T017/T021/T024) — Lockdown section with status badge, Boots/Expires rows, destructive Lock Now button with confirmation alert, Forget Stored Passphrase button.Connect.swift(T025) — region-unset banner now also checks!lockdown.isBlockingSession; non-lockdown firmware unchanged.Tests
LockdownCoordinatorTests.swift(T013/T015/T020/T022) — 16 unit tests covering every state transition incl. forward-compat forSTATE_UNSPECIFIED.LockdownPassphraseStoreTests.swift(T023) — 6 round-trip tests against the real Keychain.XCTSkiponerrSecMissingEntitlementso unsigned harnesses don't false-fail.i18n (T016)
22
lockdown.*keys inLocalizable.xcstrings, English source. crowdin will pick them up.a11y (T027)
VoiceOver labels on the byte counter and Submit button, Dynamic Type cap at
accessibility3for Form layout integrity.Privacy (T026)
Confirmed: no Logger / os_log / print call site touches the passphrase value. Coordinator clears
pendingPassphraseon every state transition.Reviewer notes
The xcodeproj uses explicit group membership (not
fileSystemSynchronizedGroups), so the 7 new.swiftfiles (5 app + 2 test) are added directly inMeshtastic.xcodeproj/project.pbxprojwith deterministic IDs prefixedDD7E574/DD7E575/DD7E576.plutil -lintclean.The
LockdownPassphraseStoringprotocol (added for tests) is the only deviation from the documented contract inspecs/007-lockdown-mode/contracts/coordinator-protocol.md— the coordinator'sstoredep type widened fromLockdownPassphraseStoreto the protocol. Public method signatures,LockdownSender, and theLockdownCoordinatorpublic surface are unchanged.T028 (manual quickstart on a MESHTASTIC_LOCKDOWN-flashed device) remains for hardware verification.
specs/007-lockdown-mode/quickstart.mdhas the procedure.🤖 Generated with Claude Code