Home app-side state in the shared App Group container

Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):

- AppGroup namespace: container resolution with per-edition fallback
  when unprovisioned, shared UserDefaults suite, edition identity, and
  a unit-test-host redirect (the test host IS the app — its launch
  sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
  openNow keyed by bundle id; hand-written Codable keeps legacy keys
  decoding (adopted in memory as the running edition's slots, upgraded
  on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
  first click runs an open panel pre-anchored at the recorded path,
  prompt "Grant"; recordOpen mints this edition's slot onto the
  matched shared record (path fallback only after identity fails and
  only against records holding no grant of ours, so re-granting never
  forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
  registry when the sibling edition wrote it, so one edition's save
  never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
  popover gains BoardEditionPresence ("Also open in Lanework Pro"),
  pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
  claims doomed trees by atomic rename into .sweeping/ then deletes,
  so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
  re-ruling; scalars (quick-style recents, window size) move to the
  shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
  family group). No pathfinder 1.x migrator: 1.x predates the
  registry; state starts fresh in the group container.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 20:18:15 -04:00
parent a99e1a52f0
commit 566deab506
28 changed files with 1733 additions and 181 deletions
+189
View File
@@ -0,0 +1,189 @@
import Foundation
import os
/// **The family App Group** where every edition's app-side state lives (12-editions.md
/// § Distribution, ruled 2026-07-29; 02-architecture.md § Per-board app state).
///
/// ### Why a shared container at all
///
/// Boards are files, so they need no migration between editions but the *app-side* state around
/// them (the recents list, per-board frames, the quick-style row, the clipboard's staged snapshot)
/// is app-private, and per-app-private means an upgrader lands on an empty home screen. So every
/// edition declares one group `group.dev.rzen.indie.Kanban` and homes that state in its
/// container **from day one**: base 2.0 ships with the entitlement, Pro's first release joins the
/// same group, Teams later does too, and at no point is there a migration or an ordering dependency
/// between them.
///
/// ### The one thing that cannot be shared
///
/// **Security-scoped bookmarks never cross sandboxes** App Group or not, a bookmark is minted for
/// one app's sandbox and resolves in that one only. So the registry record is *common* except for
/// a per-edition **grant slot** keyed by bundle id (`BoardRecord.grants`), and a record whose only
/// grant another edition minted reads as unavailable-until-reopened. Open-now flags are keyed the
/// same way, for the same shape of reason: an edition restores the boards *it* had open.
///
/// ### It degrades rather than fails
///
/// `containerURL(forSecurityApplicationGroupIdentifier:)` answers `nil` when the group is not
/// provisioned for the running binary a unit-test host without the capability, a locally signed
/// build before the group is registered on the team. Every path here falls back to the *previous*
/// per-edition Application Support home in that case, so nothing depends on provisioning to work:
/// state simply stops being shared, which is exactly the old behaviour.
public enum AppGroup {
/// The group id every edition declares, verbatim (12-editions.md Distribution). It is
/// deliberately the *family* name rather than an edition's: Pro and Teams declare this same
/// string, and a future edition's bundle id joins with no further ceremony.
public static let identifier = "group.dev.rzen.indie.Kanban"
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-group")
// MARK: - Edition identity
/// Base's bundle id also the fallback when `Bundle.main` has none, which is a test host's
/// case and never a shipped app's.
public static let baseEditionID = "dev.rzen.indie.Kanban"
/// Pro's bundle id (12 Targets, ruled 2026-07-27). Named here as well as in
/// `ProEdition.bundleIdentifier` because *base* has to know it: the popover's awareness line and
/// the grant-slot keying are shared-tree code that must be able to name the other edition
/// without compiling any of it.
public static let proEditionID = "dev.rzen.indie.KanbanPro"
/// Which edition is running the key every per-edition slot on a shared record is stored under.
///
/// Read from `Bundle.main` rather than declared per target, which is what keeps this file free of
/// any edition conditional: the binary already knows which app it is.
public static var editionID: String {
Bundle.main.bundleIdentifier ?? baseEditionID
}
/// The user-facing name of an edition, for the popover's awareness line ("Also open in Lanework
/// Pro"). `nil` for a bundle id this build has never heard of a future edition's, or a stale
/// slot left by something else because inventing a name for it would be worse than saying
/// nothing, and the line's whole posture is that it never lies.
public static func editionDisplayName(_ bundleID: String) -> String? {
switch bundleID {
case baseEditionID: "Lanework"
case proEditionID: "Lanework Pro"
default: nil
}
}
// MARK: - The container
/// The group container, or `nil` when the running binary has no such capability.
///
/// Not cached: the answer is a property of the process's entitlements and cannot change within
/// a launch, but the call is a cheap lookup and a cached `nil` from an early read (before the
/// container has been created for the first time) is the sort of staleness this file should not
/// invent.
public static var containerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
}
/// Where every app-side file store lives the registry, the clipboard's staging snapshots, and
/// whatever joins them.
///
/// In a shipped app this is `productionStateDirectory`. **In a unit-test host it is a scratch
/// directory** (`isUnitTestHost`), which is not a nicety: the real container is shared with the
/// sibling edition and with the developer's own running copy, so a suite that used it would be
/// sweeping real staged clipboard copies and rewriting a real recents list.
public static var stateDirectory: URL {
isUnitTestHost ? unitTestStateDirectory : productionStateDirectory
}
/// What a shipped app uses: `<group container>/Library/Application Support/`.
///
/// **No bundle-id subfolder**, unlike the per-edition home this replaces that subfolder was
/// exactly what kept two editions from seeing one list, and its absence is the whole feature.
/// `Library/Application Support` is kept as the path *inside* the container for Apple's
/// convention rather than for any behaviour: the container is the app's either way.
///
/// Falls back to `perEditionSupportDirectory` when there is no group container (see the type's
/// note): unshared, but working.
public static var productionStateDirectory: URL {
guard let containerURL else {
logger.debug("no group container for \(identifier, privacy: .public); using the per-edition home")
return perEditionSupportDirectory
}
return containerURL
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Application Support", isDirectory: true)
}
/// The pre-2.0 home `~/Library/Application Support/<bundle id>/`, inside this edition's own
/// sandbox container. Kept as the fallback above and as the home of anything deliberately *not*
/// shared.
public static var perEditionSupportDirectory: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
return support.appendingPathComponent(editionID, isDirectory: true)
}
// MARK: - Keeping the suites out of it
/// Whether this process is hosting a unit-test bundle.
///
/// ### Why the app has to know
///
/// The unit-test host **is the app** (`KanbanTests` and `KanbanProTests` are hosted bundles), so
/// `KanbanApp.init()` runs for real on every test launch and builds an `AppModel` over whatever
/// the defaults resolve to. Every *object* a test constructs takes its storage by injection that
/// is the seam, and it is untouched but the host's own launch has no injection point, and after
/// the 2026-07-29 ruling the thing it would reach for is a container shared with the sibling
/// edition and with the developer's running copy. Its launch sweep would collect real staged
/// clipboard trees; its `refreshRecents()` would resolve, refresh and rewrite real records.
///
/// So the *default* moves for a test host, which is the one place a default can be wrong in a way
/// injection cannot fix.
///
/// ### Why this variable and not a launch flag
///
/// `UITestLaunch.fixtureFlag` is the flag-shaped answer and remains the right one for the UI
/// suites, which launch the app themselves and can pass arguments. A *unit*-test host is launched
/// by the test runner, which passes nothing of ours but it does set these variables, and it has
/// set them for as long as XCTest has existed. Three spellings are checked because Apple has used
/// each at some point and a missed one would silently mean "not a test".
///
/// It cannot fire in a shipped app: nothing sets these but a test runner.
public static var isUnitTestHost: Bool {
let environment = ProcessInfo.processInfo.environment
return environment["XCTestConfigurationFilePath"] != nil
|| environment["XCTestBundlePath"] != nil
|| environment["XCTestSessionIdentifier"] != nil
}
/// The scratch home a test host uses instead. Inside the app's own container (`NSTemporaryDirectory`
/// sandboxes there), so nothing outside this app can see it and the OS reclaims it.
///
/// One fixed folder rather than one per run: the suites do not depend on it being empty they
/// inject their own paths for anything they assert on and a stable name keeps it inspectable when
/// something writes there that should not have.
public static var unitTestStateDirectory: URL {
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("LaneworkUnitTestState", isDirectory: true)
}
// MARK: - The shared defaults suite
/// The group's shared `UserDefaults` suite where app-side state that is a *scalar* lives
/// (02-architecture.md § Per-board app state: "or the group's shared `UserDefaults` suite where
/// a scalar fits").
///
/// `UserDefaults(suiteName:)` answers `nil` only for a suite name equal to the app's own bundle
/// id, which this never is; `.standard` is the fallback anyway, for the reason every fallback
/// here exists an unshared preference is a papercut, an unreadable one is a bug.
///
/// Without the entitlement the suite is an ordinary named domain rather than a shared one, so
/// this works unprovisioned too: the values are simply this edition's alone.
///
/// A **test host gets its own suite name** for `isUnitTestHost`'s reason applied to preferences: a
/// suite that read and wrote the real one would be reading and writing the developer's quick-style
/// row and window size, which is the objection `StyleModelTests` already states about
/// `UserDefaults.standard`.
public static var defaults: UserDefaults {
UserDefaults(suiteName: isUnitTestHost ? "\(identifier).unit-tests" : identifier) ?? .standard
}
}
+411 -62
View File
@@ -55,35 +55,80 @@ 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 Application Support, which is also why two
/// machines sharing a board through a remote each keep their own (push-on-commit and window frames
/// are genuinely per-machine choices).
/// 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
///
/// 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
///
/// The synthesized `Codable` conformance rejects a file missing any non-optional key, and
/// `BoardRegistry` responds to a rejected file by quarantining it every record lost. So **a key
/// added here must be optional or have a decoding default**, or the addition silently empties every
/// existing user's recents on upgrade. That policy, not a version field, is what keeps this format
/// readable across releases.
/// `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 security-scoped bookmark **the board's identity**, refreshed on every open. Empty only
/// in the degenerate case where the system refused to make one at all, which is a record born
/// orphaned: it shows in recents with Forget and never matches an open.
public var bookmark: Data
/// 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. **Never used for matching** that is the bookmark's job, and
/// a path that matched would reintroduce exactly the identity-by-string bug this design excludes.
/// 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
@@ -136,21 +181,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
/// time never here.
public var iconColor: String?
/// Whether this board's window is open **right now** the restoration set, as a live marker
/// rather than an at-quit write (02-architecture.md § Launch and window lifecycle, settled).
///
/// Set when the board's window opens, cleared on *user-initiated* close; quit's teardown
/// deliberately leaves it standing, because the boards open at quit are by definition the ones
/// to restore. **Crash recovery falls out for free**: after a crash the flags describe what was
/// open at crash time, so the next launch restores exactly that no separate recovery logic, no
/// once-at-quit stamp to race teardown or miss when the app dies.
///
/// **Optional because every key here must be** (see Evolving this struct above): a registry file
/// written before this key existed decodes with `nil`, which reads as "not open" and costs the
/// user nothing. A non-optional `Bool` would have quarantined every existing file on upgrade and
/// emptied everyone's recents.
public var isOpenNow: Bool?
/// Whether committing also pushes (07-sync-collab.md). Off by default: pushing is a decision,
/// not a side effect.
public var pushOnCommit: Bool
@@ -162,7 +192,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
public init(
id: UUID = UUID(),
bookmark: Data,
grants: [String: Data] = [:],
displayName: String,
lastKnownPath: String,
lastOpened: Date,
@@ -170,14 +200,14 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
cardCount: Int? = nil,
windowFrame: WindowFrame? = nil,
cardWindowFrames: [String: WindowFrame]? = nil,
isOpenNow: Bool? = nil,
openNow: [String: Bool] = [:],
pushOnCommit: Bool = false,
remoteLocationWarned: Bool = false,
icon: String? = nil,
iconColor: String? = nil
) {
self.id = id
self.bookmark = bookmark
self.grants = grants
self.displayName = displayName
self.lastKnownPath = lastKnownPath
self.lastOpened = lastOpened
@@ -185,12 +215,153 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
self.cardCount = cardCount
self.windowFrame = windowFrame
self.cardWindowFrames = cardWindowFrames
self.isOpenNow = isOpenNow
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.
@@ -206,27 +377,48 @@ 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` for an orphan.
/// 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: nil
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 Application Support
/// (02-architecture.md § Per-board app state).
/// 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
///
@@ -249,25 +441,31 @@ 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
/// Application Support directory.
/// 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")
/// `~/Library/Application Support/<bundle id>/board-registry.json`, inside the sandbox
/// container. The bundle-id subfolder is Apple's convention and keeps the app's files together
/// as the app-wide neighbours arrive.
/// `<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 {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"
return support
.appendingPathComponent(bundleIdentifier, isDirectory: true)
.appendingPathComponent("board-registry.json", isDirectory: false)
AppGroup.stateDirectory.appendingPathComponent("board-registry.json", isDirectory: false)
}
/// Loads the registry, tolerating everything a file on disk can be.
@@ -277,10 +475,85 @@ public final class BoardRegistry {
/// because the file may be the only trace of a user's board list, and a support request can read
/// it even when this app cannot; empty rather than fatal because a truncated convenience file
/// must not stand between the user and their boards.
public init(storageURL: URL) {
///
/// 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 = loadFromDisk()
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
@@ -335,6 +608,8 @@ 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
@@ -344,7 +619,11 @@ public final class BoardRegistry {
}
if let index = indexOfRecord(matching: rootURL) {
records[index].bookmark = bookmark
// **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
@@ -357,7 +636,7 @@ public final class BoardRegistry {
}
let record = BoardRecord(
bookmark: bookmark,
grants: [editionID: bookmark],
displayName: displayName ?? Self.folderName(of: rootURL),
lastKnownPath: rootURL.path,
lastOpened: Self.stamp(),
@@ -410,7 +689,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) { $0.isOpenNow = true }
update(id) { [editionID] in $0.setOpen(true, inEdition: editionID) }
}
/// Clears the marker **user-initiated close only**.
@@ -421,7 +700,20 @@ 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) { $0.isOpenNow = false }
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`
@@ -435,10 +727,15 @@ 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.isOpenNow == true }
.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.
@@ -493,6 +790,7 @@ 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
@@ -535,18 +833,27 @@ 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 resolution = Self.resolve(records[index].bookmark) else { continue }
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].bookmark = refreshed.data
records[index].setGrant(refreshed.data, forEdition: editionID)
refreshedAny = true
}
}
@@ -563,20 +870,30 @@ 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? {
records.first { $0.id == id }
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()
@@ -592,6 +909,7 @@ 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()
@@ -599,11 +917,22 @@ public final class BoardRegistry {
// MARK: - Matching
/// The index of the record whose bookmark resolves to the same file as `url`, if any.
/// 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 }
return records.firstIndex { record in
guard let resolution = Self.resolve(record.bookmark) else { return false }
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
@@ -612,6 +941,21 @@ 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? {
@@ -621,6 +965,7 @@ 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
@@ -761,6 +1106,10 @@ 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)")
}
+4 -2
View File
@@ -46,8 +46,10 @@ public final class StyleRecents {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "style-recents")
/// - Parameter defaults: the domain to persist in. Injected for the reason `BoardRegistry` takes
/// its storage URL: a test must be able to hold its own without touching the user's.
public init(defaults: UserDefaults = .standard) {
/// its storage URL: a test must be able to hold its own without touching the user's. The app's
/// own is the **group's shared suite** (02-architecture.md § Per-board app state, ruled
/// 2026-07-29), so the row an upgrader built up in base is the row Pro offers.
public init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
// Anything but an array of strings is treated as an empty list rather than as an error: this
// is a convenience, and a hand-edited or truncated preference must never be the reason a