Add IndieAbout + IndieBackup, TestFlight release pipeline

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.
This commit is contained in:
2026-07-14 20:50:06 -04:00
parent 9b231a1978
commit cf8616107b
17 changed files with 460 additions and 7 deletions
+43 -1
View File
@@ -1,9 +1,11 @@
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 {
@@ -14,6 +16,7 @@ struct NotesApp: App {
.modelContainer(appServices.modelContainer)
.environment(appServices.syncEngine)
.environment(appServices.contextService)
.environmentObject(appServices.backupController)
} else {
ProgressView()
}
@@ -23,8 +26,15 @@ struct NotesApp: App {
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
@@ -43,7 +53,12 @@ struct NotesApp: App {
case .available:
// Pull in records that synced down (and drop ones
// deleted elsewhere) while the app was backgrounded.
await syncEngine.reconcile()
// 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
}
@@ -52,6 +67,33 @@ struct NotesApp: App {
}
#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)
}
}
}