Files
notes/Notes Watch App/WatchAppServices.swift
T
rzen 3a9c36d843 Fix dead record button after mic permission denial
The watch app's permission-denied state was a computed property reading
AVAudioApplication.shared.recordPermission — external state Observation
cannot track — so after a denied prompt the UI never flipped to the
'Microphone access is off' screen and the record button silently did
nothing. Store the denied flag as a tracked property instead, updated
on every permission request.

Also: log (instead of swallow) recorder start failures, drop phantom
recordings when no file was written (the watch simulator has no audio
input device), sweep orphaned metadata sidecars on reconcile, and add a
DEBUG-only 'autorecord' launch argument for CLI-driven testing.

Claude-Session: https://claude.ai/code/session_01GKfAiKiQKLDTmbiGva4FGE
2026-07-15 14:58:57 -04:00

84 lines
2.9 KiB
Swift

import Foundation
import Observation
import OSLog
import SwiftUI
/// Composition root for the watch app. Owns the recorder and the transfer
/// service and mediates between them: a finished recording is handed to the
/// transfer queue. The watch has no iCloud or local database — its whole job is
/// to capture audio and get it to the phone.
@Observable
@MainActor
final class WatchAppServices {
let recorder = WatchAudioRecorder()
let transfer = WatchTransferService()
func bootstrap() {
transfer.activate()
transfer.reconcileQueue()
// The 10-minute safety cap ends a take without a Stop tap — queue it
// rather than lose it.
recorder.onInvoluntaryFinish = { [weak self] recording in
self?.transfer.enqueue(recording)
}
}
/// Single-button behaviour: start if idle, stop-and-queue if recording.
func toggleRecording() {
if recorder.isRecording {
stopAndQueue()
} else {
Task { await startRecording() }
}
}
private let logger = Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording")
/// Request permission (once) then begin recording. A no-op if already
/// recording or permission is refused.
func startRecording() async {
guard !recorder.isRecording else { return }
guard await recorder.requestPermission() else {
logger.warning("microphone permission refused")
return
}
do {
try recorder.startRecording()
} catch {
logger.error("could not start recording: \(error, privacy: .public)")
}
}
private func stopAndQueue() {
if let recording = recorder.stopRecording() {
transfer.enqueue(recording)
}
}
/// Complication deep link (`contextful://record?autostart=1`) → auto-start a
/// recording. Plain launches never carry the URL, so they never auto-record.
func handleDeepLink(_ url: URL) {
guard url.scheme == "contextful", url.host == "record" else { return }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let autostart = components?.queryItems?.first { $0.name == "autostart" }?.value == "1"
guard autostart else { return }
Task { await startRecording() }
}
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .active:
// Catch up any file whose delivery we never confirmed.
transfer.reconcileQueue()
case .background:
// Never lose audio to the app being suspended: stop-and-queue
// whatever is recording rather than dropping the take. (A brief
// wrist-down is `.inactive` and is covered by the recorder's
// frontmost-timeout extension, so it keeps recording.)
stopAndQueue()
default:
break
}
}
}