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
+65
View File
@@ -0,0 +1,65 @@
import SwiftUI
import IndieBackup
import os
/// The iPhone app's entry point. Two tabs, per the mobile MVP charter: Boards (the whole
/// board lane card navigation stack) and Settings (backup/restore).
@main
struct MobileApp: App {
/// **The app's root model, owned here and nowhere else** the container home, the one
/// `NSMetadataQuery` the process runs, and the per-board session cache. `@State` because the
/// scene owns its lifetime; every screen reaches it through `.environment`, never by
/// constructing one.
@State private var boardIndex = BoardIndexStore()
/// Backup/restore (indie-backup skill), built once `boardIndex.home` resolves to a real
/// `CloudHome` see `MobileBackupConfiguration`'s doc comment for why this can't happen at
/// `@State` init time. Stays `nil` for the lifetime of a run with no iCloud account;
/// `SettingsTabView` renders the quiet disabled state for exactly that case.
@State private var backupController: BackupController?
private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "backup")
var body: some Scene {
WindowGroup {
RootTabView()
.environment(boardIndex)
.environment(\.backupController, backupController)
.task(id: boardIndex.home) {
guard backupController == nil, let home = boardIndex.home else { return }
backupController = BackupController(
configuration: MobileBackupConfiguration(root: home.documentsURL, index: boardIndex)
)
}
.onOpenURL { url in
// A backup file opened from Files, AirDrop, or a share sheet. Silently
// ignored before `backupController` exists there is no resolved cloud home
// to restore into either, at that point.
guard url.pathExtension == MobileBackupConfiguration.fileExtension,
let backupController
else { return }
Task {
do {
try await backupController.restoreBackup(from: url)
} catch {
Self.logger.error("restore from opened file failed: \(error.localizedDescription, privacy: .public)")
}
}
}
}
}
}
struct RootTabView: View {
var body: some View {
TabView {
Tab("Boards", systemImage: "rectangle.stack") {
BoardsTabView()
}
Tab("Settings", systemImage: "gearshape") {
SettingsTabView()
}
}
}
}
@@ -0,0 +1,68 @@
import Foundation
import IndieBackup
import SwiftUI
/// IndieBackup's app-specific wiring (indie-backup skill): what gets backed up, and how the
/// app's in-memory board list is rebuilt after a restore.
///
/// **Why this type is constructed rather than defaulted.** `BackupConfiguration.backupRoot` is a
/// non-optional `URL`, but the ubiquity container resolves asynchronously and can fail outright
/// (`CloudHomeResolver`, CloudHome.swift) there is no root to hand IndieBackup until
/// `BoardIndexStore.home` exists. Rather than answer with a placeholder path, this type is only
/// ever constructed once a real `CloudHome` is in hand (`MobileApp`'s `.task(id:)`); before that
/// there is no `BackupController` at all, and `SettingsTabView` renders a quiet disabled
/// explanation instead of a section built on a fabricated root.
final class MobileBackupConfiguration: BackupConfiguration {
/// No dot. Shared by the Info.plist document-type/UTI registration
/// (`dev.rzen.indie.kanban-backup`) and the archive filenames IndieBackup writes.
static let fileExtension = "kanbanbackup"
private let root: URL
private let index: BoardIndexStore
/// - Parameters:
/// - root: The resolved `CloudHome.documentsURL` the same `Documents/` folder every
/// `.kanban` board lives directly inside. IndieBackup walks it file-by-file (it has no
/// notion of "package"), so a board's directory structure round-trips through a backup
/// as an ordinary tree of files no directory registration or special-casing needed.
/// - index: Rescanned after a restore replaces the tree, in `rebuildCacheAfterRestore`.
init(root: URL, index: BoardIndexStore) {
self.root = root
self.index = index
}
var backupFileExtension: String { Self.fileExtension }
var backupDisplayName: String { "Lanework Backup" }
var backupRoot: URL { root }
/// `BoardIndexStore` caches nothing durable no Core Data, no SwiftData, no file on disk
/// only an in-memory `[BoardSummary]` scanned straight from the files under `backupRoot`. A
/// restore already replaced that tree by the time this runs, so the only rebuild step is
/// asking the store to rescan it; a `BoardSession` for a board that's open elsewhere reloads
/// its own content from disk independently the next time it's asked (BoardIndexStore's doc
/// comment on the observer flow).
///
/// `refresh()` only *starts* the rescan (it hands off to a detached `Task` and returns), so
/// this reports done as soon as the request lands, not once the boards tab has repainted
/// same as any other call to `refresh()` in this app (e.g. pull-to-refresh).
func rebuildCacheAfterRestore(progress: @escaping (Double, String) -> Void) async throws {
progress(0, "Rescanning boards…")
await index.refresh()
progress(1, "Rescanning boards…")
}
}
/// Threads the lazily-constructed `BackupController` from `MobileApp` down to `SettingsTabView`
/// (the backup section) and back up to `MobileApp`'s own `onOpenURL` handler (imported backup
/// files), without forcing `RootTabView` or anything else between them to know backup exists.
/// `nil` until the cloud home resolves; see `MobileBackupConfiguration`.
private struct BackupControllerKey: EnvironmentKey {
static let defaultValue: BackupController? = nil
}
extension EnvironmentValues {
var backupController: BackupController? {
get { self[BackupControllerKey.self] }
set { self[BackupControllerKey.self] = newValue }
}
}
+425
View File
@@ -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
}
}
}
+288
View File
@@ -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
}
}
}
+174
View File
@@ -0,0 +1,174 @@
import Foundation
/// What the board list renders for one `.kanban` package: enough to name it, size it and say whether
/// it is here yet and deliberately nothing more. A summary is never the input to anything that
/// edits; opening a board mints a `BoardSession`, which walks the package properly.
struct BoardSummary: Identifiable, Sendable, Equatable {
/// **The package root is the identity.** A board has no UUID folder name and no id key its
/// identity is where it is (`BoardModel.rootURL` says so), and on the phone that URL is stable
/// for as long as nobody renames the document in Files.app.
var id: URL { rootURL }
let rootURL: URL
/// The board `index.md`'s `title:`, falling back to the folder name minus `.kanban` which is
/// exactly the fallback 01-storage-format.md § Board naming states, and the reason a board with
/// no `title` key is a normal board rather than an untitled one.
let title: String
/// Lanes the loader would show. `nil` where the package is not materialized enough to count
/// see `BoardSummaryScanner` for why a number is withheld rather than guessed at zero.
let laneCount: Int?
/// Cards the loader would show, across every lane. Excludes `<root>/.trash/`. `nil` on the same
/// terms as `laneCount`.
let cardCount: Int?
/// The package's content-change date, as the metadata query reports it. `nil` under the DEBUG
/// local root, where it is read from the directory instead, and on any item that has none yet.
let modified: Date?
let download: BoardDownloadState
}
/// Whether a board's bytes are on this device the summary-level reading, from the metadata item's
/// own attributes.
///
/// **Coarse on purpose.** This drives one row's badge. The authoritative per-file answer, the one a
/// load actually depends on, is `PackageMaterialization.sweep` a package can report `.current` here
/// and still be missing a card's `index.md`, which is exactly why the session sweeps rather than
/// trusting this.
enum BoardDownloadState: Sendable, Equatable {
/// A local copy exists. Covers both `StatusCurrent` and `StatusDownloaded` (a local copy that
/// may be behind the cloud's) the distinction changes nothing the list can act on.
case current
/// Bytes are arriving. `fraction` is 01 where the daemon reports a percentage.
case downloading(fraction: Double?)
/// In the cloud, not here, nothing in flight. The index requests a download for every board in
/// this state, so it is a transient the list should render as such.
case notDownloaded
/// No metadata to read: the DEBUG local root, or an item whose attributes have not arrived.
case unknown
/// Whether the shallow content walk may touch this package's files at all.
var isReadable: Bool {
switch self {
case .current, .unknown: true
case .downloading, .notDownloaded: false
}
}
}
/// One `.kanban` package as the index found it, before its contents were looked at the `Sendable`
/// hand-off from the main-actor metadata read to the off-main scan.
struct BoardIndexEntry: Sendable, Equatable {
let rootURL: URL
let modified: Date?
let download: BoardDownloadState
}
/// Turns an index entry into a summary by looking, shallowly, at the package.
///
/// **A shallow walk, not a load.** `BoardLoader` parses every `index.md` in the tree; a board list
/// showing six boards cannot afford six of those on a phone. So this counts folders through the same
/// gate the loader counts them by UUID-shaped name (`IntegrityRules.isIdentityShaped`) holding an
/// `index.md` and reads exactly one file, the board's own `index.md`, for its title. A folder that
/// fails the gate is a stray the loader would ignore too, so the counts agree with what the board
/// window will show without paying for the agreement.
///
/// Two known and accepted divergences from a real load, both in the direction of over-counting by at
/// most a hair: a card whose `index.md` is present but malformed is counted here and would be a
/// fail-fast defect there, and a card carrying a legacy `deleted:` key is counted here and rides
/// along flagged there. Deciding either requires parsing the file, which is the cost this walk
/// exists to avoid.
///
/// **Uncoordinated, deliberately.** These are display reads that re-run on every metadata update; a
/// torn read costs a stale title for one refresh, while an `NSFileCoordinator` bracket per board
/// would put a daemon round-trip on the path of drawing a list. The session coordinates; the index
/// does not.
enum BoardSummaryScanner {
/// Blocking callers run it off the main actor.
nonisolated static func scan(_ entry: BoardIndexEntry) -> BoardSummary {
let fallbackTitle = entry.rootURL.deletingPathExtension().lastPathComponent
// Nothing on disk to read, and reading anyway risks a blocking materialization on whatever
// network the phone is on. The name is still known it is in the URL so the row is
// nameable while it downloads, and the next refresh fills in the rest.
guard entry.download.isReadable else {
return BoardSummary(
rootURL: entry.rootURL,
title: fallbackTitle,
laneCount: nil,
cardCount: nil,
modified: entry.modified,
download: entry.download
)
}
let indexURL = entry.rootURL.appendingPathComponent(IntegrityRules.indexFileName)
let title = readTitle(at: indexURL) ?? fallbackTitle
// An unreadable board `index.md` means this is not a board the loader would open a package
// still arriving, or one whose root file is genuinely broken. Either way a count would be a
// fiction, so none is offered.
guard FileManager.default.fileExists(atPath: indexURL.path) else {
return BoardSummary(
rootURL: entry.rootURL,
title: title,
laneCount: nil,
cardCount: nil,
modified: entry.modified,
download: entry.download
)
}
var lanes = 0
var cards = 0
for lane in itemFolders(in: entry.rootURL) {
lanes += 1
cards += itemFolders(in: lane).count
}
return BoardSummary(
rootURL: entry.rootURL,
title: title,
laneCount: lanes,
cardCount: cards,
modified: entry.modified,
download: entry.download
)
}
/// The board title as written, or `nil` where the file is absent, is not UTF-8, has no
/// frontmatter, or carries no usable `title:` every one of which is the folder-name fallback.
private nonisolated static func readTitle(at indexURL: URL) -> String? {
guard let data = try? Data(contentsOf: indexURL),
let text = String(data: data, encoding: .utf8),
let document = try? FrontmatterDocument.parse(text),
let title = document.title.value,
!title.isEmpty
else {
return nil
}
return title
}
/// Direct subfolders that are lanes or cards by the loader's own two gates, reached through the
/// loader's own enumeration (`BoardLoader.directoryCandidates`) so the two can never disagree
/// about what a candidate is hidden entries skipped, which is what keeps `<root>/.trash/` out
/// of every count here without a second rule.
private nonisolated static func itemFolders(in parent: URL) -> [URL] {
guard let candidates = try? BoardLoader.directoryCandidates(in: parent) else { return [] }
return candidates.filter { folder in
IntegrityRules.isIdentityShaped(folder.lastPathComponent)
&& FileManager.default.fileExists(
atPath: folder.appendingPathComponent(IntegrityRules.indexFileName).path
)
}
}
}
+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))
}
}
@@ -0,0 +1,88 @@
import Foundation
/// `NSFileCoordinator` brackets around the storage layer's own I/O.
///
/// **Required, not defensive.** `BoardLoader` and `BoardWriter` read and write with plain
/// `FileManager` calls, which is correct on the Mac where the app owns the folder outright. In a
/// ubiquity container the daemon is a second writer: it materializes, evicts and replaces items
/// underneath a walk with no warning. Coordination is the only thing that makes "the tree did not
/// move while I read it" true, and the only thing that tells the daemon not to push a remote version
/// into a folder mid-write.
///
/// The bracket wraps the **package root**, not each file inside it. A board is one document
/// (`LSTypeIsPackage`), so one coordination covers the whole walk which is also the only shape
/// that can hold a multi-file write (a move is two folders, a delete is a folder plus its trash
/// destination) as one unit.
///
/// **Every call blocks.** `coordinate` waits for the daemon and for other presenters, so these run
/// off the main actor without exception; the types here are `nonisolated` and stateless so they can.
enum CoordinatedFileAccess {
/// Runs `body` under a read intent on `url`, and answers what it returned.
///
/// `body` is handed the URL the coordinator resolved which may differ from `url` if the item
/// moved and must use it rather than closing over the original. It is deliberately
/// non-throwing: the storage layer's typed errors (`BoardLoadFailure`, `BoardWriteError`) are far
/// richer than anything this layer could wrap, so a caller returns its own `Result` from `body`
/// and the outer `Result` carries only the coordinator's own refusal.
static func read<T>(
itemAt url: URL,
options: NSFileCoordinator.ReadingOptions = [],
by body: (URL) -> T
) -> Result<T, CoordinationFailure> {
var captured: T?
var ran = false
var coordinatorError: NSError?
let coordinator = NSFileCoordinator(filePresenter: nil)
coordinator.coordinate(readingItemAt: url, options: options, error: &coordinatorError) { resolved in
ran = true
captured = body(resolved)
}
// `ran` rather than `captured != nil`: a `T` that is itself optional would otherwise read a
// legitimate nil result as "the block never ran".
guard ran, let captured else {
return .failure(CoordinationFailure(coordinatorError, fallback: "the coordinated read did not run"))
}
return .success(captured)
}
/// Runs `body` under a write intent on `url` the bracket every `BoardWriter` call on the phone
/// goes through. Same contract as `read(itemAt:options:by:)`.
static func write<T>(
itemAt url: URL,
options: NSFileCoordinator.WritingOptions = [],
by body: (URL) -> T
) -> Result<T, CoordinationFailure> {
var captured: T?
var ran = false
var coordinatorError: NSError?
let coordinator = NSFileCoordinator(filePresenter: nil)
coordinator.coordinate(writingItemAt: url, options: options, error: &coordinatorError) { resolved in
ran = true
captured = body(resolved)
}
guard ran, let captured else {
return .failure(CoordinationFailure(coordinatorError, fallback: "the coordinated write did not run"))
}
return .success(captured)
}
}
/// The coordinator refused a lock it could not take, an item it could not reach.
///
/// A flattened value rather than the `NSError` itself: this crosses from a detached task back to the
/// main actor, and three `Sendable` scalars carry everything a log line or an alert needs without
/// smuggling a reference type across the boundary.
struct CoordinationFailure: Error, Sendable, Equatable, CustomStringConvertible {
let domain: String
let code: Int
let message: String
init(_ error: NSError?, fallback: String) {
domain = error?.domain ?? "dev.rzen.indie.KanbanMobile.coordination"
code = error?.code ?? -1
message = error?.localizedDescription ?? fallback
}
var description: String { "\(message) (\(domain) \(code))" }
}
@@ -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 }
/// 01 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
}
}
+123
View File
@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Lanework</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Lanework</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026 rzen</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<!-- Document-based, so App Store Connect requires this (indie-backup skill ▸ Register the
Backup File Type) — the in-place editing the imported-backup `onOpenURL` handler
(MobileApp.swift) relies on. -->
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<!-- Same document-type + UTI declaration as the Mac app's Info.plist, and it must stay
byte-identical in meaning: the package conformance is what makes NSMetadataQuery and the
Files app treat a `.kanban` directory as one document instead of a folder of loose files.
The iOS app *exports* the type because it is a separate App Store product — on this
platform, it is the owner. -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Lanework Board</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSItemContentTypes</key>
<array>
<string>dev.rzen.indie.kanban-board</string>
</array>
<key>LSTypeIsPackage</key>
<true/>
</dict>
<!-- The backup archive IndieBackup writes/reads (indie-backup skill), so Files, AirDrop
and share sheets route a tapped `.kanbanbackup` file to this app's `onOpenURL`
instead of leaving it unopenable. -->
<dict>
<key>CFBundleTypeName</key>
<string>Lanework Backup</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>dev.rzen.indie.kanban-backup</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>com.apple.package</string>
<string>public.directory</string>
</array>
<key>UTTypeDescription</key>
<string>Lanework Board</string>
<key>UTTypeIdentifier</key>
<string>dev.rzen.indie.kanban-board</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<string>kanban</string>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Lanework Backup</string>
<key>UTTypeIdentifier</key>
<string>dev.rzen.indie.kanban-backup</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>kanbanbackup</string>
</array>
</dict>
</dict>
</array>
<!-- Publishes the container's Documents/ as a visible folder in iCloud Drive — on every
platform, including the Mac's Finder, which is how boards reach the Mac app before it
adopts the container natively (open panel into iCloud Drive ▸ Lanework). -->
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.dev.rzen.indie.Kanban</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerName</key>
<string>Lanework</string>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>Any</string>
</dict>
</dict>
</dict>
</plist>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The container is named after the *Mac* app's bundle id, deliberately: container ids are
not tied to bundle ids, and `iCloud.dev.rzen.indie.Kanban` is the name the Mac app can
adopt later without a data migration. The iOS app (dev.rzen.indie.KanbanMobile) is merely
the first tenant. -->
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Kanban</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Kanban</string>
</array>
</dict>
</plist>
+134
View File
@@ -0,0 +1,134 @@
import SwiftUI
/// The lane list for one board `BoardRoute.lanes`'s destination.
///
/// Holds only `boardRoot`, never a `BoardSummary`/`BoardModel` value: the session and its
/// snapshot are pulled from the environment's index store fresh on every body evaluation, so a
/// write from anywhere in the stack (a lane created, a card moved) reaches this screen the moment
/// the session's reload lands.
struct BoardScreen: View {
let boardRoot: URL
@Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewLane = false
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
var body: some View {
content
.navigationTitle(title)
.toolbar {
if case .ready = session.phase {
Button("New Lane", systemImage: "plus") {
isPresentingNewLane = true
}
}
}
.task { session.open() }
.onDisappear {
// Stops the materializing/downloading retry timer. In practice a no-op here: a
// lane row (the only thing on this screen that pushes forward) only renders in
// `.ready`, the one phase where `close()`'s `cancelRetry()` has nothing to cancel
// so pushing deeper into the board costs nothing, and popping back out to the
// board list is where this actually stops the timer.
session.close()
}
.titlePromptAlert("New Lane", isPresented: $isPresentingNewLane, placeholder: "Lane Title") { title in
let newTitle: String? = title.isEmpty ? nil : title
Task {
// The closure's throws type must be spelled out a trailing closure literal
// does not pick up `perform`'s `throws(BoardWriteError)` from context alone.
await session.perform { (root: URL) throws(BoardWriteError) -> ItemID in
try BoardWriter.createLane(inBoard: root, title: newTitle)
}
}
}
}
private var title: String {
// The same fallback `BoardSummaryScanner` uses the folder name minus `.kanban` so the
// title never blanks out while the session is still opening.
session.snapshot?.title.value ?? boardRoot.deletingPathExtension().lastPathComponent
}
@ViewBuilder
private var content: some View {
switch session.phase {
case .idle, .loading:
ProgressView("Opening board")
case let .materializing(progress):
ProgressView(value: progress.fractionMaterialized) {
Text("Downloading board")
}
.padding()
case let .failed(error):
ContentUnavailableView {
Label("Can't Open Board", systemImage: "exclamationmark.triangle")
} description: {
Text(error.description)
} actions: {
Button("Try Again") { session.reload() }
}
case .ready:
if let snapshot = session.snapshot {
laneList(snapshot)
} else {
// Unreachable in practice `.ready` is only ever set alongside a landed load,
// which sets `snapshot` in the same step (`BoardSession.apply`). Kept so the
// switch is exhaustive without a force-unwrap.
ProgressView("Opening board")
}
}
}
@ViewBuilder
private func laneList(_ snapshot: BoardModel) -> some View {
List {
if session.lastError != nil {
Section {
Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if snapshot.lanes.isEmpty {
ContentUnavailableView(
"No Lanes Yet",
systemImage: "rectangle.split.3x1",
description: Text("Add a lane to start organizing cards.")
)
} else {
// Already in display order (`BoardModel.lanes`'s own contract) the loader's
// `Ranks.sortedForDisplay` is trusted rather than re-sorted here.
ForEach(snapshot.lanes) { lane in
NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) {
LaneSummaryRow(lane: lane)
}
}
}
}
.refreshable { session.reload() }
}
}
/// One lane row: title, and its card count.
private struct LaneSummaryRow: View {
let lane: Lane
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle)
Text("\(lane.cards.count) card\(lane.cards.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var displayTitle: String {
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
return title
}
}
+113
View File
@@ -0,0 +1,113 @@
import SwiftUI
/// The root of the boards navigation stack: board list lanes cards card detail
/// (`BoardRoute`'s destinations).
///
/// Renders all three of `BoardIndexStore.Phase` the property the placeholder this replaces
/// existed to prove, and the split this screen keeps.
struct BoardsTabView: View {
@Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewBoard = false
var body: some View {
NavigationStack {
content
.navigationTitle("Boards")
.toolbar {
if case .ready = index.phase {
Button("New Board", systemImage: "plus") {
isPresentingNewBoard = true
}
}
}
.navigationDestination(for: BoardRoute.self) { route in
switch route {
case let .lanes(boardRoot):
BoardScreen(boardRoot: boardRoot)
case let .cards(boardRoot, laneID):
LaneScreen(boardRoot: boardRoot, laneID: laneID)
case let .card(boardRoot, laneID, cardID):
CardDetailScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID)
}
}
}
.task { index.start() }
.titlePromptAlert("New Board", isPresented: $isPresentingNewBoard, placeholder: "Board Title") { title in
Task { await index.createBoard(titled: title) }
}
}
@ViewBuilder
private var content: some View {
switch index.phase {
case .idle, .loading:
ProgressView("Looking for boards")
case let .unavailable(reason):
// The hard-iCloud-requirement wall. `reason` distinguishes "sign in" from "the container
// did not resolve", which are different asks of the user.
ContentUnavailableView {
Label("iCloud Required", systemImage: "icloud.slash")
} description: {
Text(reason == .noAccount
? "Sign into iCloud in Settings to use Lanework."
: "Lanework can't reach its iCloud Drive folder right now.")
} actions: {
Button("Try Again") { index.start() }
}
case .ready where index.boards.isEmpty:
ContentUnavailableView(
"No Boards Yet",
systemImage: "rectangle.stack",
description: Text("Boards in your iCloud Drive will appear here.")
)
case .ready:
List(index.boards) { board in
NavigationLink(value: BoardRoute.lanes(boardRoot: board.rootURL)) {
BoardSummaryRow(board: board)
}
}
.refreshable { index.refresh() }
}
}
}
/// One board row: title, lane/card counts, modified date, and while the package is not fully
/// current a download-state subtitle in place of the counts a shallow scan cannot yet answer.
private struct BoardSummaryRow: View {
let board: BoardSummary
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(board.title)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var subtitle: String {
switch board.download {
case .notDownloaded:
"Waiting to download"
case let .downloading(fraction):
fraction.map { "Downloading \(Int($0 * 100))%" } ?? "Downloading"
case .current, .unknown:
countsAndModified
}
}
private var countsAndModified: String {
let counts: String
if let lanes = board.laneCount, let cards = board.cardCount {
counts = "\(lanes) lane\(lanes == 1 ? "" : "s") · \(cards) card\(cards == 1 ? "" : "s")"
} else {
counts = ""
}
guard let modified = board.modified else { return counts }
return "\(counts) · \(modified.formatted(.relative(presentation: .named)))"
}
}
@@ -0,0 +1,175 @@
import SwiftUI
/// The card detail screen's Attributes section: icon, icon colour, and background colour the
/// three *typed* style fields a card carries (`FrontmatterFields.icon`/`.iconColor`/`.background`).
/// Every pick writes immediately through `BoardWriter.updateIndex` these are one-tap choices
/// from a fixed set, not free text, so there is no draft to debounce the way title/body have.
///
/// **There is no labels row.** `labels` is not a field either `BoardModel` or `FrontmatterDocument`
/// exposes as typed: `BoardModel.document`'s own doc comment names it as one of the reserved keys
/// that "ride along uninterpreted via `document.unknownFields`", alongside `assignees`, `due`,
/// `remote`. Rendering or editing it here would mean this screen parsing and rewriting a key the
/// model layer deliberately treats as opaque exactly the unknown-field promise `updateIndex`'s
/// surgical edits exist to keep (agent-written or hand-written overlays round-trip untouched). If
/// a typed `labels` field is ever added to the storage schema, its editor belongs in this section.
struct CardAttributesSection: View {
let card: Card
let laneID: ItemID
let cardID: ItemID
let session: BoardSession
@State private var isPresentingIconPicker = false
var body: some View {
Section("Attributes") {
Button {
isPresentingIconPicker = true
} label: {
LabeledContent("Icon") {
if let icon = card.icon.value, !icon.isEmpty {
Image(systemName: icon)
} else {
Text("None").foregroundStyle(.secondary)
}
}
}
.tint(.primary)
swatchRow(title: "Icon Color", swatches: CardPalette.foregrounds, current: card.iconColor.value) { name in
setStyle(FrontmatterKeys.iconColor, to: name)
}
swatchRow(title: "Background", swatches: CardPalette.backgrounds, current: card.background.value) { name in
setStyle(FrontmatterKeys.background, to: name)
}
}
.sheet(isPresented: $isPresentingIconPicker) {
IconPickerSheet(current: card.icon.value) { name in
setStyle(FrontmatterKeys.icon, to: name)
}
}
}
@ViewBuilder
private func swatchRow(
title: String,
swatches: [CardPalette.Swatch],
current: String?,
onSelect: @escaping (String?) -> Void
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.subheadline)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
SwatchButton(isSelected: current == nil, color: nil) { onSelect(nil) }
ForEach(swatches) { swatch in
SwatchButton(
isSelected: current == swatch.name,
color: CardPalette.color(named: swatch.name, in: swatches)
) {
onSelect(swatch.name)
}
}
}
}
}
.padding(.vertical, 4)
}
/// Writes one style key immediately. `operation: .style` is the vocabulary's own case for
/// "`updateIndex` on behalf of styling flows" (`WriteOperation.style`); `card.title.value` is
/// read before the closure runs so a failure banner can still name the card by the title on
/// screen.
private func setStyle(_ key: String, to value: String?) {
let laneID = self.laneID
let cardID = self.cardID
let cardTitle = card.title.value
Task {
// The closure's throws type must be spelled out a trailing closure literal does not
// pick up `perform`'s `throws(BoardWriteError)` from context alone.
await session.perform { (root: URL) throws(BoardWriteError) -> Void in
let folder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: cardTitle)) { document in
document.setStyleValue(value, for: key)
}
}
}
}
}
/// One colour well: a filled circle, a slashed placeholder for "None", and a selection ring.
private struct SwatchButton: View {
let isSelected: Bool
let color: Color?
let action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.fill(color ?? Color(.systemGray5))
.frame(width: 28, height: 28)
.overlay {
if color == nil {
Image(systemName: "slash.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.overlay {
Circle()
.strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)
.padding(-3)
}
}
.buttonStyle(.plain)
}
}
/// The icon picker's grid `CardPalette.icons` plus a "None" well that removes the key.
private struct IconPickerSheet: View {
let current: String?
let onSelect: (String?) -> Void
@Environment(\.dismiss) private var dismiss
private let columns = Array(repeating: GridItem(.flexible()), count: 6)
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
Button {
onSelect(nil)
dismiss()
} label: {
Image(systemName: "slash.circle")
.font(.title2)
.foregroundStyle(current == nil ? Color.accentColor : .secondary)
}
ForEach(CardPalette.icons, id: \.self) { name in
Button {
onSelect(name)
dismiss()
} label: {
Image(systemName: name)
.font(.title2)
.foregroundStyle(current == name ? Color.accentColor : .primary)
}
}
}
.padding()
}
.navigationTitle("Icon")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
+157
View File
@@ -0,0 +1,157 @@
import SwiftUI
/// The card editor title, body, and attributes `BoardRoute.card`'s destination.
///
/// Holds board-relative IDs only, never a `Card` value: every body evaluation re-reads the card
/// from `session.snapshot`, so a write this screen makes (or one that lands from elsewhere while
/// it's open) is reflected the moment the session's reload lands, and a card trashed on another
/// device is noticed rather than edited into thin air.
///
/// ### Save timing
///
/// Title and body are edited into local `@State` drafts and committed through `BoardWriter` only
/// when they differ from the snapshot's own value never on every keystroke. Three triggers cover
/// every way editing can end without dropping a change: the field's own submit (title, on Return),
/// `onDisappear` (the user navigates back), and `scenePhase` leaving `.active` (the user backgrounds
/// the app, or is interrupted, mid-edit in the body editor). A `Done` toolbar button folds in the
/// same commit and drops focus, for a user who wants an explicit "I'm finished" without leaving the
/// screen. All four funnel through `commitAll()`, so there is exactly one place that decides what
/// "changed" means for each field.
struct CardDetailScreen: View {
let boardRoot: URL
let laneID: ItemID
let cardID: ItemID
@Environment(BoardIndexStore.self) private var index
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@State private var titleDraft = ""
@State private var bodyDraft = ""
@FocusState private var isBodyFocused: Bool
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
private var card: Card? {
session.snapshot?.lanes.first { $0.id == laneID }?.cards.first { $0.id == cardID }
}
var body: some View {
content
.navigationTitle("Card")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
isBodyFocused = false
commitAll()
}
}
}
.task { session.open() }
// Seeds the drafts once per card. `.task(id:)` re-runs only when `card?.id` changes
// nil while the session is still opening, then the card's own id once it lands so a
// reload that lands *while this screen is open* (including the reload this screen's
// own commit triggers) never clobbers text the user is mid-typing.
.task(id: card?.id) {
guard let card else { return }
titleDraft = card.title.value ?? ""
bodyDraft = card.body
}
.onDisappear { commitAll() }
.onChange(of: scenePhase) { _, newPhase in
if newPhase != .active { commitAll() }
}
}
@ViewBuilder
private var content: some View {
if let card {
Form {
if session.lastError != nil {
Section {
Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Title") {
TextField("Untitled Card", text: $titleDraft)
.onSubmit { commitTitle(against: card) }
}
Section("Body") {
TextEditor(text: $bodyDraft)
.frame(minHeight: 200)
.focused($isBodyFocused)
}
CardAttributesSection(card: card, laneID: laneID, cardID: cardID, session: session)
Section("Details") {
LabeledContent("Created", value: card.created.value.map(Self.formatted) ?? "")
LabeledContent("Modified", value: card.modified.value.map(Self.formatted) ?? "")
}
}
} else if case .ready = session.phase {
ContentUnavailableView("Card Removed", systemImage: "trash", description: Text("This card was deleted."))
.task { dismiss() }
} else {
ProgressView("Opening card")
}
}
private func commitAll() {
guard let card else { return }
commitTitle(against: card)
commitBody(against: card)
}
private func commitTitle(against card: Card) {
let trimmed = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed != (card.title.value ?? "") else { return }
let laneID = self.laneID
let cardID = self.cardID
let newValue: String? = trimmed.isEmpty ? nil : trimmed
Task {
// The closure's throws type must be spelled out a trailing closure literal does not
// pick up `perform`'s `throws(BoardWriteError)` from context alone.
await session.perform { (root: URL) throws(BoardWriteError) -> Void in
let folder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
// The Mac's own rename path, verbatim (`BoardStore.setTitle`): plain `set`/`remove`
// `setStyleValue` is the style gesture's helper, not the title's and
// `.rename(title: nil)` so `updateIndex` enriches the operation from the document
// it reads rather than trusting a snapshot that may have aged.
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newValue {
document.set(FrontmatterKeys.title, to: .string(newValue))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
}
}
private func commitBody(against card: Card) {
guard bodyDraft != card.body else { return }
let laneID = self.laneID
let cardID = self.cardID
let newBody = bodyDraft
Task {
await session.perform { (root: URL) throws(BoardWriteError) -> Bool in
let folder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
return try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
}
}
}
private static func formatted(_ date: Date) -> String {
date.formatted(date: .abbreviated, time: .shortened)
}
}
+205
View File
@@ -0,0 +1,205 @@
import SwiftUI
/// The card list for one lane `BoardRoute.cards`'s destination.
///
/// Holds `boardRoot` and `laneID`, never a `Lane` value: the lane is re-read from
/// `session.snapshot` on every body evaluation, so a write this screen makes or one relayed
/// from elsewhere through the metadata query reaches the list the moment the session's reload
/// lands, and a lane deleted on another device is noticed rather than shown stale.
struct LaneScreen: View {
let boardRoot: URL
let laneID: ItemID
@Environment(BoardIndexStore.self) private var index
@Environment(\.dismiss) private var dismiss
@State private var isPresentingNewCard = false
@State private var cardPendingMove: ItemID?
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
private var lane: Lane? {
session.snapshot?.lanes.first { $0.id == laneID }
}
var body: some View {
content
.navigationTitle(navigationTitle)
.toolbar {
if lane != nil {
Button("New Card", systemImage: "plus") {
isPresentingNewCard = true
}
}
}
.task { session.open() }
.titlePromptAlert("New Card", isPresented: $isPresentingNewCard, placeholder: "Card Title") { title in
createCard(titled: title.isEmpty ? nil : title)
}
.confirmationDialog(
"Move Card",
isPresented: Binding(get: { cardPendingMove != nil }, set: { if !$0 { cardPendingMove = nil } }),
titleVisibility: .visible
) {
ForEach(otherLanes) { destination in
Button(displayTitle(of: destination)) {
if let cardID = cardPendingMove {
move(cardID, to: destination.id)
}
cardPendingMove = nil
}
}
Button("Cancel", role: .cancel) { cardPendingMove = nil }
}
}
private var navigationTitle: String {
lane.map(displayTitle(of:)) ?? "Lane"
}
private var otherLanes: [Lane] {
(session.snapshot?.lanes ?? []).filter { $0.id != laneID }
}
private func displayTitle(of lane: Lane) -> String {
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
return title
}
@ViewBuilder
private var content: some View {
if let lane {
List {
if session.lastError != nil {
Section {
Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if lane.cards.isEmpty {
ContentUnavailableView(
"No Cards Yet",
systemImage: "rectangle.on.rectangle",
description: Text("Add a card to this lane.")
)
} else {
// Already in display order (`Lane.cards`'s own contract) trusted rather than
// re-sorted here, exactly as the lane list trusts `BoardModel.lanes`.
ForEach(lane.cards) { card in
NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) {
CardSummaryRow(card: card)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
trash(card.id)
} label: {
Label("Trash", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
if !otherLanes.isEmpty {
Button {
cardPendingMove = card.id
} label: {
Label("Move", systemImage: "arrow.right.arrow.left")
}
.tint(.blue)
}
}
}
}
}
.refreshable { session.reload() }
} else if case .ready = session.phase {
// The lane is gone from a `.ready` snapshot deleted elsewhere while this screen was
// open. Nothing left to list; back out rather than leave a dead screen on top of the
// stack.
ContentUnavailableView("Lane Removed", systemImage: "trash", description: Text("This lane was deleted."))
.task { dismiss() }
} else {
ProgressView("Opening lane")
}
}
private func createCard(titled title: String?) {
let laneID = self.laneID
Task {
// The closure's throws type must be spelled out a trailing closure literal does not
// pick up `perform`'s `throws(BoardWriteError)` from context alone.
await session.perform { (root: URL) throws(BoardWriteError) -> ItemID in
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
return try BoardWriter.createCard(inLane: laneFolder, title: title)
}
}
}
private func trash(_ cardID: ItemID) {
let laneID = self.laneID
Task {
await session.perform { (root: URL) throws(BoardWriteError) -> ItemID in
let cardFolder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
return try BoardWriter.deleteCardToTrash(at: cardFolder, inBoard: root)
}
}
}
private func move(_ cardID: ItemID, to destinationLaneID: ItemID) {
let laneID = self.laneID
Task {
await session.perform { (root: URL) throws(BoardWriteError) -> MoveResult in
let sourceFolder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
let destinationParent = root.appendingPathComponent(destinationLaneID.rawValue, isDirectory: true)
// `order: nil` append at the end of the destination lane's visible cards, the
// same placement a hand-created card gets.
return try BoardWriter.moveItem(
at: sourceFolder,
toParent: destinationParent,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: nil
)
}
}
}
}
/// One card row: title, an attachment-count hint, and the card's own icon when it has one.
///
/// **No label chips.** `labels` is not a field `BoardModel`/`FrontmatterFields` expose see
/// `CardAttributesSection`'s doc comment for why so `attachments` (a field the model already
/// carries cheaply) stands in as the row's "cheap material" instead.
private struct CardSummaryRow: View {
let card: Card
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle)
if !card.attachments.isEmpty {
Label("\(card.attachments.count)", systemImage: "paperclip")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if let icon = card.icon.value, !icon.isEmpty {
Image(systemName: icon)
.foregroundStyle(iconTint)
}
}
}
private var displayTitle: String {
guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" }
return title
}
private var iconTint: Color {
card.iconColor.value.flatMap { CardPalette.color(named: $0, in: CardPalette.foregrounds) } ?? .secondary
}
}
@@ -0,0 +1,54 @@
import SwiftUI
import IndieBackup
/// App version, and backup/restore (indie-backup skill).
struct SettingsTabView: View {
@Environment(BoardIndexStore.self) private var index
@Environment(\.backupController) private var backupController
var body: some View {
NavigationStack {
Form {
Section {
LabeledContent("Version") {
Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")
}
}
backupSection
}
.navigationTitle("Settings")
}
// Idempotent (BoardIndexStore.start()), and harmless if BoardsTabView already called it
// this tab can be the first one the user opens, and `backupController` only ever appears
// once `index.home` resolves.
.task { index.start() }
}
/// `backupController` is `nil` until `MobileApp` has a resolved `CloudHome` to build one from
/// which is also exactly the condition under which there is a `Documents/` folder to back
/// up. Everything short of that gets the same quiet explanation rather than a section that
/// looks broken or, worse, a crash on a nil root.
@ViewBuilder
private var backupSection: some View {
if let backupController {
BackupsSectionView(controller: backupController)
} else {
Section {
switch index.phase {
case .idle, .loading:
LabeledContent("Backup") {
ProgressView()
}
case .unavailable, .ready:
// `.ready` can appear briefly here too: `backupController` lags one runloop
// turn behind `index.home` resolving (MobileApp's `.task(id:)`).
Label("Backup and restore need iCloud Drive.", systemImage: "icloud.slash")
.foregroundStyle(.secondary)
}
} footer: {
Text("Sign into iCloud in Settings, then come back here to back up your boards.")
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import Foundation
/// The boards navigation stack's value-based routes: board list lanes cards card detail.
///
/// **Board-relative IDs only, never a model value.** Every screen this drives re-reads its subject
/// from `session.snapshot` on each body evaluation (see `BoardScreen`/`LaneScreen`/
/// `CardDetailScreen`), so a route only has to say *where* to look, not *what was there* a write
/// that lands while a screen is on top of the stack is picked up automatically, and a route never
/// goes stale the way a captured `Lane`/`Card` value would.
enum BoardRoute: Hashable {
/// The lane list for the board at `boardRoot`.
case lanes(boardRoot: URL)
/// The card list for one lane.
case cards(boardRoot: URL, laneID: ItemID)
/// The editor for one card.
case card(boardRoot: URL, laneID: ItemID, cardID: ItemID)
}
+98
View File
@@ -0,0 +1,98 @@
import SwiftUI
/// The colour and icon vocabulary `icon`, `iconColor`, and `background` are written in.
///
/// **Mirrors `Kanban/UI/Palette.swift` and `SymbolPickerCatalog`, not shared with them.**
/// `project.yml` excludes `Kanban/UI` from the phone target outright it is AppKit-only so this
/// is an independent copy of the same namehex tables and the same curated symbol grid, kept as
/// pure data (no `NSColor`, no `AppKit`). The two pickers are free to diverge; nothing here reads
/// the Mac's file or vice versa.
enum CardPalette {
struct Swatch: Identifiable, Sendable {
/// Kebab-case, exactly as written to frontmatter (`iconColor`/`background`'s `color`
/// subkey read this way see `FrontmatterFields.swift`).
let name: String
let hex: String
var id: String { name }
}
/// `iconColor`'s twelve wells.
static let foregrounds: [Swatch] = [
Swatch(name: "obsidian", hex: "#000000"),
Swatch(name: "aluminum", hex: "#9B9B9B"),
Swatch(name: "soapstone", hex: "#D5D5D5"),
Swatch(name: "chalk", hex: "#FFFFFF"),
Swatch(name: "carnation", hex: "#FF576C"),
Swatch(name: "rich-grapefruit", hex: "#FF864C"),
Swatch(name: "smokey-tangerine", hex: "#E5A334"),
Swatch(name: "fern", hex: "#50B23D"),
Swatch(name: "light-teal", hex: "#00B7B7"),
Swatch(name: "deep-sky-blue", hex: "#0084E5"),
Swatch(name: "pale-violet", hex: "#8C59C5"),
Swatch(name: "deep-cool-granite", hex: "#597199"),
]
/// `background`'s twelve wells written into the mapping's `color` subkey
/// (`FrontmatterDocument.setStyleValue`).
static let backgrounds: [Swatch] = [
Swatch(name: "obsidian", hex: "#000000"),
Swatch(name: "shale", hex: "#5B5B5B"),
Swatch(name: "aluminum", hex: "#9B9B9B"),
Swatch(name: "chalk", hex: "#FFFFFF"),
Swatch(name: "light-cayenne", hex: "#B6071E"),
Swatch(name: "light-mocha", hex: "#B73C14"),
Swatch(name: "smokey-mocha", hex: "#674611"),
Swatch(name: "smokey-fern", hex: "#145312"),
Swatch(name: "dark-teal", hex: "#005152"),
Swatch(name: "smokey-ocean", hex: "#003168"),
Swatch(name: "smokey-rich-eggplant", hex: "#290659"),
Swatch(name: "intense-cool-shale", hex: "#1F2E45"),
]
/// A general "boards and projects" grid `SymbolPickerCatalog.defaultSet`, mirrored for the
/// same reason as the colour tables above.
static let icons: [String] = [
"star", "flag", "heart", "bolt", "flame", "leaf", "drop", "sun.max", "moon", "sparkles",
"tag", "bookmark", "pin", "bell", "paperplane", "tray", "folder", "archivebox", "doc.text",
"list.bullet", "checklist", "calendar", "clock", "hammer", "wrench.and.screwdriver",
"paintbrush", "lightbulb", "brain", "book", "graduationcap", "briefcase", "cart", "house",
"airplane", "gamecontroller", "globe",
]
/// Resolves a stored palette name to a colour, for rendering a well or a row accent.
///
/// **Lenient, like the field itself** (`Kanban/UI/Palette.swift`'s own framing): an
/// unrecognized name a custom hex the field allows but this picker does not offer, a typo, a
/// name from some future palette answers `nil`, and every caller here falls back to its own
/// default rather than guessing at a colour. Nothing on disk is affected either way.
static func color(named name: String, in swatches: [Swatch]) -> Color? {
guard let swatch = swatches.first(where: { $0.name == name }) else { return nil }
return Color(paletteHex: swatch.hex)
}
}
private extension Color {
/// `#RRGGBB` or `#RRGGBBAA` the two forms `CardPalette.Swatch.hex` uses. Malformed input
/// answers `nil` rather than a guessed colour, matching `CardPalette.color(named:in:)`'s own
/// contract.
init?(paletteHex hex: String) {
var value = hex
if value.hasPrefix("#") { value.removeFirst() }
guard value.count == 6 || value.count == 8, let intValue = UInt64(value, radix: 16) else { return nil }
let r, g, b, a: Double
if value.count == 8 {
r = Double((intValue >> 24) & 0xFF) / 255
g = Double((intValue >> 16) & 0xFF) / 255
b = Double((intValue >> 8) & 0xFF) / 255
a = Double(intValue & 0xFF) / 255
} else {
r = Double((intValue >> 16) & 0xFF) / 255
g = Double((intValue >> 8) & 0xFF) / 255
b = Double(intValue & 0xFF) / 255
a = 1
}
self.init(red: r, green: g, blue: b, opacity: a)
}
}
+49
View File
@@ -0,0 +1,49 @@
import SwiftUI
/// A single-`TextField` alert the "name a new thing" prompt shared by New Board, New Lane, and
/// New Card, so each toolbar button does not grow its own `@State` pair and `.alert` block.
private struct TitlePromptAlert: ViewModifier {
@Binding var isPresented: Bool
let titleText: String
let message: String?
let placeholder: String
let onSubmit: (String) -> Void
@State private var text = ""
func body(content: Content) -> some View {
content.alert(titleText, isPresented: $isPresented) {
TextField(placeholder, text: $text)
Button("Create") {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
text = ""
onSubmit(trimmed)
}
Button("Cancel", role: .cancel) { text = "" }
} message: {
if let message { Text(message) }
}
}
}
extension View {
/// Presents a title-only creation prompt. `onSubmit` receives the trimmed text, which may be
/// empty every create call this feeds (`BoardIndexStore.createBoard`, `BoardWriter.createLane`,
/// `BoardWriter.createCard`) already treats an empty/whitespace title as "no title" and falls
/// back to the untitled placeholder, exactly as a hand-emptied field would.
func titlePromptAlert(
_ titleText: String,
isPresented: Binding<Bool>,
message: String? = nil,
placeholder: String = "Title",
onSubmit: @escaping (String) -> Void
) -> some View {
modifier(TitlePromptAlert(
isPresented: isPresented,
titleText: titleText,
message: message,
placeholder: placeholder,
onSubmit: onSubmit
))
}
}