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:
@@ -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")
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,5 +49,43 @@
|
||||
<string>Notes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Notes Backup</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>dev.rzen.indie.Notes.backup</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>dev.rzen.indie.Notes.backup</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Notes Backup</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.archive</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>notesbackup</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -40,5 +40,41 @@
|
||||
<string>Notes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Notes Backup</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>dev.rzen.indie.Notes.backup</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>dev.rzen.indie.Notes.backup</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Notes Backup</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.archive</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>notesbackup</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -398,6 +398,21 @@ final class SyncEngine {
|
||||
.sorted()
|
||||
}
|
||||
|
||||
// MARK: - Restore support
|
||||
|
||||
/// Stops the metadata observers so a backup restore's bulk file
|
||||
/// replacement isn't observed as live sync events.
|
||||
func suspendForRestore() {
|
||||
stopMonitoring()
|
||||
}
|
||||
|
||||
/// Restarts fresh observers after a restore. The new NSMetadataQuery
|
||||
/// re-baselines its known files on gather, so the restored tree does not
|
||||
/// replay as a flood of add/remove events.
|
||||
func resumeAfterRestore() {
|
||||
startMonitoring()
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
private func performLaunchMaintenance() {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import IndieAbout
|
||||
import IndieBackup
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
@Environment(ContextService.self) private var contextService
|
||||
@EnvironmentObject private var backupController: BackupController
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -48,6 +51,17 @@ struct SettingsView: View {
|
||||
}
|
||||
.disabled(contextService.isRefreshing)
|
||||
}
|
||||
|
||||
BackupsSectionView(controller: backupController)
|
||||
|
||||
Section {
|
||||
IndieAbout(configuration: .init(
|
||||
documents: [
|
||||
.license(filename: "LICENSE", extension: "md"),
|
||||
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md")
|
||||
]
|
||||
))
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Settings")
|
||||
|
||||
Reference in New Issue
Block a user