Files
lanework/Kanban/LiveStore/BoardRegistry.swift
T
rzen f2d9f3ad07 Give the registry live display-state write-through and record-before-load
BoardRecord carries icon/iconColor; recordOpen/recordClose stamp them with the
display name, and a displayStateDelegate on the store (wired in BoardWindowHost
beside onFrameChanged) syncs all three through BoardRegistry.syncDisplayState on
every successful reload — welcome rows now wear the board's own icon and follow
in-app renames live. recordOpen now runs before the load with the folder name as
a brand-new record's provisional display name, so a first open that fails
fail-fast still lands in recents carrying the failure row-level (02's rule); an
existing record's cached name survives a failing retry, and the welcome fallback
list remains only for failures naming no record at all.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-27 20:44:30 -04:00

791 lines
39 KiB
Swift

import Foundation
import os
// MARK: - Timestamps
/// The one shape a `Date` takes in the registry file — ISO 8601 **with fractional seconds**, UTC.
///
/// **Why fractional seconds** rather than `JSONEncoder.DateEncodingStrategy.iso8601`'s whole ones:
/// `lastOpened` is not a display stamp, it is the recents list's *sort key* (02-architecture.md
/// § Per-board app state, "The recents list *is* this registry sorted by last-opened"). Restoring
/// several boards at launch happens well inside one second, and at whole-second resolution those
/// rows would tie and order arbitrarily.
///
/// **Why `ISO8601DateFormatter`** and not `Date.ISO8601FormatStyle`, the modern value type that
/// would be `Sendable` and need none of the ceremony below: the format style *truncates* to the
/// millisecond while its own parser rounds, so `parse(format(d)) != d` for about half of all dates
/// (measured, not assumed — it drifts a millisecond earlier on each round trip). This formatter is
/// an exact fixed point, which is what lets `BoardRegistry.stamp(_:)` guarantee that a record in
/// memory and the same record reloaded from disk are *equal*, not merely close.
///
/// **Built per use rather than shared**: the formatter is a non-`Sendable` class and the encoder's
/// and decoder's date strategies are `@Sendable` closures, so a captured or global instance would
/// be an unchecked concurrency promise. One costs microseconds and this file holds a handful of
/// records — the safe answer is also the cheap one.
private func boardRegistryTimestampFormatter() -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter
}
// MARK: - Records
/// A board's window frame, remembered per board (02-architecture.md § Windows, "per-board frame
/// memory").
///
/// Stored as four `Double`s rather than an `NSRect`/`CGRect` so the persisted JSON is a shape this
/// file owns, independent of any framework's `Codable` conformance. Repositioning a frame onto a
/// live screen when the saved one is gone is the window layer's job, not this type's — nothing here
/// validates the numbers.
public struct WindowFrame: Codable, Sendable, Equatable {
public var x: Double
public var y: Double
public var width: Double
public var height: Double
public init(x: Double, y: Double, width: Double, height: Double) {
self.x = x
self.y = y
self.width = width
self.height = height
}
}
/// One known board: everything the app keeps *about* a board that must never be written *into* it.
///
/// **Files-first is absolute** (02-architecture.md § Per-board app state): no frontmatter key, no
/// sidecar, no xattr — this record lives wholly in Application Support, which is also why two
/// machines sharing a board through a remote each keep their own (push-on-commit and window frames
/// are genuinely per-machine choices).
///
/// The **bookmark is the identity**; `lastKnownPath` is display text and nothing else. Matching an
/// opened folder to its record resolves the bookmark and compares file identity, so a board renamed
/// or moved on the same volume keeps its settings and its place in recents.
///
/// ### Evolving this struct
///
/// The synthesized `Codable` conformance rejects a file missing any non-optional key, and
/// `BoardRegistry` responds to a rejected file by quarantining it — every record lost. So **a key
/// added here must be optional or have a decoding default**, or the addition silently empties every
/// existing user's recents on upgrade. That policy, not a version field, is what keeps this format
/// readable across releases.
public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
public let id: UUID
/// The security-scoped bookmark — **the board's identity**, refreshed on every open. Empty only
/// in the degenerate case where the system refused to make one at all, which is a record born
/// orphaned: it shows in recents with Forget and never matches an open.
public var bookmark: Data
/// Last-known title or folder name, for the recents row. Display only.
public var displayName: String
/// Where the board was last seen, for the recents row when the bookmark no longer resolves and
/// there is nothing else to show. **Never used for matching** — that is the bookmark's job, and
/// a path that matched would reintroduce exactly the identity-by-string bug this design excludes.
public var lastKnownPath: String
public var lastOpened: Date
/// Counts stamped at last close, `nil` until a close has stamped them.
///
/// Registry-cached on purpose: the welcome window renders these instead of scanning
/// (§ Per-board app state, "no directory scan at welcome time" — which would be slow on a big
/// board and would hang on an unavailable one). Staleness until the next open is accepted, and a
/// board that has never been closed simply shows no counts.
public var laneCount: Int?
public var cardCount: Int?
public var windowFrame: WindowFrame?
/// The board's `icon` — registry-cached with live write-through, beside `displayName`
/// (02-architecture.md § Per-board app state: "The row's title and icon are registry-cached
/// too — with live write-through"). `nil` when the board's `icon` key is missing or
/// malformed, which the welcome row reads exactly as the board window itself does: draw the
/// level default, never a guess (`ItemSymbol`). The value round-trips verbatim as the
/// frontmatter carries it — an SF Symbol name, unvalidated here; resolving it against what
/// the running system can draw is the renderer's job, not this record's.
///
/// Stamped at the same moments `displayName` is (`recordOpen`, `recordClose`), and — unlike
/// the counts — refreshed *live* while the board is open: `BoardRegistry.syncDisplayState`
/// is the write-through `BoardStore`'s reload pipeline calls into.
public var icon: String?
/// The board's `iconColor`, cached beside `icon` for the same reason and at the same
/// moments. A palette name or a `#RRGGBB[AA]` hex, resolved through `Palette` at render
/// time — never here.
public var iconColor: String?
/// Whether this board's window is open **right now** — the restoration set, as a live marker
/// rather than an at-quit write (02-architecture.md § Launch and window lifecycle, settled).
///
/// Set when the board's window opens, cleared on *user-initiated* close; quit's teardown
/// deliberately leaves it standing, because the boards open at quit are by definition the ones
/// to restore. **Crash recovery falls out for free**: after a crash the flags describe what was
/// open at crash time, so the next launch restores exactly that — no separate recovery logic, no
/// once-at-quit stamp to race teardown or miss when the app dies.
///
/// **Optional because every key here must be** (see Evolving this struct above): a registry file
/// written before this key existed decodes with `nil`, which reads as "not open" and costs the
/// user nothing. A non-optional `Bool` would have quarantined every existing file on upgrade and
/// emptied everyone's recents.
public var isOpenNow: Bool?
/// Whether committing also pushes (07-sync-collab.md). Off by default: pushing is a decision,
/// not a side effect.
public var pushOnCommit: Bool
/// Whether this board's once-per-board iCloud/network-volume warning has been shown
/// (07-sync-collab.md). Once-per-*board*, which is why it lives on the record rather than in
/// `UserDefaults`.
public var remoteLocationWarned: Bool
public init(
id: UUID = UUID(),
bookmark: Data,
displayName: String,
lastKnownPath: String,
lastOpened: Date,
laneCount: Int? = nil,
cardCount: Int? = nil,
windowFrame: WindowFrame? = nil,
isOpenNow: Bool? = nil,
pushOnCommit: Bool = false,
remoteLocationWarned: Bool = false,
icon: String? = nil,
iconColor: String? = nil
) {
self.id = id
self.bookmark = bookmark
self.displayName = displayName
self.lastKnownPath = lastKnownPath
self.lastOpened = lastOpened
self.laneCount = laneCount
self.cardCount = cardCount
self.windowFrame = windowFrame
self.isOpenNow = isOpenNow
self.pushOnCommit = pushOnCommit
self.remoteLocationWarned = remoteLocationWarned
self.icon = icon
self.iconColor = iconColor
}
}
/// A recents row: the record, plus whether its board can be reached right now.
///
/// The classification is a **bookmark resolution and nothing else** — no `fileExists`, no listing,
/// no counting. That is what makes `BoardRegistry.recents()` safe to call while a network volume is
/// offline (§ Per-board app state, and § Graceful orphaning for what `unavailable` means to the UI:
/// the row shows with Forget rather than disappearing).
public enum RecentBoard: Sendable, Equatable {
/// Resolved. `at` is where the board lives *now*, which is not necessarily `lastKnownPath` — a
/// bookmark follows renames and moves on the same volume.
case available(BoardRecord, at: URL)
/// The bookmark no longer resolves: deleted, or moved across a volume boundary a bookmark
/// cannot follow. Orphaned — "its settings are conveniences and die with it".
case unavailable(BoardRecord)
public var record: BoardRecord {
switch self {
case let .available(record, _): record
case let .unavailable(record): record
}
}
/// Where the board is now, or `nil` for an orphan.
public var url: URL? {
switch self {
case let .available(_, url): url
case .unavailable: nil
}
}
}
// MARK: - BoardRegistry
/// The persistent side of per-board app state: one record per known board, in Application Support
/// (02-architecture.md § Per-board app state).
///
/// ### Three rules do most of the work
///
/// 1. **Identity, never paths.** An opened folder finds its record by resolving each record's
/// bookmark and comparing file identity. A renamed board keeps its settings; a board reopened
/// through a symlink or a moved parent does not acquire a second record.
/// 2. **Nothing it does touches a board folder.** Not a byte, not an xattr. The one file it writes
/// is its own, and it lives somewhere else entirely.
/// 3. **It never takes the app down.** A missing file is an empty registry; a corrupt one is moved
/// aside and the registry starts empty; a save that fails is logged. Every method here is
/// non-throwing on purpose — "its settings are conveniences", and losing a window frame must
/// never cost the user a board.
///
/// ### Deliberately not here
///
/// App-wide state that is not board-scoped — quick-style recents, the SSH host-key table, the
/// last-used card-window size — lives *beside* this file, not in it (§ Per-board app state, "App-wide
/// state has the same home"). Secrets live in the Keychain and nowhere near here.
@MainActor
public final class BoardRegistry {
/// The JSON file this registry is. Public because a diagnostic ("Reveal registry in Finder") and
/// every test want to name it, and because injecting it is how a test stays out of the real
/// Application Support directory.
public let storageURL: URL
private var records: [BoardRecord]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-registry")
/// `~/Library/Application Support/<bundle id>/board-registry.json`, inside the sandbox
/// container. The bundle-id subfolder is Apple's convention and keeps the app's files together
/// as the app-wide neighbours arrive.
public static var defaultStorageURL: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"
return support
.appendingPathComponent(bundleIdentifier, isDirectory: true)
.appendingPathComponent("board-registry.json", isDirectory: false)
}
/// Loads the registry, tolerating everything a file on disk can be.
///
/// Missing file: an empty registry — the first-launch case, and not an error. **Corrupt file:
/// renamed aside with a timestamp and the registry starts empty.** Renamed rather than deleted
/// because the file may be the only trace of a user's board list, and a support request can read
/// it even when this app cannot; empty rather than fatal because a truncated convenience file
/// must not stand between the user and their boards.
public init(storageURL: URL) {
self.storageURL = storageURL
self.records = []
self.records = loadFromDisk()
}
// MARK: - Opening and closing
/// Records that a board was opened, and answers with the id of the record it belongs to.
///
/// Matching is the interesting half: every existing record's bookmark is resolved and its file
/// identity compared with the opened folder's. A hit is *the* record no matter what path either
/// side is spelled with — the done-when criterion for a renamed board keeping its settings.
/// Records whose bookmarks no longer resolve are skipped rather than repaired: an orphan is a
/// recents row with Forget, not a candidate for a board that is demonstrably somewhere else.
///
/// A match is updated in place with a **fresh bookmark** (subsuming the stale-refresh case), the
/// path it was opened at, and `lastOpened` = now. No match creates a record. Either way the
/// file is saved before returning.
///
/// **`isOpenNow` is deliberately untouched here.** Recording an open and *being* open are two
/// different facts: this method is called before a window exists (and, later, by flows that
/// record a board without showing one), so the flag is set by `setOpenNow(id:)` once the window
/// has actually opened. Folding it in would flag boards that never made it onto screen and hand
/// the next launch a restoration set describing failures.
///
/// ### `displayName` is `nil` before a load has run — that is the whole of "record before load"
///
/// 02-architecture.md § Per-board app state (settled): **"the registry record is created before
/// loading"**, and **"the record's provisional display name is the folder name … the first
/// successful load replaces it with the cached title."** This method is the one open-time stamp
/// both moments share, told apart by whether the caller has anything authoritative to say yet:
///
/// - `nil` (the "before load" call, `BoardWindowHost.start()`'s first act): the bookmark,
/// `lastKnownPath`, and `lastOpened` refresh as always, but `displayName`/`icon`/`iconColor`
/// are left **untouched** on a record that already has them — the last successful open's
/// cached title is still the best information this board's row has, and a load that is about
/// to fail must not regress it to the bare folder name. A **brand-new** record has nothing
/// cached yet, so it takes the folder name — "a never-successfully-opened record is not a
/// special class; it lingers in recents like any other."
/// - Non-`nil` (a caller with real, loaded values): overwrites `displayName`/`icon`/`iconColor`
/// unconditionally, exactly as before this distinction existed. Nothing in this app calls it
/// that way any more — a successful load's replacement goes through `syncDisplayState`
/// instead, which shares its no-op-skip discipline with every reload afterward — but the
/// parameter stays meaningful on its own rather than folding into a second method, and every
/// existing test naming a concrete string exercises exactly this branch.
///
/// **Why this does not cost a second bookmark mint.** `recordOpen` is called exactly once per
/// open attempt (the "before load" call), so the one bookmark it mints here is the *whole* of
/// this open's mint (02 § Per-board app state, "one bookmark per open board"). The successful-load
/// follow-up is `syncDisplayState`, which never touches `bookmark` at all.
@discardableResult
public func recordOpen(
of rootURL: URL,
displayName: String? = nil,
icon: String? = nil,
iconColor: String? = nil
) -> UUID {
let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data()
if bookmark.isEmpty {
// Both the security-scoped and the plain attempt failed — vanishingly unlikely for a
// folder the app has just opened. The record is still created: a row the user can see
// and Forget beats a board that silently never joins recents.
Self.logger.error("no bookmark could be created for the opened board; its record is born orphaned")
}
if let index = indexOfRecord(matching: rootURL) {
records[index].bookmark = bookmark
if let displayName {
records[index].displayName = displayName
records[index].icon = icon
records[index].iconColor = iconColor
}
records[index].lastKnownPath = rootURL.path
records[index].lastOpened = Self.stamp()
save()
return records[index].id
}
let record = BoardRecord(
bookmark: bookmark,
displayName: displayName ?? Self.folderName(of: rootURL),
lastKnownPath: rootURL.path,
lastOpened: Self.stamp(),
icon: icon,
iconColor: iconColor
)
records.append(record)
save()
return record.id
}
/// The folder name, extension stripped — `AppModel.folderDisplayName(of:)`'s own rule, restated
/// here rather than reached for: this file is `Foundation`-only and must not import the app
/// layer to borrow four words of `URL` math.
private static func folderName(of url: URL) -> String {
url.deletingPathExtension().lastPathComponent
}
/// Stamps the counts the welcome window will render for this board until it is opened again
/// (§ Per-board app state, "Recents counts are registry-cached ... stamped at last close").
///
/// Called from the close-flush sequence (02-architecture.md § Launch and window lifecycle),
/// where the store's last snapshot is still in hand — which is the whole reason the counts are
/// free here and expensive anywhere else.
///
/// `displayName`/`icon`/`iconColor` are re-stamped here too, from the same last-held snapshot.
/// Unlike the counts, these three are already kept current while the board is open — every
/// reload write-throughs via `syncDisplayState` — so this is the belt-and-braces close-time
/// stamp rather than their only update path: a final, cheap guarantee that closing a board
/// never leaves its row one edit behind, whatever wired the live path.
public func recordClose(
id: UUID,
displayName: String,
laneCount: Int,
cardCount: Int,
icon: String? = nil,
iconColor: String? = nil
) {
update(id) { record in
record.displayName = displayName
record.icon = icon
record.iconColor = iconColor
record.laneCount = laneCount
record.cardCount = cardCount
}
}
// MARK: - The open-now marker
/// Marks this board as open — called when its window has actually opened, not when the open was
/// merely attempted (02-architecture.md § Launch and window lifecycle).
public func setOpenNow(id: UUID) {
update(id) { $0.isOpenNow = true }
}
/// Clears the marker — **user-initiated close only**.
///
/// Quit's teardown must never call this, and that omission is the entire restoration mechanism:
/// "quit's teardown closes deliberately leave it standing (the boards were open at quit by
/// definition; teardown distinguishes user-close from quit-close, and that distinction is the
/// whole mechanism)". A crash calls nothing at all, which is why crash recovery needs no code of
/// its own — the flags already describe what was open when the app died.
public func clearOpenNow(id: UUID) {
update(id) { $0.isOpenNow = false }
}
/// The boards to reopen at launch: every record still flagged open, **oldest `lastOpened`
/// first**.
///
/// Ascending, unlike `recents()`, because these are reopened in order and the result should be
/// the stacking the user left behind — the most recently opened board ends up frontmost because
/// it opens last. Classification is `recents()`' own bookmark resolution, reused rather than
/// re-implemented: a flagged board on an unmounted volume is `unavailable` here for exactly the
/// reason it is unavailable there, and the launch flow renders it as a failed restoration
/// (§ "A restored board that fails surfaces on welcome, row-level") instead of hanging on a
/// mount.
///
/// The preference gates only whether this is *consulted*; the flags are maintained regardless.
public func restorables() -> [RecentBoard] {
recents()
.filter { $0.record.isOpenNow == true }
// Ascending, with the same id tie-break `recents()` uses inverted, so two boards opened
// in the same millisecond still come back in one stable order rather than whichever
// `sorted(by:)` felt like.
.sorted { lhs, rhs in
lhs.record.lastOpened == rhs.record.lastOpened
? lhs.record.id.uuidString < rhs.record.id.uuidString
: lhs.record.lastOpened < rhs.record.lastOpened
}
}
// MARK: - Per-board settings
public func updateWindowFrame(id: UUID, frame: WindowFrame) {
update(id) { $0.windowFrame = frame }
}
/// The live write-through for an *open* board's title, icon, and iconColor
/// (02-architecture.md § Per-board app state, "these three refresh whenever an open board's
/// reload changes them"). `BoardStore` calls into this — indirectly, through the delegate
/// `BoardWindowHost.configureWindow` wires — on every successful reload, so an in-app rename
/// or restyle lands in the welcome row the instant the store's snapshot shows it, and a
/// foreign edit of an *open* board's root rides the same reload for free.
///
/// **A no-op, and no save, when nothing differs from what is already cached.** A reload fires
/// on every tree change anywhere in the board, most of which touch no board-level field at
/// all, so calling this unconditionally must not churn the registry file on an unrelated card
/// edit — the same "an unchanged value writes nothing" rule `setLaneWidth` and `commitRename`
/// already keep.
///
/// An unknown id is `update`'s own no-op (a board closed and forgotten mid-reload), for the
/// same reason every other setter here tolerates one.
public func syncDisplayState(id: UUID, title: String, icon: String?, iconColor: String?) {
guard let index = indexOfRecord(id) else {
Self.logger.debug("syncDisplayState: no record for this id — ignored")
return
}
guard records[index].displayName != title
|| records[index].icon != icon
|| records[index].iconColor != iconColor
else { return }
records[index].displayName = title
records[index].icon = icon
records[index].iconColor = iconColor
save()
}
public func setPushOnCommit(id: UUID, _ value: Bool) {
update(id) { $0.pushOnCommit = value }
}
/// Marks this board's once-per-board remote-location warning as shown (07-sync-collab.md).
/// One-way: there is no un-warn, because the warning is about a location the board is already at.
public func setRemoteLocationWarned(id: UUID) {
update(id) { $0.remoteLocationWarned = true }
}
// MARK: - Reading
/// Every known board, most recently opened first, each classified by whether its bookmark
/// resolves — the recents list, which *is* this registry sorted by last-opened.
///
/// **No directory is scanned and nothing is counted here.** Counts come from the records; the
/// only filesystem work is bookmark resolution, done with `.withoutUI` and `.withoutMounting`
/// so an offline volume classifies as unavailable instead of blocking the welcome window on a
/// mount.
///
/// Two quiet side effects, both of them the "stale is fine — refresh silently" rule: a bookmark
/// that resolves *stale* is replaced with a fresh one, and if any was, the file is saved. A
/// stale bookmark still works, but only for a while — refreshing it here is what keeps a board
/// that moves around from eventually orphaning itself. `lastKnownPath` is deliberately **not**
/// refreshed: it is the display fallback for a record that *cannot* be resolved, so the resolved
/// URL, not the record, is what an available row shows.
///
/// The sort is total — `lastOpened` descending, then id — because `sorted(by:)` is not stable
/// and two rows that tie should still come back in the same order every call.
public func recents() -> [RecentBoard] {
var resolvedURLs: [UUID: URL] = [:]
var refreshedAny = false
for index in records.indices {
guard let resolution = Self.resolve(records[index].bookmark) else { continue }
resolvedURLs[records[index].id] = resolution.url
guard resolution.isStale else { continue }
if let refreshed = Self.withScopedAccess(to: resolution.url, { Self.makeBookmark(for: $0) }) {
records[index].bookmark = refreshed.data
refreshedAny = true
}
}
if refreshedAny {
save()
}
return records
.sorted { lhs, rhs in
lhs.lastOpened == rhs.lastOpened
? lhs.id.uuidString > rhs.id.uuidString
: lhs.lastOpened > rhs.lastOpened
}
.map { record in
if let url = resolvedURLs[record.id] {
.available(record, at: url)
} else {
.unavailable(record)
}
}
}
public func record(id: UUID) -> BoardRecord? {
records.first { $0.id == id }
}
/// Drops a record — the Forget action on an orphaned recents row, and the only way a record
/// leaves. Nothing else prunes: a board that is merely unavailable today may be a remounted
/// volume tomorrow, so forgetting is always the user's call.
public func forget(id: UUID) {
guard let index = indexOfRecord(id) else { return }
records.remove(at: index)
save()
}
/// Drops every record — File ▸ Open Recent ▸ Clear Menu (11-command-nexus.md).
///
/// **Finder's Clear Menu clears the menu; this registry *is* the menu**, so the equivalence is
/// exact: there is no separate recents list that could be emptied while the records stayed, and
/// a record whose board never appears anywhere is a setting nothing can reach. It is therefore
/// `forget(id:)` applied to every row, and the one test worth writing says exactly that.
///
/// One save rather than one per record: the file is rewritten wholesale anyway, and forgetting
/// twenty boards should not be twenty writes.
public func forgetAll() {
guard !records.isEmpty else { return }
records.removeAll()
save()
}
// MARK: - Matching
/// The index of the record whose bookmark resolves to the same file as `url`, if any.
private func indexOfRecord(matching url: URL) -> Int? {
guard let target = FileIdentity(of: url) else { return nil }
return records.firstIndex { record in
guard let resolution = Self.resolve(record.bookmark) else { return false }
// Scope is started around the identity read and stopped immediately. Resolving a
// security-scoped bookmark grants nothing by itself, and in the sandbox an unscoped
// `resourceValues` call on some *other* board's folder is exactly the read that gets
// refused — which would silently turn "the same board" into "a new one" and duplicate
// the record. The pairing is balanced, so an outer scope this app holds elsewhere is
// unaffected.
return Self.withScopedAccess(to: resolution.url) { FileIdentity(of: $0) == target }
}
}
private func indexOfRecord(_ id: UUID) -> Int? {
records.firstIndex { $0.id == id }
}
/// Mutates a record and saves. An unknown id is a no-op: a window that outlived its record —
/// the user pressed Forget while the board was open — must not crash on its way out.
private func update(_ id: UUID, _ mutate: (inout BoardRecord) -> Void) {
guard let index = indexOfRecord(id) else {
Self.logger.debug("update: no record for this id — ignored")
return
}
mutate(&records[index])
save()
}
// MARK: - Bookmarks
/// A bookmark and which flavor the system actually gave us.
struct Bookmark {
let data: Data
let isSecurityScoped: Bool
}
/// Creates a bookmark for `url`, security-scoped if the system allows it.
///
/// **The security-scoped path is the real one.** In production every board URL arrives through
/// NSOpenPanel, a Finder drag, or a previously resolved bookmark, so the app already holds
/// access and `.withSecurityScope` succeeds — which is what lets the board reopen after a
/// relaunch at all (02-architecture.md § Platform, "security-scoped bookmarks for reopening
/// boards across launches"; the `com.apple.security.files.bookmarks.app-scope` entitlement is
/// what backs it).
///
/// **The plain fallback exists for the cases where that is not true**: a unit test pointing at a
/// temp directory the panel never blessed, and a non-sandboxed debug build where the option is
/// meaningless. A plain bookmark still tracks renames and moves — the identity story is intact —
/// it simply grants no access on resolution, which outside the sandbox is nothing to grant.
/// Returning `nil` from both attempts is left to the caller, which records an orphan rather than
/// dropping the board.
static func makeBookmark(for url: URL) -> Bookmark? {
if let data = try? url.bookmarkData(options: [.withSecurityScope]) {
return Bookmark(data: data, isSecurityScoped: true)
}
if let data = try? url.bookmarkData(options: []) {
Self.logger.debug("security-scoped bookmark unavailable; fell back to a plain bookmark")
return Bookmark(data: data, isSecurityScoped: false)
}
return nil
}
struct Resolution {
let url: URL
let isStale: Bool
}
/// Resolves a bookmark, mirroring `makeBookmark(for:)`'s two flavors in the same order.
///
/// `.withoutUI` and `.withoutMounting`: resolution runs during a recents listing, and a recents
/// listing must never put up a dialog or block for seconds mounting a server. A board on an
/// unmounted volume is *unavailable right now*, which is precisely what the row should say.
///
/// Resolution alone does **not** start security-scoped access — the caller does that around the
/// use, balanced. That is why `recents()` can classify every record without ever holding a scope
/// open.
static func resolve(_ bookmark: Data) -> Resolution? {
guard !bookmark.isEmpty else { return nil }
var isStale = false
if let url = try? URL(
resolvingBookmarkData: bookmark,
options: [.withSecurityScope, .withoutUI, .withoutMounting],
relativeTo: nil,
bookmarkDataIsStale: &isStale
) {
return Resolution(url: url, isStale: isStale)
}
isStale = false
if let url = try? URL(
resolvingBookmarkData: bookmark,
options: [.withoutUI, .withoutMounting],
relativeTo: nil,
bookmarkDataIsStale: &isStale
) {
return Resolution(url: url, isStale: isStale)
}
return nil
}
/// Runs `body` with security-scoped access held, if this URL has any to hold.
///
/// `startAccessingSecurityScopedResource()` returns `false` for a URL that is not
/// security-scoped (a plain bookmark's, or anything already inside the container), and then
/// there is nothing to stop — so the pairing stays balanced either way.
static func withScopedAccess<T>(to url: URL, _ body: (URL) -> T) -> T {
let started = url.startAccessingSecurityScopedResource()
defer {
if started {
url.stopAccessingSecurityScopedResource()
}
}
return body(url)
}
// MARK: - Persistence
/// Now, at the resolution the file records — so the registry in memory and the registry on disk
/// are *identical*, not merely close.
///
/// Stamping through the same formatter the encoder uses makes the round trip exact by
/// construction: what `string(from:)` produces, `date(from:)` maps back to the value that will
/// be decoded, and that value formats to the same string again. Without this a freshly stamped
/// record and its reloaded self would differ in the microseconds no format carries — an equality
/// that fails only sometimes, which is the worst kind.
private static func stamp(_ date: Date = Date()) -> Date {
let formatter = boardRegistryTimestampFormatter()
return formatter.date(from: formatter.string(from: date)) ?? date
}
/// Writes the whole file, atomically, and never throws.
///
/// Whole-file because it is a handful of small records and a partial writer would be a
/// consistency problem in exchange for nothing. Atomic because a crash mid-write must leave the
/// previous file, not half of this one — the same temp-then-rename discipline `BoardWriter` uses
/// on the user's own files. A failure is logged and swallowed: the caller is a window close or a
/// settings toggle, and neither has anything useful to do about a full disk.
private func save() {
let encoder = JSONEncoder()
// `.sortedKeys` for a byte-stable file (the same records always produce the same bytes) and
// `.prettyPrinted` because the one time anybody reads this file by hand is when something
// has gone wrong.
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
encoder.dateEncodingStrategy = .custom { date, encoder in
var container = encoder.singleValueContainer()
try container.encode(boardRegistryTimestampFormatter().string(from: date))
}
// Sorted by id, not by recency: the array's order carries no meaning (`recents()` sorts),
// so pinning it keeps the file from churning every time a board is opened.
let ordered = records.sorted { $0.id.uuidString < $1.id.uuidString }
do {
try FileManager.default.createDirectory(
at: storageURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try encoder.encode(ordered).write(to: storageURL, options: .atomic)
} catch {
Self.logger.error("could not save the board registry: \(error.localizedDescription, privacy: .public)")
}
}
private func loadFromDisk() -> [BoardRecord] {
guard FileManager.default.fileExists(atPath: storageURL.path) else { return [] }
guard let data = try? Data(contentsOf: storageURL) else {
quarantine("unreadable")
return []
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let text = try container.decode(String.self)
guard let date = boardRegistryTimestampFormatter().date(from: text) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "not an ISO 8601 timestamp: \(text)"
)
}
return date
}
do {
return try decoder.decode([BoardRecord].self, from: data)
} catch {
quarantine(error.localizedDescription)
return []
}
}
/// Renames the unreadable file aside so the next save starts clean.
///
/// Renamed, never deleted: this file may be the only record of which boards a user had, and a
/// bug that corrupts it should leave evidence to diagnose rather than a hole. The timestamp
/// keeps repeat corruptions from overwriting each other; the id suffix covers two in the same
/// millisecond.
private func quarantine(_ reason: String) {
Self.logger.error("board registry is unreadable (\(reason, privacy: .public)); quarantining it")
// The timestamp's colons are legal in a path but read as `/` in Finder, so they go.
let stamp = boardRegistryTimestampFormatter()
.string(from: Date())
.replacingOccurrences(of: ":", with: "-")
let folder = storageURL.deletingLastPathComponent()
let base = storageURL.deletingPathExtension().lastPathComponent
let fileExtension = storageURL.pathExtension
func destination(_ name: String) -> URL {
let file = folder.appendingPathComponent(name, isDirectory: false)
return fileExtension.isEmpty ? file : file.appendingPathExtension(fileExtension)
}
var target = destination("\(base)-corrupt-\(stamp)")
if FileManager.default.fileExists(atPath: target.path) {
target = destination("\(base)-corrupt-\(stamp)-\(UUID().uuidString.prefix(8))")
}
do {
try FileManager.default.moveItem(at: storageURL, to: target)
} catch {
// Nothing further to do: the next `save()` overwrites the file atomically anyway, so a
// failed quarantine costs the evidence, not the registry.
Self.logger.error("could not quarantine it: \(error.localizedDescription, privacy: .public)")
}
}
}