Files
notes/Notes/Connectivity/PhoneWatchReceiver.swift
T
rzen 86d74806c9 Add voice notes: on-device transcription, multi-language, watch capture
- Schema v2: Note.AudioInfo, .m4a sidecar next to the note JSON in iCloud
- AudioRecorderService (iOS/macOS) + SpeechAnalyzer transcription pipeline
  with enabled-language set and confidence-based auto-pick, re-transcribe menu
- Settings > Transcription > Languages with asset download/reserve handling
- watchOS app + accessory complication: one-button recorder, WCSession
  transferFile to phone, WatchInbox staging, direct-upsert ingestion
- Player UI, transcription status chips, quick-capture mic in notes list
- Version 0.2, changelog + README updated
2026-07-15 13:30:43 -04:00

115 lines
4.9 KiB
Swift

#if os(iOS)
import Foundation
import os
import WatchConnectivity
/// Phone side of the watch recording hand-off. Receives a `WCSessionFile` the
/// watch sent via `transferFile`, stages it into the local `WatchInbox` (with the
/// `<ULID>.m4a` + `<ULID>.meta.plist` layout `SyncEngine.ingestStagedWatchRecordings`
/// expects), then asks the sync engine to drain the inbox.
///
/// The move happens **synchronously in the nonisolated delegate callback**: the
/// system deletes the received inbox file the instant `didReceive` returns, so
/// deferring the move to a `MainActor` hop would race the bytes away. Only after
/// staging do we hop to the main actor to trigger ingest.
///
/// Activated from the `UIApplicationDelegate` (not a SwiftUI `.task`) so a
/// background launch the system makes purely to deliver a transfer still stages
/// the file. `syncEngine` is wired in later, once `AppServices` exists; when it's
/// nil (a background launch with no UI yet) the staged file is drained on the
/// next foreground connect instead.
@MainActor
final class PhoneWatchReceiver: NSObject {
private nonisolated static let log = Logger(
subsystem: "dev.rzen.indie.Notes", category: "phone-watch-receiver")
/// The live sync engine, set once `AppServices` is built. Weak: `AppServices`
/// owns it for the app's lifetime; the receiver only borrows it to trigger a
/// drain when one is available.
weak var syncEngine: SyncEngine?
private var session: WCSession?
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
self.session = session
}
}
// MARK: - WCSessionDelegate
extension PhoneWatchReceiver: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error { Self.log.error("phone WC activation failed: \(error, privacy: .public)") }
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
// Reactivate for a switched watch.
session.activate()
}
nonisolated func session(_ session: WCSession, didReceive file: WCSessionFile) {
// Synchronous move — the inbox file is deleted the moment this returns.
Self.stage(file: file)
// Drain if the engine is live; otherwise the next foreground connect drains.
Task { @MainActor in await self.syncEngine?.ingestStagedWatchRecordings() }
}
/// Move the received audio into `WatchInbox` and write the metadata sidecar
/// `SyncEngine`'s drain reads. On a decodable payload this stages a full
/// `<ULID>.m4a` + `<ULID>.meta.plist` pair. On an unknown/newer payload
/// version (the forward gate) the bytes are still quarantined in the inbox —
/// but without a sidecar, so the drain skips them and a future build can pick
/// them up.
nonisolated private static func stage(file: WCSessionFile) {
let fm = FileManager.default
let inbox = SyncEngine.watchInboxURL
try? fm.createDirectory(at: inbox, withIntermediateDirectories: true)
let metadata = file.metadata ?? [:]
guard let payload = AudioNotePayload(metadata: metadata) else {
// Forward gate: keep the bytes but don't make them ingestible.
let base = (metadata["id"] as? String) ?? UUID().uuidString
let dest = inbox.appending(path: "\(base).m4a")
try? fm.removeItem(at: dest)
do {
try fm.moveItem(at: file.fileURL, to: dest)
log.error("staged watch recording \(base, privacy: .public) with unknown payload version — skipping ingest")
} catch {
log.error("failed to stage unknown-version recording: \(error, privacy: .public)")
}
return
}
let base = payload.id
let audioDest = inbox.appending(path: "\(base).m4a")
let metaDest = inbox.appending(path: "\(base).meta.plist")
// Idempotent for a redelivery of the same recording.
try? fm.removeItem(at: audioDest)
do {
try fm.moveItem(at: file.fileURL, to: audioDest)
} catch {
log.error("failed to stage recording \(base, privacy: .public): \(error, privacy: .public)")
return
}
var meta: [String: Any] = [
"id": payload.id,
"recordedAt": payload.recordedAt,
"duration": payload.duration,
]
if let tz = payload.timeZoneID { meta["timeZoneID"] = tz }
if let data = try? PropertyListSerialization.data(fromPropertyList: meta, format: .binary, options: 0) {
try? data.write(to: metaDest)
}
log.info("staged watch recording \(base, privacy: .public) for ingest")
}
}
#endif