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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,48 +61,89 @@ 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 where index.boards.isEmpty:
|
||||
ContentUnavailableView(
|
||||
"No Boards Yet",
|
||||
systemImage: "rectangle.stack",
|
||||
description: Text("Boards in your iCloud Drive will appear here.")
|
||||
)
|
||||
|
||||
case .ready:
|
||||
List(index.boards) { board in
|
||||
NavigationLink(value: BoardRoute.lanes(boardRoot: board.rootURL)) {
|
||||
BoardSummaryRow(board: board)
|
||||
List {
|
||||
if let reason = index.cloudUnavailable {
|
||||
cloudNotice(reason)
|
||||
}
|
||||
if index.boards.isEmpty {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"No Boards Yet",
|
||||
systemImage: "rectangle.stack",
|
||||
description: Text(emptyDescription)
|
||||
)
|
||||
}
|
||||
} 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."
|
||||
}
|
||||
|
||||
/// 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, and — while the package is not fully
|
||||
/// current — a download-state subtitle in place of the counts a shallow scan cannot yet answer.
|
||||
/// 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)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
if board.location == .local {
|
||||
Label("Local", systemImage: "iphone")
|
||||
}
|
||||
Text(subtitle)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user