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
+2
View File
@@ -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.
+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
}
}
+7
View File
@@ -33,6 +33,13 @@
(MobileApp.swift) relies on. -->
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<!-- Publishes the sandbox's Documents/ as "On My iPhone ▸ Lanework" in the Files app — the
device home (`DeviceHomeResolver`), which is where a board that is not in iCloud lives.
Without this a local board would be a real board the user could not reach, back up by
hand, or move off the phone any way except through this app, which is not what "local"
is allowed to mean here. -->
<key>UIFileSharingEnabled</key>
<true/>
<!-- Same document-type + UTI declaration as the Mac app's Info.plist, and it must stay
byte-identical in meaning: the package conformance is what makes NSMetadataQuery and the
Files app treat a `.kanban` directory as one document instead of a folder of loose files.
@@ -0,0 +1,122 @@
import SwiftUI
/// One board's own settings, reached by swiping its row in the boards list.
///
/// **Reached from the list on purpose.** Its one setting today which home the board is stored in
/// is the only thing in the app that changes a board's URL, and a board's URL is its identity
/// (`BoardSummary.id`). Presenting this from the list means no screen is holding the old identity
/// when it retires; the sheet dismisses itself on success and the row underneath is already the new
/// board.
struct BoardSettingsSheet: View {
/// A snapshot, taken when the swipe action fired. It goes stale exactly once at the moment a
/// move succeeds and the sheet is gone by then.
let board: BoardSummary
@Environment(BoardIndexStore.self) private var index
@Environment(\.dismiss) private var dismiss
@State private var isConfirmingMove = false
@State private var isMoving = false
/// The last move's refusal, shown in the section footer rather than as an alert: the failure is
/// about the row the user is looking at, and every one of them is a "try again" rather than a
/// decision to make.
@State private var failure: String?
/// There are two homes, so there is one destination and the button can name it instead of asking.
private var destination: BoardLocation {
board.location == .icloud ? .local : .icloud
}
/// iCloud is a destination only when it resolved. In practice a run with no iCloud lists no iCloud
/// boards at all the query is dead so this guards a state the app should not be able to reach
/// rather than one it routinely does.
private var canMove: Bool {
destination == .local || index.cloudUnavailable == nil
}
var body: some View {
NavigationStack {
Form {
Section {
LabeledContent("Stored In", value: board.location.name)
Button {
isConfirmingMove = true
} label: {
HStack {
Text("Move to \(destination.name)")
if isMoving {
Spacer()
ProgressView()
}
}
}
.disabled(isMoving || !canMove)
} header: {
Text("Location")
} footer: {
Text(footer)
}
}
.navigationTitle("Board Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.confirmationDialog(
confirmationTitle,
isPresented: $isConfirmingMove,
titleVisibility: .visible
) {
// Destructive only in the direction that removes protection: a board leaving iCloud
// stops syncing and stops being backed up by anything but this phone. The return trip
// only adds, so it is an ordinary button.
Button("Move", role: destination == .local ? .destructive : nil) { move() }
Button("Cancel", role: .cancel) {}
} message: {
Text(confirmationMessage)
}
}
}
private var footer: String {
if let failure { return failure }
if !canMove { return "Sign into iCloud to move boards there." }
switch board.location {
case .icloud: return "This board is in iCloud Drive and syncs to your other devices."
case .local: return "This board is on this iPhone only."
}
}
private var confirmationTitle: String {
destination == .local
? "Move “\(board.title)” out of iCloud?"
: "Move “\(board.title)” to iCloud?"
}
private var confirmationMessage: String {
destination == .local
? "It will stop syncing and live only on this iPhone. Your other devices won't see it."
: "It will sync to your other devices."
}
private func move() {
isMoving = true
failure = nil
Task {
let outcome = await index.relocateBoard(at: board.rootURL, to: destination)
isMoving = false
switch outcome {
case .success:
// The list underneath has already refreshed, and this sheet's `board` names a package
// that is no longer there.
dismiss()
case let .failure(reason):
failure = reason.description
}
}
}
}
+80 -22
View File
@@ -3,13 +3,19 @@ import SwiftUI
/// The root of the boards navigation stack: board list lanes cards card detail
/// (`BoardRoute`'s destinations).
///
/// Renders all three of `BoardIndexStore.Phase` the property the placeholder this replaces
/// existed to prove, and the split this screen keeps.
/// Renders both of `BoardIndexStore.Phase`'s live states and, above the list, the iCloud notice
/// which is a row, not a wall (softened 2026-08-08). A phone with no account still has a device home
/// and therefore still has boards, so the missing half of the app is reported next to the half that
/// works rather than in place of it.
struct BoardsTabView: View {
@Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewBoard = false
/// The board whose settings sheet is up. A value, not a URL: the sheet renders the row's own
/// summary, and a successful move dismisses it before the stale copy could matter.
@State private var boardInSettings: BoardSummary?
var body: some View {
NavigationStack {
content
@@ -33,9 +39,20 @@ struct BoardsTabView: View {
}
}
.task { index.start() }
.titlePromptAlert("New Board", isPresented: $isPresentingNewBoard, placeholder: "Board Title") { title in
.titlePromptAlert(
"New Board",
isPresented: $isPresentingNewBoard,
// Said only when it is news. With iCloud available the answer is the one every board
// already gives; without it, where the board lands is the thing the user most needs to
// know before naming it.
message: index.cloudUnavailable == nil ? nil : "This board will be created on this iPhone.",
placeholder: "Board Title"
) { title in
Task { await index.createBoard(titled: title) }
}
.sheet(item: $boardInSettings) { board in
BoardSettingsSheet(board: board)
}
}
@ViewBuilder
@@ -44,46 +61,87 @@ struct BoardsTabView: View {
case .idle, .loading:
ProgressView("Looking for boards")
case let .unavailable(reason):
// The hard-iCloud-requirement wall. `reason` distinguishes "sign in" from "the container
// did not resolve", which are different asks of the user.
ContentUnavailableView {
Label("iCloud Required", systemImage: "icloud.slash")
} description: {
Text(reason == .noAccount
? "Sign into iCloud in Settings to use Lanework."
: "Lanework can't reach its iCloud Drive folder right now.")
} actions: {
Button("Try Again") { index.start() }
case .ready:
List {
if let reason = index.cloudUnavailable {
cloudNotice(reason)
}
case .ready where index.boards.isEmpty:
if index.boards.isEmpty {
Section {
ContentUnavailableView(
"No Boards Yet",
systemImage: "rectangle.stack",
description: Text("Boards in your iCloud Drive will appear here.")
description: Text(emptyDescription)
)
case .ready:
List(index.boards) { board in
}
} else {
ForEach(index.boards) { board in
NavigationLink(value: BoardRoute.lanes(boardRoot: board.rootURL)) {
BoardSummaryRow(board: board)
}
.swipeActions(edge: .trailing) {
// Neutral tint: this reveals a screen, it does not destroy anything, and
// the one destructive-looking thing behind it has its own confirmation.
Button("Settings", systemImage: "gearshape") {
boardInSettings = board
}
.tint(.gray)
}
}
}
}
.refreshable { index.refresh() }
}
}
private var emptyDescription: String {
index.cloudUnavailable == nil
? "Boards in your iCloud Drive will appear here."
: "New boards will be saved on this iPhone."
}
/// One board row: title, lane/card counts, modified date, and while the package is not fully
/// current a download-state subtitle in place of the counts a shallow scan cannot yet answer.
/// The demoted wall. Same two sentences it used to say full-screen, and the same retry button
/// `start()` re-attempts the container resolution and nothing else, because everything else
/// already resolved.
private func cloudNotice(_ reason: CloudHomeUnavailable) -> some View {
Section {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("iCloud Unavailable")
Text(reason == .noAccount
? "Sign into iCloud in Settings to sync your boards."
: "Lanework can't reach its iCloud Drive folder right now.")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "icloud.slash")
}
Button("Try Again") { index.start() }
} footer: {
Text("Boards on this iPhone still work, and can be moved to iCloud later.")
}
}
}
/// One board row: title, lane/card counts, modified date, a marker for a board that is not in iCloud,
/// and while the package is not fully current a download-state subtitle in place of the counts a
/// shallow scan cannot yet answer.
///
/// Only the local side is marked. iCloud is where a board is expected to be, so saying so on every
/// row would be noise; "Local" is the exception, and the exception is what a marker is for.
private struct BoardSummaryRow: View {
let board: BoardSummary
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(board.title)
HStack(spacing: 6) {
if board.location == .local {
Label("Local", systemImage: "iphone")
}
Text(subtitle)
}
.font(.caption)
.foregroundStyle(.secondary)
}
+4 -3
View File
@@ -47,9 +47,10 @@ struct SettingsTabView: View {
LabeledContent("Backup") {
ProgressView()
}
case .unavailable, .ready:
// `.ready` can appear briefly here too: `backupController` lags one runloop
// turn behind `index.home` resolving (MobileApp's `.task(id:)`).
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)
}
@@ -0,0 +1,113 @@
import XCTest
/// Where a board is stored, and the one operation that changes it.
///
/// Both tests drive the real `BoardIndexStore.relocateBoard(at:to:)` over two real directories the
/// test owns the DEBUG stand-in cloud root and the DEBUG device root so the move that lands is a
/// coordinated move of an actual `.kanban` package, not a model edit. The `setUbiquitous` path a
/// shipped build takes cannot be exercised without an iCloud account; what these cover is everything
/// on either side of it.
final class BoardLocationUITests: XCTestCase {
@MainActor
func testMoveBoardOutOfICloudAndBack() throws {
let (app, cloudRoot, deviceRoot) = XCUIApplication.launchedWithFixtureBoardInCloud()
let inCloud = cloudRoot.appendingPathComponent(RichBoard.packageName, isDirectory: true)
let onDevice = deviceRoot.appendingPathComponent(RichBoard.packageName, isDirectory: true)
// Out of iCloud.
app.openBoardSettings(for: RichBoard.title)
// `LabeledContent` publishes label and value as one flattened node "Stored In, iCloud"
// which is the sheet's whole claim about where the board is, in one assertion.
XCTAssertTrue(
app.staticTexts["Stored In, iCloud"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the sheet did not report the board as stored in iCloud"
)
app.buttons["Move to Local"].tap()
app.confirmMove()
XCTAssertTrue(
app.navigationBars["Board Settings"].waitForNonExistence(timeout: XCUIApplication.uiTimeout),
"the sheet stayed up after a move that should have succeeded"
)
XCTAssertTrue(
waitForDirectory(at: onDevice),
"the package never arrived under the device root at \(onDevice.path)"
)
XCTAssertTrue(
waitForDirectory(at: inCloud, toExist: false),
"the package was still under the cloud root at \(inCloud.path)"
)
XCTAssertTrue(
app.boardRow(RichBoard.title, alsoContaining: "Local")
.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the row never picked up the Local marker"
)
// And back.
app.openBoardSettings(for: RichBoard.title)
XCTAssertTrue(
app.staticTexts["Stored In, Local"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the sheet did not report the moved board as stored locally"
)
let moveToCloud = app.buttons["Move to iCloud"]
XCTAssertTrue(
moveToCloud.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the sheet did not offer the return trip"
)
moveToCloud.tap()
app.confirmMove()
XCTAssertTrue(
waitForDirectory(at: inCloud),
"the package never came back under the cloud root at \(inCloud.path)"
)
XCTAssertTrue(
waitForDirectory(at: onDevice, toExist: false),
"the package was still under the device root at \(onDevice.path)"
)
}
/// The softened wall (2026-08-08): no iCloud is a notice above a working list, not a screen
/// instead of one.
@MainActor
func testLocalBoardWorksWithoutICloud() throws {
let (app, _) = XCUIApplication.launchedWithFixtureBoardOnDevice()
XCTAssertTrue(
app.element(labelContaining: "iCloud Unavailable").waitForExistence(timeout: XCUIApplication.uiTimeout),
"the iCloud notice never appeared"
)
let boardRow = app.boardRow(RichBoard.title, alsoContaining: "Local")
XCTAssertTrue(
boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the device-home board never listed — the old wall would have replaced the list here"
)
// It is a whole board, not a listing: it opens and navigates like any other.
boardRow.tap()
let laneRow = app.element(labelContaining: RichBoard.firstLane)
XCTAssertTrue(
laneRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the local board's lanes never appeared"
)
app.navigationBars.buttons.element(boundBy: 0).tap()
// The one thing it cannot do: go somewhere that does not exist.
app.openBoardSettings(for: RichBoard.title)
XCTAssertTrue(
app.staticTexts["Stored In, Local"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the sheet did not report the board as stored locally"
)
let moveToCloud = app.buttons["Move to iCloud"]
XCTAssertTrue(
moveToCloud.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the sheet never offered a move to iCloud"
)
XCTAssertFalse(
moveToCloud.isEnabled,
"moving to iCloud was offered as available with no iCloud to move to"
)
}
}
+139 -6
View File
@@ -24,6 +24,12 @@ enum RichBoard {
static let title = "Rich Demo Board"
static let firstLane = "Doing"
static let firstLaneFirstCard = "Design the fixture taxonomy"
/// The package's folder name the thing a move relocates, and what a disk assertion looks for
/// under one root or the other. Not derived from `title`: the fixture's folder and its `title:`
/// key deliberately differ (01-storage-format.md § Board naming allows it), and a move preserves
/// the folder name rather than re-deriving one.
static let packageName = "rich-board.kanban"
}
// MARK: - Driving the app
@@ -58,25 +64,133 @@ extension XCUIApplication {
/// on disk after driving an edit through the UI.
@MainActor
static func launchedWithFixtureBoard() -> (app: XCUIApplication, root: URL) {
let root = scratchRoot(seeded: true)
return (launched(["LANEWORK_LOCAL_ROOT": root.path]), root)
}
/// The same launch with a **second** scratch directory behind `LANEWORK_DEVICE_ROOT`
/// (`DeviceHomeResolver`), so both of the app's homes are directories this test created and
/// owns which is what makes a move between them assertable on disk. The fixture starts in the
/// stand-in cloud root; the device root starts empty.
///
/// Overriding the device root matters as much as overriding the cloud one: the real device home
/// is the installed app's own `Documents/`, which survives between test runs and between tests.
@MainActor
static func launchedWithFixtureBoardInCloud() -> (app: XCUIApplication, cloudRoot: URL, deviceRoot: URL) {
let cloudRoot = scratchRoot(seeded: true)
let deviceRoot = scratchRoot(seeded: false)
let app = launched([
"LANEWORK_LOCAL_ROOT": cloudRoot.path,
"LANEWORK_DEVICE_ROOT": deviceRoot.path,
])
return (app, cloudRoot, deviceRoot)
}
/// A launch with **no iCloud at all** and the fixture in the device home the no-account run,
/// which must still list, open and edit boards.
///
/// `LANEWORK_FORCE_NO_ICLOUD` rather than simply omitting `LANEWORK_LOCAL_ROOT`: without the
/// override the resolver reaches for the real ubiquity container, and a simulator that happens to
/// be signed into an account would resolve one making this test pass or fail on whose machine
/// it ran.
@MainActor
static func launchedWithFixtureBoardOnDevice() -> (app: XCUIApplication, deviceRoot: URL) {
let deviceRoot = scratchRoot(seeded: true)
let app = launched([
"LANEWORK_FORCE_NO_ICLOUD": "1",
"LANEWORK_DEVICE_ROOT": deviceRoot.path,
])
return (app, deviceRoot)
}
/// A directory no other test shares, optionally holding a copy of the fixture board.
@MainActor
private static func scratchRoot(seeded: Bool) -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("KanbanMobileUITests-\(UUID().uuidString)", isDirectory: true)
do {
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let source = fixturesRoot().appendingPathComponent("Valid/rich-board.kanban", isDirectory: true)
let destination = root.appendingPathComponent("rich-board.kanban", isDirectory: true)
try FileManager.default.copyItem(at: source, to: destination)
if seeded {
let source = fixturesRoot()
.appendingPathComponent("Valid/\(RichBoard.packageName)", isDirectory: true)
try FileManager.default.copyItem(
at: source,
to: root.appendingPathComponent(RichBoard.packageName, isDirectory: true)
)
}
} catch {
fatalError("could not seed the fixture board into a scratch root: \(error)")
fatalError("could not seed a scratch root: \(error)")
}
return root
}
@MainActor
private static func launched(_ environment: [String: String]) -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["LANEWORK_LOCAL_ROOT"] = root.path
for (key, value) in environment {
app.launchEnvironment[key] = value
}
app.launch()
XCTAssertTrue(
app.wait(for: .runningForeground, timeout: uiTimeout),
"the app did not reach the foreground"
)
return (app, root)
return app
}
/// The board list row for `title`, optionally narrowed to one that also carries `marker`
/// "Local" being the only marker there is. Both fragments have to be matched on the *same*
/// element because the row is one flattened accessibility node (see `element(labelContaining:)`),
/// so its label is the whole subtitle line concatenated onto the title.
@MainActor
func boardRow(_ title: String, alsoContaining marker: String? = nil) -> XCUIElement {
var predicate = NSPredicate(format: "label CONTAINS %@", title)
if let marker {
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
predicate,
NSPredicate(format: "label CONTAINS %@", marker),
])
}
return descendants(matching: .any).matching(predicate).firstMatch
}
/// Swipes a board row open and taps its Settings action.
///
/// The label is scoped to the list because the tab bar carries a "Settings" button of its own
/// same word, entirely different destination and an unscoped `buttons["Settings"]` is a coin
/// flip between them.
@MainActor
func openBoardSettings(for title: String) {
let row = boardRow(title)
XCTAssertTrue(
row.waitForExistence(timeout: Self.uiTimeout),
"the \"\(title)\" row was not there to swipe"
)
row.swipeLeft()
let action = collectionViews.buttons["Settings"]
XCTAssertTrue(
action.waitForExistence(timeout: Self.uiTimeout),
"the row's Settings swipe action never appeared"
)
action.tap()
XCTAssertTrue(
navigationBars["Board Settings"].waitForExistence(timeout: Self.uiTimeout),
"the Board Settings sheet never appeared"
)
}
/// Confirms the move in the `confirmationDialog` the sheet raises. Deliberately labelled with a
/// bare verb, which is also what keeps it distinct from the "Move to " button that raised it.
@MainActor
func confirmMove() {
let confirm = buttons["Move"]
XCTAssertTrue(
confirm.waitForExistence(timeout: Self.uiTimeout),
"the move confirmation dialog never appeared"
)
confirm.tap()
}
/// Scrolls the frontmost view upward, a little at a time, until `element` is hittable or
@@ -108,6 +222,25 @@ func waitForFile(under root: URL, containing substring: String, timeout: TimeInt
return fileExists(under: root, containing: substring)
}
/// Waits up to `timeout` for the directory at `url` to exist or, with `toExist: false`, to be
/// gone. The package-shaped counterpart to `waitForFile(under:containing:)`, and what a move
/// assertion needs: `relocateBoard` hands the actual transfer to a detached task and answers the UI
/// well before either root has settled.
func waitForDirectory(at url: URL, toExist shouldExist: Bool = true, timeout: TimeInterval = 15) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
repeat {
if directoryExists(at: url) == shouldExist { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
} while Date() < deadline
return directoryExists(at: url) == shouldExist
}
private func directoryExists(at url: URL) -> Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
return exists && isDirectory.boolValue
}
private func fileExists(under root: URL, containing substring: String) -> Bool {
guard let enumerator = FileManager.default.enumerator(
at: root,