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,425 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// The app's root model: which boards exist in the container, and what state each is in.
|
||||
///
|
||||
/// **One per process, owned by `MobileApp` and handed down through `.environment`.** It holds the
|
||||
/// container home, the one `NSMetadataQuery` the app runs, and the `BoardSession` cache — all three
|
||||
/// are process-wide facts, and a second instance would mean a second query over the same scope for
|
||||
/// no gain.
|
||||
///
|
||||
/// ### The observer flow
|
||||
///
|
||||
/// The query is the only change signal on this platform (there is no FSEvents here — see project.yml
|
||||
/// ▸ Lanework for iPhone). Both of its notifications are handled identically: bracket the result set,
|
||||
/// pull `Sendable` descriptors out of it on the main actor, then scan and assemble off it. The delta
|
||||
/// keys are deliberately unused — the board count is small enough that a full re-enumeration is
|
||||
/// cheaper than the bookkeeping a delta needs, and re-enumeration cannot drift out of sync with the
|
||||
/// container the way a maintained baseline can.
|
||||
///
|
||||
/// A gather is not treated as an arrival: it is the first full picture, and it publishes summaries
|
||||
/// exactly as an update does. That differs from the sync-engine skill's monitor, which must baseline
|
||||
/// silently because its consumer replays events into a cache — here the published state *is* the
|
||||
/// query's result set, so there is no replay to protect.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BoardIndexStore {
|
||||
|
||||
/// The three states the boards tab renders.
|
||||
enum Phase: Sendable, Equatable {
|
||||
/// `start()` has not run.
|
||||
case idle
|
||||
|
||||
/// Resolving the container, or waiting for the query's first gather.
|
||||
case loading
|
||||
|
||||
/// No home — the app cannot function. The boards tab shows the sign-into-iCloud wall.
|
||||
case unavailable(CloudHomeUnavailable)
|
||||
|
||||
/// The list is live. `boards` may be empty; an empty container is not a failure, it is the
|
||||
/// state the create-a-board bootstrap exists for.
|
||||
case ready
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .idle
|
||||
|
||||
/// Resolved exactly once per process. `nil` until then, and whenever `phase` is `.unavailable`.
|
||||
private(set) var home: CloudHome?
|
||||
|
||||
/// Every `.kanban` package in the container, sorted by title (case- and diacritic-insensitively,
|
||||
/// tie-broken by path so the order is total and stable across refreshes).
|
||||
private(set) var boards: [BoardSummary] = []
|
||||
|
||||
/// A scan is in flight. Distinct from `phase == .loading`, which is about there being nothing to
|
||||
/// show yet: this stays true through refreshes of a list that is already on screen.
|
||||
private(set) var isScanning = false
|
||||
|
||||
/// The most recent failure that did not cost the whole home — a refused board create, a directory
|
||||
/// that would not enumerate. Cleared by the next successful operation of the same kind.
|
||||
private(set) var lastError: String?
|
||||
|
||||
private var query: NSMetadataQuery?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
/// Only the newest scan applies. Serialization makes staleness unlikely rather than impossible —
|
||||
/// a metadata update landing while a scan runs starts a second one — so the guard is enforced
|
||||
/// rather than assumed, exactly as `BoardStore` does on the Mac.
|
||||
private var scanGeneration = 0
|
||||
|
||||
/// The query's previous answer, for the unchanged-notification gate in `scan(entries:force:)`.
|
||||
private var lastEntries: [BoardIndexEntry]?
|
||||
|
||||
/// Open sessions, keyed by standardized root URL. Cached so navigating out of a board and back
|
||||
/// into it does not re-materialize and re-walk a package the app already has in hand.
|
||||
private var sessions: [URL: BoardSession] = [:]
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud")
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Resolves the home and starts watching. Idempotent while loading or ready; calling it again
|
||||
/// after `.unavailable` is the retry an unavailable screen's button wants.
|
||||
func start() {
|
||||
switch phase {
|
||||
case .loading, .ready:
|
||||
return
|
||||
case .idle, .unavailable:
|
||||
break
|
||||
}
|
||||
|
||||
phase = .loading
|
||||
Task { [weak self] in
|
||||
let resolution = await CloudHomeResolver.resolve()
|
||||
guard let self else { return }
|
||||
switch resolution {
|
||||
case let .success(home):
|
||||
self.home = home
|
||||
self.adopt(home)
|
||||
case let .failure(reason):
|
||||
self.home = nil
|
||||
self.boards = []
|
||||
self.phase = .unavailable(reason)
|
||||
Self.logger.error("no cloud home: \(reason.description, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads the container now, whether or not anything looks different.
|
||||
///
|
||||
/// Under the metadata query this is a courtesy — the query already reports every change — and it
|
||||
/// is what pull-to-refresh calls. Under `LANEWORK_LOCAL_ROOT` it is the *only* refresh there is,
|
||||
/// since a plain directory sends no notifications.
|
||||
func refresh() {
|
||||
guard let home else { return }
|
||||
if home.isWatchable, let query {
|
||||
scan(entries: readEntries(from: query), force: true)
|
||||
} else {
|
||||
scanLocalRoot(home)
|
||||
}
|
||||
}
|
||||
|
||||
private func adopt(_ home: CloudHome) {
|
||||
guard home.isWatchable else {
|
||||
scanLocalRoot(home)
|
||||
return
|
||||
}
|
||||
startQuery()
|
||||
}
|
||||
|
||||
// MARK: - The metadata query
|
||||
|
||||
/// The predicate is a filename match on `*.kanban`, and it matches **one item per board** rather
|
||||
/// than one per file inside it: the app exports `dev.rzen.indie.kanban-board` conforming to
|
||||
/// `com.apple.package` (KanbanMobile/Info.plist), which is what makes the daemon treat a `.kanban`
|
||||
/// directory as a single document. Without that export this query would return every `index.md`
|
||||
/// in every board.
|
||||
private func startQuery() {
|
||||
guard query == nil else { return }
|
||||
|
||||
let query = NSMetadataQuery()
|
||||
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
|
||||
query.predicate = NSPredicate(format: "%K LIKE %@", NSMetadataItemFSNameKey, "*.kanban")
|
||||
self.query = query
|
||||
|
||||
observers = [
|
||||
observe(.NSMetadataQueryDidFinishGathering, from: query),
|
||||
observe(.NSMetadataQueryDidUpdate, from: query),
|
||||
]
|
||||
|
||||
// `NSMetadataQuery` is main-thread-only and delivers on the runloop that started it, which is
|
||||
// why this whole type is `@MainActor` — the isolation is the guarantee, not a convention.
|
||||
query.start()
|
||||
}
|
||||
|
||||
/// The observer block is `@Sendable` and must not carry the `Notification` anywhere: the result
|
||||
/// set is re-read from the query instead, on the main thread the block is already on
|
||||
/// (`queue: .main` is what makes `assumeIsolated` sound here).
|
||||
private func observe(_ name: Notification.Name, from query: NSMetadataQuery) -> NSObjectProtocol {
|
||||
NotificationCenter.default.addObserver(forName: name, object: query, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.queryDidFire()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gather and update are handled identically — see the type's note on why there is no delta
|
||||
/// bookkeeping here.
|
||||
private func queryDidFire() {
|
||||
guard let query else { return }
|
||||
scan(entries: readEntries(from: query), force: false)
|
||||
}
|
||||
|
||||
/// Pulls plain values out of the result set. Bracketed by `disableUpdates`/`enableUpdates`
|
||||
/// because the query is free to swap its own storage mid-iteration otherwise, and nothing that
|
||||
/// leaves here is an `NSMetadataItem` — that type is not `Sendable` and must never cross to the
|
||||
/// scan.
|
||||
private func readEntries(from query: NSMetadataQuery) -> [BoardIndexEntry] {
|
||||
query.disableUpdates()
|
||||
defer { query.enableUpdates() }
|
||||
|
||||
var entries: [BoardIndexEntry] = []
|
||||
entries.reserveCapacity(query.resultCount)
|
||||
for index in 0 ..< query.resultCount {
|
||||
guard let item = query.result(at: index) as? NSMetadataItem,
|
||||
let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL
|
||||
else {
|
||||
continue
|
||||
}
|
||||
entries.append(BoardIndexEntry(
|
||||
rootURL: url.standardizedFileURL,
|
||||
modified: item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date,
|
||||
download: Self.downloadState(of: item)
|
||||
))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private static func downloadState(of item: NSMetadataItem) -> BoardDownloadState {
|
||||
let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
|
||||
let isDownloading = item.value(forAttribute: NSMetadataUbiquitousItemIsDownloadingKey) as? Bool ?? false
|
||||
let percent = item.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? Double
|
||||
|
||||
if isDownloading {
|
||||
return .downloading(fraction: percent.map { $0 / 100 })
|
||||
}
|
||||
switch status {
|
||||
case NSMetadataUbiquitousItemDownloadingStatusCurrent,
|
||||
NSMetadataUbiquitousItemDownloadingStatusDownloaded:
|
||||
return .current
|
||||
case NSMetadataUbiquitousItemDownloadingStatusNotDownloaded:
|
||||
return .notDownloaded
|
||||
default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scanning
|
||||
|
||||
/// The DEBUG local root's stand-in for a gather: a directory listing, on demand.
|
||||
private func scanLocalRoot(_ home: CloudHome) {
|
||||
let root = home.documentsURL
|
||||
scanGeneration += 1
|
||||
let generation = scanGeneration
|
||||
isScanning = true
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
let entries = Self.enumerateBoards(under: root)
|
||||
let summaries = entries.map(BoardSummaryScanner.scan).sorted(by: Self.displayOrder)
|
||||
await self?.land(summaries, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameter force: run even when the query's answer is byte-identical to the last one.
|
||||
/// Notifications do not set it, because `NSMetadataQueryDidUpdate` fires on upload and download
|
||||
/// *progress* as well as on real changes and a repeat scan of an unchanged container is pure
|
||||
/// churn — a directory walk and a file read per board. An explicit `refresh()` does set it: a
|
||||
/// pull-to-refresh that provably does nothing is worse than a wasted walk.
|
||||
private func scan(entries: [BoardIndexEntry], force: Bool) {
|
||||
if !force, entries == lastEntries { return }
|
||||
lastEntries = entries
|
||||
scanGeneration += 1
|
||||
let generation = scanGeneration
|
||||
isScanning = true
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
// The one write this whole path makes: a board that is in the cloud and not here is asked
|
||||
// for. Requested off-main with the scan because `startDownloadingUbiquitousItem` talks to
|
||||
// the daemon, and requested on every pass because the daemon drops requests under memory
|
||||
// pressure and a repeat is free.
|
||||
for entry in entries where entry.download == .notDownloaded {
|
||||
try? FileManager.default.startDownloadingUbiquitousItem(at: entry.rootURL)
|
||||
}
|
||||
let summaries = entries.map(BoardSummaryScanner.scan).sorted(by: Self.displayOrder)
|
||||
await self?.land(summaries, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func land(_ summaries: [BoardSummary], generation: Int) {
|
||||
guard generation == scanGeneration else { return }
|
||||
boards = summaries
|
||||
isScanning = false
|
||||
phase = .ready
|
||||
|
||||
// Every open session hears about it: the metadata query is also the only signal a board's
|
||||
// *contents* changed remotely, and a session waiting on materialization is waiting on
|
||||
// exactly this notification.
|
||||
let live = Set(summaries.map(\.rootURL))
|
||||
for (root, session) in sessions where live.contains(root) {
|
||||
session.containerDidUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Title order, case- and diacritic-insensitive, tie-broken by path. Total and stable, so a
|
||||
/// refresh never reshuffles rows that did not change.
|
||||
private nonisolated static func displayOrder(_ lhs: BoardSummary, _ rhs: BoardSummary) -> Bool {
|
||||
switch lhs.title.localizedStandardCompare(rhs.title) {
|
||||
case .orderedAscending: true
|
||||
case .orderedDescending: false
|
||||
case .orderedSame: lhs.rootURL.path < rhs.rootURL.path
|
||||
}
|
||||
}
|
||||
|
||||
/// `.kanban` directories directly inside `root`. The name gate matches the metadata query's
|
||||
/// predicate exactly — an extension-less board folder loads fine but is not a *document*, and the
|
||||
/// index is a list of documents.
|
||||
private nonisolated static func enumerateBoards(under root: URL) -> [BoardIndexEntry] {
|
||||
let contents = (try? FileManager.default.contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
|
||||
return contents.compactMap { url in
|
||||
guard url.pathExtension == "kanban",
|
||||
let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey]),
|
||||
values.isDirectory == true
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return BoardIndexEntry(
|
||||
rootURL: url.standardizedFileURL,
|
||||
modified: values.contentModificationDate,
|
||||
download: .unknown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Creating a board
|
||||
|
||||
/// Creates an empty board and answers where it landed — the empty-container bootstrap, and the
|
||||
/// only write this store makes.
|
||||
///
|
||||
/// The folder name comes from the title, because on this platform the document name *is* what the
|
||||
/// user sees in Files.app (01-storage-format.md § Board naming — the app writes the title key and
|
||||
/// names the folder to match). A collision appends a counter rather than failing: two boards
|
||||
/// called "Work" is a thing a person may reasonably want.
|
||||
///
|
||||
/// Coordinated as a write on the new package's URL, then a refresh — the query would report the
|
||||
/// new board on its own within a second, but a create that does not immediately show its result
|
||||
/// is a create that looks broken.
|
||||
@discardableResult
|
||||
func createBoard(titled title: String) async -> Result<URL, BoardCreateFailure> {
|
||||
guard let home else {
|
||||
return .failure(.noHome)
|
||||
}
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let documents = home.documentsURL
|
||||
|
||||
let outcome: Result<URL, BoardCreateFailure> = await Task.detached(priority: .userInitiated) {
|
||||
let rootURL = Self.availableBoardURL(for: trimmed, in: documents)
|
||||
let coordinated = CoordinatedFileAccess.write(itemAt: rootURL) { resolved -> Result<URL, BoardWriteError> in
|
||||
do throws(BoardWriteError) {
|
||||
try BoardWriter.createBoard(at: resolved, title: trimmed.isEmpty ? nil : trimmed)
|
||||
return .success(resolved)
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
switch coordinated {
|
||||
case let .failure(failure):
|
||||
return .failure(.coordination(failure))
|
||||
case let .success(inner):
|
||||
return inner.mapError(BoardCreateFailure.write)
|
||||
}
|
||||
}.value
|
||||
|
||||
switch outcome {
|
||||
case let .success(url):
|
||||
lastError = nil
|
||||
refresh()
|
||||
return .success(url.standardizedFileURL)
|
||||
case let .failure(failure):
|
||||
lastError = failure.description
|
||||
Self.logger.error("board create failed: \(failure.description, privacy: .public)")
|
||||
return .failure(failure)
|
||||
}
|
||||
}
|
||||
|
||||
/// A free `<name>.kanban` under `documents`. Blocking (it stats), so it runs with the create.
|
||||
private nonisolated static func availableBoardURL(for title: String, in documents: URL) -> URL {
|
||||
let base = sanitizedFolderName(title)
|
||||
var candidate = documents.appendingPathComponent("\(base).kanban", isDirectory: true)
|
||||
var counter = 2
|
||||
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||
candidate = documents.appendingPathComponent("\(base) \(counter).kanban", isDirectory: true)
|
||||
counter += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// Path separators and colons out (the two characters a file name cannot survive), leading dots
|
||||
/// out (a board must not be hidden from its own index), length capped well under the 255-byte
|
||||
/// limit so the `.kanban` suffix and a collision counter always fit.
|
||||
private nonisolated static func sanitizedFolderName(_ title: String) -> String {
|
||||
let stripped = title
|
||||
.components(separatedBy: CharacterSet(charactersIn: "/:\\"))
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let unhidden = stripped.drop(while: { $0 == "." })
|
||||
let name = unhidden.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return "Board" }
|
||||
return String(name.prefix(120))
|
||||
}
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
/// The session for one board, minted on first ask and kept.
|
||||
///
|
||||
/// Returned unopened: a view calls `open()` when it appears, which is what starts the
|
||||
/// materialization sweep. Two callers asking for the same root get the same object, so a
|
||||
/// navigation stack that holds a board list and a lane screen shares one snapshot.
|
||||
func session(forBoardAt rootURL: URL) -> BoardSession {
|
||||
let key = rootURL.standardizedFileURL
|
||||
if let existing = sessions[key] { return existing }
|
||||
let session = BoardSession(rootURL: key)
|
||||
sessions[key] = session
|
||||
return session
|
||||
}
|
||||
|
||||
/// Drops a cached session and stops its retry timer. Call when a board's screen is gone for good;
|
||||
/// keeping it costs one snapshot's memory, which is why nothing calls this automatically.
|
||||
func forgetSession(forBoardAt rootURL: URL) {
|
||||
let key = rootURL.standardizedFileURL
|
||||
sessions[key]?.close()
|
||||
sessions[key] = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a board create did not happen.
|
||||
enum BoardCreateFailure: Error, Sendable, Equatable, CustomStringConvertible {
|
||||
/// No container — the create was asked for before the home resolved, or while it is unavailable.
|
||||
case noHome
|
||||
case coordination(CoordinationFailure)
|
||||
case write(BoardWriteError)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .noHome: "iCloud Drive is not available"
|
||||
case let .coordination(failure): failure.description
|
||||
case let .write(error): error.description
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user