M1-07: Live Activity — lock screen recording control #20

Merged
wk merged 7 commits from m1-07-live-activity into main 2026-08-06 17:48:21 -04:00
Member

Closes #7 — Live Activity with a lock screen stop button, validated on wes-iphone (iOS 26.5.2).

Start a recording, lock the phone, stop it from the lock screen without unlocking. That works, and so does swapping the microphone while locked, which is where most of this branch went.

What is here

The Live Activity (StashWidgets, a new app-extension target). ActivityKit only lets an extension declare one, so the lock screen UI lives outside the app binary even though the recording it controls does not. Stash/Activity compiles into both targets — the widget needs the attribute and intent types to render, the app needs them to start the activity and perform the intent.

The stop button is a LiveActivityIntent, which performs in the app process. A plain AppIntent would run in the extension, which has no recorder to stop. It reaches the recording through RecordingControl, a one-property indirection that exists so the intent type compiled into the widget never links AudioRecorder.

The elapsed time is Text(timerInterval:), counted locally by the widget. Pushing the clock through Activity.update would spend the whole update budget on something the system can draw itself.

Route repair. Most of the diff. A microphone swap with the phone locked ended the recording, and fixing it took four attempts because the failure looked like a background kill and was a crash. Read the issue comment for the full trail; the short version is that installTapOnBus raises an NSException, Swift cannot catch one, and it aborts the process. ObjCExceptions.guarded — the only Objective-C in the app — turns that into an error the retry loop already handles.

Reviewing it

Seven commits, each one standing alone. de4f46b is the interesting one and the rest is scaffolding around it.

1e18796  Live Activity with a lock screen stop button
64afc41  stop tracking the generated widget Info.plist
3ef6e32  recover from a microphone swap that fails while locked
7c7657c  repair a route change without reactivating the session
962ffc1  deep link the Live Activity, and record why a recording ended
c954629  hold a background assertion across a route repair
de4f46b  stop an AVFoundation exception from aborting the recording

c954629 was written against the wrong diagnosis. It is kept because keeping the process alive across a gap where no audio flows is correct on its own terms, but it is not what fixed anything and the commit says so.

AudioRecorder was split three times to stay under the 400-line limit — RecordingStore, RecordingError, SessionEvents, RecordingSession, RepairAssertion. Those are moves, not changes.

Two things to decide

CaptureDiagnostics and the diagnostics section in DebugCaptureView are debug scaffolding that shipped to find the crash. They write four UserDefaults keys. Fine to keep for now, worth removing alongside the debug capture view.

A call arriving while the phone is locked is untested. It shares the repair path, and the background assertion that covers it is finite — a long call will outlive it. If that turns out to be broken it wants its own issue rather than more commits here.

Nothing in this branch is covered by an automated test. It is a hardware path end to end.

🤖 Generated with Claude Code

Closes #7 — Live Activity with a lock screen stop button, validated on `wes-iphone` (iOS 26.5.2). Start a recording, lock the phone, stop it from the lock screen without unlocking. That works, and so does swapping the microphone while locked, which is where most of this branch went. ## What is here **The Live Activity** (`StashWidgets`, a new app-extension target). ActivityKit only lets an extension declare one, so the lock screen UI lives outside the app binary even though the recording it controls does not. `Stash/Activity` compiles into both targets — the widget needs the attribute and intent types to render, the app needs them to start the activity and perform the intent. **The stop button** is a `LiveActivityIntent`, which performs in the app process. A plain `AppIntent` would run in the extension, which has no recorder to stop. It reaches the recording through `RecordingControl`, a one-property indirection that exists so the intent type compiled into the widget never links `AudioRecorder`. **The elapsed time** is `Text(timerInterval:)`, counted locally by the widget. Pushing the clock through `Activity.update` would spend the whole update budget on something the system can draw itself. **Route repair.** Most of the diff. A microphone swap with the phone locked ended the recording, and fixing it took four attempts because the failure looked like a background kill and was a crash. Read the issue comment for the full trail; the short version is that `installTapOnBus` raises an `NSException`, Swift cannot catch one, and it aborts the process. `ObjCExceptions.guarded` — the only Objective-C in the app — turns that into an error the retry loop already handles. ## Reviewing it Seven commits, each one standing alone. `de4f46b` is the interesting one and the rest is scaffolding around it. ``` 1e18796 Live Activity with a lock screen stop button 64afc41 stop tracking the generated widget Info.plist 3ef6e32 recover from a microphone swap that fails while locked 7c7657c repair a route change without reactivating the session 962ffc1 deep link the Live Activity, and record why a recording ended c954629 hold a background assertion across a route repair de4f46b stop an AVFoundation exception from aborting the recording ``` `c954629` was written against the wrong diagnosis. It is kept because keeping the process alive across a gap where no audio flows is correct on its own terms, but it is not what fixed anything and the commit says so. `AudioRecorder` was split three times to stay under the 400-line limit — `RecordingStore`, `RecordingError`, `SessionEvents`, `RecordingSession`, `RepairAssertion`. Those are moves, not changes. ## Two things to decide `CaptureDiagnostics` and the diagnostics section in `DebugCaptureView` are debug scaffolding that shipped to find the crash. They write four `UserDefaults` keys. Fine to keep for now, worth removing alongside the debug capture view. A call arriving while the phone is locked is untested. It shares the repair path, and the background assertion that covers it is finite — a long call will outlive it. If that turns out to be broken it wants its own issue rather than more commits here. Nothing in this branch is covered by an automated test. It is a hardware path end to end. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A StashWidgets extension declaring a Live Activity for the recording in
progress, with an interactive stop button.

LiveActivityIntent, not AppIntent, and that is the whole feature: a
LiveActivityIntent is performed in the app's process, so it can reach the
running recorder. A plain AppIntent from a widget runs in the extension, which
has no engine, no file and nothing to stop.

AudioRecorder becomes a singleton for the same reason. The stop button is
performed by no view, so there is no instance to hand it, and two recorders
would mean the button stopping one while the other kept writing.

Elapsed time uses Text(timerInterval:), counted locally by the widget from the
start date. Live Activity updates are rate limited by the system, and pushing
one a second to move a clock would spend the whole budget on something the
widget can work out for itself. Updates are reserved for facts that change:
losing the microphone, or getting it back.

The activity stores an id rather than the Activity object. Activity is a class,
is not Sendable, and its update and end are nonisolated async — holding one on
the main actor and awaiting them hands a non-Sendable reference across an
isolation boundary, which Swift 6 rejects. Looking it up inside a nonisolated
function keeps it in one domain.

Nothing here can fail a recording. Live Activities can be switched off per app
or refused when too many are running, so every path returns quietly and the
recorder does not check.

Stale activities are ended at launch. An activity outlives the process that
started it, so a killed app leaves a lock screen control for a recording that no
longer exists, offering a stop button that would do nothing.
XcodeGen writes StashWidgets/Info.plist from project.yml, exactly like the
app's, so it is a build product rather than source. The app's was already
ignored and this one was missed.
Swapping to wired headphones with the screen locked left the recording stalled:
the lock screen said 'Paused, waiting for the microphone', and unlocking did not
fix it. Only unplugging and plugging back in recovered.

Three separate faults, all in the route-change path.

A route change notification means the route is CHANGING, not that it is ready.
Reading the input format at that instant usually gives zero, so the rebuild
throws. The rebuild was one attempt per notification, so that ordinary failure
was terminal — the old comment even said 'the next route change gets another
attempt', which is exactly why replugging was the only cure. Restarts now retry
with backoff and the task is cancellable, so a second change supersedes the
first instead of racing it.

Nothing re-checked on unlock. didBecomeActiveNotification now compares the
remembered input against the session's actual route and rebuilds when they
disagree, or when the engine is not running.

currentInputName was only set on a SUCCESSFUL start, so a failed switch left the
previous microphone's name on screen and on the lock screen — the most
misleading thing it could have said. It is now read from the session on every
route change, whatever happens next.

Also filters route changes with reason .categoryChange, which the rebuild causes
itself by reactivating the session, so a restart cannot trigger a restart.

Verified by injecting three consecutive restart failures: attempts at 0ms,
185ms, 428ms and 1064ms, recovering on the fourth. Before this the first failure
ended it.

AudioRecorder went past the 400-line lint limit, so file naming and location
moved to RecordingStore and the error vocabulary to RecordingError. Neither was
the recorder's job.
Swapping to wired headphones with the screen locked still failed after the retry
fix, and retrying was never going to help — every attempt asked iOS for the one
thing it will not grant.

iOS lets an app CONTINUE recording in the background but never START one, and
setActive(true) reads as starting. It is refused with
AVAudioSession.ErrorCode.cannotStartRecording (561145187). attemptRestart called
activateSession() on every try, so all six attempts were denied for the same
reason, and only returning to the foreground could ever have fixed it. That
matches the reported behaviour exactly: recovered on unlock, never before.

A significant route change makes iOS reset the AVAudioEngine itself. The
documented response is to rebuild the graph and start it again, leaving the
session alone — it was activated in the foreground when recording began and has
never been deactivated, so there is nothing to reactivate.

Restarts now reuse the engine, reset it, re-read the input format and reinstall
the tap, without touching the session. Only the final attempt is allowed to
rebuild the session as well, which succeeds solely in the foreground and is
therefore the repair for 'the swap failed while locked and you have just
unlocked'.

Found by reading Apple's error semantics rather than by trying more delays. An
earlier guess — that the app was being suspended and the retry loop never ran —
was tested and disproved: all six attempts execute while backgrounded.

AudioRecorder crossed the 400-line lint limit again, so watching the session
moved to SessionEvent. The recorder keeps what it does about these events rather
than also owning the business of noticing them.
Tapping the Live Activity opened the app wherever it was last left rather than
at the recording it came from, and the recording stopped. Two problems, one
fixed and one instrumented.

The activity now carries a stash://capture widgetURL and the app opens the
capture view on it. The scheme is registered in the Info.plist.

Why the recording stopped is not yet known, and the two candidate explanations
are indistinguishable from the outside: something called stop(), or the process
died and came back. Both leave the app at the root view, not recording, with the
activity gone. So rather than guess, stop() now records its reason and every
launch records itself, both in UserDefaults so they survive a kill, and the
debug view shows them. A launch stamped LATER than the last stop means the app
was relaunched rather than stopped.

Debug scaffolding, and it goes when capture no longer needs explaining.
An app's right to run in the background comes from audio actually flowing.
Tearing down the engine to rebuild it for a new microphone ends that flow, and
with the screen locked iOS suspends and then kills the process within seconds —
before the rebuild can finish.

Observed on device: record on the built-in mic, lock, plug in wired headphones.
The lock screen freezes showing the old microphone, and tapping it cold-launches
into a stopped state. Device diagnostics across seven launches show `stop()` was
never called once, and the last repair breadcrumb reads "started" with no
matching "succeeded" — the app died mid-rebuild. The audio survived because the
file is fragmented as it is written; only the unflushed tail was lost.

`RepairAssertion` wraps the gap, from before the teardown to after the rebuild,
for both a route change and an interruption. It is finite — around half a minute
— so a short interruption is covered and a long call still is not.

Also records how far a repair got, so a kill partway leaves evidence.
The app was not being killed by iOS. It was crashing, and it had been the
same crash the whole time.

    EXC_CRASH (SIGABRT), abort() called, procRole "Non UI"
      objc_exception_throw
      +[NSException raise:format:]
      AVAudioEngineImpl::InstallTapOnNode
      AudioRecorder.startEngine(into:)
      AudioRecorder.attemptRestart(into:allowSessionRestart:)

Three crash reports in one afternoon, identical, the last of them eleven
minutes into the build that added a background assertion to survive a kill
that was never happening. `installTapOnBus` raises an Objective-C exception
rather than returning an error, Swift has no `@catch`, and the raise unwinds
past every `do`/`catch` into `abort()`. Every symptom this was read from —
`stop()` never called, a truncated but playable file, a cold relaunch, a Live
Activity still claiming to record — is that abort.

Three fixes:

`ObjCExceptions.guarded` wraps the AVAudioEngine calls on the repair path in
an `@try`/`@catch` shim, turning a raise into a failed attempt the retry loop
already knows how to handle. This is the only Objective-C in the app.

`startEngine` now rejects a format with zero channels, not just a zero sample
rate. AVFoundation checks both inside `installTapOnBus` and a route change
reports the second one on the way past.

`attemptRestart` builds a fresh `AVAudioEngine` on every attempt instead of
only on the one allowed to reactivate the session. Those were coupled for no
reason — only the reactivation is refused in the background — which left the
background path reusing an engine whose input hardware had gone, which is the
state that reports the format that raises.

A failed repair now records why, so the next one leaves evidence.
wk merged commit f1b0cdf0a9 into main 2026-08-06 17:48:21 -04:00
wk deleted branch m1-07-live-activity 2026-08-06 17:48:21 -04:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Stash/stash-ios!20
No description provided.