Decouple transcripts from note text; end-to-end watch transfer acks
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
This commit is contained in:
@@ -8,11 +8,14 @@ import WatchConnectivity
|
||||
///
|
||||
/// 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.
|
||||
/// with an `AudioNotePayload` as metadata. Local files are deleted **only** when
|
||||
/// the phone sends an `IngestAck` — the end-to-end confirmation that the
|
||||
/// recording is durably in the iCloud container (or permanently vetoed there).
|
||||
/// WC-level `didFinish` success is not enough: the phone can still fail after
|
||||
/// delivery (staging, iCloud write), and this local copy is the only recovery.
|
||||
/// Anything never acked is re-enqueued by `reconcileQueue()` from the persisted
|
||||
/// sidecar on the next activation/foreground; the phone dedupes and re-acks.
|
||||
/// `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
|
||||
@@ -30,6 +33,13 @@ final class WatchTransferService: NSObject {
|
||||
|
||||
private var session: WCSession?
|
||||
|
||||
/// Recordings WC reported delivered this session but the phone hasn't yet
|
||||
/// acked as ingested. Skipped by `reconcileQueue` so a foreground pass right
|
||||
/// after delivery doesn't re-send a whole audio file the phone is busy
|
||||
/// ingesting. In-memory only: after a relaunch these fall back into the
|
||||
/// reconcile pool, and a redundant redelivery is harmless (phone dedupes).
|
||||
private var deliveredAwaitingAck: Set<String> = []
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
@@ -62,6 +72,7 @@ final class WatchTransferService: NSObject {
|
||||
let entries = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
|
||||
for m4a in entries where m4a.pathExtension == "m4a" {
|
||||
if inFlight.contains(m4a.lastPathComponent) { continue }
|
||||
if deliveredAwaitingAck.contains(m4a.lastPathComponent) { continue }
|
||||
let base = m4a.deletingPathExtension().lastPathComponent
|
||||
guard ULID(string: base) != nil else { continue }
|
||||
let meta = readSidecar(base: base)
|
||||
@@ -93,13 +104,20 @@ final class WatchTransferService: NSObject {
|
||||
session.transferFile(url, metadata: payload.metadata())
|
||||
}
|
||||
|
||||
/// Delete a delivered recording and its sidecar (called only on a confirmed
|
||||
/// `didFinish` with no error).
|
||||
/// Delete an **acked** recording and its sidecar — called only when the
|
||||
/// phone's `IngestAck` arrives, never on mere WC delivery success.
|
||||
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))
|
||||
deliveredAwaitingAck.remove(fileName)
|
||||
refreshPendingCount()
|
||||
}
|
||||
|
||||
/// WC delivered the file to the phone; park it until the ingest ack.
|
||||
private func markDelivered(fileName: String) {
|
||||
deliveredAwaitingAck.insert(fileName)
|
||||
refreshPendingCount()
|
||||
}
|
||||
|
||||
@@ -160,6 +178,14 @@ extension WatchTransferService: WCSessionDelegate {
|
||||
Task { @MainActor in self.refreshPendingCount() }
|
||||
return
|
||||
}
|
||||
Task { @MainActor in self.completeTransfer(fileName: fileName) }
|
||||
// Delivered ≠ ingested: keep the local copy until the phone's ack.
|
||||
Task { @MainActor in self.markDelivered(fileName: fileName) }
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
// The phone's ingest ack — the recording is durable in iCloud (or
|
||||
// permanently vetoed); the local copy has done its job.
|
||||
guard let ack = IngestAck(userInfo: userInfo) else { return }
|
||||
Task { @MainActor in self.completeTransfer(fileName: "\(ack.id).m4a") }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user