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") /// Why the last start attempt failed, surfaced in the UI — on a real watch /// there's no console, so a silent failure is indistinguishable from a dead /// button. Cleared when a recording starts successfully. private(set) var lastError: String? /// 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() lastError = nil } catch { logger.error("could not start recording: \(error, privacy: .public)") lastError = error.localizedDescription } } 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 } } }