import Foundation import os // MARK: - File identity /// A file's identity as the filesystem understands it — the opaque token behind /// `URLResourceKey.fileResourceIdentifierKey` — wrapped so it can key a dictionary. /// /// **Both registries in this folder key on this, never on a path string** (02-architecture.md /// § Per-board app state, "Keyed by file identity, never by path"). A board is the *folder*, not /// the string that currently names it: Finder renames are ordinary (01-storage-format.md), and a /// board renamed while open — or reopened through a moved path — must land on the store and the /// record it already has rather than on a second copy of itself. /// /// The token is documented as opaque and comparable only with `isEqual:`; nothing here reads into /// it. `hash` is `NSObject`'s, which is the hash that agrees with `isEqual:` — the pair is what /// makes this usable as a `Hashable` key at all. /// /// **Not `Sendable`, deliberately**: the token is an Objective-C object with no concurrency /// contract. Both registries are `@MainActor`, so it never leaves that actor, and claiming /// otherwise would be an unchecked promise for no gain. struct FileIdentity: Hashable { private let token: NSObject /// `nil` when the URL has no identity to read — it does not exist, or its volume cannot answer. /// /// Failable rather than throwing on purpose: every caller here already has a better error to /// produce than "no resource value". `BoardStoreRegistry.acquire` lets `BoardStore`'s own /// fail-fast load speak (it produces the *right* `BoardLoadError` for a missing, unreadable, or /// non-directory root), and `BoardRegistry` treats an unreadable identity as "no match", which /// is exactly what it means there. init?(of url: URL) { guard let values = try? url.resourceValues(forKeys: [.fileResourceIdentifierKey]), let identifier = values.fileResourceIdentifier as? NSObject else { return nil } token = identifier } static func == (lhs: FileIdentity, rhs: FileIdentity) -> Bool { lhs.token.isEqual(rhs.token) } func hash(into hasher: inout Hasher) { hasher.combine(token.hash) } } // MARK: - BoardStoreRegistry /// The live side of "one board, one truth": every window showing a board shares **one** /// `BoardStore` and **one** `FolderWatcher`, and this is what hands them out /// (02-architecture.md § Layering ▸ Components). /// /// ### Why a refcount when the answer is already known /// /// **The board window owns the board** (settled, § Components): card windows never outlive it, so /// the last release and the board window's close always coincide — the count could in principle be /// replaced by "the board window closed". It earns its keep anyway, and only, as *ordering*: /// closing a board window closes its card windows too, and those closes arrive as a handful of /// separate SwiftUI teardowns. The count is what stops the first of them from stopping the watcher /// out from under the ones still on screen. /// /// ### What it owns, and what it deliberately does not /// /// It owns the pairing: it creates the store, creates and starts the watcher, and wires them to /// each other. It owns no policy — every reload rule lives in `BoardStore`, every debounce and /// bracket rule in `FolderWatcher`. It is not a singleton either: the app holds one instance, so a /// test can hold its own without the two colliding. /// /// ### The app's own instance is the only one that matters /// /// Two registries over the same board would defeat the point (two stores, two watchers, two /// snapshots drifting apart). That is a wiring rule for the app, not something this type can /// enforce, and it is stated here rather than defended in code. @MainActor public final class BoardStoreRegistry { /// One open board: the shared store, the watcher feeding it, how many windows are holding them /// open — and the two facts the root-recovery loop needs. private struct Entry { let store: BoardStore let watcher: FolderWatcher var referenceCount: Int /// The board's **identity**, minted at acquire (02-architecture.md § Write-failure /// surfacing: "the registry's security-scoped bookmark is the identity, mid-session as much /// as across opens"). Re-minted on every absorbed rename, so it always names the folder /// where the board is now rather than where it was opened. /// /// Optional because bookmark creation can fail outright — vanishingly unlikely for a folder /// just walked, and survivable: a board with no bookmark simply falls through to the /// path-reappearance half of the recovery loop, which is the same path a *dead* bookmark /// takes. var bookmark: Data? /// Where the board was last known to live. The vanished-root case re-attaches the watcher /// here — FSEvents is content to watch a path that does not exist and reports its /// *creation* as a root change, which is exactly the return detection the lock needs. var lastKnownRoot: URL } /// Keyed by `FileIdentity`, so a board acquired through a renamed or moved path finds the entry /// it already has. A `[URL: Entry]` here would be a latent bug with a plausible shape. private var entries: [FileIdentity: Entry] = [:] private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store-registry") /// The app owns one instance and passes it down; there is no shared singleton to reach for. public init() {} /// How many boards are open — entries, not references. This is the welcome-window and /// quit-time question ("is anything still open?"), never the refcount. public var openBoardCount: Int { entries.count } // MARK: - Acquire / release /// The store for `rootURL`, opening the board if this is the first window to ask for it. /// /// **First acquire**: loads the board (fail-fast — the `BoardLoadError` is rethrown untouched, /// because there is nothing to render and nothing to fall back on), then creates and starts the /// watcher and ties the two together in both directions — watcher events into /// `BoardStore.handleWatcherEvent(_:)`, the store's write brackets out to /// `FolderWatcher.beginBracket()`/`endBracket()`. /// /// **Subsequent acquires** return the *same* store and bump the count. Not a fresh load, not a /// second watcher: a card window opening must not cost a tree walk, and two watchers over one /// board would double every reload for nothing. /// /// The identity read happens *before* the load so an already-open board is found under whatever /// path it is being asked for now. An identity that cannot be read is not decided here — it /// falls through to `BoardStore.init`, whose load produces the honest error for a root that is /// missing, unreadable, or not a directory. This type invents no error of its own. /// /// A failed load leaves **nothing behind**: no entry, no watcher, no count. A board that failed /// to open is not open. public func acquire(_ rootURL: URL) throws(BoardLoadError) -> BoardStore { if let identity = FileIdentity(of: rootURL), var entry = entries[identity] { entry.referenceCount += 1 entries[identity] = entry Self.logger.debug("acquire: existing board, \(entry.referenceCount, privacy: .public) references") return entry.store } let store = try BoardStore(rootURL: rootURL) // **The open-time writability probe** (02-architecture.md § Write-failure surfacing, "An // unwritable board location enters the read-only lock at open"), and this is the seam for // it: every way a board opens — welcome's recents, a Finder open, File ▸ Open, restoration, // a card window arriving first — funnels through `acquire`, so the probe is wired once here // instead of at each caller, and no future open path can forget it. // // **First, immediately after the load.** The lock has to be standing before anything else // in this method can act on the board, and two things below would otherwise write into a // location this line already knows is read-only: the loose-file relocation and the agent // guide. Ordering them after the probe is what makes 02's "the open-time agent-guide write // is skipped-with-log" true by construction rather than by that method's own second gate. // // The board still opens. This is a lock, not a refusal: the window comes up, the snapshot // renders, and reading, selecting, searching and copying out all stay live. store.probeWritabilityAtOpen() // Unreachable in practice — the load above just walked this directory — but the alternative // is a force-unwrap on a resource value the filesystem is free to refuse, so it is spelled // out. The loader's own vocabulary says it; no new error path is invented for a case that // means exactly what `unreadableRoot` already means. guard let identity = FileIdentity(of: rootURL) else { throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "the board root has no file identity")) } // Both directions of the wiring capture weakly, and the registry's entry is what keeps the // pair alive: the watcher never props up a store nobody is showing, and the store's brackets // become no-ops the moment `release` drops the watcher. Neither closure can form a cycle // with the object it calls into. let watcher = FolderWatcher(root: rootURL) { [weak store] event in // `.rootChanged` rides through like any other event; the store hands it straight back // here through `rootChangeDelegate`, below. store?.handleWatcherEvent(event) } store.watcherBrackets = ( begin: { [weak watcher] in watcher?.beginBracket() }, end: { [weak watcher] in watcher?.endBracket() } ) // The recovery loop's entry point. It captures the **store**, not the entry's key: an // absorbed rename can re-key the entry (a delete-and-recreate at the same path changes file // identity), and a closure holding a stale key would quietly stop finding its own board on // the second root change. Object identity cannot go stale. store.rootChangeDelegate = { [weak self, weak store] in guard let self, let store else { return } recoverFromRootChange(of: store) } // The bookmark is minted here — at acquire, once — because that is the moment the app // demonstrably has access to this folder (it just walked it). A bookmark minted later, // after the root has already gone missing, would be exactly the one that cannot be made. let bookmark = BoardRegistry.makeBookmark(for: rootURL)?.data if bookmark == nil { // Not a failure to open: the board loads, renders, and writes. Only the *rename* // half of root recovery is lost, and the path-reappearance half still works. Self.logger.error("no bookmark could be minted for this board; a mid-session move will not be absorbed") } // Default debounce and latency: the numbers are settled in 02-architecture.md § Components // (200 ms trailing over 50 ms FSEvents latency) and live in `FolderWatcher`'s defaults. // Restating them here would be a second place for them to drift. if !watcher.start() { // Not a failure to open. A board whose stream would not come up still loads, still // renders, and still writes — it simply will not notice foreign edits, which is the // same degradation 07-sync-collab.md already accepts on warned-against volumes. The // reconciling reloads that wake and activation drive are the recovery path. Self.logger.error("watcher stream failed to start; live reload is degraded for this board") } entries[identity] = Entry( store: store, watcher: watcher, referenceCount: 1, bookmark: bookmark, lastKnownRoot: rootURL ) // The loose-file carve-out's first firing (01-storage-format.md § Fractal layout ▸ Rules, // settled 2026-07-28): files an agent or a hand-editor left beside a card's `index.md` // while this board was closed are relocated into `attachments/` now, with the notice. // // **Here rather than in `BoardStore.init`**, and last rather than first: the store's own // init is one tree walk and no writes, and a relocation written before the brackets and the // watcher exist would be a write nothing is watching — landing on disk with the snapshot // above it left one reload stale. By this line the pair is wired, so it is an ordinary // bracketed app write whose echo reload refreshes the board like any other. Every reload // thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk // would otherwise have no reload behind. store.relocateLooseCardFiles() // The agent guide's first firing (08-agent-integration.md ▸ The agent guide), here for the // same reason and with the same timing as the relocation above: a board opened with no // `CLAUDE.md`, or with one an older version wrote, gets the current guide now — as a // bracketed app write with a reload behind it, never as a write into a board nothing is // watching. Every reload thereafter re-checks it from `BoardStore.land`. store.refreshAgentGuide() return store } /// Drops one window's reference; at zero the watcher stops and the entry goes. /// /// Looked up by **object identity of the store**, not by its `rootURL`: a board renamed while /// open keeps the `rootURL` it was opened with (see `BoardStore.rootURL`), so a path-based /// lookup would miss precisely the case the whole file-identity keying exists for, and leak the /// entry — a watcher running over a board with no windows. /// /// **Releasing a store this registry never handed out is a no-op**, not a trap. Teardown races /// are the ordinary shape of window closing (a card window and its board window closing in the /// same run loop turn, a double-dismiss, a store built directly in a test), and none of them is /// a programming error worth taking the app down for. public func release(_ store: BoardStore) { guard let identity = entries.first(where: { $0.value.store === store })?.key else { Self.logger.debug("release: store is not registered — ignored") return } guard var entry = entries[identity] else { return } entry.referenceCount -= 1 guard entry.referenceCount <= 0 else { entries[identity] = entry return } entry.watcher.stop() // Explicit rather than left to the weak captures: after this the store may still be alive in // whatever is closing, and brackets that quietly did nothing would be a lie about a watcher // that is gone. The root-change delegate goes for the same reason — a released store has no // entry to recover, and leaving the closure attached would invite it to look for one. entry.store.watcherBrackets = nil entry.store.rootChangeDelegate = nil entries[identity] = nil } // MARK: - Root recovery /// The whole response to a root-gone signal, in the one object that can give it: the store to /// relocate or lock, the watcher to re-attach, and the bookmark that decides which /// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its /// file identity" and "A vanished board root locks the board read-only"). /// /// ### Three outcomes, tried in order /// /// 1. **The bookmark resolves to a folder that is really there** — a rename or a move on the /// same volume. Absorbed transparently: relocate, re-attach, re-mint. **No banner, no lock, /// nothing was ever wrong.** /// 2. **The bookmark is dead, but the last-known path exists again** — a Finder undo, a /// remount, a folder recreated where the board used to be, arriving as the *creation* of a /// watched path. Treated as the root returning: the bookmark is re-minted from the path and /// the same absorption runs, and the reconciling reload's success is what clears the /// vanished-root lock through the store's own rule. /// 3. **Neither** — the root is truly vanished. The lock goes up and the watcher re-attaches at /// the last-known path anyway: FSEvents happily watches a path that does not exist and /// reports its creation as another `.rootChanged` (pinned in `FolderWatcherTests`), which is /// what arms outcome 2 for later. The re-attach's reconciling reload fails against the /// missing root, which is correct and harmless — a failed reload never replaces a good /// snapshot, and the lock is already standing. /// /// ### Why outcome 1 checks the filesystem /// /// Bookmark resolution is not a liveness test. `BoardRegistryTests` says as much where it /// refuses to build a dead-bookmark case by deleting a folder ("'ought to' is the filesystem's /// opinion"): a bookmark can resolve by its recorded *path* to something that is no longer /// there. Absorbing that would relocate the board onto a ghost and leave it with a reload /// failure instead of the honest vanished-root lock, so a resolution that does not exist on /// disk falls through to outcomes 2 and 3. /// /// ### Re-entrancy /// /// Every outcome ends in a fresh stream, and every future root change lands right back here. /// That is the design, not an accident: outcome 3 arms outcome 2, and a board that is renamed /// twice is absorbed twice. Nothing here holds state between calls beyond the entry itself, and /// the entry is looked up by store identity on each pass, so a re-key mid-loop cannot strand a /// later signal. private func recoverFromRootChange(of store: BoardStore) { guard let identity = entries.first(where: { $0.value.store === store })?.key, let entry = entries[identity] else { Self.logger.debug("root change for a store that is no longer registered — ignored") return } // 1. The rename-absorption path. if let bookmark = entry.bookmark, let resolution = BoardRegistry.resolve(bookmark), BoardRegistry.withScopedAccess(to: resolution.url, { FileManager.default.fileExists(atPath: $0.path) }) { Self.logger.debug("root change resolved through the bookmark — absorbing the move") absorb(newRoot: resolution.url, at: identity) return } // 2. The root returning at its last-known path — Finder undo, remount, recreation. if FileManager.default.fileExists(atPath: entry.lastKnownRoot.path) { Self.logger.debug("root reappeared at its last-known path — absorbing the return") absorb(newRoot: entry.lastKnownRoot, at: identity) return } // 3. Truly vanished. store.enterVanishedRootLock() entry.watcher.reattach(to: entry.lastKnownRoot) } /// Moves an entry's board to `newRoot`: the store's URLs, the watcher's stream, the entry's /// bookmark and last-known path, and — where it changed — the entry's key. /// /// **Order matters.** `relocate(to:)` first, so the reconciling reload that `reattach(to:)` /// schedules walks the new root and lands a snapshot that agrees with the store's `rootURL`. /// Re-attaching first would leave a window in which the reload's tree and the Writer's URLs /// disagreed about where the board is. /// /// **Re-keying.** A rename keeps the folder's file identity, so the key is usually unchanged /// and this is a no-op. A board deleted and recreated at the same path is a *different inode* /// under the same name, and its entry has to move to the new identity or the next /// `acquire`/`liveStore(for:)` would miss it and open a second store over the board already on /// screen — the exact bug the identity keying exists to prevent. An identity that cannot be /// read leaves the key alone: a stale key still finds the entry by store identity, where /// dropping the entry would strand a live watcher. private func absorb(newRoot: URL, at identity: FileIdentity) { guard var entry = entries[identity] else { return } entry.store.relocate(to: newRoot) entry.watcher.reattach(to: newRoot) entry.lastKnownRoot = newRoot // Re-minted from where the board is now: a bookmark tracks moves, but a *stale* one is only // good for a while, and this is the moment the app has both access and certainty. if let refreshed = BoardRegistry.withScopedAccess(to: newRoot, { BoardRegistry.makeBookmark(for: $0) }) { entry.bookmark = refreshed.data } guard let currentIdentity = FileIdentity(of: newRoot), currentIdentity != identity else { entries[identity] = entry return } entries[identity] = nil entries[currentIdentity] = entry Self.logger.debug("board re-keyed: the folder at this path is a different file than it was") } /// The live store for a board, if any window has it open — **without** taking a reference. /// /// This is the "is this board already open?" question: focusing an existing window rather than /// opening a second one, routing a cross-board drag into a board that happens to be on screen. /// A bump here would be a leak, since nothing is being opened. public func liveStore(for rootURL: URL) -> BoardStore? { guard let identity = FileIdentity(of: rootURL) else { return nil } return entries[identity]?.store } }