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
+21
View File
@@ -330,11 +330,17 @@ public final class EchoLedger: Sendable {
/// Nothing is retired here, which is what makes this safe to call on every bracket: the
/// announcer's consumption still decides what speaks, and the committer's copy still decides what
/// each commit is authored by.
// The harvest speaks `HarvestedReceipt` committer-side vocabulary from
// Git/CommitAttribution.swift, which the phone target doesn't compile (the mobile MVP has
// no git). The recording side above is what BoardWriter needs on every platform; this copy
// has exactly one caller and it is Mac-only.
#if os(macOS)
func outstandingEntries() -> [String: HarvestedReceipt] {
receipts.withLock { store in
store.mapValues { HarvestedReceipt(receipt: $0.receipt, isHeal: $0.isHeal) }
}
}
#endif
/// Whether the receipt at this path is heal-marked `false` for a path with no receipt at all,
/// which is the same shrug every other read here gives an unknown path.
@@ -380,6 +386,11 @@ public final class EchoLedger: Sendable {
/// spelling `CommentPath.classify` reads. The two are handed in together rather than derived
/// from each other because the store already holds both (`BoardStore.commentSubject`).
/// - Returns: the identities of the posted and just-deleted comments the app wrote.
// Mac-only like the harvest above, and for the same shape of reason: the classification
// here speaks `CommentPath` (LiveStore's comment-thread vocabulary), and its one caller is
// the Mac store's comment reload. The phone's writers stamp comment receipts identically;
// nothing on the phone retires them yet.
#if os(macOS)
func vouchedComments(inCard cardFolder: URL, cardPath: String) -> Set<ItemID> {
let root = Self.key(cardFolder)
let paths = receiptPaths(under: root)
@@ -408,6 +419,7 @@ public final class EchoLedger: Sendable {
}
return vouched
}
#endif
/// Every path the ledger holds a receipt for strictly *below* `folder`.
///
@@ -494,6 +506,13 @@ extension EchoLedger.Receipt {
// MARK: - The reload's verdicts
// Consumer-side machinery, Mac-only by compilation: everything below speaks `BoardDiff` and
// `ItemPath` the landing reload's vocabulary, which the phone target doesn't compile. The
// phone records receipts through the surface above (BoardWriter stamps identically on every
// platform); its metadata-query observer has no verdict surface yet, and when it grows one this
// gate is the seam it lands behind.
#if os(macOS)
/// One landing reload's provenance answers, in the two shapes the announcer needs.
public struct EchoVerdicts: Sendable, Equatable {
@@ -700,3 +719,5 @@ extension EchoLedger {
return footprints
}
}
#endif
+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
))
}
}
+11 -1
View File
@@ -68,6 +68,8 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Branches and commit identity** — the board popover's git section is where a git board's branches live: the current branch is itself the picker, and beside the local branches it offers create-and-switch, which starts the new branch at the commit you are on. **Switching is never silent.** If any card window is holding unsaved keystrokes, an open Edit session whose crash-safe saves no commit has yet, or an open raw-source buffer, the switch stops and asks — **Save All**, **Discard**, or **Cancel** — for *every* open window, not just the ones the checkout would touch, because an unapplied raw buffer would otherwise write the whole pre-switch file onto the new branch's card later on. Save All ends each session with its normal commit and applies each raw buffer; a buffer that won't validate cancels the whole switch and puts you in front of the window that refused, nothing half-switched. Discard puts both the buffers and their uncommitted on-disk saves back to the last commit. With the sessions settled the pending auto-commit flushes onto the branch you are **leaving**, so the checkout runs on a genuinely settled tree and cannot fail dirty — and the checkout itself is git's *safe* one, never a force: work the app somehow didn't settle refuses the switch rather than being overwritten. The switch is bracketed like every wholesale operation — the watcher suspended, one full reload at the end, an in-progress row saying "Switching to 'main'…" — and if that final reload fails, the board locks read-only until a reload succeeds rather than letting you edit a snapshot of the branch you just left. Undo and redo do not survive the switch: the stack is discarded and reseeded from the new branch's own history, with redo empty, because replaying a restore from the old branch onto the new one would be wrong. When another program holds the repository's index, the switch waits and retries quietly, the row changing to say it is waiting for another writer's git lock and eventually naming the lock file — never a dialog, and the lock is never removed, because it isn't the app's. **An interrupted switch cleans up after itself**: before touching the repository the app writes down what it is about to do — in its own state, never in your board and never inside `.git` — so a crash or an unplugged volume mid-switch is recognized at the next open, rolled back to the state you were in, and announced ("A branch switch was interrupted — the previous state is restored"); an unfinished merge or rebase that *isn't* the app's is still left strictly alone. Beneath the branches sit the **commit-identity** fields: what you type there is written to the repository's own `.git/config`, so the setting is the file — portable to any git client, per board, and what the next commit is signed with. Leave a field empty and it shows the derived default (your account name, and `you@yourmac`) as a placeholder rather than filling it in, because a value the app wrote there would quietly outrank your own global git config for that board. A config edited from outside while the popover is open refreshes the fields you aren't typing in and leaves the one you are alone.
- **A card's History** — the card window's sidebar gains a read-only **History** section on git boards: every commit that touched that card, newest first, each row its own semantic subject over a relative date and the author who made it — so an agent's work and your own read as one story ("Move card 'Fix login' to Doing · 2 days ago · Claude"). It follows the card by identity rather than by path, so moving between lanes — or into the trash and back — keeps one continuous trail. Read-only in this version: restoring a single old version stays a git-client job. The section is simply absent on boards without app-managed git — no placeholder, no greyed-out promise.
- **Lanework for iPhone (in development)** — a companion iOS app, second product rather than second edition (`dev.rzen.indie.KanbanMobile`), whose boards are the same `.kanban` folders in an iCloud Drive container (`iCloud.dev.rzen.indie.Kanban` — a folder the Mac's Finder shows as "Lanework", so the Mac app opens the same boards through the ordinary open panel today and can adopt the container natively later with no migration). It compiles the Mac app's storage engine *verbatim* — same loader, same writer, same byte-fidelity contract, so the two apps cannot drift on the format — and drives it from an `NSMetadataQuery` observer instead of FSEvents. The MVP surface: a Boards tab (boards → lanes → cards → card editor, with per-item download/materialization states honestly rendered), swipe actions to move a card between lanes or send it to `.trash/`, a card editor for title, body, and the typed style attributes (icon, icon color, background), and a Settings tab with local backup/restore of the whole container (IndieBackup). No git on the phone — the change signal is the container itself.
## Development
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:
@@ -86,7 +88,15 @@ xcodebuild build-for-testing -project Kanban.xcodeproj -scheme Kanban -destinati
macOS 26+, Swift 6 (strict concurrency), SwiftUI, sandboxed. Internal codename `Kanban` (target, scheme, bundle id `dev.rzen.indie.Kanban`); the app ships under the display name **Lanework**.
**One app target** (DESIGN/12-editions.md ▸ The target). `project.yml` declares exactly three: the app `Kanban`, the unit bundle `KanbanTests` hosted by it, and the UI bundle `KanbanUITests`; one scheme, `Kanban`, builds and tests all of them. The 2026-07-27 two-target edition split — a second `KanbanPro` application, its `LaneworkPro` scheme, a `KanbanProTests` bundle bound to it by `-module-alias`, and `scripts/verify-editions.sh` — was retired on 2026-07-30 in favour of one app with Lanework Pro as a subscription inside it. The `dev.rzen.indie.kanban-board` UTI and the three pasteboard types are exported once, by the one app that owns them.
The iPhone app builds from the same project — `KanbanMobile` (iOS 26+, iPhone-only for now), sharing `Kanban/Storage` as source files:
```sh
xcodebuild build -project Kanban.xcodeproj -scheme KanbanMobile -destination 'generic/platform=iOS Simulator'
```
Simulator work without an iCloud account: launch with `LANEWORK_LOCAL_ROOT=<path>` in the environment (DEBUG builds only) and the app uses that folder in place of the ubiquity container.
**One *Mac* app target** (DESIGN/12-editions.md ▸ The target). `project.yml` declares the app `Kanban`, the unit bundle `KanbanTests` hosted by it, the UI bundle `KanbanUITests`, and the iPhone app `KanbanMobile` — a separate product, not a second Mac edition; one scheme per app. The 2026-07-27 two-target edition split — a second `KanbanPro` application, its `LaneworkPro` scheme, a `KanbanProTests` bundle bound to it by `-module-alias`, and `scripts/verify-editions.sh` — was retired on 2026-07-30 in favour of one app with Lanework Pro as a subscription inside it. The `dev.rzen.indie.kanban-board` UTI and the three pasteboard types are exported once, by the one app that owns them.
**StoreKit runs locally in development.** `Configuration.storekit` at the repo root declares the "Lanework Pro" subscription group and its monthly product (`dev.rzen.indie.kanban.pro.monthly`), and `project.yml` attaches it to the scheme's *run* action, so a ⌘R build shows a real price and completes a real purchase with no App Store Connect product and no sandbox account. It is a run-action setting only — builds, tests and `scripts/release.sh` are untouched, and the file is `buildPhase: none`, so it never reaches the shipped bundle. The same three strings live in App Store Connect (created by hand — RELEASE.md) and in `Kanban/Tier/ProProducts.swift`; a mismatch shows up in the app as "Can't reach the App Store right now."
+67
View File
@@ -3,6 +3,7 @@ options:
bundleIdPrefix: dev.rzen.indie
deploymentTarget:
macOS: "26.0"
iOS: "26.0"
xcodeVersion: "26.0"
defaultConfig: Debug
@@ -51,6 +52,15 @@ packages:
libgit2:
url: https://github.com/ibrahimcetin/libgit2.git
exactVersion: 1.9.2
# Local backup/restore for the phone app's Settings tab (indie-backup skill). `KanbanMobile`
# only — see that target's dependencies below; the Mac app has no Settings surface for this
# yet. Pinned to `from: "2.0.0"`, the tag the persistence-agnostic API (a single `backupRoot`
# plus a rebuild-cache-on-restore callback — here, `BoardIndexStore.refresh()`, no database in
# the loop) shipped under; 1.0.x resolves to the older iCloud-required, CloudKit-coupled
# surface this app doesn't want.
IndieBackup:
url: https://git.rzen.dev/rzen/indie-backup.git
from: "2.0.0"
settings:
base:
@@ -143,6 +153,63 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# MARK: - Lanework for iPhone
#
# **The mobile app is a second product, not a second edition** (distinct bundle id, ruled
# 2026-08-07): `dev.rzen.indie.KanbanMobile`, its own App Store record, iPhone-only for the MVP
# (iPad joins by widening TARGETED_DEVICE_FAMILY, not by a new target). It compiles
# `Kanban/Storage` **verbatim — the same source files, not a copy and not a package** — so the
# two apps cannot drift on the format contract: a storage change that breaks the phone breaks
# this project's build, in this repo, on the day it's made.
#
# What it deliberately does NOT compile: LiveStore (FSEvents — macOS-only machinery; the phone's
# change signal is NSMetadataQuery over the ubiquity container), Git/History (no git support in
# the mobile MVP), Tier, and all of `Kanban/UI`. The mobile app's own sources live under
# `KanbanMobile/`.
#
# Boards live in the iCloud Drive container `iCloud.dev.rzen.indie.Kanban` — named after the
# *Mac* app's bundle id because container ids are not bundle ids, and that name is the one the
# Mac app can adopt later without migrating anyone's data (see KanbanMobile.entitlements).
KanbanMobile:
type: application
platform: iOS
sources:
- KanbanMobile
# The shared storage engine — frontmatter, loader, writer, ranks, integrity. Pure
# Foundation + Yams + swift-markdown; no AppKit anywhere in the directory, which is what
# makes this line possible. One exclusion: AgentGuide reaches up into LiveStore's
# EchoLedger (its bracketed-write seam), and the AGENTS.md guide it writes is a Mac
# feature — agents work where the Mac app runs.
- path: Kanban/Storage
excludes:
- AgentGuide.swift
# The writer layer's one upward dependency: BoardWriter stamps every write into the
# task-local receipt ledger so a store can tell its own echoes from foreign changes. The
# recording side is pure CryptoKit + Foundation + Synchronization and compiles here; the
# ledger's *consumer* surfaces — verdicts (BoardDiff), harvest (HarvestedReceipt), comment
# retirement (CommentPath) — are the Mac watcher/committer's vocabulary and sit behind
# `#if os(macOS)` gates inside the file. On the phone the "watcher" is the metadata-query
# observer; when it grows a verdict surface, those gates are the seam it lands behind.
- path: Kanban/LiveStore/EchoLedger.swift
dependencies:
- package: Yams
# BodyMarkup (Kanban/Storage) imports Markdown, so the dependency rides along even though
# the MVP has no preview surface yet.
- package: swift-markdown
product: Markdown
- package: IndieBackup
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.KanbanMobile
MARKETING_VERSION: "1.0"
INFOPLIST_FILE: KanbanMobile/Info.plist
CODE_SIGN_ENTITLEMENTS: KanbanMobile/KanbanMobile.entitlements
GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete
# iPhone only. The iPad milestone widens this to "1,2" rather than adding a target.
TARGETED_DEVICE_FAMILY: "1"
# MARK: - Unit tests
#
# The suite, hosted by the app: every test in `KanbanTests/` exercises the storage format, the