The phone joins the format — KanbanMobile MVP: shared storage verbatim over an iCloud container
A second product, not a second edition: dev.rzen.indie.KanbanMobile (iOS 26,
iPhone-only) compiles Kanban/Storage as source files, so a format change that
breaks the phone breaks this build the day it's made. Boards live in
iCloud.dev.rzen.indie.Kanban — named after the Mac bundle id so the Mac app can
adopt the container later without a migration; until then the folder is
"Lanework" in iCloud Drive and the Mac opens boards there through the open
panel.
EchoLedger grows #if os(macOS) gates around its three consumer surfaces
(verdicts/BoardDiff, harvest/HarvestedReceipt, comment retirement/CommentPath)
— the recording side BoardWriter stamps compiles on every platform, and the
gates are the seam a future phone verdict surface lands behind. AgentGuide
stays Mac-only.
The phone's watcher is NSMetadataQuery: BoardIndexStore (one query, package
UTI export makes a .kanban directory one item, equality-gated rescans,
download kicks per pass), BoardSession (materialization sweep before every
fail-fast walk, NSFileCoordinator brackets, perform{} = coordinated write then
awaited reload, ParseMemo threaded), CloudHome (off-main container resolution,
LANEWORK_LOCAL_ROOT DEBUG override for simulator work without an account).
Screens: Boards -> lanes -> cards -> card editor, value-routed by ItemID with
every screen re-reading the live snapshot; leading swipe moves a card via
confirmationDialog, trailing swipe sends it to .trash/; the editor commits
title through the Mac's canonical rename path and body through writeBody, with
drafts that survive reloads; attributes are the three typed style fields
(icon, iconColor, background) — labels is a reserved unknown-field key and
deliberately has no editor. Settings carries IndieBackup (backup root = the
container's Documents, restore rebuild = an index rescan, controller
constructed only once the home resolves).
Arbiter: KanbanMobile green for iOS Simulator, Kanban green for macOS, 3006
unit tests / 517 suites passed (PointerLatencyTests excluded — mid-rework
uncommitted in a parallel session).
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// The app's root model: which boards exist in the container, and what state each 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.
|
||||
///
|
||||
/// ### 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.
|
||||
///
|
||||
/// 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.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BoardIndexStore {
|
||||
|
||||
/// The three states the boards tab renders.
|
||||
enum Phase: Sendable, Equatable {
|
||||
/// `start()` has not run.
|
||||
case idle
|
||||
|
||||
/// Resolving the container, or waiting for the query's first gather.
|
||||
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.
|
||||
case ready
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .idle
|
||||
|
||||
/// Resolved exactly once per process. `nil` until then, and whenever `phase` is `.unavailable`.
|
||||
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).
|
||||
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.
|
||||
private(set) var lastError: String?
|
||||
|
||||
private var query: NSMetadataQuery?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
/// 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:)`.
|
||||
private var lastEntries: [BoardIndexEntry]?
|
||||
|
||||
/// Open sessions, keyed by standardized root URL. Cached so navigating out of a board and back
|
||||
/// into it does not re-materialize and re-walk a package the app already has in hand.
|
||||
private var sessions: [URL: BoardSession] = [:]
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud")
|
||||
|
||||
init() {}
|
||||
|
||||
// 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.
|
||||
func start() {
|
||||
switch phase {
|
||||
case .loading, .ready:
|
||||
return
|
||||
case .idle, .unavailable:
|
||||
break
|
||||
}
|
||||
|
||||
phase = .loading
|
||||
Task { [weak self] in
|
||||
let resolution = await CloudHomeResolver.resolve()
|
||||
guard let self else { return }
|
||||
switch resolution {
|
||||
case let .success(home):
|
||||
self.home = home
|
||||
self.adopt(home)
|
||||
case let .failure(reason):
|
||||
self.home = nil
|
||||
self.boards = []
|
||||
self.phase = .unavailable(reason)
|
||||
Self.logger.error("no cloud home: \(reason.description, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads the container 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.
|
||||
func refresh() {
|
||||
guard let home else { return }
|
||||
if home.isWatchable, let query {
|
||||
scan(entries: readEntries(from: query), force: true)
|
||||
} else {
|
||||
scanLocalRoot(home)
|
||||
}
|
||||
}
|
||||
|
||||
private func adopt(_ home: CloudHome) {
|
||||
guard home.isWatchable else {
|
||||
scanLocalRoot(home)
|
||||
return
|
||||
}
|
||||
startQuery()
|
||||
}
|
||||
|
||||
// MARK: - The metadata query
|
||||
|
||||
/// The predicate is a filename match on `*.kanban`, and it matches **one item per board** rather
|
||||
/// than one per file inside it: the app exports `dev.rzen.indie.kanban-board` conforming to
|
||||
/// `com.apple.package` (KanbanMobile/Info.plist), which is what makes the daemon treat a `.kanban`
|
||||
/// directory as a single document. Without that export this query would return every `index.md`
|
||||
/// in every board.
|
||||
private func startQuery() {
|
||||
guard query == nil else { return }
|
||||
|
||||
let query = NSMetadataQuery()
|
||||
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
|
||||
query.predicate = NSPredicate(format: "%K LIKE %@", NSMetadataItemFSNameKey, "*.kanban")
|
||||
self.query = query
|
||||
|
||||
observers = [
|
||||
observe(.NSMetadataQueryDidFinishGathering, from: query),
|
||||
observe(.NSMetadataQueryDidUpdate, from: query),
|
||||
]
|
||||
|
||||
// `NSMetadataQuery` is main-thread-only and delivers on the runloop that started it, which is
|
||||
// why this whole type is `@MainActor` — the isolation is the guarantee, not a convention.
|
||||
query.start()
|
||||
}
|
||||
|
||||
/// The observer block is `@Sendable` and must not carry the `Notification` anywhere: the result
|
||||
/// set is re-read from the query instead, on the main thread the block is already on
|
||||
/// (`queue: .main` is what makes `assumeIsolated` sound here).
|
||||
private func observe(_ name: Notification.Name, from query: NSMetadataQuery) -> NSObjectProtocol {
|
||||
NotificationCenter.default.addObserver(forName: name, object: query, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.queryDidFire()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gather and update are handled identically — see the type's note on why there is no delta
|
||||
/// bookkeeping here.
|
||||
private func queryDidFire() {
|
||||
guard let query else { return }
|
||||
scan(entries: readEntries(from: query), force: false)
|
||||
}
|
||||
|
||||
/// Pulls plain values out of the result set. Bracketed by `disableUpdates`/`enableUpdates`
|
||||
/// because the query is free to swap its own storage mid-iteration otherwise, and nothing that
|
||||
/// leaves here is an `NSMetadataItem` — that type is not `Sendable` and must never cross to the
|
||||
/// scan.
|
||||
private func readEntries(from query: NSMetadataQuery) -> [BoardIndexEntry] {
|
||||
query.disableUpdates()
|
||||
defer { query.enableUpdates() }
|
||||
|
||||
var entries: [BoardIndexEntry] = []
|
||||
entries.reserveCapacity(query.resultCount)
|
||||
for index in 0 ..< query.resultCount {
|
||||
guard let item = query.result(at: index) as? NSMetadataItem,
|
||||
let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL
|
||||
else {
|
||||
continue
|
||||
}
|
||||
entries.append(BoardIndexEntry(
|
||||
rootURL: url.standardizedFileURL,
|
||||
modified: item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date,
|
||||
download: Self.downloadState(of: item)
|
||||
))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private static func downloadState(of item: NSMetadataItem) -> BoardDownloadState {
|
||||
let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
|
||||
let isDownloading = item.value(forAttribute: NSMetadataUbiquitousItemIsDownloadingKey) as? Bool ?? false
|
||||
let percent = item.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? Double
|
||||
|
||||
if isDownloading {
|
||||
return .downloading(fraction: percent.map { $0 / 100 })
|
||||
}
|
||||
switch status {
|
||||
case NSMetadataUbiquitousItemDownloadingStatusCurrent,
|
||||
NSMetadataUbiquitousItemDownloadingStatusDownloaded:
|
||||
return .current
|
||||
case NSMetadataUbiquitousItemDownloadingStatusNotDownloaded:
|
||||
return .notDownloaded
|
||||
default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// - 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
|
||||
/// 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
|
||||
scanGeneration += 1
|
||||
let generation = scanGeneration
|
||||
isScanning = true
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
// 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
|
||||
// pressure and a repeat is free.
|
||||
for entry in entries where entry.download == .notDownloaded {
|
||||
try? FileManager.default.startDownloadingUbiquitousItem(at: entry.rootURL)
|
||||
}
|
||||
let summaries = entries.map(BoardSummaryScanner.scan).sorted(by: Self.displayOrder)
|
||||
await self?.land(summaries, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func land(_ summaries: [BoardSummary], generation: Int) {
|
||||
guard generation == scanGeneration else { return }
|
||||
boards = summaries
|
||||
isScanning = false
|
||||
phase = .ready
|
||||
|
||||
// Every open session hears about it: the metadata query is also the only signal a board's
|
||||
// *contents* changed remotely, and a session waiting on materialization is waiting on
|
||||
// exactly this notification.
|
||||
let live = Set(summaries.map(\.rootURL))
|
||||
for (root, session) in sessions where live.contains(root) {
|
||||
session.containerDidUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Title order, case- and diacritic-insensitive, tie-broken by path. Total and stable, so a
|
||||
/// refresh never reshuffles rows that did not change.
|
||||
private nonisolated static func displayOrder(_ lhs: BoardSummary, _ rhs: BoardSummary) -> Bool {
|
||||
switch lhs.title.localizedStandardCompare(rhs.title) {
|
||||
case .orderedAscending: true
|
||||
case .orderedDescending: false
|
||||
case .orderedSame: lhs.rootURL.path < rhs.rootURL.path
|
||||
}
|
||||
}
|
||||
|
||||
/// `.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] {
|
||||
let contents = (try? FileManager.default.contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
|
||||
return contents.compactMap { url in
|
||||
guard url.pathExtension == "kanban",
|
||||
let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey]),
|
||||
values.isDirectory == true
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return BoardIndexEntry(
|
||||
rootURL: url.standardizedFileURL,
|
||||
modified: values.contentModificationDate,
|
||||
download: .unknown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Creating a board
|
||||
|
||||
/// Creates an empty board and answers where it landed — the empty-container bootstrap, and the
|
||||
/// only write this store makes.
|
||||
///
|
||||
/// 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
|
||||
/// names the folder to match). A collision appends a counter rather than failing: two boards
|
||||
/// called "Work" is a thing a person may reasonably want.
|
||||
///
|
||||
/// 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.
|
||||
@discardableResult
|
||||
func createBoard(titled title: String) async -> Result<URL, BoardCreateFailure> {
|
||||
guard let home 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)
|
||||
let coordinated = CoordinatedFileAccess.write(itemAt: rootURL) { resolved -> Result<URL, BoardWriteError> in
|
||||
do throws(BoardWriteError) {
|
||||
try BoardWriter.createBoard(at: resolved, title: trimmed.isEmpty ? nil : trimmed)
|
||||
return .success(resolved)
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
switch coordinated {
|
||||
case let .failure(failure):
|
||||
return .failure(.coordination(failure))
|
||||
case let .success(inner):
|
||||
return inner.mapError(BoardCreateFailure.write)
|
||||
}
|
||||
}.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 create failed: \(failure.description, privacy: .public)")
|
||||
return .failure(failure)
|
||||
}
|
||||
}
|
||||
|
||||
/// A free `<name>.kanban` under `documents`. Blocking (it stats), so it runs with the create.
|
||||
private nonisolated static func availableBoardURL(for title: String, in documents: URL) -> URL {
|
||||
let base = sanitizedFolderName(title)
|
||||
var candidate = documents.appendingPathComponent("\(base).kanban", isDirectory: true)
|
||||
var counter = 2
|
||||
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||
candidate = documents.appendingPathComponent("\(base) \(counter).kanban", isDirectory: true)
|
||||
counter += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// Path separators and colons out (the two characters a file name cannot survive), leading dots
|
||||
/// out (a board must not be hidden from its own index), length capped well under the 255-byte
|
||||
/// limit so the `.kanban` suffix and a collision counter always fit.
|
||||
private nonisolated static func sanitizedFolderName(_ title: String) -> String {
|
||||
let stripped = title
|
||||
.components(separatedBy: CharacterSet(charactersIn: "/:\\"))
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let unhidden = stripped.drop(while: { $0 == "." })
|
||||
let name = unhidden.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return "Board" }
|
||||
return String(name.prefix(120))
|
||||
}
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
/// The session for one board, minted on first ask and kept.
|
||||
///
|
||||
/// Returned unopened: a view calls `open()` when it appears, which is what starts the
|
||||
/// materialization sweep. Two callers asking for the same root get the same object, so a
|
||||
/// navigation stack that holds a board list and a lane screen shares one snapshot.
|
||||
func session(forBoardAt rootURL: URL) -> BoardSession {
|
||||
let key = rootURL.standardizedFileURL
|
||||
if let existing = sessions[key] { return existing }
|
||||
let session = BoardSession(rootURL: key)
|
||||
sessions[key] = session
|
||||
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.
|
||||
func forgetSession(forBoardAt rootURL: URL) {
|
||||
let key = rootURL.standardizedFileURL
|
||||
sessions[key]?.close()
|
||||
sessions[key] = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
case noHome
|
||||
case coordination(CoordinationFailure)
|
||||
case write(BoardWriteError)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .noHome: "iCloud Drive is not available"
|
||||
case let .coordination(failure): failure.description
|
||||
case let .write(error): error.description
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// One open board: its snapshot, and the bracket every write to it goes through.
|
||||
///
|
||||
/// **The phone's answer to `BoardStore`.** It keeps that type's discipline — walks run off the main
|
||||
/// actor, one walk at a time, a stale result never lands, and a failed reload keeps the last good
|
||||
/// snapshot on screen — and none of its machinery: no FSEvents, no echo verdicts, no heal scheduler,
|
||||
/// no git. The change signal here is the container's metadata query, relayed by `BoardIndexStore`.
|
||||
///
|
||||
/// Minted and cached by `BoardIndexStore.session(forBoardAt:)`, never constructed directly by a
|
||||
/// screen: two screens looking at one board must share one snapshot.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BoardSession {
|
||||
|
||||
/// What the board screen renders.
|
||||
enum Phase: Sendable, Equatable {
|
||||
/// `open()` has not run.
|
||||
case idle
|
||||
|
||||
/// The package is not fully here yet. Carries the sweep's own numbers so the screen can show
|
||||
/// progress rather than an indefinite spinner. Not a failure state: it retries itself.
|
||||
case materializing(PackageMaterialization.Progress)
|
||||
|
||||
/// First walk in flight, nothing to show yet. A *re*-walk over an existing snapshot does not
|
||||
/// enter this phase — the snapshot stays on screen instead.
|
||||
case loading
|
||||
|
||||
/// `snapshot` is populated. `lastError` may still be set: that combination means the snapshot
|
||||
/// on screen is the last one that loaded and a later walk failed (see `lastError`).
|
||||
case ready
|
||||
|
||||
/// The first walk failed and there is nothing to fall back to.
|
||||
case failed(BoardSessionError)
|
||||
}
|
||||
|
||||
/// The package root. Identity, and the argument every loader and writer call is anchored on.
|
||||
let rootURL: URL
|
||||
|
||||
private(set) var phase: Phase = .idle
|
||||
|
||||
/// The last snapshot that loaded. **Deliberately not cleared on a failed reload** — a board that
|
||||
/// momentarily will not walk (a file mid-sync, a coordination refusal) must not blank the screen
|
||||
/// the user is working in.
|
||||
private(set) var snapshot: BoardModel?
|
||||
|
||||
/// The tolerated anomalies of the walk that produced `snapshot` — missing indexes, non-UUID
|
||||
/// folders, ignored keys. Never blocks anything; carried so a future notice surface has it.
|
||||
private(set) var warnings: [LoadWarning] = []
|
||||
|
||||
/// The most recent failure, load or write. Non-`nil` alongside `phase == .ready` is the
|
||||
/// stale-snapshot signal: what is drawn is real but is not the newest state of the disk.
|
||||
/// Cleared by the next walk that succeeds.
|
||||
private(set) var lastError: BoardSessionError?
|
||||
|
||||
/// The previous walk's parsed documents, offered to the next one (`BoardLoader.ParseMemo`). Pure
|
||||
/// optimization — it cannot change what a walk answers, only how many files it opens — and it is
|
||||
/// what keeps the reload after every single write from re-parsing the whole board on a phone.
|
||||
private var parseMemo: BoardLoader.ParseMemo?
|
||||
|
||||
/// One walk at a time, with a depth-one bank behind it: any number of requests arriving during a
|
||||
/// walk collapse into exactly one follow-up. Awaiting the returned task therefore awaits the
|
||||
/// banked walk too, which is what lets `perform` promise a snapshot that includes its own write.
|
||||
private var walk: Task<Void, Never>?
|
||||
private var banked = false
|
||||
|
||||
/// Only the newest walk's result applies. Serialization makes a stale landing unreachable today;
|
||||
/// the guard is written down anyway because "the newest result wins" is the rule every future
|
||||
/// overlapping-load change has to hold.
|
||||
private var loadGeneration = 0
|
||||
|
||||
private var retry: Task<Void, Never>?
|
||||
|
||||
/// How often a package that is still downloading re-checks itself. The metadata query usually
|
||||
/// beats the timer; the timer exists because a download that finishes without moving the
|
||||
/// package's own content-change date produces no notification at all.
|
||||
private static let retryInterval: Duration = .seconds(2)
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud")
|
||||
|
||||
init(rootURL: URL) {
|
||||
self.rootURL = rootURL
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// Starts the first walk. Idempotent — a screen may call it on every appearance.
|
||||
func open() {
|
||||
guard case .idle = phase else { return }
|
||||
reload()
|
||||
}
|
||||
|
||||
/// Re-walks the board. Returns the task that will have landed the newest result, so a caller who
|
||||
/// needs the fresh snapshot can `await` it and one who does not can ignore it.
|
||||
@discardableResult
|
||||
func reload() -> Task<Void, Never> {
|
||||
if let walk {
|
||||
banked = true
|
||||
return walk
|
||||
}
|
||||
let task = Task { [self] in
|
||||
repeat {
|
||||
banked = false
|
||||
await runLoad()
|
||||
} while banked
|
||||
walk = nil
|
||||
}
|
||||
walk = task
|
||||
return task
|
||||
}
|
||||
|
||||
/// Stops the retry timer. The board screen calls this when it is finished with the session; the
|
||||
/// snapshot survives, so a session that is reopened redraws immediately and re-walks behind that.
|
||||
func close() {
|
||||
cancelRetry()
|
||||
}
|
||||
|
||||
private func cancelRetry() {
|
||||
retry?.cancel()
|
||||
retry = nil
|
||||
}
|
||||
|
||||
/// The container changed — relayed by `BoardIndexStore` on every metadata notification.
|
||||
///
|
||||
/// Always a reload rather than a filtered one: the query reports a *package*, and the only thing
|
||||
/// that can be said from the outside about a package that changed is that something inside it
|
||||
/// did. The memo makes the resulting walk cheap, and the coalescing bank makes a burst of
|
||||
/// notifications one walk.
|
||||
func containerDidUpdate() {
|
||||
if case .idle = phase { return }
|
||||
reload()
|
||||
}
|
||||
|
||||
private func runLoad() async {
|
||||
loadGeneration += 1
|
||||
let generation = loadGeneration
|
||||
let root = rootURL
|
||||
let memo = parseMemo
|
||||
|
||||
// Only announce loading when there is nothing to show. A reload over a live board is
|
||||
// invisible by design, and a package still downloading keeps its own phase until the sweep
|
||||
// says otherwise.
|
||||
if snapshot == nil {
|
||||
switch phase {
|
||||
case .idle, .failed: phase = .loading
|
||||
case .loading, .materializing, .ready: break
|
||||
}
|
||||
}
|
||||
|
||||
let outcome = await Task.detached(priority: .userInitiated) { () -> LoadOutcome in
|
||||
// Materialization first, always. A package with a dataless `index.md` anywhere in it would
|
||||
// otherwise reach the loader, which is fail-fast — and would report ordinary sync latency
|
||||
// as a broken board.
|
||||
let progress = PackageMaterialization.sweep(packageAt: root)
|
||||
guard progress.isComplete else { return .incomplete(progress) }
|
||||
|
||||
let coordinated = CoordinatedFileAccess.read(itemAt: root) { resolved -> Result<LoadResult, BoardLoadFailure> in
|
||||
do throws(BoardLoadFailure) {
|
||||
return .success(try BoardLoader.load(boardRoot: resolved, memo: memo))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
switch coordinated {
|
||||
case let .failure(failure):
|
||||
return .failed(.coordination(failure))
|
||||
case let .success(.success(result)):
|
||||
return .loaded(result)
|
||||
case let .success(.failure(failure)):
|
||||
return .failed(.load(failure))
|
||||
}
|
||||
}.value
|
||||
|
||||
guard generation == loadGeneration else { return }
|
||||
apply(outcome)
|
||||
}
|
||||
|
||||
private func apply(_ outcome: LoadOutcome) {
|
||||
switch outcome {
|
||||
case let .incomplete(progress):
|
||||
phase = .materializing(progress)
|
||||
scheduleRetry()
|
||||
|
||||
case let .loaded(result):
|
||||
snapshot = result.model
|
||||
warnings = result.warnings
|
||||
parseMemo = result.memo
|
||||
lastError = nil
|
||||
phase = .ready
|
||||
cancelRetry()
|
||||
|
||||
case let .failed(error):
|
||||
lastError = error
|
||||
// The whole point of keeping the last snapshot: a board that is on screen stays on screen,
|
||||
// and the error rides alongside it instead of replacing it.
|
||||
phase = snapshot == nil ? .failed(error) : .ready
|
||||
cancelRetry()
|
||||
Self.logger.error("board walk failed: \(error.description, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// One pending re-check while the package downloads. Replaces itself rather than stacking: a
|
||||
/// second timer would double the sweep rate for no extra news.
|
||||
private func scheduleRetry() {
|
||||
retry?.cancel()
|
||||
retry = Task { [weak self] in
|
||||
try? await Task.sleep(for: Self.retryInterval)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
/// Runs a closure of `BoardWriter` calls against this board and reloads.
|
||||
///
|
||||
/// **The one door for every mutation a screen makes.** It supplies the three things a phone write
|
||||
/// needs and a bare `BoardWriter` call does not: a background executor (writes are synchronous
|
||||
/// filesystem work and must never run on the main actor), an `NSFileCoordinator` write intent over
|
||||
/// the whole package (so the daemon does not push a remote version into a folder mid-write, and so
|
||||
/// a two-folder operation like a move lands as one unit), and the reload that follows — views
|
||||
/// render only what is on disk, exactly as on the Mac, so a write that is not followed by a walk
|
||||
/// changes nothing on screen.
|
||||
///
|
||||
/// `work` is handed the coordinator-resolved root and should derive every URL from it. It may
|
||||
/// make any number of writer calls; they all land inside the one bracket, and the first one to
|
||||
/// throw abandons the rest — which is the writer's own contract, not a policy added here.
|
||||
///
|
||||
/// Awaiting the returned result means the reload has already landed, so `snapshot` reflects the
|
||||
/// write. A failure is returned *and* recorded in `lastError`; the reload runs either way, because
|
||||
/// a partially-applied multi-call write leaves disk in a state the screen must be shown.
|
||||
@discardableResult
|
||||
func perform<T: Sendable>(
|
||||
_ work: @escaping @Sendable (URL) throws(BoardWriteError) -> T
|
||||
) async -> Result<T, BoardSessionError> {
|
||||
let root = rootURL
|
||||
|
||||
let outcome: Result<T, BoardSessionError> = await Task.detached(priority: .userInitiated) {
|
||||
let coordinated = CoordinatedFileAccess.write(itemAt: root) { resolved -> Result<T, BoardWriteError> in
|
||||
do throws(BoardWriteError) {
|
||||
return .success(try work(resolved))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
switch coordinated {
|
||||
case let .failure(failure):
|
||||
return .failure(.coordination(failure))
|
||||
case let .success(inner):
|
||||
return inner.mapError(BoardSessionError.write)
|
||||
}
|
||||
}.value
|
||||
|
||||
if case let .failure(error) = outcome {
|
||||
lastError = error
|
||||
Self.logger.error("board write failed: \(error.description, privacy: .public)")
|
||||
}
|
||||
|
||||
await reload().value
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
|
||||
/// What one walk produced — the `Sendable` currency between the detached load and the main actor.
|
||||
private enum LoadOutcome: Sendable {
|
||||
case incomplete(PackageMaterialization.Progress)
|
||||
case loaded(LoadResult)
|
||||
case failed(BoardSessionError)
|
||||
}
|
||||
|
||||
/// Everything that can go wrong for one board, in one vocabulary: the coordinator refused, the walk
|
||||
/// found a fail-fast defect, or a write did not land. The two storage cases carry the storage layer's
|
||||
/// own typed errors verbatim — a re-worded copy would be a second, worse taxonomy.
|
||||
enum BoardSessionError: Error, Sendable, Equatable, CustomStringConvertible {
|
||||
case coordination(CoordinationFailure)
|
||||
case load(BoardLoadFailure)
|
||||
case write(BoardWriteError)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case let .coordination(failure): failure.description
|
||||
case let .load(failure): failure.description
|
||||
case let .write(error): error.description
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
|
||||
/// What the board list renders for one `.kanban` package: enough to name it, size it and say whether
|
||||
/// it is here yet — and deliberately nothing more. A summary is never the input to anything that
|
||||
/// edits; opening a board mints a `BoardSession`, which walks the package properly.
|
||||
struct BoardSummary: Identifiable, Sendable, Equatable {
|
||||
|
||||
/// **The package root is the identity.** A board has no UUID folder name and no id key — its
|
||||
/// identity is where it is (`BoardModel.rootURL` says so), and on the phone that URL is stable
|
||||
/// for as long as nobody renames the document in Files.app.
|
||||
var id: URL { rootURL }
|
||||
|
||||
let rootURL: URL
|
||||
|
||||
/// The board `index.md`'s `title:`, falling back to the folder name minus `.kanban` — which is
|
||||
/// exactly the fallback 01-storage-format.md § Board naming states, and the reason a board with
|
||||
/// no `title` key is a normal board rather than an untitled one.
|
||||
let title: String
|
||||
|
||||
/// Lanes the loader would show. `nil` where the package is not materialized enough to count —
|
||||
/// see `BoardSummaryScanner` for why a number is withheld rather than guessed at zero.
|
||||
let laneCount: Int?
|
||||
|
||||
/// Cards the loader would show, across every lane. Excludes `<root>/.trash/`. `nil` on the same
|
||||
/// terms as `laneCount`.
|
||||
let cardCount: Int?
|
||||
|
||||
/// The package's content-change date, as the metadata query reports it. `nil` under the DEBUG
|
||||
/// local root, where it is read from the directory instead, and on any item that has none yet.
|
||||
let modified: Date?
|
||||
|
||||
let download: BoardDownloadState
|
||||
}
|
||||
|
||||
/// Whether a board's bytes are on this device — the summary-level reading, from the metadata item's
|
||||
/// own attributes.
|
||||
///
|
||||
/// **Coarse on purpose.** This drives one row's badge. The authoritative per-file answer, the one a
|
||||
/// load actually depends on, is `PackageMaterialization.sweep` — a package can report `.current` here
|
||||
/// and still be missing a card's `index.md`, which is exactly why the session sweeps rather than
|
||||
/// trusting this.
|
||||
enum BoardDownloadState: Sendable, Equatable {
|
||||
/// A local copy exists. Covers both `…StatusCurrent` and `…StatusDownloaded` (a local copy that
|
||||
/// may be behind the cloud's) — the distinction changes nothing the list can act on.
|
||||
case current
|
||||
|
||||
/// Bytes are arriving. `fraction` is 0…1 where the daemon reports a percentage.
|
||||
case downloading(fraction: Double?)
|
||||
|
||||
/// In the cloud, not here, nothing in flight. The index requests a download for every board in
|
||||
/// this state, so it is a transient the list should render as such.
|
||||
case notDownloaded
|
||||
|
||||
/// No metadata to read: the DEBUG local root, or an item whose attributes have not arrived.
|
||||
case unknown
|
||||
|
||||
/// Whether the shallow content walk may touch this package's files at all.
|
||||
var isReadable: Bool {
|
||||
switch self {
|
||||
case .current, .unknown: true
|
||||
case .downloading, .notDownloaded: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `.kanban` package as the index found it, before its contents were looked at — the `Sendable`
|
||||
/// hand-off from the main-actor metadata read to the off-main scan.
|
||||
struct BoardIndexEntry: Sendable, Equatable {
|
||||
let rootURL: URL
|
||||
let modified: Date?
|
||||
let download: BoardDownloadState
|
||||
}
|
||||
|
||||
/// Turns an index entry into a summary by looking, shallowly, at the package.
|
||||
///
|
||||
/// **A shallow walk, not a load.** `BoardLoader` parses every `index.md` in the tree; a board list
|
||||
/// showing six boards cannot afford six of those on a phone. So this counts folders through the same
|
||||
/// gate the loader counts them by — UUID-shaped name (`IntegrityRules.isIdentityShaped`) holding an
|
||||
/// `index.md` — and reads exactly one file, the board's own `index.md`, for its title. A folder that
|
||||
/// fails the gate is a stray the loader would ignore too, so the counts agree with what the board
|
||||
/// window will show without paying for the agreement.
|
||||
///
|
||||
/// Two known and accepted divergences from a real load, both in the direction of over-counting by at
|
||||
/// most a hair: a card whose `index.md` is present but malformed is counted here and would be a
|
||||
/// fail-fast defect there, and a card carrying a legacy `deleted:` key is counted here and rides
|
||||
/// along flagged there. Deciding either requires parsing the file, which is the cost this walk
|
||||
/// exists to avoid.
|
||||
///
|
||||
/// **Uncoordinated, deliberately.** These are display reads that re-run on every metadata update; a
|
||||
/// torn read costs a stale title for one refresh, while an `NSFileCoordinator` bracket per board
|
||||
/// would put a daemon round-trip on the path of drawing a list. The session coordinates; the index
|
||||
/// does not.
|
||||
enum BoardSummaryScanner {
|
||||
|
||||
/// Blocking — callers run it off the main actor.
|
||||
nonisolated static func scan(_ entry: BoardIndexEntry) -> BoardSummary {
|
||||
let fallbackTitle = entry.rootURL.deletingPathExtension().lastPathComponent
|
||||
|
||||
// Nothing on disk to read, and reading anyway risks a blocking materialization on whatever
|
||||
// network the phone is on. The name is still known — it is in the URL — so the row is
|
||||
// nameable while it downloads, and the next refresh fills in the rest.
|
||||
guard entry.download.isReadable else {
|
||||
return BoardSummary(
|
||||
rootURL: entry.rootURL,
|
||||
title: fallbackTitle,
|
||||
laneCount: nil,
|
||||
cardCount: nil,
|
||||
modified: entry.modified,
|
||||
download: entry.download
|
||||
)
|
||||
}
|
||||
|
||||
let indexURL = entry.rootURL.appendingPathComponent(IntegrityRules.indexFileName)
|
||||
let title = readTitle(at: indexURL) ?? fallbackTitle
|
||||
|
||||
// An unreadable board `index.md` means this is not a board the loader would open — a package
|
||||
// still arriving, or one whose root file is genuinely broken. Either way a count would be a
|
||||
// fiction, so none is offered.
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else {
|
||||
return BoardSummary(
|
||||
rootURL: entry.rootURL,
|
||||
title: title,
|
||||
laneCount: nil,
|
||||
cardCount: nil,
|
||||
modified: entry.modified,
|
||||
download: entry.download
|
||||
)
|
||||
}
|
||||
|
||||
var lanes = 0
|
||||
var cards = 0
|
||||
for lane in itemFolders(in: entry.rootURL) {
|
||||
lanes += 1
|
||||
cards += itemFolders(in: lane).count
|
||||
}
|
||||
|
||||
return BoardSummary(
|
||||
rootURL: entry.rootURL,
|
||||
title: title,
|
||||
laneCount: lanes,
|
||||
cardCount: cards,
|
||||
modified: entry.modified,
|
||||
download: entry.download
|
||||
)
|
||||
}
|
||||
|
||||
/// The board title as written, or `nil` where the file is absent, is not UTF-8, has no
|
||||
/// frontmatter, or carries no usable `title:` — every one of which is the folder-name fallback.
|
||||
private nonisolated static func readTitle(at indexURL: URL) -> String? {
|
||||
guard let data = try? Data(contentsOf: indexURL),
|
||||
let text = String(data: data, encoding: .utf8),
|
||||
let document = try? FrontmatterDocument.parse(text),
|
||||
let title = document.title.value,
|
||||
!title.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
/// Direct subfolders that are lanes or cards by the loader's own two gates, reached through the
|
||||
/// loader's own enumeration (`BoardLoader.directoryCandidates`) so the two can never disagree
|
||||
/// about what a candidate is — hidden entries skipped, which is what keeps `<root>/.trash/` out
|
||||
/// of every count here without a second rule.
|
||||
private nonisolated static func itemFolders(in parent: URL) -> [URL] {
|
||||
guard let candidates = try? BoardLoader.directoryCandidates(in: parent) else { return [] }
|
||||
return candidates.filter { folder in
|
||||
IntegrityRules.isIdentityShaped(folder.lastPathComponent)
|
||||
&& FileManager.default.fileExists(
|
||||
atPath: folder.appendingPathComponent(IntegrityRules.indexFileName).path
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// The one folder every board on this phone lives 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.
|
||||
struct CloudHome: Sendable, Equatable {
|
||||
/// `<container>/Documents` — created if missing. Boards must live here and nowhere else:
|
||||
/// `NSMetadataQueryUbiquitousDocumentsScope` reports on this subtree alone, and
|
||||
/// `NSUbiquitousContainerIsDocumentScopePublic` (KanbanMobile/Info.plist) is what publishes it as
|
||||
/// a visible iCloud Drive folder — the same folder the Mac app opens boards out of today.
|
||||
let documentsURL: URL
|
||||
|
||||
let origin: Origin
|
||||
|
||||
enum Origin: Sendable, Equatable {
|
||||
/// The real ubiquity container. The only origin a shipped build can produce.
|
||||
case ubiquityContainer
|
||||
|
||||
/// `LANEWORK_LOCAL_ROOT` — a plain directory standing in for the container, DEBUG only.
|
||||
///
|
||||
/// There is no metadata query over a plain directory, so an index over this origin refreshes
|
||||
/// on demand rather than on notification, and every download state reads `.unknown`. That is
|
||||
/// the whole difference; the loader, the writer and the coordination brackets are identical,
|
||||
/// which is what makes the override worth having for simulator work and for UI tests that
|
||||
/// must not depend on an iCloud account.
|
||||
case localOverride
|
||||
}
|
||||
|
||||
/// Whether a `NSMetadataQuery` can watch this home. False under the DEBUG override, where the
|
||||
/// index enumerates instead.
|
||||
var isWatchable: Bool { origin == .ubiquityContainer }
|
||||
}
|
||||
|
||||
/// Why there is no home — the closed set the unavailable screen 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.
|
||||
case noAccount
|
||||
|
||||
/// An account exists but the container did not resolve — provisioning not yet propagated,
|
||||
/// restricted by a profile, or iCloud Drive switched off for this app.
|
||||
case containerUnreachable
|
||||
|
||||
/// The container resolved but its `Documents/` subdirectory could not be created.
|
||||
case documentsUnavailable(message: String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .noAccount:
|
||||
"no iCloud account is signed in"
|
||||
case .containerUnreachable:
|
||||
"the iCloud container could not be reached"
|
||||
case let .documentsUnavailable(message):
|
||||
"the container's Documents folder is unusable: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the container, once, off the main thread.
|
||||
///
|
||||
/// **Off-main is not an optimization.** `FileManager.url(forUbiquityContainerIdentifier:)` blocks —
|
||||
/// seconds on a first launch while the daemon materializes the container, and indefinitely against a
|
||||
/// wedged account. Called on the main actor it is a hang, so the blocking half lives in a
|
||||
/// `nonisolated` function and the only entry point is `async`.
|
||||
enum CloudHomeResolver {
|
||||
|
||||
/// The container this app is a tenant of — named after the *Mac* app's bundle id, deliberately
|
||||
/// (KanbanMobile.entitlements states why). Hard-coded rather than read back from the
|
||||
/// entitlements at runtime: a mismatch between this string and the entitlement is a
|
||||
/// provisioning error, and the loudest place for it is a container that does not resolve.
|
||||
static let containerIdentifier = "iCloud.dev.rzen.indie.Kanban"
|
||||
|
||||
#if DEBUG
|
||||
/// The DEBUG escape hatch: a filesystem path to use *instead of* the ubiquity container, whole.
|
||||
/// 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"
|
||||
#endif
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud")
|
||||
|
||||
static func resolve() async -> Result<CloudHome, CloudHomeUnavailable> {
|
||||
await Task.detached(priority: .userInitiated) { resolveBlocking() }.value
|
||||
}
|
||||
|
||||
/// The blocking half. `nonisolated` and free of any stored state, so it is safe from any
|
||||
/// executor — and so a caller that already has a background context can use it directly.
|
||||
nonisolated static func resolveBlocking() -> Result<CloudHome, CloudHomeUnavailable> {
|
||||
#if DEBUG
|
||||
if let override = ProcessInfo.processInfo.environment[localRootEnvironmentKey],
|
||||
!override.isEmpty {
|
||||
let root = URL(fileURLWithPath: override, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
return .failure(.documentsUnavailable(message: error.localizedDescription))
|
||||
}
|
||||
logger.notice("using LANEWORK_LOCAL_ROOT instead of the ubiquity container")
|
||||
return .success(CloudHome(documentsURL: root, origin: .localOverride))
|
||||
}
|
||||
#endif
|
||||
|
||||
// Cheap and non-blocking, and it is the one distinction the wall's copy turns on: "sign into
|
||||
// iCloud" is only the right sentence when there is no account, not when a provisioned
|
||||
// container has failed to appear.
|
||||
guard FileManager.default.ubiquityIdentityToken != nil else {
|
||||
return .failure(.noAccount)
|
||||
}
|
||||
|
||||
guard let container = FileManager.default.url(forUbiquityContainerIdentifier: containerIdentifier) else {
|
||||
return .failure(.containerUnreachable)
|
||||
}
|
||||
|
||||
let documents = container.appendingPathComponent("Documents", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: documents, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
return .failure(.documentsUnavailable(message: error.localizedDescription))
|
||||
}
|
||||
|
||||
return .success(CloudHome(documentsURL: documents, origin: .ubiquityContainer))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
/// `NSFileCoordinator` brackets around the storage layer's own I/O.
|
||||
///
|
||||
/// **Required, not defensive.** `BoardLoader` and `BoardWriter` read and write with plain
|
||||
/// `FileManager` calls, which is correct on the Mac where the app owns the folder outright. In a
|
||||
/// ubiquity container the daemon is a second writer: it materializes, evicts and replaces items
|
||||
/// underneath a walk with no warning. Coordination is the only thing that makes "the tree did not
|
||||
/// move while I read it" true, and the only thing that tells the daemon not to push a remote version
|
||||
/// into a folder mid-write.
|
||||
///
|
||||
/// The bracket wraps the **package root**, not each file inside it. A board is one document
|
||||
/// (`LSTypeIsPackage`), so one coordination covers the whole walk — which is also the only shape
|
||||
/// that can hold a multi-file write (a move is two folders, a delete is a folder plus its trash
|
||||
/// destination) as one unit.
|
||||
///
|
||||
/// **Every call blocks.** `coordinate` waits for the daemon and for other presenters, so these run
|
||||
/// off the main actor without exception; the types here are `nonisolated` and stateless so they can.
|
||||
enum CoordinatedFileAccess {
|
||||
|
||||
/// Runs `body` under a read intent on `url`, and answers what it returned.
|
||||
///
|
||||
/// `body` is handed the URL the coordinator resolved — which may differ from `url` if the item
|
||||
/// moved — and must use it rather than closing over the original. It is deliberately
|
||||
/// non-throwing: the storage layer's typed errors (`BoardLoadFailure`, `BoardWriteError`) are far
|
||||
/// richer than anything this layer could wrap, so a caller returns its own `Result` from `body`
|
||||
/// and the outer `Result` carries only the coordinator's own refusal.
|
||||
static func read<T>(
|
||||
itemAt url: URL,
|
||||
options: NSFileCoordinator.ReadingOptions = [],
|
||||
by body: (URL) -> T
|
||||
) -> Result<T, CoordinationFailure> {
|
||||
var captured: T?
|
||||
var ran = false
|
||||
var coordinatorError: NSError?
|
||||
let coordinator = NSFileCoordinator(filePresenter: nil)
|
||||
coordinator.coordinate(readingItemAt: url, options: options, error: &coordinatorError) { resolved in
|
||||
ran = true
|
||||
captured = body(resolved)
|
||||
}
|
||||
// `ran` rather than `captured != nil`: a `T` that is itself optional would otherwise read a
|
||||
// legitimate nil result as "the block never ran".
|
||||
guard ran, let captured else {
|
||||
return .failure(CoordinationFailure(coordinatorError, fallback: "the coordinated read did not run"))
|
||||
}
|
||||
return .success(captured)
|
||||
}
|
||||
|
||||
/// Runs `body` under a write intent on `url` — the bracket every `BoardWriter` call on the phone
|
||||
/// goes through. Same contract as `read(itemAt:options:by:)`.
|
||||
static func write<T>(
|
||||
itemAt url: URL,
|
||||
options: NSFileCoordinator.WritingOptions = [],
|
||||
by body: (URL) -> T
|
||||
) -> Result<T, CoordinationFailure> {
|
||||
var captured: T?
|
||||
var ran = false
|
||||
var coordinatorError: NSError?
|
||||
let coordinator = NSFileCoordinator(filePresenter: nil)
|
||||
coordinator.coordinate(writingItemAt: url, options: options, error: &coordinatorError) { resolved in
|
||||
ran = true
|
||||
captured = body(resolved)
|
||||
}
|
||||
guard ran, let captured else {
|
||||
return .failure(CoordinationFailure(coordinatorError, fallback: "the coordinated write did not run"))
|
||||
}
|
||||
return .success(captured)
|
||||
}
|
||||
}
|
||||
|
||||
/// The coordinator refused — a lock it could not take, an item it could not reach.
|
||||
///
|
||||
/// A flattened value rather than the `NSError` itself: this crosses from a detached task back to the
|
||||
/// main actor, and three `Sendable` scalars carry everything a log line or an alert needs without
|
||||
/// smuggling a reference type across the boundary.
|
||||
struct CoordinationFailure: Error, Sendable, Equatable, CustomStringConvertible {
|
||||
let domain: String
|
||||
let code: Int
|
||||
let message: String
|
||||
|
||||
init(_ error: NSError?, fallback: String) {
|
||||
domain = error?.domain ?? "dev.rzen.indie.KanbanMobile.coordination"
|
||||
code = error?.code ?? -1
|
||||
message = error?.localizedDescription ?? fallback
|
||||
}
|
||||
|
||||
var description: String { "\(message) (\(domain) \(code))" }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Makes sure every file inside a board package actually has bytes on this device before the loader
|
||||
/// is allowed to walk it.
|
||||
///
|
||||
/// **Why this exists at all.** `BoardLoader` is fail-fast by design: an `index.md` it cannot read is
|
||||
/// a `BoardLoadError`, not a warning. On the Mac that is exactly right — an unreadable file is a real
|
||||
/// defect. On the phone it is routinely a file iCloud has simply not brought down yet, or has evicted
|
||||
/// to reclaim storage. Handing the loader a half-materialized package would turn ordinary sync
|
||||
/// latency into the decision surface's "this board is broken", which is the wrong sentence and the
|
||||
/// wrong recovery. So the sweep runs first, and a board that is not yet whole waits in a downloading
|
||||
/// state instead of failing.
|
||||
///
|
||||
/// **A package's own metadata item is not enough to decide this.** `NSMetadataQuery` reports a
|
||||
/// download status for the `.kanban` item as a whole, but that aggregate has been unreliable for
|
||||
/// packages across releases and says nothing about *which* item is missing. The sweep asks each file
|
||||
/// directly, which is also what lets it request the downloads.
|
||||
enum PackageMaterialization {
|
||||
|
||||
/// One sweep's answer.
|
||||
struct Progress: Sendable, Equatable {
|
||||
/// Items that are ubiquitous and not yet current. A download has been requested for each.
|
||||
var pending: Int
|
||||
|
||||
/// Every item the walk saw, including directories and the package root.
|
||||
var total: Int
|
||||
|
||||
/// The first download request that was refused, if any — best-effort observability. A refusal
|
||||
/// is not a failure of the sweep: the next sweep asks again, and the daemon usually answers
|
||||
/// the second time.
|
||||
var refusal: String?
|
||||
|
||||
var isComplete: Bool { pending == 0 }
|
||||
|
||||
/// 0…1 across the package, for a determinate progress view. `nil` where there is nothing to
|
||||
/// report on.
|
||||
var fractionMaterialized: Double? {
|
||||
guard total > 0 else { return nil }
|
||||
return Double(total - pending) / Double(total)
|
||||
}
|
||||
}
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud")
|
||||
|
||||
/// Walks the package, requests a download for every item that is not current, and answers what is
|
||||
/// still outstanding.
|
||||
///
|
||||
/// Blocking (a full directory enumeration plus a resource-value read per item) — callers run it
|
||||
/// off the main actor.
|
||||
///
|
||||
/// **Hidden entries are included, deliberately**: `<root>/.trash/` is materialized trash the
|
||||
/// loader reads, so a package whose trash has not come down is not yet loadable. This is the one
|
||||
/// walk in the mobile layer that does *not* use the loader's `.skipsHiddenFiles` posture.
|
||||
///
|
||||
/// Answers `Progress(pending: 0, total: 0)` for a package under `LANEWORK_LOCAL_ROOT`, where
|
||||
/// nothing is a ubiquitous item — "complete", which is the correct reading of a folder that is
|
||||
/// simply already there.
|
||||
nonisolated static func sweep(packageAt root: URL) -> Progress {
|
||||
var progress = Progress(pending: 0, total: 0, refusal: nil)
|
||||
|
||||
func consider(_ url: URL) {
|
||||
progress.total += 1
|
||||
guard !isCurrent(url) else { return }
|
||||
progress.pending += 1
|
||||
do {
|
||||
try FileManager.default.startDownloadingUbiquitousItem(at: url)
|
||||
} catch {
|
||||
if progress.refusal == nil {
|
||||
progress.refusal = error.localizedDescription
|
||||
logger.warning("download request refused for \(url.lastPathComponent, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
consider(root)
|
||||
|
||||
guard let walk = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey],
|
||||
options: []
|
||||
) else {
|
||||
return progress
|
||||
}
|
||||
|
||||
for case let url as URL in walk {
|
||||
consider(url)
|
||||
}
|
||||
|
||||
return progress
|
||||
}
|
||||
|
||||
/// Whether one item has bytes here now.
|
||||
///
|
||||
/// Two shapes are read as "not here". The modern one is a dataless file at its real path whose
|
||||
/// `ubiquitousItemDownloadingStatus` is `.notDownloaded`. The legacy one is a hidden `.icloud`
|
||||
/// placeholder standing where the file will land — still produced in some states, and invisible
|
||||
/// to a resource-value read on the *real* name because that name does not exist yet. Both are
|
||||
/// counted, and `startDownloadingUbiquitousItem` accepts either URL.
|
||||
///
|
||||
/// A non-ubiquitous item (anything under the DEBUG local root, and any stray the daemon does not
|
||||
/// manage) is current by definition.
|
||||
private nonisolated static func isCurrent(_ url: URL) -> Bool {
|
||||
if url.pathExtension == "icloud", url.lastPathComponent.hasPrefix(".") {
|
||||
return false
|
||||
}
|
||||
guard let values = try? url.resourceValues(forKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey]),
|
||||
values.isUbiquitousItem == true
|
||||
else {
|
||||
return true
|
||||
}
|
||||
// `.downloaded` means "a local copy exists but a newer one may be in the cloud" — bytes are
|
||||
// here, which is the only question this walk asks. Only `.notDownloaded` blocks a load.
|
||||
return values.ubiquitousItemDownloadingStatus != .notDownloaded
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user