Files
notes/Notes Watch App/WatchAppServices.swift
T
rzen 798abf3b90 Watch: show version/build on screen, surface recording errors in UI
A tester on real hardware has no console, so a failed start looked like
a dead button and there was no way to tell which build the watch had
actually installed. Show the stamped version/build under the record
button, display the last start error in place of the hint text, and
fall back to the default audio session mode if .spokenAudio is rejected
with the .record category.

Claude-Session: https://claude.ai/code/session_01GKfAiKiQKLDTmbiGva4FGE
2026-07-15 16:07:32 -04:00

91 lines
3.2 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")
/// 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
}
}
}