Files
notes/Notes/NotesApp.swift
T
rzen c5c90e9441 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
2026-07-16 12:29:49 -04:00

119 lines
4.9 KiB
Swift

import IndieAbout
import SwiftData
import SwiftUI
@main
struct NotesApp: App {
#if os(iOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#endif
@State private var appServices: AppServices?
@State private var pendingBackupURL: URL?
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
Group {
if let appServices {
ContentView()
.modelContainer(appServices.modelContainer)
.environment(appServices.syncEngine)
.environment(appServices.contextService)
.environment(appServices.audioRecorder)
.environment(appServices.transcriptionSettings)
.environment(appServices.transcriptionService)
.environmentObject(appServices.backupController)
} else {
ProgressView()
}
}
.task {
if appServices == nil {
let services = await AppServices()
appServices = services
#if os(iOS)
// Hand the (already-activated) watch receiver the live sync
// engine so a delivery arriving now drains immediately, and
// route ingest acks back so the watch can free its copies.
let receiver = appDelegate.watchReceiver
receiver.syncEngine = services.syncEngine
services.syncEngine.onWatchRecordingHandled = { [weak receiver] id in
receiver?.acknowledgeIngest(id: id)
}
#endif
await services.startDeferredServices()
if let pending = pendingBackupURL {
pendingBackupURL = nil
restore(pending, with: services)
}
}
}
.onOpenURL { url in
handle(url: url)
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active, let appServices else { return }
let syncEngine = appServices.syncEngine
let contextService = appServices.contextService
Task {
// Fresh context whenever the user comes back to the app —
// recall ranking should reflect where they are right now.
await contextService.refresh()
}
Task {
switch syncEngine.iCloudStatus {
case .unavailable:
// The user typically fixes iCloud in Settings and
// switches straight back — retry on foreground.
await syncEngine.connect()
case .available:
// Pull in records that synced down (and drop ones
// deleted elsewhere) while the app was backgrounded.
// Skip during a restore — it stops the monitors and
// rebuilds the cache itself; a reconcile racing the
// file swap would import a half-restored state.
if !appServices.backupController.isRestoring {
await syncEngine.reconcile()
// Ingest any watch recordings staged since we last
// looked (delivered while backgrounded).
await syncEngine.ingestStagedWatchRecordings()
}
case .checking:
break
}
}
}
}
#if os(macOS)
.defaultSize(width: 900, height: 700)
.commands {
IndieAboutCommand(configuration: .init(
documents: [
.license(filename: "LICENSE", extension: "md"),
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md")
]
))
}
#endif
}
/// A tapped `.notesbackup` file (Files, AirDrop, Mail) restores the
/// backup. If it arrives before services finish launching, it's queued.
private func handle(url: URL) {
guard url.isFileURL, url.pathExtension == "notesbackup" else { return }
if let appServices {
restore(url, with: appServices)
} else {
pendingBackupURL = url
}
}
private func restore(_ url: URL, with services: AppServices) {
let didScope = url.startAccessingSecurityScopedResource()
Task {
defer { if didScope { url.stopAccessingSecurityScopedResource() } }
try? await services.backupController.restoreBackup(from: url)
}
}
}