Schema v3: transcription attempts accumulate on AudioInfo (text, locale, confidence, forced flag) — latest attempt is displayed, payload.text is purely user writing, v2 notes migrate at read time. Editor shows the transcript in its own live-updating section with insert-into-note; search matches transcripts weighted by confidence. Watch recordings are now deleted only on the phone's durable-ingest ack (transferUserInfo), closing every phone-side loss window; failed sidecar writes unstage instead of orphaning, and unreadable inbox recordings are surfaced in logs. Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
135 lines
6.0 KiB
Swift
135 lines
6.0 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
|
|
}
|
|
|
|
/// Tell the watch a recording reached a terminal state here, so it can
|
|
/// delete its local copy. `transferUserInfo` queues durably — the ack
|
|
/// arrives even if the watch app is closed right now. Wired to
|
|
/// `SyncEngine.onWatchRecordingHandled` by `NotesApp`.
|
|
func acknowledgeIngest(id: String) {
|
|
guard let session, session.activationState == .activated else { return }
|
|
session.transferUserInfo(IngestAck(id: id).userInfo())
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// Bytes lost on this delivery — safe because the watch keeps its
|
|
// copy until the ingest ack, which this recording never gets; the
|
|
// next watch reconcile redelivers it.
|
|
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 }
|
|
do {
|
|
let data = try PropertyListSerialization.data(fromPropertyList: meta, format: .binary, options: 0)
|
|
try data.write(to: metaDest)
|
|
} catch {
|
|
// A sidecar-less m4a would sit in the inbox forever (the drain
|
|
// can't ingest it). Remove the audio too and let the un-acked
|
|
// watch copy drive a clean redelivery instead.
|
|
try? fm.removeItem(at: audioDest)
|
|
log.error("failed to write sidecar for \(base, privacy: .public), unstaging: \(error, privacy: .public)")
|
|
return
|
|
}
|
|
log.info("staged watch recording \(base, privacy: .public) for ingest")
|
|
}
|
|
}
|
|
#endif
|