Files
lanework/KanbanMobile/Cloud/CoordinatedFileAccess.swift
T
rzen eac1c02a7d 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
2026-08-07 22:49:36 -04:00

89 lines
4.2 KiB
Swift

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))" }
}