Build the board registry and per-board app state
Two registries (Kanban/LiveStore/): BoardStoreRegistry shares one live store and one started, fully wired watcher per open board across its windows — keyed by file identity (fileResourceIdentifier), never path, refcounted to order teardown; release matches by store identity so a board renamed while open can't leak its watcher. BoardRegistry persists app-private per-board state in Application Support as diff-stable JSON: records anchored by security-scoped bookmarks, recents = the registry sorted by lastOpened (counts registry-cached, never scanned), graceful orphaning with Forget, corrupt files quarantined aside, and files-first verified — the board tree is untouched byte-for-byte. Timestamps use ISO8601DateFormatter with fractional seconds: the FormatStyle variant truncates-then-rounds and drifts a millisecond per round trip. 16 registry tests; full suite 297 tests in 56 suites green. Four findings filed on the Redesign board. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -0,0 +1,595 @@
|
|||||||
|
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?
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
pushOnCommit: Bool = false,
|
||||||
|
remoteLocationWarned: Bool = false
|
||||||
|
) {
|
||||||
|
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.pushOnCommit = pushOnCommit
|
||||||
|
self.remoteLocationWarned = remoteLocationWarned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// caller's `displayName`, the path it was opened at, and `lastOpened` = now. No match creates a
|
||||||
|
/// record. Either way the file is saved before returning.
|
||||||
|
@discardableResult
|
||||||
|
public func recordOpen(of rootURL: URL, displayName: String) -> 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
|
||||||
|
records[index].displayName = displayName
|
||||||
|
records[index].lastKnownPath = rootURL.path
|
||||||
|
records[index].lastOpened = Self.stamp()
|
||||||
|
save()
|
||||||
|
return records[index].id
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = BoardRecord(
|
||||||
|
bookmark: bookmark,
|
||||||
|
displayName: displayName,
|
||||||
|
lastKnownPath: rootURL.path,
|
||||||
|
lastOpened: Self.stamp()
|
||||||
|
)
|
||||||
|
records.append(record)
|
||||||
|
save()
|
||||||
|
return record.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
public func recordClose(id: UUID, laneCount: Int, cardCount: Int) {
|
||||||
|
update(id) { record in
|
||||||
|
record.laneCount = laneCount
|
||||||
|
record.cardCount = cardCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-board settings
|
||||||
|
|
||||||
|
public func updateWindowFrame(id: UUID, frame: WindowFrame) {
|
||||||
|
update(id) { $0.windowFrame = frame }
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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, and how many windows are holding
|
||||||
|
/// them open.
|
||||||
|
private struct Entry {
|
||||||
|
let store: BoardStore
|
||||||
|
let watcher: FolderWatcher
|
||||||
|
var referenceCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
|
||||||
|
// 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 stubs it today; the
|
||||||
|
// settled response — re-resolve the board's security-scoped bookmark and either
|
||||||
|
// `reattach(to:)` at the new location or enter the vanished-root read-only lock
|
||||||
|
// (02-architecture.md § Write-failure surfacing) — needs `BoardRegistry`'s bookmark and
|
||||||
|
// belongs to the card that brings the two together. This registry is the natural place
|
||||||
|
// for that seam, since it is the only object holding the store, the watcher, and (by
|
||||||
|
// then) the record at once.
|
||||||
|
store?.handleWatcherEvent(event)
|
||||||
|
}
|
||||||
|
store.watcherBrackets = (
|
||||||
|
begin: { [weak watcher] in watcher?.beginBracket() },
|
||||||
|
end: { [weak watcher] in watcher?.endBracket() }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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.
|
||||||
|
entry.store.watcherBrackets = nil
|
||||||
|
entries[identity] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// `BoardRegistry` is app-private state about the user's boards, and its two hardest promises are
|
||||||
|
/// negative ones: it must match a board that moved (so settings survive a rename), and it must
|
||||||
|
/// never write a byte into a board folder (so files-first stays absolute). Both are tested here
|
||||||
|
/// against real directories and a real storage file in temp — a fake filesystem would prove neither,
|
||||||
|
/// since both are claims about file identity and about what is on disk.
|
||||||
|
///
|
||||||
|
/// The third promise is that nothing it does can take the app down: a missing file, a corrupt file,
|
||||||
|
/// and a bookmark that no longer resolves all have expected, boring outcomes, and each has a test.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// A temp folder holding the registry's JSON file, kept separate from every board folder so the
|
||||||
|
/// quarantine test can see exactly what the registry put next to it.
|
||||||
|
@MainActor
|
||||||
|
private struct RegistryStorage {
|
||||||
|
let folder: URL
|
||||||
|
|
||||||
|
var url: URL { folder.appendingPathComponent("board-registry.json", isDirectory: false) }
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
folder = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("BoardRegistryTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryNames() throws -> [String] {
|
||||||
|
try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small real board — the registry never reads inside one, but a folder with contents is what
|
||||||
|
/// makes the files-first test able to notice a single stray byte.
|
||||||
|
@MainActor
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every path under `root` (the root itself included, hidden entries included) with its
|
||||||
|
/// modification date — the shape "the registry touched nothing" takes as an assertion.
|
||||||
|
private func treeSnapshot(of root: URL) throws -> [String: Date] {
|
||||||
|
var snapshot: [String: Date] = [:]
|
||||||
|
|
||||||
|
func modified(_ url: URL) throws -> Date {
|
||||||
|
try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate ?? .distantPast
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot["."] = try modified(root)
|
||||||
|
let walker = FileManager.default.enumerator(
|
||||||
|
at: root,
|
||||||
|
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
while let url = walker?.nextObject() as? URL {
|
||||||
|
let relative = url.path.replacingOccurrences(of: root.path + "/", with: "")
|
||||||
|
snapshot[relative] = try modified(url)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ids(_ recents: [RecentBoard]) -> [UUID] {
|
||||||
|
recents.map(\.record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardRegistry")
|
||||||
|
struct BoardRegistryTests {
|
||||||
|
|
||||||
|
// MARK: Matching on open
|
||||||
|
|
||||||
|
@Test("A second open of the same board updates its record; a rename does not fool it")
|
||||||
|
func recordOpenMatchesByIdentity() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
|
||||||
|
let created = try #require(registry.record(id: id))
|
||||||
|
#expect(!created.bookmark.isEmpty)
|
||||||
|
#expect(created.displayName == "Todo Board")
|
||||||
|
#expect(created.lastKnownPath == fixture.root.path)
|
||||||
|
#expect(created.laneCount == nil, "counts are stamped at close, never guessed at open")
|
||||||
|
#expect(created.pushOnCommit == false)
|
||||||
|
#expect(created.remoteLocationWarned == false)
|
||||||
|
#expect(registry.recents().count == 1)
|
||||||
|
|
||||||
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
|
let again = registry.recordOpen(of: fixture.root, displayName: "Renamed In Title")
|
||||||
|
#expect(again == id, "the same folder is the same board")
|
||||||
|
#expect(registry.recents().count == 1, "a second open updates a record, it does not add one")
|
||||||
|
let updated = try #require(registry.record(id: id))
|
||||||
|
#expect(updated.displayName == "Renamed In Title")
|
||||||
|
#expect(updated.lastOpened > created.lastOpened)
|
||||||
|
|
||||||
|
// The done-when criterion: a Finder rename, then an open through the new path. Only file
|
||||||
|
// identity gets this right — every path-keyed answer produces a second record here.
|
||||||
|
let renamed = fixture.root
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.moveItem(at: fixture.root, to: renamed)
|
||||||
|
defer { try? FileManager.default.removeItem(at: renamed) }
|
||||||
|
|
||||||
|
let afterRename = registry.recordOpen(of: renamed, displayName: "Todo Board")
|
||||||
|
#expect(afterRename == id, "a renamed board keeps its record, and so keeps its settings")
|
||||||
|
#expect(registry.recents().count == 1)
|
||||||
|
#expect(registry.record(id: id)?.lastKnownPath == renamed.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Counts
|
||||||
|
|
||||||
|
@Test("Counts are stamped at close and read back without a scan")
|
||||||
|
func closeStampsCountsAndRecentsNeverScans() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
|
||||||
|
registry.recordClose(id: id, laneCount: 2, cardCount: 7)
|
||||||
|
|
||||||
|
// The board grows after the close — an agent filing cards, a colleague's pull. A welcome
|
||||||
|
// window that scanned would notice; this one must not, because scanning is what makes
|
||||||
|
// welcome slow on a big board and hangs it on an unavailable one.
|
||||||
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
|
try fixture.item("\(Ident.lane2)/\(Ident.card2)", Item.rich(order: "1024", title: "Second"))
|
||||||
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "2048", title: "Third"))
|
||||||
|
|
||||||
|
let rows = registry.recents()
|
||||||
|
#expect(rows.count == 1)
|
||||||
|
guard case let .available(record, at: url) = rows[0] else {
|
||||||
|
Issue.record("expected the board to be available, got \(rows[0])")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(record.laneCount == 2, "the stamped count, not the tree's count")
|
||||||
|
#expect(record.cardCount == 7)
|
||||||
|
#expect(FileIdentity(of: url) == FileIdentity(of: fixture.root))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Orphaning
|
||||||
|
|
||||||
|
@Test("A deleted board is orphaned in recents and can be forgotten")
|
||||||
|
func deletedBoardIsOrphaned() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
let id = registry.recordOpen(of: fixture.root, displayName: "Doomed")
|
||||||
|
registry.recordClose(id: id, laneCount: 1, cardCount: 1)
|
||||||
|
fixture.tearDown()
|
||||||
|
|
||||||
|
let rows = registry.recents()
|
||||||
|
#expect(rows.count == 1)
|
||||||
|
guard case let .unavailable(record) = rows[0] else {
|
||||||
|
Issue.record("expected an orphan, got \(rows[0])")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(record.id == id)
|
||||||
|
#expect(record.displayName == "Doomed", "an orphan still renders — with Forget, not nothing")
|
||||||
|
#expect(record.lastKnownPath == fixture.root.path)
|
||||||
|
|
||||||
|
registry.forget(id: id)
|
||||||
|
#expect(registry.recents().isEmpty)
|
||||||
|
#expect(registry.record(id: id) == nil)
|
||||||
|
#expect(BoardRegistry(storageURL: storage.url).recents().isEmpty, "forgetting persists")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A bookmark that cannot resolve at all classifies as unavailable")
|
||||||
|
func unresolvableBookmarkIsUnavailable() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
|
||||||
|
// Hand-written rather than produced by the registry: deleting a folder *ought* to make its
|
||||||
|
// bookmark stop resolving, but "ought to" is the filesystem's opinion, and this rule needs a
|
||||||
|
// case that cannot resolve by construction. It doubles as the only place the on-disk shape
|
||||||
|
// is pinned literally — including the fractional-seconds timestamp format.
|
||||||
|
let id = UUID()
|
||||||
|
let garbage = Data("not a bookmark".utf8).base64EncodedString()
|
||||||
|
let json = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"bookmark" : "\(garbage)",
|
||||||
|
"cardCount" : 9,
|
||||||
|
"displayName" : "Archive",
|
||||||
|
"id" : "\(id.uuidString)",
|
||||||
|
"laneCount" : 4,
|
||||||
|
"lastKnownPath" : "/Volumes/Archive/Boards/Archive",
|
||||||
|
"lastOpened" : "2026-01-01T09:00:00.000Z",
|
||||||
|
"pushOnCommit" : true,
|
||||||
|
"remoteLocationWarned" : true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
try Data(json.utf8).write(to: storage.url)
|
||||||
|
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
let rows = registry.recents()
|
||||||
|
#expect(rows.count == 1, "an unreadable bookmark is an orphaned row, not a decoding failure")
|
||||||
|
guard case let .unavailable(record) = rows[0] else {
|
||||||
|
Issue.record("expected an orphan, got \(rows[0])")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(record.id == id)
|
||||||
|
#expect(record.laneCount == 4, "an orphan still shows the counts it was closed with")
|
||||||
|
#expect(record.pushOnCommit)
|
||||||
|
#expect(record.remoteLocationWarned)
|
||||||
|
#expect(record.lastKnownPath == "/Volumes/Archive/Boards/Archive")
|
||||||
|
|
||||||
|
registry.forget(id: id)
|
||||||
|
#expect(registry.recents().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Persistence
|
||||||
|
|
||||||
|
@Test("Every mutation survives a reload of the file, dates included")
|
||||||
|
func mutationsPersistAcrossInstances() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let first = try makeBoard()
|
||||||
|
defer { first.tearDown() }
|
||||||
|
let second = try makeBoard()
|
||||||
|
defer { second.tearDown() }
|
||||||
|
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
let firstID = registry.recordOpen(of: first.root, displayName: "First")
|
||||||
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
|
let secondID = registry.recordOpen(of: second.root, displayName: "Second")
|
||||||
|
registry.recordClose(id: firstID, laneCount: 3, cardCount: 11)
|
||||||
|
registry.updateWindowFrame(id: firstID, frame: WindowFrame(x: 120, y: 60, width: 1440, height: 900))
|
||||||
|
registry.setPushOnCommit(id: firstID, true)
|
||||||
|
registry.setRemoteLocationWarned(id: firstID)
|
||||||
|
|
||||||
|
let reloaded = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
// Equality of the whole record, not a field-by-field approximation: `lastOpened` is stamped
|
||||||
|
// at the resolution the file records, so the reloaded value is the *same* date rather than
|
||||||
|
// one within a second of it.
|
||||||
|
#expect(reloaded.record(id: firstID) == registry.record(id: firstID))
|
||||||
|
#expect(reloaded.record(id: secondID) == registry.record(id: secondID))
|
||||||
|
|
||||||
|
let restored = try #require(reloaded.record(id: firstID))
|
||||||
|
#expect(restored.laneCount == 3)
|
||||||
|
#expect(restored.cardCount == 11)
|
||||||
|
#expect(restored.windowFrame == WindowFrame(x: 120, y: 60, width: 1440, height: 900))
|
||||||
|
#expect(restored.pushOnCommit)
|
||||||
|
#expect(restored.remoteLocationWarned)
|
||||||
|
#expect(restored.lastOpened == registry.record(id: firstID)?.lastOpened)
|
||||||
|
|
||||||
|
#expect(ids(reloaded.recents()) == ids(registry.recents()), "order survives too")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Corruption
|
||||||
|
|
||||||
|
@Test("A corrupt file is quarantined, not deleted, and the registry carries on empty")
|
||||||
|
func corruptFileIsQuarantined() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let garbage = Data("{ this is not the registry you are looking for".utf8)
|
||||||
|
try garbage.write(to: storage.url)
|
||||||
|
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
#expect(registry.recents().isEmpty, "app-private convenience state never takes the app down")
|
||||||
|
|
||||||
|
let quarantined = try storage.entryNames().filter { $0 != storage.url.lastPathComponent }
|
||||||
|
#expect(quarantined.count == 1)
|
||||||
|
let quarantinedName = try #require(quarantined.first)
|
||||||
|
#expect(quarantinedName.contains("corrupt"))
|
||||||
|
#expect(quarantinedName.hasSuffix(".json"))
|
||||||
|
let preserved = try Data(contentsOf: storage.folder.appendingPathComponent(quarantinedName))
|
||||||
|
#expect(preserved == garbage, "renamed aside, never deleted — it may be the only trace of the user's boards")
|
||||||
|
|
||||||
|
// And the registry is usable from here: the next save writes a clean file over the hole the
|
||||||
|
// quarantine left.
|
||||||
|
let id = registry.recordOpen(of: fixture.root, displayName: "Fresh Start")
|
||||||
|
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.displayName == "Fresh Start")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Files-first
|
||||||
|
|
||||||
|
@Test("Nothing the registry does touches the board folder")
|
||||||
|
func theBoardFolderIsNeverTouched() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let before = try treeSnapshot(of: fixture.root)
|
||||||
|
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
let id = registry.recordOpen(of: fixture.root, displayName: "Untouched")
|
||||||
|
registry.recordClose(id: id, laneCount: 1, cardCount: 1)
|
||||||
|
registry.updateWindowFrame(id: id, frame: WindowFrame(x: 0, y: 0, width: 800, height: 600))
|
||||||
|
registry.setPushOnCommit(id: id, true)
|
||||||
|
registry.setRemoteLocationWarned(id: id)
|
||||||
|
_ = registry.recents()
|
||||||
|
_ = BoardRegistry(storageURL: storage.url).recents()
|
||||||
|
|
||||||
|
let after = try treeSnapshot(of: fixture.root)
|
||||||
|
#expect(after == before, "no sidecar, no frontmatter key, no xattr — nothing app-private goes in the board")
|
||||||
|
#expect(!storage.url.path.hasPrefix(fixture.root.path), "and the file itself lives elsewhere entirely")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Ordering
|
||||||
|
|
||||||
|
@Test("Recents is this registry sorted by last-opened, and an open bumps a board to the top")
|
||||||
|
func recentsOrderFollowsLastOpened() async throws {
|
||||||
|
let storage = try RegistryStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let first = try makeBoard()
|
||||||
|
defer { first.tearDown() }
|
||||||
|
let second = try makeBoard()
|
||||||
|
defer { second.tearDown() }
|
||||||
|
let third = try makeBoard()
|
||||||
|
defer { third.tearDown() }
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
|
let firstID = registry.recordOpen(of: first.root, displayName: "First")
|
||||||
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
|
let secondID = registry.recordOpen(of: second.root, displayName: "Second")
|
||||||
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
|
let thirdID = registry.recordOpen(of: third.root, displayName: "Third")
|
||||||
|
|
||||||
|
#expect(ids(registry.recents()) == [thirdID, secondID, firstID])
|
||||||
|
|
||||||
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
|
_ = registry.recordOpen(of: first.root, displayName: "First")
|
||||||
|
#expect(ids(registry.recents()) == [firstID, thirdID, secondID])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Bookmarks in a sandboxed host
|
||||||
|
|
||||||
|
@Test("A bookmark is always produced, and resolves back to the same folder")
|
||||||
|
func bookmarkCreationAndResolutionRoundTrip() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
// The contract the fallback exists for: *some* bookmark is always available. Which flavor is
|
||||||
|
// the sandbox's call, and production's answer is the security-scoped one — every board URL
|
||||||
|
// there arrives through NSOpenPanel or a drag and already carries access.
|
||||||
|
let bookmark = try #require(BoardRegistry.makeBookmark(for: fixture.root))
|
||||||
|
print("BoardRegistryTests: bookmark flavor in this test host = \(bookmark.isSecurityScoped ? "security-scoped" : "plain")")
|
||||||
|
|
||||||
|
let resolution = try #require(BoardRegistry.resolve(bookmark.data))
|
||||||
|
let target = try #require(FileIdentity(of: fixture.root))
|
||||||
|
let resolved = BoardRegistry.withScopedAccess(to: resolution.url) { FileIdentity(of: $0) }
|
||||||
|
#expect(resolved == target, "a bookmark of either flavor names the file, not the path")
|
||||||
|
|
||||||
|
// Resolution's own fallback, forced: whichever flavor this host hands out, `resolve` must
|
||||||
|
// also cope with a plain bookmark, because a registry file written by a non-sandboxed debug
|
||||||
|
// build (or on a host where the security-scoped attempt failed) is full of them. Nothing
|
||||||
|
// else in this suite reaches that branch when the sandbox is cooperating.
|
||||||
|
let plain = try #require(try? fixture.root.bookmarkData(options: []))
|
||||||
|
let plainResolution = try #require(BoardRegistry.resolve(plain))
|
||||||
|
#expect(FileIdentity(of: plainResolution.url) == target)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// `BoardStoreRegistry`'s whole job is *sharing*: one store and one watcher per board, handed to
|
||||||
|
/// every window that asks, torn down once the last one lets go. So these tests are almost entirely
|
||||||
|
/// about object identity (`===`/`!==`) and about the count that orders teardown — the two things a
|
||||||
|
/// second store or a leaked watcher would break silently.
|
||||||
|
///
|
||||||
|
/// One test deliberately runs the **real** FSEvents path rather than poking
|
||||||
|
/// `handleWatcherEvent(_:)` by hand: the registry's reason to exist is the wiring, and wiring
|
||||||
|
/// asserted against a hand-delivered event is wiring that was never tested. It borrows
|
||||||
|
/// `FolderWatcherTests`' idiom for that — generous polling when waiting *for* something, never a
|
||||||
|
/// fixed sleep standing in for an ordering claim.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`, like every other suite
|
||||||
|
/// here that needs a real board in a real temp directory.
|
||||||
|
|
||||||
|
/// Frontmatter that opens and closes but does not parse — the fail-fast case, borrowed in shape
|
||||||
|
/// from `BoardStoreTests`.
|
||||||
|
private let brokenIndex = "---\nschema: 1\norder: 1024\nlabels: [a, b\n---\nbody\n"
|
||||||
|
|
||||||
|
/// Two lanes, two cards in the first. Enough tree that a reload has something to notice.
|
||||||
|
@MainActor
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||||
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] {
|
||||||
|
(snapshot.lanes.first { $0.id.rawValue == id }?.cards ?? []).compactMap(\.title.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is
|
||||||
|
/// not a bounded-latency promise and a slow machine must not fail a correctness test.
|
||||||
|
@MainActor
|
||||||
|
private func waitUntil(_ deadline: Duration = .seconds(10), _ condition: () -> Bool) async {
|
||||||
|
let start = ContinuousClock.now
|
||||||
|
while ContinuousClock.now - start < deadline {
|
||||||
|
if condition() { return }
|
||||||
|
try? await Task.sleep(for: .milliseconds(25))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gives a freshly started stream a beat to register with `fseventsd`. Without it the first write
|
||||||
|
/// of a test can land in the window between `FSEventStreamStart` and the stream actually being
|
||||||
|
/// live — see `FolderWatcherTests` for the same note.
|
||||||
|
@MainActor
|
||||||
|
private func settle() async {
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStoreRegistry")
|
||||||
|
struct BoardStoreRegistryTests {
|
||||||
|
|
||||||
|
// MARK: Sharing
|
||||||
|
|
||||||
|
@Test("Two acquires of one board share a store; two boards get two")
|
||||||
|
func acquireSharesOneStorePerBoard() async throws {
|
||||||
|
let first = try makeBoard()
|
||||||
|
defer { first.tearDown() }
|
||||||
|
let second = try makeBoard()
|
||||||
|
defer { second.tearDown() }
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
|
||||||
|
// The board window, then one of its card windows.
|
||||||
|
let boardWindowStore = try registry.acquire(first.root)
|
||||||
|
let cardWindowStore = try registry.acquire(first.root)
|
||||||
|
|
||||||
|
#expect(boardWindowStore === cardWindowStore, "a card window must share its board's store, not load a second one")
|
||||||
|
#expect(registry.openBoardCount == 1, "two references, one open board")
|
||||||
|
|
||||||
|
let other = try registry.acquire(second.root)
|
||||||
|
#expect(other !== boardWindowStore)
|
||||||
|
#expect(registry.openBoardCount == 2)
|
||||||
|
|
||||||
|
registry.release(boardWindowStore)
|
||||||
|
registry.release(cardWindowStore)
|
||||||
|
registry.release(other)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Refcounted teardown
|
||||||
|
|
||||||
|
@Test("The last release tears the board down; a later acquire opens it fresh")
|
||||||
|
func refcountOrdersTeardown() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
|
||||||
|
let store = try registry.acquire(fixture.root)
|
||||||
|
_ = try registry.acquire(fixture.root)
|
||||||
|
|
||||||
|
// The card window closes first. The board is still on screen, so nothing may be torn down —
|
||||||
|
// this is the ordering the refcount exists for.
|
||||||
|
registry.release(store)
|
||||||
|
#expect(registry.liveStore(for: fixture.root) === store)
|
||||||
|
#expect(registry.openBoardCount == 1)
|
||||||
|
|
||||||
|
registry.release(store)
|
||||||
|
#expect(registry.liveStore(for: fixture.root) == nil)
|
||||||
|
#expect(registry.openBoardCount == 0)
|
||||||
|
|
||||||
|
// Reopening is a genuine open — a fresh load, a fresh watcher — not a resurrection of the
|
||||||
|
// store that was let go.
|
||||||
|
let reopened = try registry.acquire(fixture.root)
|
||||||
|
#expect(reopened !== store)
|
||||||
|
#expect(registry.openBoardCount == 1)
|
||||||
|
registry.release(reopened)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Identity, not paths
|
||||||
|
|
||||||
|
@Test("A board acquired through a renamed path lands on the store it already has")
|
||||||
|
func acquireFollowsFileIdentityAcrossARename() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
|
||||||
|
let store = try registry.acquire(fixture.root)
|
||||||
|
|
||||||
|
// A Finder rename, which 01-storage-format.md calls ordinary: same volume, same folder, new
|
||||||
|
// name. The board is the file, not the string that names it.
|
||||||
|
let renamed = fixture.root
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.moveItem(at: fixture.root, to: renamed)
|
||||||
|
defer { try? FileManager.default.removeItem(at: renamed) }
|
||||||
|
|
||||||
|
let again = try registry.acquire(renamed)
|
||||||
|
#expect(again === store, "path-keyed registries open a second store here; identity-keyed ones do not")
|
||||||
|
#expect(registry.openBoardCount == 1)
|
||||||
|
#expect(registry.liveStore(for: renamed) === store)
|
||||||
|
|
||||||
|
registry.release(store)
|
||||||
|
registry.release(store)
|
||||||
|
#expect(registry.openBoardCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The wiring
|
||||||
|
|
||||||
|
@Test("The watcher the registry attaches really drives the store")
|
||||||
|
func watcherWiringIsReal() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
|
||||||
|
let store = try registry.acquire(fixture.root)
|
||||||
|
defer { registry.release(store) }
|
||||||
|
|
||||||
|
// The other half of the wiring: the store can suspend the watcher for its own writes.
|
||||||
|
#expect(store.watcherBrackets != nil)
|
||||||
|
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
// A card folder appearing with no Writer and no bracket anywhere near it — an agent, or an
|
||||||
|
// editor. Nothing in this test hands the store an event; FSEvents does.
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
||||||
|
|
||||||
|
await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Third") }
|
||||||
|
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"])
|
||||||
|
#expect(store.reloadFailure == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Teardown races
|
||||||
|
|
||||||
|
@Test("Releasing a store the registry never handed out is a no-op")
|
||||||
|
func releasingAnUnknownStoreIsHarmless() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let stranger = try makeBoard()
|
||||||
|
defer { stranger.tearDown() }
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
|
||||||
|
let store = try registry.acquire(fixture.root)
|
||||||
|
|
||||||
|
// A store nobody registered — the shape a double-dismiss or a directly built test store
|
||||||
|
// takes. Trapping here would turn an ordinary window-close race into a crash.
|
||||||
|
registry.release(try BoardStore(rootURL: stranger.root))
|
||||||
|
#expect(registry.openBoardCount == 1)
|
||||||
|
#expect(registry.liveStore(for: fixture.root) === store)
|
||||||
|
|
||||||
|
// And releasing one twice past zero.
|
||||||
|
registry.release(store)
|
||||||
|
registry.release(store)
|
||||||
|
#expect(registry.openBoardCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Fail-fast
|
||||||
|
|
||||||
|
@Test("A board that fails to open leaves no entry behind")
|
||||||
|
func failedAcquireRegistersNothing() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item(Ident.lane1, brokenIndex)
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
|
||||||
|
do throws(BoardLoadError) {
|
||||||
|
_ = try registry.acquire(fixture.root)
|
||||||
|
Issue.record("expected the load to fail fast")
|
||||||
|
} catch {
|
||||||
|
if case .unparseableYAML = error.reason {} else {
|
||||||
|
Issue.record("expected unparseable YAML, got \(error.reason)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(registry.openBoardCount == 0, "a board that failed to open is not open")
|
||||||
|
#expect(registry.liveStore(for: fixture.root) == nil)
|
||||||
|
|
||||||
|
// And the registry is not poisoned by the failure: the repaired board opens normally.
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
let store = try registry.acquire(fixture.root)
|
||||||
|
#expect(registry.openBoardCount == 1)
|
||||||
|
#expect(store.snapshot.lanes.count == 2)
|
||||||
|
registry.release(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Acquiring a root that does not exist throws the loader's own error")
|
||||||
|
func acquireOfAMissingRootThrows() async throws {
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
let missing = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("no-such-board-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
|
||||||
|
do throws(BoardLoadError) {
|
||||||
|
_ = try registry.acquire(missing)
|
||||||
|
Issue.record("expected a missing root to fail")
|
||||||
|
} catch {
|
||||||
|
// The identity read fails first, and the registry deliberately says nothing about that —
|
||||||
|
// it lets `BoardStore`'s load produce the honest reason.
|
||||||
|
#expect(error.path == ".")
|
||||||
|
if case .unreadableRoot = error.reason {} else {
|
||||||
|
Issue.record("expected an unreadable root, got \(error.reason)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(registry.openBoardCount == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user