Files
notes/Notes Watch App/Connectivity/WatchTransferService.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

166 lines
7.8 KiB
Swift

import Foundation
import IndieSync
import os
import WatchConnectivity
/// Watch side of the recording hand-off. The watch has no iCloud; a finished
/// recording becomes durable on the phone, which ingests and transcribes it.
///
/// Each queued recording is a `<ULID>.m4a` plus a `<ULID>.meta.plist` sidecar in
/// `Documents/Recordings/`. `transferFile` hands the audio to WatchConnectivity
/// with an `AudioNotePayload` as metadata. Local files are deleted **only** in
/// `didFinish` with a nil error — so a delivery we never confirm (app killed
/// mid-flight, transfer failed) is re-enqueued by `reconcileQueue()` from the
/// persisted sidecar on the next activation. `pendingCount` (files still on disk)
/// drives the "N waiting for iPhone" footer.
///
/// Strict concurrency: the delegate callbacks are nonisolated and run on
/// WatchConnectivity's background queue — they extract Sendable values (a file
/// name string) before hopping to the main actor. `WCSessionFile*` are not
/// Sendable and never cross the hop.
@Observable
@MainActor
final class WatchTransferService: NSObject {
private nonisolated static let log = Logger(
subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "watch-transfer")
/// Recordings still on disk (queued or in-flight) — the count the phone has
/// not yet confirmed receiving.
private(set) var pendingCount = 0
private var session: WCSession?
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
self.session = session
refreshPendingCount()
}
/// Queue a finished recording for delivery. Writes the sidecar first (so a
/// relaunch before delivery can re-enqueue it), then hands the file to
/// `transferFile`.
func enqueue(_ recording: WatchAudioRecorder.Recording) {
let tz = TimeZone.current.identifier
writeSidecar(base: recording.id.stringValue,
recordedAt: recording.recordedAt, duration: recording.duration, timeZoneID: tz)
startTransfer(id: recording.id.stringValue, url: recording.url,
recordedAt: recording.recordedAt, duration: recording.duration, timeZoneID: tz)
refreshPendingCount()
}
/// Re-enqueue any recording still on disk that the session isn't already
/// transferring — a file whose delivery we never confirmed. Runs when the
/// session activates (relaunch) and when the app returns to the foreground.
func reconcileQueue() {
guard let session else { refreshPendingCount(); return }
let inFlight = Set(session.outstandingFileTransfers.map { $0.file.fileURL.lastPathComponent })
let fm = FileManager.default
let dir = WatchAudioRecorder.recordingsDirectory
let entries = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
for m4a in entries where m4a.pathExtension == "m4a" {
if inFlight.contains(m4a.lastPathComponent) { continue }
let base = m4a.deletingPathExtension().lastPathComponent
guard ULID(string: base) != nil else { continue }
let meta = readSidecar(base: base)
startTransfer(id: base, url: m4a,
recordedAt: meta?.recordedAt ?? Date(),
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()
}
// MARK: - Transfer
private func startTransfer(id: String, url: URL, recordedAt: Date, duration: TimeInterval, timeZoneID: String?) {
guard let session, session.activationState == .activated,
FileManager.default.fileExists(atPath: url.path) else { return }
let payload = AudioNotePayload(
id: id, recordedAt: recordedAt, duration: duration, timeZoneID: timeZoneID)
session.transferFile(url, metadata: payload.metadata())
}
/// Delete a delivered recording and its sidecar (called only on a confirmed
/// `didFinish` with no error).
private func completeTransfer(fileName: String) {
let dir = WatchAudioRecorder.recordingsDirectory
let base = (fileName as NSString).deletingPathExtension
try? FileManager.default.removeItem(at: dir.appending(path: fileName))
try? FileManager.default.removeItem(at: sidecarURL(base: base))
refreshPendingCount()
}
// MARK: - Pending count
private func refreshPendingCount() {
let dir = WatchAudioRecorder.recordingsDirectory
let entries = (try? FileManager.default.contentsOfDirectory(
at: dir, includingPropertiesForKeys: nil)) ?? []
pendingCount = entries.filter { $0.pathExtension == "m4a" }.count
}
// MARK: - Sidecar (persisted metadata for re-enqueue after relaunch)
private func sidecarURL(base: String) -> URL {
WatchAudioRecorder.recordingsDirectory.appending(path: "\(base).meta.plist")
}
private func writeSidecar(base: String, recordedAt: Date, duration: TimeInterval, timeZoneID: String?) {
var dict: [String: Any] = ["recordedAt": recordedAt, "duration": duration]
if let timeZoneID { dict["timeZoneID"] = timeZoneID }
if let data = try? PropertyListSerialization.data(fromPropertyList: dict, format: .binary, options: 0) {
try? data.write(to: sidecarURL(base: base))
}
}
private func readSidecar(base: String) -> (recordedAt: Date, duration: TimeInterval, timeZoneID: String?)? {
guard let data = try? Data(contentsOf: sidecarURL(base: base)),
let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any],
let recordedAt = dict["recordedAt"] as? Date,
let duration = dict["duration"] as? Double else { return nil }
return (recordedAt, duration, dict["timeZoneID"] as? String)
}
}
// MARK: - WCSessionDelegate
extension WatchTransferService: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error { Self.log.error("watch WC activation failed: \(error, privacy: .public)") }
Task { @MainActor in self.reconcileQueue() }
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
// A queued file whose transfer never registered (session wasn't yet
// activated at enqueue) gets re-driven once the phone is reachable again.
if session.isReachable {
Task { @MainActor in self.reconcileQueue() }
}
}
nonisolated func session(_ session: WCSession, didFinish fileTransfer: WCSessionFileTransfer, error: (any Error)?) {
// Extract the Sendable file name before hopping — WCSessionFileTransfer /
// WCSessionFile are not Sendable.
let fileName = fileTransfer.file.fileURL.lastPathComponent
if let error {
Self.log.warning("file transfer failed (\(fileName, privacy: .public)): \(error, privacy: .public)")
Task { @MainActor in self.refreshPendingCount() }
return
}
Task { @MainActor in self.completeTransfer(fileName: fileName) }
}
}