Make panic wipe deterministic and device-bound (#1431)

* Make panic wipe deterministic and device-bound

* Scope install markers to iOS

* Harden panic recovery and service shutdown

* Invalidate queued BLE ingress during panic

* Harden panic keychain and media cleanup

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jack@deck.local>
This commit is contained in:
jack
2026-07-26 10:28:50 +02:00
committed by GitHub
co-authored by jack jack
parent fb8fe39713
commit cd727c6867
30 changed files with 2747 additions and 299 deletions
@@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
private let startError: Error?
private(set) var finishStarted = false
private(set) var cancelCount = 0
private(set) var panicCancelCount = 0
private var finishContinuation: CheckedContinuation<URL?, Never>?
init(startError: Error? = nil) {
@@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
cancelCount += 1
}
func panicCancelSynchronously() {
panicCancelCount += 1
}
func resolveFinish(with url: URL?) {
let continuation = finishContinuation
finishContinuation = nil
@@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests {
}
#expect(viewModel.state == .idle)
}
@Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async {
let session = GatedVoiceCaptureSession()
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { session }
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.panicWipe()
#expect(session.panicCancelCount == 1)
#expect(viewModel.state == .idle)
#expect(!viewModel.isLiveStreaming)
}
@Test func panicInvalidatesARecordingAlreadyFinalizing() async throws {
let session = GatedVoiceCaptureSession()
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { session }
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-panic-\(UUID().uuidString).m4a")
try Data([0x01]).write(to: url)
var delivered = false
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.finish { _ in delivered = true }
await waitUntil { session.finishStarted }
viewModel.panicWipe()
session.resolveFinish(with: url)
await waitUntil {
!FileManager.default.fileExists(atPath: url.path)
}
#expect(!delivered)
#expect(viewModel.state == .idle)
}
@Test func liveSessionPanicStopsCaptureWithoutSendingControl() {
let capture = StubPTTCapture(stopResult: (nil, 0))
var sentPackets: [Data] = []
let session = PTTLiveVoiceSession(
sendPacket: { sentPackets.append($0) },
capture: capture
)
session.panicCancelSynchronously()
#expect(capture.cancelCount == 1)
#expect(sentPackets.isEmpty)
}
}