IndieAbout: About section in iOS Settings and macOS About window; LICENSE
switched to the portfolio-standard ISC text (reflowed for in-app rendering).
IndieBackup: files-only backup/restore of the iCloud container documents
('.notesbackup' document type on both platforms), SwiftData cache rebuilt
via SyncEngine.rebuildCache() after restore, monitors suspended during the
file swap, onOpenURL import with pre-launch queueing.
Release pipeline: Scripts/release.sh (ios|mac|all) archives and uploads via
xcodebuild + ASC API key; platform-partitioned build numbers (iOS even,
macOS odd) since both targets share one bundle ID and build sequence.
100 lines
3.8 KiB
Swift
100 lines
3.8 KiB
Swift
import IndieAbout
|
|
import SwiftData
|
|
import SwiftUI
|
|
|
|
@main
|
|
struct NotesApp: App {
|
|
@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)
|
|
.environmentObject(appServices.backupController)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.task {
|
|
if appServices == nil {
|
|
let services = await AppServices()
|
|
appServices = services
|
|
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()
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|