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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user