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:
2026-08-07 22:49:36 -04:00
parent c67c1037f1
commit eac1c02a7d
22 changed files with 2599 additions and 1 deletions
+127
View File
@@ -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))
}
}