Remove the App Group wholesale — one sandbox, one bookmark, one flag
Phase 2 of the one-app pivot (DESIGN 12 ▸ App-side state, re-ruled
2026-07-30; reworks 566deab). AppGroup retires; what remains is
AppStateHome — ordinary sandbox Application Support as the one home for
the registry, clipboard staging and template stores, keeping the
unit-test-host redirect (the test host is the app and would sweep real
state). Scalar defaults return to UserDefaults.standard.
BoardRecord's per-edition grant slots and openNow flags collapse to one
bookmark + one isOpenNow; the legacy-key decode and adopt-in-memory
paths go (nothing shipped with group-era records), while the founding
four-keys-required / defaults-for-everything-since decode policy stays —
a bookmarkless record decodes as the born-orphan row rather than
quarantining the list. needsReopen and the pre-anchored re-grant panel
are removed whole: the only state that flow served — a record granted by
a sibling sandbox — is unrepresentable now, and a dead bookmark of our
own was already the orphan case by explicit comment. The
indexOfRecord path fallback dies with it; path is never a key again.
The cross-process freshness stamp (mtime+size re-read) and
BoardEditionPresence with its popover "Also open in…" line retire; the
clipboard prune keeps its atomic .sweeping/ claim-then-delete, reframed
for crash residue and open -n copies rather than sibling editions. The
application-groups entitlement key is gone.
1880 tests in 317 suites green (13 cross-edition tests retired with
their subject).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -55,71 +55,39 @@ public struct WindowFrame: Codable, Sendable, Equatable {
|
||||
/// 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).
|
||||
/// sidecar, no xattr — this record lives wholly in the app's own Application Support home
|
||||
/// (`AppStateHome`), 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.
|
||||
/// this format readable across releases; `init(from:)` below applies it by hand for every key past
|
||||
/// the four founding ones, which stay required so a genuinely broken file still quarantines.
|
||||
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.
|
||||
/// The security-scoped bookmark — **the board's identity** — refreshed on every open.
|
||||
///
|
||||
/// 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]
|
||||
/// Empty is the degenerate case where the system refused to mint one: born orphaned, shown in
|
||||
/// recents with Forget, never matching an open. It is not an error and never fails a decode.
|
||||
public var bookmark: 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**.
|
||||
/// Whether this board is open right now — the restoration set, as a live marker rather than an
|
||||
/// at-quit write (02-architecture.md § Launch and window lifecycle, settled).
|
||||
///
|
||||
/// 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?
|
||||
/// 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 flag describes 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.
|
||||
public var isOpenNow: Bool
|
||||
|
||||
/// Last-known title or folder name, for the recents row. Display only.
|
||||
public var displayName: String
|
||||
@@ -127,13 +95,8 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
/// 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.
|
||||
/// **Never used for matching** — that is the bookmark's job, and a path used as a key would
|
||||
/// reintroduce exactly the identity-by-string bug this design excludes.
|
||||
public var lastKnownPath: String
|
||||
|
||||
public var lastOpened: Date
|
||||
@@ -197,7 +160,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
grants: [String: Data] = [:],
|
||||
bookmark: Data,
|
||||
displayName: String,
|
||||
lastKnownPath: String,
|
||||
lastOpened: Date,
|
||||
@@ -205,14 +168,14 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
cardCount: Int? = nil,
|
||||
windowFrame: WindowFrame? = nil,
|
||||
cardWindowFrames: [String: WindowFrame]? = nil,
|
||||
openNow: [String: Bool] = [:],
|
||||
isOpenNow: Bool = false,
|
||||
pushOnCommit: Bool = false,
|
||||
remoteLocationWarned: Bool = false,
|
||||
icon: String? = nil,
|
||||
iconColor: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.grants = grants
|
||||
self.bookmark = bookmark
|
||||
self.displayName = displayName
|
||||
self.lastKnownPath = lastKnownPath
|
||||
self.lastOpened = lastOpened
|
||||
@@ -220,77 +183,27 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
self.cardCount = cardCount
|
||||
self.windowFrame = windowFrame
|
||||
self.cardWindowFrames = cardWindowFrames
|
||||
self.openNow = openNow
|
||||
self.isOpenNow = isOpenNow
|
||||
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`.
|
||||
/// Hand-written rather than synthesized, to say § Evolving this struct's policy in code instead
|
||||
/// of implying it through `Optional`: the four founding keys are **required**, so a genuinely
|
||||
/// broken file still quarantines, and every key added since carries a decoding default, so a
|
||||
/// registry written by an older build keeps every record it can.
|
||||
///
|
||||
/// `bookmark` is defaulted rather than required for the same reason it is allowed to be empty at
|
||||
/// all: a record with no key to the board is a recents row with Forget (the born-orphaned case),
|
||||
/// which is a far better outcome than quarantining the user's whole list over one entry.
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case grants
|
||||
case openNow
|
||||
case bookmark
|
||||
case isOpenNow
|
||||
case displayName
|
||||
case lastKnownPath
|
||||
case lastOpened
|
||||
@@ -302,10 +215,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
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 {
|
||||
@@ -314,8 +223,8 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
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) ?? [:]
|
||||
bookmark = try container.decodeIfPresent(Data.self, forKey: .bookmark) ?? Data()
|
||||
isOpenNow = try container.decodeIfPresent(Bool.self, forKey: .isOpenNow) ?? false
|
||||
laneCount = try container.decodeIfPresent(Int.self, forKey: .laneCount)
|
||||
cardCount = try container.decodeIfPresent(Int.self, forKey: .cardCount)
|
||||
windowFrame = try container.decodeIfPresent(WindowFrame.self, forKey: .windowFrame)
|
||||
@@ -324,15 +233,13 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
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(bookmark, forKey: .bookmark)
|
||||
try container.encode(isOpenNow, forKey: .isOpenNow)
|
||||
try container.encode(displayName, forKey: .displayName)
|
||||
try container.encode(lastKnownPath, forKey: .lastKnownPath)
|
||||
try container.encode(lastOpened, forKey: .lastOpened)
|
||||
@@ -344,28 +251,8 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
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
|
||||
// An unknown key is dropped, exactly as the synthesized conformance dropped it: the file's
|
||||
// forward tolerance is a decoding property, and nothing here preserves what it cannot read.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,48 +269,27 @@ public enum RecentBoard: Sendable, Equatable {
|
||||
/// 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.
|
||||
/// Where the board is now, or `nil` for an orphan.
|
||||
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
|
||||
case .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`).
|
||||
/// The persistent side of per-board app state: one record per known board, in the app's Application
|
||||
/// Support home (02-architecture.md § Per-board app state; `AppStateHome`).
|
||||
///
|
||||
/// ### Three rules do most of the work
|
||||
///
|
||||
@@ -446,31 +312,20 @@ public enum RecentBoard: Sendable, Equatable {
|
||||
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.
|
||||
/// every test want to name it, and because injecting it is how a test stays out of the real
|
||||
/// Application Support home (`AppStateHome`) — which is the developer's own running copy's state,
|
||||
/// so a suite writing there would be editing a real recents list.
|
||||
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.
|
||||
/// `<Application Support>/board-registry.json`, inside the sandbox container — one app, one
|
||||
/// sandbox, so the container is already this app's alone and no bundle-id subfolder is wanted
|
||||
/// (`AppStateHome`).
|
||||
public static var defaultStorageURL: URL {
|
||||
AppGroup.stateDirectory.appendingPathComponent("board-registry.json", isDirectory: false)
|
||||
AppStateHome.directory.appendingPathComponent("board-registry.json", isDirectory: false)
|
||||
}
|
||||
|
||||
/// Loads the registry, tolerating everything a file on disk can be.
|
||||
@@ -481,84 +336,13 @@ public final class BoardRegistry {
|
||||
/// 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) {
|
||||
/// **Read once, at construction.** There is one app and macOS runs one instance of it, so nothing
|
||||
/// else writes this file while this object lives — the in-memory array is the file, and every
|
||||
/// mutation saves it whole immediately.
|
||||
public init(storageURL: URL) {
|
||||
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
|
||||
}
|
||||
self.records = loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Opening and closing
|
||||
@@ -613,8 +397,6 @@ public final class BoardRegistry {
|
||||
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
|
||||
@@ -624,11 +406,7 @@ public final class BoardRegistry {
|
||||
}
|
||||
|
||||
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)
|
||||
records[index].bookmark = bookmark
|
||||
if let displayName {
|
||||
records[index].displayName = displayName
|
||||
records[index].icon = icon
|
||||
@@ -641,7 +419,7 @@ public final class BoardRegistry {
|
||||
}
|
||||
|
||||
let record = BoardRecord(
|
||||
grants: [editionID: bookmark],
|
||||
bookmark: bookmark,
|
||||
displayName: displayName ?? Self.folderName(of: rootURL),
|
||||
lastKnownPath: rootURL.path,
|
||||
lastOpened: Self.stamp(),
|
||||
@@ -694,7 +472,7 @@ public final class BoardRegistry {
|
||||
/// 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) }
|
||||
update(id) { $0.isOpenNow = true }
|
||||
}
|
||||
|
||||
/// Clears the marker — **user-initiated close only**.
|
||||
@@ -705,20 +483,7 @@ public final class BoardRegistry {
|
||||
/// 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) ?? []
|
||||
update(id) { $0.isOpenNow = false }
|
||||
}
|
||||
|
||||
/// The boards to reopen at launch: every record still flagged open, **oldest `lastOpened`
|
||||
@@ -732,15 +497,10 @@ public final class BoardRegistry {
|
||||
/// (§ "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) }
|
||||
.filter { $0.record.isOpenNow }
|
||||
// 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.
|
||||
@@ -795,7 +555,6 @@ public final class BoardRegistry {
|
||||
/// 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
|
||||
@@ -838,27 +597,18 @@ public final class BoardRegistry {
|
||||
/// 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 }
|
||||
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].setGrant(refreshed.data, forEdition: editionID)
|
||||
records[index].bookmark = refreshed.data
|
||||
refreshedAny = true
|
||||
}
|
||||
}
|
||||
@@ -875,30 +625,20 @@ public final class BoardRegistry {
|
||||
.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 }
|
||||
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()
|
||||
@@ -914,7 +654,6 @@ public final class BoardRegistry {
|
||||
/// 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()
|
||||
@@ -922,22 +661,16 @@ public final class BoardRegistry {
|
||||
|
||||
// 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.
|
||||
/// The index of the record whose bookmark resolves to the same file as `url`, if any.
|
||||
///
|
||||
/// **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.
|
||||
/// **File identity is the rule, and the only one.** A record whose bookmark no longer resolves is
|
||||
/// skipped rather than matched by its recorded path: a path used as a fallback key is exactly the
|
||||
/// identity-by-string bug this design excludes.
|
||||
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 }
|
||||
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
|
||||
@@ -946,21 +679,6 @@ public final class BoardRegistry {
|
||||
// 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? {
|
||||
@@ -970,7 +688,6 @@ public final class BoardRegistry {
|
||||
/// 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
|
||||
@@ -1111,10 +828,6 @@ public final class BoardRegistry {
|
||||
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)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user