#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 /// `.m4a` + `.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 /// `.m4a` + `.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