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
+11 -1
View File
@@ -1,4 +1,5 @@
import Foundation
import IndieBackup
import SwiftData
/// Owns all service objects; created asynchronously at launch and injected
@@ -9,14 +10,23 @@ final class AppServices {
let modelContainer: ModelContainer
let syncEngine: SyncEngine
let contextService: ContextService
let backupController: BackupController
init() async {
let container = NotesModelContainer.make()
self.modelContainer = container
self.syncEngine = SyncEngine(modelContainer: container)
let engine = SyncEngine(modelContainer: container)
self.syncEngine = engine
self.contextService = ContextService()
self.backupController = BackupController(
configuration: NotesBackupConfiguration(syncEngine: engine)
)
}
/// The file extension this app owns for backup documents. Used to route
/// `onOpenURL` imports to a restore.
var backupFileExtension: String { backupController.configuration.backupFileExtension }
/// Slow startup work (iCloud container discovery) runs after the UI is up.
func startDeferredServices() async {
await syncEngine.connect()
@@ -0,0 +1,37 @@
import Foundation
import IndieBackup
/// Backup configuration for IndieBackup (files-only).
///
/// IndieBackup backs up the entire iCloud container `Documents` folder
/// (`backupRoot`, the package default) exactly where the source of truth
/// lives (`Records/`, `Stubs/`). The SwiftData cache is rebuildable and not
/// part of the backup; it's regenerated from the restored files via
/// `rebuildCacheAfterRestore`.
struct NotesBackupConfiguration: BackupConfiguration {
/// The sync engine whose observers are suspended during restore and whose
/// cache is rebuilt afterwards.
let syncEngine: SyncEngine
var backupFileExtension: String { "notesbackup" }
var backupDisplayName: String { "Notes Backup" }
/// Suspend the metadata observers for the whole restore so the bulk file
/// replacement isn't processed as a flood of live sync events.
func prepareForRestore() async {
await syncEngine.suspendForRestore()
}
/// Restart fresh observers once the restored files have settled a new
/// NSMetadataQuery re-baselines on gather, so nothing replays.
func finishRestore() async {
await syncEngine.resumeAfterRestore()
}
/// Rebuild the SwiftData cache so it exactly mirrors the restored files.
func rebuildCacheAfterRestore(progress: @escaping (Double, String) -> Void) async throws {
progress(0.0, "Rebuilding notes…")
await syncEngine.rebuildCache()
progress(1.0, "Rebuilt")
}
}