diff --git a/Notes Watch App/Connectivity/WatchTransferService.swift b/Notes Watch App/Connectivity/WatchTransferService.swift index 6687d7e..5bb79e2 100644 --- a/Notes Watch App/Connectivity/WatchTransferService.swift +++ b/Notes Watch App/Connectivity/WatchTransferService.swift @@ -70,6 +70,16 @@ final class WatchTransferService: NSObject { duration: meta?.duration ?? 0, timeZoneID: meta?.timeZoneID) } + // Sweep sidecars whose audio file is gone (e.g. a simulator "recording" + // that never produced a file) so they don't linger forever. + let audioBases = Set(entries.filter { $0.pathExtension == "m4a" } + .map { $0.deletingPathExtension().lastPathComponent }) + for sidecar in entries where sidecar.lastPathComponent.hasSuffix(".meta.plist") { + let base = sidecar.lastPathComponent.replacingOccurrences(of: ".meta.plist", with: "") + if !audioBases.contains(base) { + try? fm.removeItem(at: sidecar) + } + } refreshPendingCount() } diff --git a/Notes Watch App/NotesWatchApp.swift b/Notes Watch App/NotesWatchApp.swift index c0c8733..5fa5ef3 100644 --- a/Notes Watch App/NotesWatchApp.swift +++ b/Notes Watch App/NotesWatchApp.swift @@ -11,7 +11,15 @@ struct NotesWatchApp: App { RecordView() } .environment(services) - .task { services.bootstrap() } + .task { + services.bootstrap() + #if DEBUG + // CLI-driven repro of the record button (simctl launch … autorecord). + if ProcessInfo.processInfo.arguments.contains("autorecord") { + await services.startRecording() + } + #endif + } .onOpenURL { url in services.handleDeepLink(url) } } .onChange(of: scenePhase) { _, phase in diff --git a/Notes Watch App/Services/WatchAudioRecorder.swift b/Notes Watch App/Services/WatchAudioRecorder.swift index 5241f81..3d13a21 100644 --- a/Notes Watch App/Services/WatchAudioRecorder.swift +++ b/Notes Watch App/Services/WatchAudioRecorder.swift @@ -1,6 +1,7 @@ import AVFoundation import Foundation import IndieSync +import OSLog import WatchKit /// Records a single voice note on the watch into a persisted @@ -53,14 +54,16 @@ final class WatchAudioRecorder { /// Ask for microphone access, returning whether it was granted. Safe to call /// every time recording starts — the system only prompts once. func requestPermission() async -> Bool { - await AVAudioApplication.requestRecordPermission() + let granted = await AVAudioApplication.requestRecordPermission() + permissionDenied = !granted + return granted } /// Whether the mic has been explicitly denied, so the UI can show a jump to - /// Settings instead of a dead record button. - var permissionDenied: Bool { - AVAudioApplication.shared.recordPermission == .denied - } + /// Settings instead of a dead record button. Stored (not computed from + /// `AVAudioApplication.shared.recordPermission`) so Observation tracks it and + /// the UI actually flips to the denied state the moment a request is refused. + private(set) var permissionDenied = AVAudioApplication.shared.recordPermission == .denied /// Begin recording into a fresh `.m4a`. A no-op if already recording. /// Throws if the audio session or recorder can't be configured. @@ -111,6 +114,14 @@ final class WatchAudioRecorder { let recording = Recording( id: current.id, url: current.url, duration: duration, recordedAt: current.startedAt) teardown() + // The watch simulator has no audio input device: record() reports + // success and currentTime advances, but no file is ever written. Don't + // hand a phantom recording up to the transfer queue. + guard FileManager.default.fileExists(atPath: recording.url.path) else { + Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording") + .warning("recording produced no file (simulator has no mic input?): \(recording.url.lastPathComponent, privacy: .public)") + return nil + } return recording } diff --git a/Notes Watch App/WatchAppServices.swift b/Notes Watch App/WatchAppServices.swift index 14adfe1..654e624 100644 --- a/Notes Watch App/WatchAppServices.swift +++ b/Notes Watch App/WatchAppServices.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import OSLog import SwiftUI /// Composition root for the watch app. Owns the recorder and the transfer @@ -31,12 +32,21 @@ final class WatchAppServices { } } + 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 { return } - try? recorder.startRecording() + 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() {