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
84 lines
2.9 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|