The board chooses where it lives — swipe-open settings move it between iCloud and this iPhone

A trailing swipe on a board row opens Board Settings, whose first setting is location: iCloud or Local, with a confirmed move to the other side — destructive-styled only outbound, because leaving iCloud is the direction that sheds protection. The move is setUbiquitous against the real container and a coordinated move under the DEBUG stand-in; evacuation sweeps materialization first and refuses honestly while content is still downloading. The local home is the sandbox Documents folder, published to the Files app, so a local board is still a folder the user owns.

With a second home the iCloud wall softens (user-ruled 2026-08-08): the index always reaches ready, cloud unavailability becomes an inline notice with a retry, creates land locally when there is no account, and LANEWORK_FORCE_NO_ICLOUD makes that state reproducible in tests regardless of the machine's sign-in. Known gap, now user-reachable: backup remains iCloud-only, so local boards sit outside it.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 10:56:39 -04:00
parent 1d97a2931c
commit 1c16bb4c38
10 changed files with 910 additions and 134 deletions
+319 -84
View File
@@ -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<Void, Never>?
/// 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<URL, BoardCreateFailure> {
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<URL, BoardCreateFailure> = await Task.detached(priority: .userInitiated) {
let rootURL = Self.availableBoardURL(for: trimmed, in: documents)
@@ -358,9 +452,15 @@ final class BoardIndexStore {
}
}
/// A free `<name>.kanban` under `documents`. Blocking (it stats), so it runs with the create.
/// A free `<name>.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 `<base>.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<URL, BoardRelocateFailure> {
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<URL, BoardRelocateFailure> = await Task.detached(priority: .userInitiated) {
let target = Self.availableBoardURL(named: base, in: destinationRoot)
guard isUbiquitous else {
let coordinated = CoordinatedFileAccess.write(itemAt: source) { resolved -> Result<URL, BoardRelocateFailure> 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
}
}
}
+35 -3
View File
@@ -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
)
}
+80 -7
View File
@@ -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 {
/// `<container>/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<CloudHome, CloudHomeUnavailable> {
#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
}
}