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
128 lines
6.3 KiB
Swift
128 lines
6.3 KiB
Swift
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))
|
|
}
|
|
}
|