diff --git a/KanbanMobile/CHANGELOG.md b/KanbanMobile/CHANGELOG.md index ac5373b..f4cd0cc 100644 --- a/KanbanMobile/CHANGELOG.md +++ b/KanbanMobile/CHANGELOG.md @@ -2,4 +2,6 @@ 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. diff --git a/KanbanMobile/Cloud/BoardIndexStore.swift b/KanbanMobile/Cloud/BoardIndexStore.swift index 07d51eb..49b6ad7 100644 --- a/KanbanMobile/Cloud/BoardIndexStore.swift +++ b/KanbanMobile/Cloud/BoardIndexStore.swift @@ -2,72 +2,103 @@ import Foundation import Observation import os -/// The app's root model: which boards exist in the container, and what state each is in. +/// The app's root model: which boards exist on this phone, where each one lives, and what state it +/// is in. /// -/// **One per process, owned by `MobileApp` and handed down through `.environment`.** It holds the -/// container home, the one `NSMetadataQuery` the app runs, and the `BoardSession` cache — all three -/// are process-wide facts, and a second instance would mean a second query over the same scope for -/// no gain. +/// **One per process, owned by `MobileApp` and handed down through `.environment`.** It holds both +/// homes, the one `NSMetadataQuery` the app runs, and the `BoardSession` cache — all three are +/// process-wide facts, and a second instance would mean a second query over the same scope for no +/// gain. +/// +/// ### Two homes, one list +/// +/// Boards live either in the ubiquity container (`CloudHome`) or in the app's own `Documents/` +/// (`DeviceHomeResolver`), and the list is the merge of both, each entry tagged with where it came +/// from. The device home always resolves, so **the list is always reachable**: a phone with no iCloud +/// account gets an inline notice above its local boards rather than a wall in place of them +/// (softened 2026-08-08 — `cloudUnavailable` is what carries that notice, and `phase` no longer has +/// an unavailable case to carry it instead). +/// +/// The two sides are found differently and always will be: the container is watched, the device +/// folder is enumerated. Every scan pass therefore takes whatever the cloud side has to offer — a +/// query result set, a directory listing under the DEBUG override, or nothing at all — and walks the +/// device root itself before merging. /// /// ### The observer flow /// -/// The query is the only change signal on this platform (there is no FSEvents here — see project.yml -/// ▸ Lanework for iPhone). Both of its notifications are handled identically: bracket the result set, -/// pull `Sendable` descriptors out of it on the main actor, then scan and assemble off it. The delta -/// keys are deliberately unused — the board count is small enough that a full re-enumeration is -/// cheaper than the bookkeeping a delta needs, and re-enumeration cannot drift out of sync with the -/// container the way a maintained baseline can. +/// The query is the only change signal the cloud side has on this platform (there is no FSEvents +/// here — see project.yml ▸ Lanework for iPhone). Both of its notifications are handled identically: +/// bracket the result set, pull `Sendable` descriptors out of it on the main actor, then scan and +/// assemble off it. The delta keys are deliberately unused — the board count is small enough that a +/// full re-enumeration is cheaper than the bookkeeping a delta needs, and re-enumeration cannot drift +/// out of sync with the container the way a maintained baseline can. /// /// A gather is not treated as an arrival: it is the first full picture, and it publishes summaries /// exactly as an update does. That differs from the sync-engine skill's monitor, which must baseline /// silently because its consumer replays events into a cache — here the published state *is* the -/// query's result set, so there is no replay to protect. +/// query's result set merged with the device folder, so there is no replay to protect. @MainActor @Observable final class BoardIndexStore { - /// The three states the boards tab renders. + /// The three states the boards tab renders. **No unavailable case**: the device home cannot fail, + /// so there is always a list to reach, and the one home that *can* fail reports through + /// `cloudUnavailable` alongside a live list rather than in place of it. enum Phase: Sendable, Equatable { /// `start()` has not run. case idle - /// Resolving the container, or waiting for the query's first gather. + /// Resolving the homes, or waiting for the first scan to land. case loading - /// No home — the app cannot function. The boards tab shows the sign-into-iCloud wall. - case unavailable(CloudHomeUnavailable) - - /// The list is live. `boards` may be empty; an empty container is not a failure, it is the - /// state the create-a-board bootstrap exists for. + /// The list is live. `boards` may be empty; an empty phone is not a failure, it is the state + /// the create-a-board bootstrap exists for. case ready } private(set) var phase: Phase = .idle - /// Resolved exactly once per process. `nil` until then, and whenever `phase` is `.unavailable`. + /// Resolved at most once per process, and `nil` for as long as it has not been — which is a state + /// the app runs in perfectly well, minus syncing. private(set) var home: CloudHome? - /// Every `.kanban` package in the container, sorted by title (case- and diacritic-insensitively, - /// tie-broken by path so the order is total and stable across refreshes). + /// Why there is no `home`, or `nil` when there is one. **The notice's whole input**: non-`nil` + /// alongside `phase == .ready` is the ordinary no-iCloud run, not an error state. + private(set) var cloudUnavailable: CloudHomeUnavailable? + + /// The sandbox's `Documents/` — the home that always answers. `nil` only before `start()`. + private(set) var deviceRoot: URL? + + /// Every `.kanban` package in either home, sorted by title (case- and diacritic-insensitively, + /// tie-broken by path so the order is total and stable across refreshes). Location is a tag on + /// each row, not a grouping: two homes are an implementation detail of where a board's bytes are, + /// and a person looking for a board is looking for its name. private(set) var boards: [BoardSummary] = [] /// A scan is in flight. Distinct from `phase == .loading`, which is about there being nothing to /// show yet: this stays true through refreshes of a list that is already on screen. private(set) var isScanning = false - /// The most recent failure that did not cost the whole home — a refused board create, a directory - /// that would not enumerate. Cleared by the next successful operation of the same kind. + /// The most recent failure that did not cost the whole list — a refused board create, a move that + /// would not land. Cleared by the next successful operation of the same kind. private(set) var lastError: String? private var query: NSMetadataQuery? private var observers: [NSObjectProtocol] = [] + /// The in-flight container resolution, so the notice's retry button — and the two tabs that each + /// call `start()` on appearance — cannot stack three of them on the daemon at once. + private var cloudResolve: Task? + /// Only the newest scan applies. Serialization makes staleness unlikely rather than impossible — /// a metadata update landing while a scan runs starts a second one — so the guard is enforced /// rather than assumed, exactly as `BoardStore` does on the Mac. private var scanGeneration = 0 - /// The query's previous answer, for the unchanged-notification gate in `scan(entries:force:)`. + /// The previous pass's **merged** entries, for the unchanged-notification gate in `scan(cloud:force:)`. + /// Merged rather than cloud-only on purpose: gating on the query's answer alone would mean a board + /// that appeared, vanished or was moved in the device home never reached the list, because nothing + /// about it can change what the query reports. private var lastEntries: [BoardIndexEntry]? /// Open sessions, keyed by standardized root URL. Cached so navigating out of a board and back @@ -80,55 +111,78 @@ final class BoardIndexStore { // MARK: - Lifecycle - /// Resolves the home and starts watching. Idempotent while loading or ready; calling it again - /// after `.unavailable` is the retry an unavailable screen's button wants. + /// Resolves both homes and starts watching. + /// + /// The first call does the work. A later one is only ever the iCloud notice's retry — the one home + /// that can fail, asked again — which is why it is safe for every screen to call this on + /// appearance. func start() { - switch phase { - case .loading, .ready: + guard case .idle = phase else { + resolveCloudHome() return - case .idle, .unavailable: - break } phase = .loading Task { [weak self] in + let root = await DeviceHomeResolver.resolve() + guard let self else { return } + self.deviceRoot = root + self.resolveCloudHome() + } + } + + /// Asks for the ubiquity container: once at a time, and never again once it has answered with a + /// home. A failure leaves the door open, because signing into iCloud is a thing that happens while + /// an app is running. + private func resolveCloudHome() { + guard home == nil, cloudResolve == nil else { return } + + cloudResolve = Task { [weak self] in let resolution = await CloudHomeResolver.resolve() guard let self else { return } + self.cloudResolve = nil + switch resolution { case let .success(home): self.home = home + self.cloudUnavailable = nil self.adopt(home) case let .failure(reason): - self.home = nil - self.boards = [] - self.phase = .unavailable(reason) + self.cloudUnavailable = reason Self.logger.error("no cloud home: \(reason.description, privacy: .public)") + // The device home is still a home, and landing its scan is what keeps both the list + // and the phase real without iCloud. The notice above the list carries the rest. + self.refresh() } } } - /// Re-reads the container now, whether or not anything looks different. + /// Re-reads both homes now, whether or not anything looks different. /// - /// Under the metadata query this is a courtesy — the query already reports every change — and it - /// is what pull-to-refresh calls. Under `LANEWORK_LOCAL_ROOT` it is the *only* refresh there is, - /// since a plain directory sends no notifications. + /// On the cloud side under the metadata query this is a courtesy — the query already reports every + /// change — and it is what pull-to-refresh calls. On the device side it is the *only* refresh there + /// is, since a plain directory sends no notifications, and the same is true of the cloud side under + /// `LANEWORK_LOCAL_ROOT`. func refresh() { - guard let home else { return } - if home.isWatchable, let query { - scan(entries: readEntries(from: query), force: true) - } else { - scanLocalRoot(home) - } + scan(cloud: cloudSource(), force: true) } private func adopt(_ home: CloudHome) { guard home.isWatchable else { - scanLocalRoot(home) + scan(cloud: .directory(home.documentsURL), force: true) return } startQuery() } + /// What the cloud half of the next scan should read: the query's answer where there is a query, a + /// directory listing under the DEBUG override, and nothing at all when iCloud never resolved. + private func cloudSource() -> CloudScanSource { + guard let home else { return .absent } + if home.isWatchable, let query { return .query(readEntries(from: query)) } + return .directory(home.documentsURL) + } + // MARK: - The metadata query /// The predicate is a filename match on `*.kanban`, and it matches **one item per board** rather @@ -169,7 +223,7 @@ final class BoardIndexStore { /// bookkeeping here. private func queryDidFire() { guard let query else { return } - scan(entries: readEntries(from: query), force: false) + scan(cloud: .query(readEntries(from: query)), force: false) } /// Pulls plain values out of the result set. Bracketed by `disableUpdates`/`enableUpdates` @@ -191,7 +245,8 @@ final class BoardIndexStore { entries.append(BoardIndexEntry( rootURL: url.standardizedFileURL, modified: item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date, - download: Self.downloadState(of: item) + download: Self.downloadState(of: item), + location: .icloud )) } return entries @@ -218,33 +273,58 @@ final class BoardIndexStore { // MARK: - Scanning - /// The DEBUG local root's stand-in for a gather: a directory listing, on demand. - private func scanLocalRoot(_ home: CloudHome) { - let root = home.documentsURL - scanGeneration += 1 - let generation = scanGeneration - isScanning = true + /// Where the cloud half of one scan pass comes from. The device half never varies — it is always + /// an enumeration of `deviceRoot` — so only this side needs saying. + private enum CloudScanSource: Sendable { + /// No container. The pass covers the device home alone. + case absent - Task.detached(priority: .userInitiated) { [weak self] in - let entries = Self.enumerateBoards(under: root) - let summaries = entries.map(BoardSummaryScanner.scan).sorted(by: Self.displayOrder) - await self?.land(summaries, generation: generation) - } + /// The metadata query's answer, already flattened on the main actor. + case query([BoardIndexEntry]) + + /// A plain directory standing in for the container (`LANEWORK_LOCAL_ROOT`), enumerated off-main + /// with the device root so one pass makes one hop. + case directory(URL) } - /// - Parameter force: run even when the query's answer is byte-identical to the last one. - /// Notifications do not set it, because `NSMetadataQueryDidUpdate` fires on upload and download - /// *progress* as well as on real changes and a repeat scan of an unchanged container is pure - /// churn — a directory walk and a file read per board. An explicit `refresh()` does set it: a + /// One pass: merge both homes, gate, request what is missing, summarize, land. + /// + /// - Parameter force: run even when the merged answer is identical to the last one. Notifications + /// do not set it, because `NSMetadataQueryDidUpdate` fires on upload and download *progress* as + /// well as on real changes and a repeat scan of an unchanged container is pure churn — a + /// directory walk and a file read per board. An explicit `refresh()` does set it: a /// pull-to-refresh that provably does nothing is worse than a wasted walk. - private func scan(entries: [BoardIndexEntry], force: Bool) { - if !force, entries == lastEntries { return } - lastEntries = entries + /// + /// The gate runs off-main, against a snapshot of `lastEntries` taken here, because the merge it + /// gates on is not knowable until the device root has been walked and that walk does not belong on + /// the main actor. Two passes racing past a stale snapshot costs one redundant scan and cannot + /// land out of order — the generation guard in `land` is what makes that true. + private func scan(cloud: CloudScanSource, force: Bool) { scanGeneration += 1 let generation = scanGeneration + let previous = lastEntries + let device = deviceRoot isScanning = true Task.detached(priority: .userInitiated) { [weak self] in + var entries: [BoardIndexEntry] + switch cloud { + case .absent: + entries = [] + case let .query(reported): + entries = reported + case let .directory(root): + entries = Self.enumerateBoards(under: root, location: .icloud) + } + if let device { + entries.append(contentsOf: Self.enumerateBoards(under: device, location: .local)) + } + + guard force || entries != previous else { + await self?.scanFoundNoChange(generation: generation) + return + } + // The one write this whole path makes: a board that is in the cloud and not here is asked // for. Requested off-main with the scan because `startDownloadingUbiquitousItem` talks to // the daemon, and requested on every pass because the daemon drops requests under memory @@ -253,12 +333,13 @@ final class BoardIndexStore { try? FileManager.default.startDownloadingUbiquitousItem(at: entry.rootURL) } let summaries = entries.map(BoardSummaryScanner.scan).sorted(by: Self.displayOrder) - await self?.land(summaries, generation: generation) + await self?.land(summaries, entries: entries, generation: generation) } } - private func land(_ summaries: [BoardSummary], generation: Int) { + private func land(_ summaries: [BoardSummary], entries: [BoardIndexEntry], generation: Int) { guard generation == scanGeneration else { return } + lastEntries = entries boards = summaries isScanning = false phase = .ready @@ -272,8 +353,16 @@ final class BoardIndexStore { } } + /// The gated pass's only effect. `phase` is deliberately untouched: a gate can only fire against a + /// non-`nil` `lastEntries`, which means a previous pass already landed and already said `.ready`. + private func scanFoundNoChange(generation: Int) { + guard generation == scanGeneration else { return } + isScanning = false + } + /// Title order, case- and diacritic-insensitive, tie-broken by path. Total and stable, so a - /// refresh never reshuffles rows that did not change. + /// refresh never reshuffles rows that did not change — and so the two homes interleave by name + /// rather than clumping by where the merge happened to put them. private nonisolated static func displayOrder(_ lhs: BoardSummary, _ rhs: BoardSummary) -> Bool { switch lhs.title.localizedStandardCompare(rhs.title) { case .orderedAscending: true @@ -282,10 +371,10 @@ final class BoardIndexStore { } } - /// `.kanban` directories directly inside `root`. The name gate matches the metadata query's - /// predicate exactly — an extension-less board folder loads fine but is not a *document*, and the - /// index is a list of documents. - private nonisolated static func enumerateBoards(under root: URL) -> [BoardIndexEntry] { + /// `.kanban` directories directly inside `root`, tagged with the home they were found in. The name + /// gate matches the metadata query's predicate exactly — an extension-less board folder loads fine + /// but is not a *document*, and the index is a list of documents. + private nonisolated static func enumerateBoards(under root: URL, location: BoardLocation) -> [BoardIndexEntry] { let contents = (try? FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], @@ -302,15 +391,21 @@ final class BoardIndexStore { return BoardIndexEntry( rootURL: url.standardizedFileURL, modified: values.contentModificationDate, - download: .unknown + download: .unknown, + location: location ) } } // MARK: - Creating a board - /// Creates an empty board and answers where it landed — the empty-container bootstrap, and the - /// only write this store makes. + /// Creates an empty board and answers where it landed — the empty-phone bootstrap, and one of the + /// two writes this store makes. + /// + /// **iCloud when there is iCloud, this phone otherwise.** There is no picker: a board the user can + /// sync is always the better default, and the one case where that is not available is the one case + /// where asking would be a question with a single answer. Moving it afterwards is what + /// `relocateBoard(at:to:)` is for, and the create prompt says which home it is about to use. /// /// The folder name comes from the title, because on this platform the document name *is* what the /// user sees in Files.app (01-storage-format.md § Board naming — the app writes the title key and @@ -319,14 +414,13 @@ final class BoardIndexStore { /// /// Coordinated as a write on the new package's URL, then a refresh — the query would report the /// new board on its own within a second, but a create that does not immediately show its result - /// is a create that looks broken. + /// is a create that looks broken, and a board created on the device is reported by nothing at all. @discardableResult func createBoard(titled title: String) async -> Result { - guard let home else { + guard let documents = home?.documentsURL ?? deviceRoot else { return .failure(.noHome) } let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - let documents = home.documentsURL let outcome: Result = await Task.detached(priority: .userInitiated) { let rootURL = Self.availableBoardURL(for: trimmed, in: documents) @@ -358,9 +452,15 @@ final class BoardIndexStore { } } - /// A free `.kanban` under `documents`. Blocking (it stats), so it runs with the create. + /// A free `.kanban` under `documents`, for a board being named from a title. private nonisolated static func availableBoardURL(for title: String, in documents: URL) -> URL { - let base = sanitizedFolderName(title) + availableBoardURL(named: sanitizedFolderName(title), in: documents) + } + + /// A free `.kanban` under `documents`. Blocking (it stats), so it runs with the write it is + /// choosing a destination for — and it is shared by create and move so the two cannot disagree + /// about what "already taken" means. + private nonisolated static func availableBoardURL(named base: String, in documents: URL) -> URL { var candidate = documents.appendingPathComponent("\(base).kanban", isDirectory: true) var counter = 2 while FileManager.default.fileExists(atPath: candidate.path) { @@ -384,6 +484,103 @@ final class BoardIndexStore { return String(name.prefix(120)) } + // MARK: - Moving a board between homes + + /// Moves one board to the other home and answers where it landed. + /// + /// **The only operation in the app that changes a board's identity.** A board *is* its root URL + /// (`BoardSummary.id`, `BoardSession.rootURL`), so a move retires one identity and mints another; + /// the cached session is closed and dropped first, and the settings sheet this is called from + /// dismisses on success, because the alternative is a screen holding a URL that no longer names + /// anything. + /// + /// **`setUbiquitous` is the move**, not `moveItem`. Dragging bytes across the ubiquity boundary + /// with `FileManager.moveItem` produces a folder in the right place with the wrong status — still + /// ubiquitous, or never ubiquitous — and the daemon is then entitled to reconcile it away. The one + /// API that transfers ubiquity along with the bytes is this one, and it is off-main because it + /// talks to the daemon for as long as that takes. + /// + /// Evacuating iCloud asks for every byte at once, so the materialization sweep runs first — the + /// same sweep a load runs, which also *requests* whatever is missing. A board that is not all here + /// yet is a "try again in a moment", not a dead end. + @discardableResult + func relocateBoard(at rootURL: URL, to destination: BoardLocation) async -> Result { + let source = rootURL.standardizedFileURL + + guard let deviceRoot else { return .failure(.noHome) } + let destinationRoot: URL + switch destination { + case .icloud: + guard let documents = home?.documentsURL else { return .failure(.noCloudHome) } + destinationRoot = documents + case .local: + destinationRoot = deviceRoot + } + + guard source.deletingLastPathComponent().standardizedFileURL != destinationRoot.standardizedFileURL else { + return .failure(.alreadyThere) + } + + forgetSession(forBoardAt: source) + + let base = source.deletingPathExtension().lastPathComponent + // Only a real container has ubiquity to transfer. Under `LANEWORK_LOCAL_ROOT` both homes are + // ordinary directories, so the move is an ordinary move — which is also the path the UI tests + // exercise, and the reason they can exercise it without an iCloud account. + let isUbiquitous = home?.origin == .ubiquityContainer + + let outcome: Result = await Task.detached(priority: .userInitiated) { + let target = Self.availableBoardURL(named: base, in: destinationRoot) + + guard isUbiquitous else { + let coordinated = CoordinatedFileAccess.write(itemAt: source) { resolved -> Result in + do { + try FileManager.default.moveItem(at: resolved, to: target) + return .success(target) + } catch { + return .failure(.move(message: error.localizedDescription)) + } + } + switch coordinated { + case let .failure(failure): return .failure(.coordination(failure)) + case let .success(inner): return inner + } + } + + if destination == .local { + let progress = PackageMaterialization.sweep(packageAt: source) + guard progress.isComplete else { return .failure(.stillDownloading) } + } + + do { + try FileManager.default.setUbiquitous( + destination == .icloud, + itemAt: source, + destinationURL: target + ) + return .success(target) + } catch let error as CocoaError where error.code == .ubiquitousFileUnavailable { + // The sweep said everything was here and the daemon disagreed — a race with an + // eviction, or a file that went dataless between the walk and the move. Same sentence + // either way, because the same thing fixes it: wait, then ask again. + return .failure(.stillDownloading) + } catch { + return .failure(.ubiquity(message: error.localizedDescription)) + } + }.value + + switch outcome { + case let .success(url): + lastError = nil + refresh() + return .success(url.standardizedFileURL) + case let .failure(failure): + lastError = failure.description + Self.logger.error("board move failed: \(failure.description, privacy: .public)") + return .failure(failure) + } + } + // MARK: - Sessions /// The session for one board, minted on first ask and kept. @@ -399,8 +596,9 @@ final class BoardIndexStore { return session } - /// Drops a cached session and stops its retry timer. Call when a board's screen is gone for good; - /// keeping it costs one snapshot's memory, which is why nothing calls this automatically. + /// Drops a cached session and stops its retry timer. Call when a board's screen is gone for good — + /// or when the board's URL is about to stop naming it, which is what a move does; keeping it + /// otherwise costs one snapshot's memory, which is why nothing calls this automatically. func forgetSession(forBoardAt rootURL: URL) { let key = rootURL.standardizedFileURL sessions[key]?.close() @@ -410,16 +608,53 @@ final class BoardIndexStore { /// Why a board create did not happen. enum BoardCreateFailure: Error, Sendable, Equatable, CustomStringConvertible { - /// No container — the create was asked for before the home resolved, or while it is unavailable. + /// Neither home has resolved yet — the create was asked for before `start()` finished, which is + /// the only window in which the app has nowhere at all to put a board. case noHome case coordination(CoordinationFailure) case write(BoardWriteError) var description: String { switch self { - case .noHome: "iCloud Drive is not available" + case .noHome: "there is nowhere to put a board yet" case let .coordination(failure): failure.description case let .write(error): error.description } } } + +/// Why a board did not move. Same shape as `BoardCreateFailure`: a closed set, each case a sentence +/// the settings sheet can show as-is. +enum BoardRelocateFailure: Error, Sendable, Equatable, CustomStringConvertible { + /// The device home has not resolved — before `start()` finished, and nowhere else. + case noHome + + /// iCloud was asked for as a destination and there is none. + case noCloudHome + + /// The board is already in the home it was asked to move to. + case alreadyThere + + /// Bytes are still coming down from iCloud, so there is nothing here to move out. + case stillDownloading + + case coordination(CoordinationFailure) + + /// A plain move refused — the DEBUG stand-in path. + case move(message: String) + + /// `setUbiquitous` refused for a reason other than missing bytes. + case ubiquity(message: String) + + var description: String { + switch self { + case .noHome: "there is nowhere to move the board to yet" + case .noCloudHome: "iCloud Drive is not available" + case .alreadyThere: "the board is already stored there" + case .stillDownloading: "still downloading from iCloud — try again once it finishes" + case let .coordination(failure): failure.description + case let .move(message): message + case let .ubiquity(message): message + } + } +} diff --git a/KanbanMobile/Cloud/BoardSummary.swift b/KanbanMobile/Cloud/BoardSummary.swift index 2eabefb..9c08822 100644 --- a/KanbanMobile/Cloud/BoardSummary.swift +++ b/KanbanMobile/Cloud/BoardSummary.swift @@ -30,6 +30,32 @@ struct BoardSummary: Identifiable, Sendable, Equatable { let modified: Date? let download: BoardDownloadState + + /// Which of the phone's two homes this board is in. + let location: BoardLocation +} + +/// Where a board is stored, and therefore whether it syncs. +/// +/// **Told, never sniffed.** A location is what the home an entry was enumerated from *is*, so the +/// index tags it at the point where that is a fact (`BoardIndexStore`, which holds both roots) and +/// everything downstream carries the tag. Deriving it from the URL instead would mean deciding +/// whether a path is inside a ubiquity container by looking at it — a question that has no stable +/// answer across the real container, the DEBUG stand-in root and a test's scratch directories. +enum BoardLocation: Sendable, Equatable { + /// The `CloudHome` — syncs to the Mac and to every other device on the account. + case icloud + + /// The `DeviceHomeResolver` home — this phone, and nowhere else. + case local + + /// The one word the list marker, the settings sheet and its move button all name it by. + var name: String { + switch self { + case .icloud: "iCloud" + case .local: "Local" + } + } } /// Whether a board's bytes are on this device — the summary-level reading, from the metadata item's @@ -69,6 +95,9 @@ struct BoardIndexEntry: Sendable, Equatable { let rootURL: URL let modified: Date? let download: BoardDownloadState + + /// Set by whoever produced the entry, from the home it enumerated — see `BoardLocation`. + let location: BoardLocation } /// Turns an index entry into a summary by looking, shallowly, at the package. @@ -106,7 +135,8 @@ enum BoardSummaryScanner { laneCount: nil, cardCount: nil, modified: entry.modified, - download: entry.download + download: entry.download, + location: entry.location ) } @@ -123,7 +153,8 @@ enum BoardSummaryScanner { laneCount: nil, cardCount: nil, modified: entry.modified, - download: entry.download + download: entry.download, + location: entry.location ) } @@ -140,7 +171,8 @@ enum BoardSummaryScanner { laneCount: lanes, cardCount: cards, modified: entry.modified, - download: entry.download + download: entry.download, + location: entry.location ) } diff --git a/KanbanMobile/Cloud/CloudHome.swift b/KanbanMobile/Cloud/CloudHome.swift index 8832c0a..0790e18 100644 --- a/KanbanMobile/Cloud/CloudHome.swift +++ b/KanbanMobile/Cloud/CloudHome.swift @@ -1,12 +1,15 @@ import Foundation import os -/// The one folder every board on this phone lives directly inside, and how it was reached. +/// The synced folder boards live directly inside, and how it was reached. /// -/// **iCloud is a hard requirement** (project.yml ▸ Lanework for iPhone): a board written outside the -/// ubiquity container would sync nowhere and the Mac would never see it, so there is deliberately no -/// local-only fallback — a phone with no iCloud account gets a "sign into iCloud" wall instead of a -/// board list. `CloudHomeUnavailable` is the vocabulary that wall is written from. +/// **One of the phone's two homes, not the only one** (softened 2026-08-08). A board here syncs: it +/// reaches the Mac, and it reaches the user's other devices. A board in the device home +/// (`DeviceHomeResolver`) does not, and that is the whole difference between them — same package +/// format, same loader, same writer. iCloud is what the app *prefers*, so a new board lands here +/// whenever this resolves; it is no longer what the app *requires*, so a phone with no account gets +/// an inline notice above a working local list rather than a wall in place of one. +/// `CloudHomeUnavailable` is the vocabulary that notice is written from. struct CloudHome: Sendable, Equatable { /// `/Documents` — created if missing. Boards must live here and nowhere else: /// `NSMetadataQueryUbiquitousDocumentsScope` reports on this subtree alone, and @@ -35,10 +38,10 @@ struct CloudHome: Sendable, Equatable { var isWatchable: Bool { origin == .ubiquityContainer } } -/// Why there is no home — the closed set the unavailable screen switches over. +/// Why there is no synced home — the closed set the iCloud notice switches over. enum CloudHomeUnavailable: Error, Sendable, Equatable, CustomStringConvertible { /// No iCloud account is signed in on the device (`ubiquityIdentityToken` is nil). The one case - /// the user can actually fix, and the one the wall's copy is aimed at. + /// the user can actually fix, and the one the notice's copy is aimed at. case noAccount /// An account exists but the container did not resolve — provisioning not yet propagated, @@ -79,6 +82,13 @@ enum CloudHomeResolver { /// Present so the simulator and future UI tests can drive the real loader and writer without an /// iCloud account; absent everywhere else, and compiled out of Release entirely. static let localRootEnvironmentKey = "LANEWORK_LOCAL_ROOT" + + /// The other DEBUG escape hatch: refuse the container outright, whatever the device would + /// actually answer. A UI test of the no-iCloud surfaces cannot get there by simply leaving + /// `LANEWORK_LOCAL_ROOT` unset — a simulator signed into an account resolves the real container + /// and the test would pass or fail on whose machine it ran. This makes "no account" a launch + /// argument instead of an environment. + static let forceNoCloudEnvironmentKey = "LANEWORK_FORCE_NO_ICLOUD" #endif private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud") @@ -91,6 +101,13 @@ enum CloudHomeResolver { /// executor — and so a caller that already has a background context can use it directly. nonisolated static func resolveBlocking() -> Result { #if DEBUG + // Ahead of the local-root override on purpose: a test that asks for no iCloud must get no + // iCloud even if a stray root is also set. + if let forced = ProcessInfo.processInfo.environment[forceNoCloudEnvironmentKey], !forced.isEmpty { + logger.notice("LANEWORK_FORCE_NO_ICLOUD is set — reporting no account") + return .failure(.noAccount) + } + if let override = ProcessInfo.processInfo.environment[localRootEnvironmentKey], !override.isEmpty { let root = URL(fileURLWithPath: override, isDirectory: true) @@ -125,3 +142,59 @@ enum CloudHomeResolver { return .success(CloudHome(documentsURL: documents, origin: .ubiquityContainer)) } } + +/// The phone's other home: the app sandbox's `Documents/`, where a board that is not in iCloud +/// lives. +/// +/// **This one cannot fail**, which is the property the whole soften-the-wall arrangement rests on. +/// There is no account to be signed into, no daemon to reach and no provisioning to propagate — the +/// directory is part of the container the app was installed with — so there is no `Result` here and +/// no unavailable case to render. A board list can therefore always be shown, and a board can always +/// be created, whatever iCloud is doing. +/// +/// `UIFileSharingEnabled` (KanbanMobile/Info.plist) publishes this folder in the Files app under +/// "On My iPhone ▸ Lanework", so a local board is as reachable, movable and backupable by hand as an +/// iCloud one — the same visibility `NSUbiquitousContainerIsDocumentScopePublic` gives the cloud +/// home, which is what makes "local" a real home rather than a hiding place. +enum DeviceHomeResolver { + + #if DEBUG + /// A filesystem path to use *instead of* the sandbox's `Documents/` — the device-side twin of + /// `CloudHomeResolver.localRootEnvironmentKey`, and present for the same reason: a UI test drives + /// real moves between two real directories it created and owns, rather than into the running + /// app's own documents folder, which survives between test runs. + static let deviceRootEnvironmentKey = "LANEWORK_DEVICE_ROOT" + #endif + + private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud") + + /// `async` to match `CloudHomeResolver.resolve()` and to keep both resolutions in one vocabulary, + /// not because this one blocks — it is a path plus, at most, one `mkdir` on local storage. + static func resolve() async -> URL { + await Task.detached(priority: .userInitiated) { resolveBlocking() }.value + } + + nonisolated static func resolveBlocking() -> URL { + #if DEBUG + if let override = ProcessInfo.processInfo.environment[deviceRootEnvironmentKey], + !override.isEmpty { + logger.notice("using LANEWORK_DEVICE_ROOT instead of the sandbox's Documents folder") + return prepared(URL(fileURLWithPath: override, isDirectory: true)) + } + #endif + return prepared(URL.documentsDirectory) + } + + /// A failed `createDirectory` is logged and otherwise ignored: the sandbox's `Documents/` is + /// always already there, so this only ever creates a DEBUG override root, and a root that could + /// not be made enumerates empty and refuses writes with the storage layer's own errors — which + /// are better sentences than anything invented here. + private nonisolated static func prepared(_ root: URL) -> URL { + do { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } catch { + logger.error("device home is not usable: \(error.localizedDescription, privacy: .public)") + } + return root + } +} diff --git a/KanbanMobile/Info.plist b/KanbanMobile/Info.plist index 98116cf..23774f3 100644 --- a/KanbanMobile/Info.plist +++ b/KanbanMobile/Info.plist @@ -33,6 +33,13 @@ (MobileApp.swift) relies on. --> LSSupportsOpeningDocumentsInPlace + + UIFileSharingEnabled +