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,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