Phase 1 of the 2026-07-30 one-app pivot (DESIGN 0bec9a6, card c3a3ddd5):
the KanbanPro target, LaneworkPro scheme, KanbanProTests module-alias
bundle, KanbanPro/ source root and scripts/verify-editions.sh retire
wholesale. project.yml reads as a single-target file again (anchors
inlined, header rewritten in tier vocabulary).
The edition twins merge: EditionTypes -> PasteboardTypes (one
UTType(exportedAs:) home — the one app owns the family types),
EditionAbout -> AboutBox (the quiet Pro signpost survives as the About
box's one line; "…in Settings" deferred until the StoreKit phase gives
it somewhere to point). InertGitTests drops its Base prefix — the
inert-.git posture is unconditional app behavior, unsubscribed and
lapsed being one state.
Entitlements gain com.apple.security.network.client, declared now and
dormant until Pro's remotes use it; no keychain access group. The App
Group key deliberately stays — it goes with AppGroup.swift in phase 2,
since pulling it first would silently drop the app into the fallback
container. README/RELEASE.md build-and-pipeline prose updated to the
one-record world; the subscription story lands with phase 3.
1893 tests in 322 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1188 lines
62 KiB
Swift
1188 lines
62 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 the shared App Group container, 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.
|
|
///
|
|
/// ### One record, shared by every edition — with two per-edition fields
|
|
///
|
|
/// **⚠ one-app collapse phase 2**: the two per-edition fields collapse to one grant and one flag
|
|
/// when the App Group goes (12-editions.md ▸ App-side state, re-ruled 2026-07-30) — there is no
|
|
/// second sandbox to hold a slot for. The section below describes the shipping code, which still
|
|
/// works; it is retired, not wrong.
|
|
///
|
|
/// The record lives in the family App Group container (`AppGroup`), so base, Pro and later Teams all
|
|
/// read and write the same one: an upgrader's recents, frames and settings are simply *there*
|
|
/// (12-editions.md ▸ Distribution, ruled 2026-07-29). Two fields cannot be common, and both are
|
|
/// keyed by bundle id for the same reason:
|
|
///
|
|
/// - **`grants`** — security-scoped bookmarks never cross sandboxes (12: "minted per sandbox, App
|
|
/// Group or not"), so each edition holds its own. A record whose only grant another edition minted
|
|
/// resolves *unavailable-until-reopened* (`RecentBoard.needsReopen`) and its first click runs an
|
|
/// open panel pre-anchored at `lastKnownPath`.
|
|
/// - **`openNow`** — "an edition restores only the boards *it* had open" (12; 02 § Launch and window
|
|
/// lifecycle), which is also what lets the board popover say "Also open in Lanework Pro" from the
|
|
/// *other* edition's flag.
|
|
///
|
|
/// Everything else here is common, deliberately: a window frame, a cached title, a lane count and a
|
|
/// push-on-commit choice are facts about the board and this machine, not about which app is looking.
|
|
///
|
|
/// ### Evolving this struct
|
|
///
|
|
/// `BoardRegistry` responds to a file it cannot decode 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; `init(from:)` below applies it by hand for the keys that
|
|
/// arrived with the App Group, and keeps the four founding keys required exactly as they were.
|
|
public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|
public let id: UUID
|
|
|
|
/// The per-edition security-scoped grant slots — **the board's identity, per sandbox** — keyed by
|
|
/// bundle id and refreshed on every open *by that edition*. Empty for an edition means "this app
|
|
/// has never been granted this board", which is the unavailable-until-reopened state and not an
|
|
/// error.
|
|
///
|
|
/// A record with no grants at all is the degenerate case where the system refused to mint one:
|
|
/// born orphaned, shown in recents with Forget, never matching an open.
|
|
public var grants: [String: Data]
|
|
|
|
/// Per-edition open-now flags, keyed by bundle id — see `isOpen(inEdition:)` for what the flag
|
|
/// *means*, which is unchanged; only its keying is new.
|
|
public var openNow: [String: Bool]
|
|
|
|
/// The pre-App-Group single-grant key (`bookmark`), decoded and **never re-encoded**.
|
|
///
|
|
/// Its whole life is `BoardRegistry.adoptLegacyKeys()`: a record written before the grant slots
|
|
/// existed has one bookmark, minted by whichever edition wrote it — and since only base existed
|
|
/// then, adopting it as the *running* edition's slot is the coherent reading. Adopted in memory
|
|
/// at load and gone from the file on the first save, which is why nothing else here ever looks
|
|
/// at it.
|
|
public private(set) var legacyBookmark: Data?
|
|
|
|
/// The pre-App-Group single open-now key (`isOpenNow`), on the same terms as `legacyBookmark`.
|
|
public private(set) var legacyOpenNow: Bool?
|
|
|
|
/// 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.
|
|
///
|
|
/// **Not how a board is identified** — that is the grant slot's job, and a path used as the primary
|
|
/// key would reintroduce exactly the identity-by-string bug this design excludes. It has precisely
|
|
/// one narrow other use, which 12-editions.md itself nominates: for a record only *another* edition
|
|
/// holds a grant for, this is the open panel's anchor and the only locator this app has, so
|
|
/// `BoardRegistry.indexOfRecord(matching:)` falls back to it — after identity has failed, and only
|
|
/// against records holding no grant of ours. Without that, granting a board in the second edition
|
|
/// would fork the shared record in two.
|
|
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?
|
|
|
|
/// This board's **card** window frames, keyed by card GUID (05-card-window.md ▸ Window: "frames
|
|
/// restore per card across relaunch where state restoration allows").
|
|
///
|
|
/// Here rather than in `UserDefaults` for the reason the board's own frame is here: a frame
|
|
/// belongs to a board that the bookmark follows through renames and moves, and a path-keyed
|
|
/// preference would lose every card frame the first time the board was renamed. And here rather
|
|
/// than in the board folder because files-first is absolute — a window frame is per-machine app
|
|
/// state, never board data.
|
|
///
|
|
/// **The key is the card id case-folded** (`ItemID`'s comparison rule), so a folder respelled
|
|
/// `ABC…` finds the frame stored under `abc…` — the same identity rule the window key itself
|
|
/// uses, since the two must agree about what "this card" means.
|
|
///
|
|
/// The honest residual: entries accumulate for every card the user has ever opened a window for
|
|
/// on this board, and a card deleted afterwards leaves its entry behind. Accepted — the record
|
|
/// is per-machine convenience state measured in tens of bytes per entry, and it dies wholesale
|
|
/// with Forget like everything else here.
|
|
public var cardWindowFrames: [String: 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 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(),
|
|
grants: [String: Data] = [:],
|
|
displayName: String,
|
|
lastKnownPath: String,
|
|
lastOpened: Date,
|
|
laneCount: Int? = nil,
|
|
cardCount: Int? = nil,
|
|
windowFrame: WindowFrame? = nil,
|
|
cardWindowFrames: [String: WindowFrame]? = nil,
|
|
openNow: [String: Bool] = [:],
|
|
pushOnCommit: Bool = false,
|
|
remoteLocationWarned: Bool = false,
|
|
icon: String? = nil,
|
|
iconColor: String? = nil
|
|
) {
|
|
self.id = id
|
|
self.grants = grants
|
|
self.displayName = displayName
|
|
self.lastKnownPath = lastKnownPath
|
|
self.lastOpened = lastOpened
|
|
self.laneCount = laneCount
|
|
self.cardCount = cardCount
|
|
self.windowFrame = windowFrame
|
|
self.cardWindowFrames = cardWindowFrames
|
|
self.openNow = openNow
|
|
self.pushOnCommit = pushOnCommit
|
|
self.remoteLocationWarned = remoteLocationWarned
|
|
self.icon = icon
|
|
self.iconColor = iconColor
|
|
}
|
|
|
|
// MARK: The per-edition slots
|
|
|
|
/// This edition's grant, or `nil` when it holds none — the unavailable-until-reopened case.
|
|
///
|
|
/// An **empty** `Data` answers `nil` too: `recordOpen` stores one when the system refused to mint
|
|
/// a bookmark at all, and a slot holding nothing is indistinguishable from no slot for every
|
|
/// purpose this type has.
|
|
public func grant(forEdition editionID: String) -> Data? {
|
|
guard let grant = grants[editionID], !grant.isEmpty else { return nil }
|
|
return grant
|
|
}
|
|
|
|
public mutating func setGrant(_ grant: Data, forEdition editionID: String) {
|
|
grants[editionID] = grant
|
|
}
|
|
|
|
/// Whether this board is open in `editionID` 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.
|
|
///
|
|
/// **Per edition** (12-editions.md ▸ Both editions installed): an edition restores only the
|
|
/// boards *it* had open, so a board Pro has open never reopens in base — and the other direction
|
|
/// of the same fact is the popover's awareness line.
|
|
public func isOpen(inEdition editionID: String) -> Bool {
|
|
openNow[editionID] == true
|
|
}
|
|
|
|
public mutating func setOpen(_ isOpen: Bool, inEdition editionID: String) {
|
|
openNow[editionID] = isOpen
|
|
}
|
|
|
|
/// The editions other than `editionID` that have this board flagged open, sorted so the answer is
|
|
/// stable rather than a dictionary's order.
|
|
///
|
|
/// **Liveness is not checked here** — that is the caller's, because a flag is a *claim* and a
|
|
/// crashed edition leaves its claims standing by design. See `BoardEditionPresence` for the rule
|
|
/// that turns this list into a line the popover can honestly show.
|
|
public func otherEditionsOpen(besides editionID: String) -> [String] {
|
|
openNow.filter { $0.key != editionID && $0.value }.keys.sorted()
|
|
}
|
|
|
|
/// Whether any edition at all holds a grant for this board — what tells
|
|
/// unavailable-until-reopened (some other edition minted the only grant) apart from a genuine
|
|
/// orphan (nobody can reach it).
|
|
public var isGrantedByAnyEdition: Bool {
|
|
grants.contains { !$0.value.isEmpty }
|
|
}
|
|
|
|
// MARK: Codable
|
|
|
|
/// Hand-written for exactly one reason: the two pre-App-Group keys have to keep decoding while
|
|
/// never being written again (see `legacyBookmark`). Everything else is the synthesized
|
|
/// behaviour restated — required for the four founding keys, so a genuinely broken file still
|
|
/// quarantines, and defaulted for every key added since, which is § Evolving this struct's policy
|
|
/// spelled out rather than implied by an `Optional`.
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case grants
|
|
case openNow
|
|
case displayName
|
|
case lastKnownPath
|
|
case lastOpened
|
|
case laneCount
|
|
case cardCount
|
|
case windowFrame
|
|
case cardWindowFrames
|
|
case icon
|
|
case iconColor
|
|
case pushOnCommit
|
|
case remoteLocationWarned
|
|
/// Pre-App-Group. Read, never written.
|
|
case bookmark
|
|
/// Pre-App-Group. Read, never written.
|
|
case isOpenNow
|
|
}
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(UUID.self, forKey: .id)
|
|
displayName = try container.decode(String.self, forKey: .displayName)
|
|
lastKnownPath = try container.decode(String.self, forKey: .lastKnownPath)
|
|
lastOpened = try container.decode(Date.self, forKey: .lastOpened)
|
|
grants = try container.decodeIfPresent([String: Data].self, forKey: .grants) ?? [:]
|
|
openNow = try container.decodeIfPresent([String: Bool].self, forKey: .openNow) ?? [:]
|
|
laneCount = try container.decodeIfPresent(Int.self, forKey: .laneCount)
|
|
cardCount = try container.decodeIfPresent(Int.self, forKey: .cardCount)
|
|
windowFrame = try container.decodeIfPresent(WindowFrame.self, forKey: .windowFrame)
|
|
cardWindowFrames = try container.decodeIfPresent([String: WindowFrame].self, forKey: .cardWindowFrames)
|
|
icon = try container.decodeIfPresent(String.self, forKey: .icon)
|
|
iconColor = try container.decodeIfPresent(String.self, forKey: .iconColor)
|
|
pushOnCommit = try container.decodeIfPresent(Bool.self, forKey: .pushOnCommit) ?? false
|
|
remoteLocationWarned = try container.decodeIfPresent(Bool.self, forKey: .remoteLocationWarned) ?? false
|
|
legacyBookmark = try container.decodeIfPresent(Data.self, forKey: .bookmark)
|
|
legacyOpenNow = try container.decodeIfPresent(Bool.self, forKey: .isOpenNow)
|
|
}
|
|
|
|
public func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(id, forKey: .id)
|
|
try container.encode(grants, forKey: .grants)
|
|
try container.encode(openNow, forKey: .openNow)
|
|
try container.encode(displayName, forKey: .displayName)
|
|
try container.encode(lastKnownPath, forKey: .lastKnownPath)
|
|
try container.encode(lastOpened, forKey: .lastOpened)
|
|
try container.encodeIfPresent(laneCount, forKey: .laneCount)
|
|
try container.encodeIfPresent(cardCount, forKey: .cardCount)
|
|
try container.encodeIfPresent(windowFrame, forKey: .windowFrame)
|
|
try container.encodeIfPresent(cardWindowFrames, forKey: .cardWindowFrames)
|
|
try container.encodeIfPresent(icon, forKey: .icon)
|
|
try container.encodeIfPresent(iconColor, forKey: .iconColor)
|
|
try container.encode(pushOnCommit, forKey: .pushOnCommit)
|
|
try container.encode(remoteLocationWarned, forKey: .remoteLocationWarned)
|
|
// `bookmark` and `isOpenNow` are deliberately absent: this is the tolerate-and-upgrade half
|
|
// of backward compatibility. An unknown key is dropped on the same terms — the synthesized
|
|
// conformance dropped them too, so the file's forward tolerance is exactly what it was.
|
|
}
|
|
|
|
/// Folds the two pre-App-Group keys into `editionID`'s slots and forgets them.
|
|
///
|
|
/// **Only when the modern slot is empty**, in both cases: a file carrying both shapes was written
|
|
/// by a build that already knew about grants, and the legacy key is then stale by definition.
|
|
///
|
|
/// **In memory only, at load.** The upgraded shape reaches disk on the next ordinary save —
|
|
/// "tolerate-and-upgrade on first write" — so merely *reading* a registry never rewrites it, and
|
|
/// a launch that opens nothing leaves the file exactly as it found it.
|
|
mutating func adoptLegacyKeys(as editionID: String) {
|
|
if let legacyBookmark, !legacyBookmark.isEmpty, grants.isEmpty {
|
|
grants[editionID] = legacyBookmark
|
|
}
|
|
if legacyOpenNow == true, openNow.isEmpty {
|
|
openNow[editionID] = true
|
|
}
|
|
legacyBookmark = nil
|
|
legacyOpenNow = nil
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
/// **Unavailable-until-reopened**: this edition holds no grant, but another edition does
|
|
/// (12-editions.md ▸ Distribution — "a record another edition minted resolves
|
|
/// unavailable-until-reopened, and the first click runs an open panel pre-anchored at the
|
|
/// recorded path: one click + Grant per board, once per edition").
|
|
///
|
|
/// `recordedAt` is `lastKnownPath` as a URL — the panel's anchor, and the one place that field is
|
|
/// used for anything but display. It is **not** a claim that the board is there: nothing here
|
|
/// touches the filesystem, and a board that has since moved simply opens the panel at its parent.
|
|
case needsReopen(BoardRecord, recordedAt: URL)
|
|
|
|
public var record: BoardRecord {
|
|
switch self {
|
|
case let .available(record, _): record
|
|
case let .unavailable(record): record
|
|
case let .needsReopen(record, _): record
|
|
}
|
|
}
|
|
|
|
/// Where the board is now, or `nil` when this edition cannot reach it — an orphan, or a record
|
|
/// awaiting this edition's grant. Both answer `nil` because both need something to happen before
|
|
/// a board can be opened; which something is `regrantAnchor`'s question.
|
|
public var url: URL? {
|
|
switch self {
|
|
case let .available(_, url): url
|
|
case .unavailable, .needsReopen: nil
|
|
}
|
|
}
|
|
|
|
/// Where an open panel should start when this row is clicked, or `nil` for a row a panel cannot
|
|
/// help — an available board (nothing to grant) or a genuine orphan (nothing to grant *to*).
|
|
public var regrantAnchor: URL? {
|
|
switch self {
|
|
case let .needsReopen(_, url): url
|
|
case .available, .unavailable: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - BoardRegistry
|
|
|
|
/// The persistent side of per-board app state: one record per known board, in the shared App Group
|
|
/// container (02-architecture.md § Per-board app state; `AppGroup`).
|
|
///
|
|
/// ### 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 shared
|
|
/// App Group container (`AppGroup`) — which after the 2026-07-29 ruling is the sibling edition's
|
|
/// registry too, so a suite writing there would be editing two apps' state.
|
|
public let storageURL: URL
|
|
|
|
/// **Which edition's slots this registry reads and writes** — the bundle id keying `grants` and
|
|
/// `openNow` on every shared record.
|
|
///
|
|
/// Injected rather than read from `Bundle.main` at each use for the reason `storageURL` is
|
|
/// injected: it is how a test can be Pro looking at base's records (and back again) inside one
|
|
/// process, which is the only way the cross-edition rules are checkable at all.
|
|
public let editionID: String
|
|
|
|
private var records: [BoardRecord]
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-registry")
|
|
|
|
/// `<group container>/Library/Application Support/board-registry.json` — the **shared** home
|
|
/// (12-editions.md ▸ Distribution, ruled 2026-07-29), with no bundle-id subfolder, because the
|
|
/// absence of that subfolder is what makes one list serve every edition.
|
|
///
|
|
/// `AppGroup.stateDirectory` falls back to the old per-edition path when the group is not
|
|
/// provisioned, so this is also the answer in a test host and in a locally signed build.
|
|
public static var defaultStorageURL: URL {
|
|
AppGroup.stateDirectory.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.
|
|
///
|
|
/// A file written before the per-edition slots existed is upgraded **in memory** on the way in
|
|
/// (`BoardRecord.adoptLegacyKeys(as:)`) — its one bookmark becomes this edition's grant — and
|
|
/// reaches disk in the new shape on the next ordinary save.
|
|
public init(storageURL: URL, editionID: String = AppGroup.editionID) {
|
|
self.storageURL = storageURL
|
|
self.editionID = editionID
|
|
self.records = []
|
|
self.records = loadRecords()
|
|
self.fileStamp = Self.fileStamp(of: storageURL)
|
|
}
|
|
|
|
// MARK: - Two editions, one file
|
|
|
|
/// What the file looked like as of this registry's last read or write.
|
|
///
|
|
/// The whole of the cross-edition freshness mechanism (see `syncFromDiskIfChanged`). `nil` means
|
|
/// "there is no file", which is the first-launch state and not a stale one.
|
|
private var fileStamp: FileStamp?
|
|
|
|
private struct FileStamp: Equatable {
|
|
let modified: Date
|
|
let size: Int
|
|
}
|
|
|
|
/// **`FileManager.attributesOfItem` and deliberately not `URL.resourceValues`.** A `URL` *caches*
|
|
/// resource values on the instance it was asked through, and this registry holds one `storageURL`
|
|
/// for its whole life — so the second question would be answered with the first question's answer,
|
|
/// and a stamp that never changes is a freshness check that never fires. (Measured, not assumed:
|
|
/// with `resourceValues` here, each edition kept its own pre-write view and the last save won
|
|
/// wholesale, which is exactly the bug this mechanism exists to prevent.)
|
|
private static func fileStamp(of url: URL) -> FileStamp? {
|
|
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
|
let modified = attributes[.modificationDate] as? Date,
|
|
let size = attributes[.size] as? Int
|
|
else { return nil }
|
|
return FileStamp(modified: modified, size: size)
|
|
}
|
|
|
|
/// Re-reads the file when somebody else has written it — **the sibling edition**.
|
|
///
|
|
/// ### Why this is needed at all
|
|
///
|
|
/// The registry is one shared file now (12-editions.md ▸ Distribution) and "the same board open in
|
|
/// both apps at once is fine … a supported steady state, not a transition to hurry past" (▸ Both
|
|
/// editions installed). But this type holds its records in memory for the life of a launch and
|
|
/// `save()` writes the array *wholesale* — so without this, base running for an hour would, on its
|
|
/// next window-frame save, silently erase every record Pro wrote in that hour. That is not a narrow
|
|
/// race; it is the ordinary outcome of the steady state the design blesses.
|
|
///
|
|
/// **Disk is the truth and this cache is only a cache**, which is what makes the fix this small:
|
|
/// there is no in-memory state to reconcile, because every mutation here saves immediately. So a
|
|
/// reload is a plain replacement, and the merge problem never arises.
|
|
///
|
|
/// ### Why a stamp rather than an unconditional read
|
|
///
|
|
/// One `stat` instead of a file read and a JSON parse, and — the part that matters — **exactly zero
|
|
/// behavioural change when one edition is running**: the stamp is recorded on every save, so our own
|
|
/// writes never look like somebody else's. Sub-second `contentModificationDate` plus the size makes
|
|
/// a missed change vanishingly unlikely, and the cost of missing one is the pre-existing behaviour.
|
|
///
|
|
/// The honest residual, stated: the window between this check and the write that follows it is still
|
|
/// last-writer-wins. It is microseconds rather than hours, and what it can cost is one convenience
|
|
/// field — 02 § Per-board app state's "its settings are conveniences" already accepts exactly that.
|
|
private func syncFromDiskIfChanged() {
|
|
let current = Self.fileStamp(of: storageURL)
|
|
guard current != fileStamp else { return }
|
|
Self.logger.debug("the registry file changed underneath us — re-reading the sibling edition's writes")
|
|
fileStamp = current
|
|
records = loadRecords()
|
|
}
|
|
|
|
/// The load, plus the legacy-key adoption every load applies (see `init`).
|
|
private func loadRecords() -> [BoardRecord] {
|
|
loadFromDisk().map { record in
|
|
var record = record
|
|
record.adoptLegacyKeys(as: editionID)
|
|
return record
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
syncFromDiskIfChanged()
|
|
|
|
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) {
|
|
// **This edition's slot only.** A match found through the cross-edition fallback below is
|
|
// precisely the re-grant flow (12-editions.md: "one click + Grant per board, once per
|
|
// edition") — it mints *this* edition's grant onto the shared record and leaves the other
|
|
// edition's untouched, so the board stays reachable from both.
|
|
records[index].setGrant(bookmark, forEdition: editionID)
|
|
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(
|
|
grants: [editionID: 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) { [editionID] in $0.setOpen(true, inEdition: editionID) }
|
|
}
|
|
|
|
/// 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) { [editionID] in $0.setOpen(false, inEdition: editionID) }
|
|
}
|
|
|
|
/// The **other** editions with this board flagged open — the raw flags, what the board popover's
|
|
/// awareness line is derived from (12-editions.md ▸ Both editions installed: "the board popover
|
|
/// carries a contextual awareness line … read from the other edition's flag,
|
|
/// pid-liveness-checked so crash residue never lies").
|
|
///
|
|
/// **Deliberately unfiltered.** Liveness is `BoardEditionPresence`'s half and lives there once: a
|
|
/// flag is a claim this file records faithfully, including the stale claim a crashed edition
|
|
/// leaves behind, and deciding which claims are still true needs `NSRunningApplication` — which
|
|
/// this `Foundation`-only file has no business reaching for.
|
|
public func otherEditionsFlaggedOpen(id: UUID) -> [String] {
|
|
record(id: id)?.otherEditionsOpen(besides: editionID) ?? []
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// **This edition's flags only** (12-editions.md ▸ Both editions installed): "an edition restores
|
|
/// only the boards *it* had open". A board Pro has open is Pro's to reopen, and base opening it
|
|
/// too at launch would be the app deciding for the user that two windows onto one board is what
|
|
/// they meant.
|
|
///
|
|
/// The preference gates only whether this is *consulted*; the flags are maintained regardless.
|
|
public func restorables() -> [RecentBoard] {
|
|
recents()
|
|
.filter { $0.record.isOpen(inEdition: editionID) }
|
|
// 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 }
|
|
}
|
|
|
|
/// One card window's remembered frame on this board, or `nil` when that card has never had one
|
|
/// (05-card-window.md ▸ Window). The caller decides what "no frame" means — for a card window it
|
|
/// means "open at the last-used size and cascade".
|
|
public func cardWindowFrame(id: UUID, cardID: ItemID) -> WindowFrame? {
|
|
record(id: id)?.cardWindowFrames?[cardID.canonicalValue]
|
|
}
|
|
|
|
/// Remembers one card window's frame. Keyed by `ItemID`'s own comparison value, so the read
|
|
/// above finds it whatever case the card's folder is spelled in.
|
|
///
|
|
/// **Unchanged writes nothing**, `syncDisplayState`'s rule: a card window reports its frame on
|
|
/// every move and at the end of every live resize, and the board window's twin already saves on
|
|
/// each of those — this one must not add a registry file write for a frame that did not move.
|
|
public func updateCardWindowFrame(id: UUID, cardID: ItemID, frame: WindowFrame) {
|
|
guard cardWindowFrame(id: id, cardID: cardID) != frame else { return }
|
|
update(id) { record in
|
|
var frames = record.cardWindowFrames ?? [:]
|
|
frames[cardID.canonicalValue] = frame
|
|
record.cardWindowFrames = frames
|
|
}
|
|
}
|
|
|
|
/// 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?) {
|
|
syncFromDiskIfChanged()
|
|
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.
|
|
///
|
|
/// **Three states, not two** (12-editions.md ▸ Distribution): a record this edition holds no grant
|
|
/// for, but some other edition does, is `needsReopen` — unavailable-until-reopened, with the
|
|
/// recorded path as the open panel's anchor. A record nobody can reach is the orphan it always
|
|
/// was. Only this edition's slot is ever resolved or refreshed; another edition's bookmark is not
|
|
/// this app's to resolve and would fail if it tried.
|
|
///
|
|
/// 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] {
|
|
syncFromDiskIfChanged()
|
|
|
|
var resolvedURLs: [UUID: URL] = [:]
|
|
var refreshedAny = false
|
|
|
|
for index in records.indices {
|
|
guard let grant = records[index].grant(forEdition: editionID),
|
|
let resolution = Self.resolve(grant) 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].setGrant(refreshed.data, forEdition: editionID)
|
|
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 if record.grant(forEdition: editionID) == nil, record.isGrantedByAnyEdition {
|
|
// No grant of ours, but somebody's — the cross-edition row. Note the order: a
|
|
// grant of ours that *failed to resolve* falls through to `unavailable` below,
|
|
// which is right. That is Graceful orphaning's case (the board is gone), not the
|
|
// re-grant case (the board is there and we were never given it).
|
|
.needsReopen(record, recordedAt: URL(fileURLWithPath: record.lastKnownPath, isDirectory: true))
|
|
} else {
|
|
.unavailable(record)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One record, fresh — the sibling edition's writes included, which is what lets the popover's
|
|
/// awareness line see a flag Pro set after this app launched.
|
|
public func record(id: UUID) -> BoardRecord? {
|
|
syncFromDiskIfChanged()
|
|
return 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) {
|
|
syncFromDiskIfChanged()
|
|
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() {
|
|
syncFromDiskIfChanged()
|
|
guard !records.isEmpty else { return }
|
|
records.removeAll()
|
|
save()
|
|
}
|
|
|
|
// MARK: - Matching
|
|
|
|
/// The index of the record whose grant resolves to the same file as `url`, if any — falling back
|
|
/// to the recorded path for records this edition has no grant for at all.
|
|
///
|
|
/// **File identity is still the rule.** The fallback is not a second identity mechanism; it is the
|
|
/// only locator available for a record only *another* edition can resolve, and it is the same
|
|
/// locator the design already nominates for that case — "an open panel pre-anchored at the
|
|
/// recorded path" (12-editions.md). It is consulted only after identity has failed, and only
|
|
/// against records holding no grant of ours, so a board this edition knows can never be matched by
|
|
/// its path. Without it, granting a board in the second edition would fork the shared record into
|
|
/// two and undo the whole point of sharing it.
|
|
private func indexOfRecord(matching url: URL) -> Int? {
|
|
guard let target = FileIdentity(of: url) else { return nil }
|
|
|
|
let byIdentity = records.firstIndex { record in
|
|
guard let grant = record.grant(forEdition: editionID),
|
|
let resolution = Self.resolve(grant) 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 }
|
|
}
|
|
if let byIdentity { return byIdentity }
|
|
|
|
let key = Self.pathKey(url.path)
|
|
return records.firstIndex { record in
|
|
record.grant(forEdition: editionID) == nil
|
|
&& record.isGrantedByAnyEdition
|
|
&& Self.pathKey(record.lastKnownPath) == key
|
|
}
|
|
}
|
|
|
|
/// How two paths are compared for "the same board" in the fallback above — standardized, never
|
|
/// resolved through symlinks, which is `WelcomeRow.pathKey`'s rule restated rather than imported
|
|
/// (this file is `Foundation`-only and must not reach into the app layer).
|
|
private static func pathKey(_ path: String) -> String {
|
|
URL(fileURLWithPath: path).standardizedFileURL.path
|
|
}
|
|
|
|
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) {
|
|
syncFromDiskIfChanged()
|
|
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)
|
|
// Recorded *after* the write, so our own bytes never read as the sibling's on the next
|
|
// `syncFromDiskIfChanged` — which is what keeps a single-edition launch behaving exactly as
|
|
// it did before that mechanism existed.
|
|
fileStamp = Self.fileStamp(of: storageURL)
|
|
} 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)")
|
|
}
|
|
}
|
|
}
|