Backup bows out of the phone — the zipped-share future replaces it, later
IndieBackup comes out whole: the package, the Backup directory, the Settings section, the .kanbanbackup document type and its onOpenURL restore path. Settings is now the About section alone. The Files-app keys stay — they serve local-board visibility, not backups. Board sharing as a zipped archive is the planned replacement and is deliberately not started here. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import SwiftUI
|
||||
import IndieBackup
|
||||
import os
|
||||
|
||||
/// The iPhone app's entry point. Two tabs, per the mobile MVP charter: Boards (the whole
|
||||
/// board → lane → card navigation stack) and Settings (backup/restore).
|
||||
/// board → lane → card navigation stack) and Settings.
|
||||
@main
|
||||
struct MobileApp: App {
|
||||
|
||||
@@ -13,40 +11,10 @@ struct MobileApp: App {
|
||||
/// constructing one.
|
||||
@State private var boardIndex = BoardIndexStore()
|
||||
|
||||
/// Backup/restore (indie-backup skill), built once `boardIndex.home` resolves to a real
|
||||
/// `CloudHome` — see `MobileBackupConfiguration`'s doc comment for why this can't happen at
|
||||
/// `@State` init time. Stays `nil` for the lifetime of a run with no iCloud account;
|
||||
/// `SettingsTabView` renders the quiet disabled state for exactly that case.
|
||||
@State private var backupController: BackupController?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "backup")
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootTabView()
|
||||
.environment(boardIndex)
|
||||
.environment(\.backupController, backupController)
|
||||
.task(id: boardIndex.home) {
|
||||
guard backupController == nil, let home = boardIndex.home else { return }
|
||||
backupController = BackupController(
|
||||
configuration: MobileBackupConfiguration(root: home.documentsURL, index: boardIndex)
|
||||
)
|
||||
}
|
||||
.onOpenURL { url in
|
||||
// A backup file opened from Files, AirDrop, or a share sheet. Silently
|
||||
// ignored before `backupController` exists — there is no resolved cloud home
|
||||
// to restore into either, at that point.
|
||||
guard url.pathExtension == MobileBackupConfiguration.fileExtension,
|
||||
let backupController
|
||||
else { return }
|
||||
Task {
|
||||
do {
|
||||
try await backupController.restoreBackup(from: url)
|
||||
} catch {
|
||||
Self.logger.error("restore from opened file failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import Foundation
|
||||
import IndieBackup
|
||||
import SwiftUI
|
||||
|
||||
/// IndieBackup's app-specific wiring (indie-backup skill): what gets backed up, and how the
|
||||
/// app's in-memory board list is rebuilt after a restore.
|
||||
///
|
||||
/// **Why this type is constructed rather than defaulted.** `BackupConfiguration.backupRoot` is a
|
||||
/// non-optional `URL`, but the ubiquity container resolves asynchronously and can fail outright
|
||||
/// (`CloudHomeResolver`, CloudHome.swift) — there is no root to hand IndieBackup until
|
||||
/// `BoardIndexStore.home` exists. Rather than answer with a placeholder path, this type is only
|
||||
/// ever constructed once a real `CloudHome` is in hand (`MobileApp`'s `.task(id:)`); before that
|
||||
/// there is no `BackupController` at all, and `SettingsTabView` renders a quiet disabled
|
||||
/// explanation instead of a section built on a fabricated root.
|
||||
final class MobileBackupConfiguration: BackupConfiguration {
|
||||
/// No dot. Shared by the Info.plist document-type/UTI registration
|
||||
/// (`dev.rzen.indie.kanban-backup`) and the archive filenames IndieBackup writes.
|
||||
static let fileExtension = "kanbanbackup"
|
||||
|
||||
private let root: URL
|
||||
private let index: BoardIndexStore
|
||||
|
||||
/// - Parameters:
|
||||
/// - root: The resolved `CloudHome.documentsURL` — the same `Documents/` folder every
|
||||
/// `.kanban` board lives directly inside. IndieBackup walks it file-by-file (it has no
|
||||
/// notion of "package"), so a board's directory structure round-trips through a backup
|
||||
/// as an ordinary tree of files — no directory registration or special-casing needed.
|
||||
/// - index: Rescanned after a restore replaces the tree, in `rebuildCacheAfterRestore`.
|
||||
init(root: URL, index: BoardIndexStore) {
|
||||
self.root = root
|
||||
self.index = index
|
||||
}
|
||||
|
||||
var backupFileExtension: String { Self.fileExtension }
|
||||
var backupDisplayName: String { "Lanework Backup" }
|
||||
var backupRoot: URL { root }
|
||||
|
||||
/// `BoardIndexStore` caches nothing durable — no Core Data, no SwiftData, no file on disk —
|
||||
/// only an in-memory `[BoardSummary]` scanned straight from the files under `backupRoot`. A
|
||||
/// restore already replaced that tree by the time this runs, so the only rebuild step is
|
||||
/// asking the store to rescan it; a `BoardSession` for a board that's open elsewhere reloads
|
||||
/// its own content from disk independently the next time it's asked (BoardIndexStore's doc
|
||||
/// comment on the observer flow).
|
||||
///
|
||||
/// `refresh()` only *starts* the rescan (it hands off to a detached `Task` and returns), so
|
||||
/// this reports done as soon as the request lands, not once the boards tab has repainted —
|
||||
/// same as any other call to `refresh()` in this app (e.g. pull-to-refresh).
|
||||
func rebuildCacheAfterRestore(progress: @escaping (Double, String) -> Void) async throws {
|
||||
progress(0, "Rescanning boards…")
|
||||
await index.refresh()
|
||||
progress(1, "Rescanning boards…")
|
||||
}
|
||||
}
|
||||
|
||||
/// Threads the lazily-constructed `BackupController` from `MobileApp` down to `SettingsTabView`
|
||||
/// (the backup section) and back up to `MobileApp`'s own `onOpenURL` handler (imported backup
|
||||
/// files), without forcing `RootTabView` — or anything else between them — to know backup exists.
|
||||
/// `nil` until the cloud home resolves; see `MobileBackupConfiguration`.
|
||||
private struct BackupControllerKey: EnvironmentKey {
|
||||
static let defaultValue: BackupController? = nil
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var backupController: BackupController? {
|
||||
get { self[BackupControllerKey.self] }
|
||||
set { self[BackupControllerKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,3 @@
|
||||
Version 1.0: Lanework comes to iPhone — browse your boards from iCloud Drive, move cards between lanes, and edit them on the go.
|
||||
|
||||
Swipe a board in the list to open its settings and move it between iCloud and this iPhone, which now works without an iCloud account.
|
||||
|
||||
Back up your boards from Settings and restore any backup later, even on a new phone.
|
||||
|
||||
+2
-35
@@ -28,9 +28,8 @@
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<!-- Document-based, so App Store Connect requires this (indie-backup skill ▸ Register the
|
||||
Backup File Type) — the in-place editing the imported-backup `onOpenURL` handler
|
||||
(MobileApp.swift) relies on. -->
|
||||
<!-- Document-based, so App Store Connect requires this — the in-place editing that lets a
|
||||
board opened from the Files app (or "On My iPhone ▸ Lanework") be edited where it sits. -->
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<!-- Publishes the sandbox's Documents/ as "On My iPhone ▸ Lanework" in the Files app — the
|
||||
@@ -59,21 +58,6 @@
|
||||
<key>LSTypeIsPackage</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<!-- The backup archive IndieBackup writes/reads (indie-backup skill), so Files, AirDrop
|
||||
and share sheets route a tapped `.kanbanbackup` file to this app's `onOpenURL`
|
||||
instead of leaving it unopenable. -->
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Lanework Backup</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>dev.rzen.indie.kanban-backup</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
@@ -93,23 +77,6 @@
|
||||
<string>kanban</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Lanework Backup</string>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>dev.rzen.indie.kanban-backup</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>kanbanbackup</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- Publishes the container's Documents/ as a visible folder in iCloud Drive — on every
|
||||
platform, including the Mac's Finder, which is how boards reach the Mac app before it
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import SwiftUI
|
||||
import IndieAbout
|
||||
import IndieBackup
|
||||
|
||||
/// Backup/restore (indie-backup skill) and the About section (indie-about skill).
|
||||
/// The About section (indie-about skill).
|
||||
struct SettingsTabView: View {
|
||||
@Environment(BoardIndexStore.self) private var index
|
||||
@Environment(\.backupController) private var backupController
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
backupSection
|
||||
|
||||
// Last section by convention. The version label doubles as the changelog link,
|
||||
// which is why the changelog sits in its dedicated slot and not in `documents`.
|
||||
// Same voice as the Mac app's `AboutBox.configuration`; the changelog itself is
|
||||
// the phone app's own (see project.yml — separate product, separate 1.0).
|
||||
Section {
|
||||
@@ -27,36 +21,7 @@ struct SettingsTabView: View {
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
// Idempotent (BoardIndexStore.start()), and harmless if BoardsTabView already called it —
|
||||
// this tab can be the first one the user opens, and `backupController` only ever appears
|
||||
// once `index.home` resolves.
|
||||
// this tab can be the first one the user opens.
|
||||
.task { index.start() }
|
||||
}
|
||||
|
||||
/// `backupController` is `nil` until `MobileApp` has a resolved `CloudHome` to build one from
|
||||
/// — which is also exactly the condition under which there is a `Documents/` folder to back
|
||||
/// up. Everything short of that gets the same quiet explanation rather than a section that
|
||||
/// looks broken or, worse, a crash on a nil root.
|
||||
@ViewBuilder
|
||||
private var backupSection: some View {
|
||||
if let backupController {
|
||||
BackupsSectionView(controller: backupController)
|
||||
} else {
|
||||
Section {
|
||||
switch index.phase {
|
||||
case .idle, .loading:
|
||||
LabeledContent("Backup") {
|
||||
ProgressView()
|
||||
}
|
||||
case .ready:
|
||||
// `.ready` is both cases now: the run with no iCloud at all, and the runloop turn
|
||||
// `backupController` lags `index.home` by (MobileApp's `.task(id:)`). Backup is
|
||||
// still iCloud-only — a known gap now that local boards exist.
|
||||
Label("Backup and restore need iCloud Drive.", systemImage: "icloud.slash")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} footer: {
|
||||
Text("Sign into iCloud in Settings, then come back here to back up your boards.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user