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:
2026-07-30 17:46:32 -04:00
parent 092300c7d2
commit 2c6b8fe63a
27 changed files with 385 additions and 1461 deletions
+2 -1
View File
@@ -79,7 +79,8 @@ struct OpenRecentMenu: View {
Menu("Open Recent") {
ForEach(rows) { row in
Button(row.displayName) {
appModel.open(row)
guard let url = row.url else { return }
appModel.openBoard(at: url)
}
.disabled(!row.canOpen)
}
+9 -71
View File
@@ -29,22 +29,11 @@ public enum WindowID {
/// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason
/// `WindowID` exists.
///
/// ### The domain is the group's, not `.standard`
///
/// 02 § Per-board app state sends these to "the group's shared `UserDefaults` suite where a scalar
/// fits" (ruled 2026-07-29; 12-editions.md), for the registry's reason exactly: a paying upgrader
/// launches Pro onto their own settings rather than onto defaults. `AppGroup.defaults` is that suite,
/// and it degrades to a plain named domain when the group is not provisioned unshared, but working.
///
/// **Every reader and writer of these keys must name that suite.** A `@AppStorage` left to its own
/// devices reads `.standard`, which after this ruling is a *different* domain so the two views that
/// bind one of these keys pass `store:` explicitly (`SettingsView`).
/// The domain is `UserDefaults.standard`, which the sandbox already scopes to this one app the
/// same reason `AppStateHome` needs no bundle-id subfolder. A `@AppStorage` left to its own devices
/// reads exactly this domain, so nothing here has to be named at a binding site.
public enum AppPreferences {
/// The domain every key here lives in. A stored `let` would capture a suite at type-load time;
/// this is a lookup of an object `UserDefaults` itself caches.
public static var defaults: UserDefaults { AppGroup.defaults }
/// "Restore open boards at launch" (Settings, , 11-command-nexus.md). **Default on.**
public static let restoreOpenBoardsAtLaunchKey = "restoreOpenBoardsAtLaunch"
@@ -52,7 +41,7 @@ public enum AppPreferences {
/// any scene exists. `object(forKey:)` rather than `bool(forKey:)` because the latter cannot
/// tell "off" from "never set", and this preference defaults to *on*.
public static var restoreOpenBoardsAtLaunch: Bool {
defaults.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
UserDefaults.standard.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
}
/// The last-used card-window size (05-card-window.md; 02 files it as app-wide, not per-board
@@ -61,14 +50,14 @@ public enum AppPreferences {
public static let lastCardWindowSizeKey = "lastCardWindowSize"
public static var lastCardWindowSize: CGSize? {
guard let text = defaults.string(forKey: lastCardWindowSizeKey) else { return nil }
guard let text = UserDefaults.standard.string(forKey: lastCardWindowSizeKey) else { return nil }
let size = NSSizeFromString(text)
guard size.width > 0, size.height > 0 else { return nil }
return size
}
public static func setLastCardWindowSize(_ size: CGSize) {
defaults.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
}
/// The quick-style row's recently-used backgrounds an array of palette names / hex strings,
@@ -434,9 +423,8 @@ public final class AppModel {
/// The app builds one of these with the real state home; a test passes its own for the reason
/// `BoardRegistry` takes a storage URL at all "injecting it is how a test stays out of the real
/// Application Support directory", which after the 2026-07-29 ruling means **out of the shared App
/// Group container** (`AppGroup`). A suite that swept the real staging root would be sweeping the
/// developer's own clipboard, and now the sibling edition's too.
/// Application Support directory" (`AppStateHome`). A suite that swept the real staging root
/// would be sweeping the developer's own clipboard.
///
/// `clipboardStagingRoot` is a separate parameter rather than derived from `registryStorageURL`'s
/// folder because the two are injected for different reasons and by different callers: the UI-test
@@ -446,7 +434,7 @@ public final class AppModel {
public init(
registryStorageURL: URL = BoardRegistry.defaultStorageURL,
clipboardStagingRoot: URL = ClipboardStore.defaultStagingRoot,
preferences: UserDefaults = AppGroup.defaults
preferences: UserDefaults = .standard
) {
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
styleRecents = StyleRecents(defaults: preferences)
@@ -553,56 +541,6 @@ public final class AppModel {
openBoard(at: url)
}
/// Opens a recents row the one door for welcome's double-click, its Open item, and File Open
/// Recent, because a row has **two** ways of leading to a board now.
///
/// An ordinary available row opens its URL. A row awaiting this edition's grant
/// (`RecentBoard.needsReopen` a board the other edition minted the only bookmark for) runs the
/// re-grant panel first: "the first click runs an open panel pre-anchored at the recorded path:
/// one click + Grant per board, once per edition" (12-editions.md Distribution).
///
/// Nothing else about the open differs. The granted URL goes through `openBoard(at:)` exactly as a
/// File Open pick would, and `BoardRegistry.recordOpen` matches the *existing* shared record and
/// mints this edition's slot onto it the other edition's grant, the frames, the counts and the
/// cached title all stay where they are.
/// Internal rather than `public` only because `WelcomeRow` is the row derivation is a UI-layer
/// value, and nothing outside this module opens boards by row.
func open(_ row: WelcomeRow) {
if let url = row.url {
openBoard(at: url)
return
}
guard let anchor = row.regrantAnchor,
let granted = presentRegrantPanel(anchoredAt: anchor, boardName: row.displayName) else { return }
openBoard(at: granted)
}
/// The re-grant panel: an ordinary open panel, pre-anchored at the board's recorded path.
///
/// **A panel and not an alert**, because the panel *is* the mechanism: a sandboxed app gains access
/// to a folder by the user choosing it, so there is nothing an intermediate explanation could add
/// that the panel's own message does not say better while doing the job.
///
/// `directoryURL` is the recorded path itself, per the ruling. For a board that is a `.kanban`
/// package the panel therefore opens *inside* it `treatsFilePackagesAsDirectories` is on for
/// `presentOpenPanel`'s reason (boards are packages *and* plain folders) and Open with nothing
/// selected chooses the folder on display, which is the board. A board that has since moved leaves
/// the panel at the nearest surviving ancestor, which is the Finder behaviour and the honest one:
/// the user knows where their board went, and this app does not.
private func presentRegrantPanel(anchoredAt anchor: URL, boardName: String) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.treatsFilePackagesAsDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = anchor
panel.prompt = "Grant"
panel.message = "Choose “\(boardName)” to let this app open it."
guard panel.runModal() == .OK else { return nil }
return panel.url
}
/// The ref of the window already showing the board at `url`, if any matched through the store,
/// which is identity-keyed, rather than through the path.
private func boardRef(forBoardAt url: URL) -> BoardWindowRef? {
+1 -12
View File
@@ -287,18 +287,7 @@ struct BoardWindowHost: View {
boardInfoTitlebarAccessory(
store: store,
recents: appModel.styleRecents,
presentation: boardInfo,
// The cross-edition awareness line (12-editions.md Both editions installed), asked
// at each popover build rather than captured as a value: both facts behind it the
// other edition's flag on the shared record, and whether that edition is still
// running change while this window sits here, and neither is observable.
otherEditionNote: { [weak appModel] in
guard let appModel else { return nil }
return BoardEditionPresence.note(
otherEditions: appModel.boardRegistry.otherEditionsFlaggedOpen(id: recordID),
isRunning: BoardEditionPresence.isRunning
)
}
presentation: boardInfo
)
)
+1 -1
View File
@@ -17,7 +17,7 @@ import UniformTypeIdentifiers
///
/// **It is self-describing twice over**, and both halves earn their keep:
///
/// - `copyID` ties the pasteboard to a staging directory `<group container>//Clipboard/<copyID>/`,
/// - `copyID` ties the pasteboard to a staging directory `<Application Support>/Clipboard/<copyID>/`,
/// the full folder snapshots a paste reproduces byte-for-byte from and to a pending cut. It is
/// also the whole of "the snapshot survives relaunch exactly as long as the pasteboard still points
/// at it": a sweep keeps the one directory this id names and collects every other.
+24 -48
View File
@@ -12,23 +12,12 @@ import os
///
/// The pasteboard carries a small JSON manifest plus a plain-text rendering; the *content* whole
/// folder trees, attachments and strays and all is **staged** under
/// `<group container>/Library/Application Support/Clipboard/<copyID>/`, so a paste reproduces the item
/// byte-for-byte across boards rather than reconstructing it from a summary. The manifest's embedded
/// `index.md` per entry is **identification metadata only** menu validation, the refusal's wording,
/// the plain-text flavor and never a materialization source: a paste whose staged snapshot is
/// missing or unreadable **refuses whole and writes nothing** (04-interactions.md Clipboard,
/// re-ruled 2026-07-29 Finder's invariant: an item arrives whole or not at all).
///
/// ** one-app collapse phase 2**: the paragraph below describes a sharing arrangement that stops
/// existing when the App Group does (12-editions.md App-side state, re-ruled 2026-07-30) the
/// staging store moves to the ordinary sandbox container and the sibling it tolerates is only ever
/// the developer's own second copy. The tolerance itself is worth keeping either way.
///
/// **The store is shared by every installed edition** (12-editions.md Both editions installed, ruled
/// 2026-07-29): the group container is one container, so C in base pastes full-fidelity in Pro. The
/// lifecycle below is unchanged by that both editions read the same machine-wide pasteboard, so both
/// sweeps compute the same keep set with one property made explicit: the sweep tolerates the sibling
/// sweeping alongside it (`prune`).
/// `<Application Support>/Clipboard/<copyID>/`, so a paste reproduces the item byte-for-byte across
/// boards rather than reconstructing it from a summary. The manifest's embedded `index.md` per entry
/// is **identification metadata only** menu validation, the refusal's wording, the plain-text
/// flavor and never a materialization source: a paste whose staged snapshot is missing or
/// unreadable **refuses whole and writes nothing** (04-interactions.md Clipboard, re-ruled
/// 2026-07-29 Finder's invariant: an item arrives whole or not at all).
///
/// ### The staging lifecycle, settled
///
@@ -115,24 +104,16 @@ public final class ClipboardStore {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard")
/// `<group container>/Library/Application Support/Clipboard/`, beside the board registry the
/// same home, for the same reason, and now the same *shared* home (12-editions.md Both editions
/// installed, ruled 2026-07-29):
///
/// > The clipboard staging store homes in the group container beside the registry, so C in one
/// > edition pastes **full-fidelity** in the other snapshot, attachments and all.
///
/// Nothing about the lifecycle changes: both editions read the same pasteboard, so both sweeps
/// compute the same answer from the same input. The shared home is also what keeps the refusal a
/// rare corner rather than the structural cross-edition outcome a copy in one edition pastes
/// full-fidelity in the other, so neither has to reach for bytes that are not there.
/// `<Application Support>/Clipboard/`, beside the board registry the same home, for the same
/// reason (`AppStateHome`; 02-architecture.md § Per-board app state, "App-wide state has the same
/// home").
public static var defaultStagingRoot: URL {
AppGroup.stateDirectory.appendingPathComponent("Clipboard", isDirectory: true)
AppStateHome.directory.appendingPathComponent("Clipboard", isDirectory: true)
}
/// The app builds one of these with the system pasteboard and the real staging directory; a test
/// passes its own of each, for the reason `BoardRegistry` takes a storage URL at all injecting
/// them is how a suite stays out of the shared App Group container *and* off the machine's one
/// them is how a suite stays out of the real Application Support home *and* off the machine's one
/// pasteboard.
///
/// **The launch sweep is here** (04: "a sweep at launch and on each copy"): a fresh store reads
@@ -533,21 +514,17 @@ public final class ClipboardStore {
/// has no isolation to need.
private nonisolated static let sweepFolderName = ".sweeping"
/// The sweep, written to be safe against **the sibling edition sweeping the same directory at the
/// same time** (12-editions.md Both editions installed: "keep the sweep tolerant of the sibling
/// app's concurrent sweep atomic removals, missing-entry = already swept").
/// The sweep, written **claim-then-delete** rather than delete-in-place.
///
/// The staging root is now shared by every installed edition, and each edition sweeps on its own
/// launches, activations, copies and pastes. Both compute the *same* answer the keep set is the
/// one `copyID` the machine-wide pasteboard names so they never disagree about what should go;
/// what they can do is arrive at the same doomed tree together. Two properties make that a
/// non-event:
/// There is one app and macOS runs one instance of it, so this is not the concurrency guard it was
/// written as (12-editions.md App-side state, re-ruled 2026-07-30 there is no sibling app to
/// race). It is kept because what it buys is cheap and still true of one process:
///
/// 1. **The claim is a rename, and a rename is atomic.** `moveItem` into `.sweeping/` either
/// happens or does not; exactly one sweeper can win it, and the loser's failure is the signal
/// that somebody else owns the tree now. Deleting in place would instead have two processes
/// walking one directory tree as it disappeared under them the case where a half-removed tree
/// is briefly *visible*, which is the only way a concurrent sweep could corrupt a paste.
/// 1. **The claim is a rename, and a rename is atomic.** A tree either leaves the staging root
/// whole or stays there whole it is never briefly *visible half-removed*, which is the one
/// state a reader could misread. That covers a crash mid-delete, and it covers the developer's
/// own second copy launched with `open -n`, which shares this container because it is the same
/// app.
/// 2. **A missing entry means already swept, never an error.** Every failure here is swallowed:
/// the listing is stale by the time it is walked, and a tree that vanished between the two is
/// precisely the outcome asked for.
@@ -573,8 +550,8 @@ public final class ClipboardStore {
}
let claim = sweepFolder.appendingPathComponent(UUID().uuidString, isDirectory: true)
guard (try? FileManager.default.moveItem(at: entry, to: claim)) != nil else {
// Gone, or the sibling's sweep claimed it first. Either way it is not ours to delete
// and nothing is wrong.
// Gone, or claimed by another pass. Either way it is not ours to delete and nothing
// is wrong.
continue
}
claimed.append(claim)
@@ -584,9 +561,8 @@ public final class ClipboardStore {
try? FileManager.default.removeItem(at: claim)
}
// Anything a previous pass claimed and did not finish including the sibling app's, whose
// claims are as much ours to collect as our own, since a claimed tree is unreachable by
// either. Best-effort, and an empty or missing folder is nothing to do.
// Anything a previous pass claimed and did not finish a crash between the claim and the
// delete. Best-effort, and an empty or missing folder is nothing to do.
if let stragglers = try? FileManager.default.contentsOfDirectory(
at: sweepFolder,
includingPropertiesForKeys: nil,
+2 -13
View File
@@ -75,8 +75,8 @@ struct RestoreBootstrapView: View {
case .restoreBoards, .welcome:
// `.welcome` arrives here by design this window presents at every launch, because it is
// the app's one reliable presenter (see `KanbanApp`'s bootstrap scene) and the pass is
// its answer: nothing is flagged for this edition, so it shows welcome, which is what
// `.welcome` asked for.
// its answer: nothing is flagged, so it shows welcome, which is what `.welcome` asked
// for.
restoreFlaggedBoards(openedAlready: replayedOpens)
}
@@ -96,17 +96,6 @@ struct RestoreBootstrapView: View {
path: record.lastKnownPath,
message: "This board is unavailable. Its volume may be offline, or it may have been moved or deleted."
)
case .needsReopen:
// Effectively unreachable this edition can only have flagged a board open by having
// opened it, which needed a grant and deliberately quiet if it ever happens.
//
// **No launch failure and no panel.** A modal grant panel at launch is exactly the
// "launch-time modal chain" 02 § Launch and window lifecycle rules out, and a failure
// row would put fail-fast's warning tone over a board that is *fine*: welcome appears
// (nothing restored), and this board's own row already carries the re-grant caption
// and the one click that resolves it (12-editions.md Distribution). That row is the
// surface, so nothing is silently dropped.
Self.logger.error("a flagged board is awaiting this edition's grant; left for its welcome row")
}
}
+10 -21
View File
@@ -96,7 +96,7 @@ enum TemplateEngine {
// MARK: - Where templates live
/// The store folder's name in both locations the bundle's and the App Group container's.
/// The store folder's name in both locations the bundle's and the app's own.
static let storeFolderName = "Templates"
/// The bundled store: `<app bundle>/Contents/Resources/Templates/`, holding one board folder per
@@ -106,32 +106,21 @@ enum TemplateEngine {
Bundle.main.resourceURL?.appendingPathComponent(storeFolderName, isDirectory: true)
}
/// The user store: `Templates/` in the **shared App Group container**, beside the board registry
/// and the clipboard's staging store (09-templates.md Save as Template Storage, re-homed
/// 2026-07-29; 02-architecture.md § Per-board app state, "App-wide state has the same home").
/// The user store: `<Application Support>/Templates/`, beside the board registry and the
/// clipboard's staging store 09's settled location ("Application Support inside the app
/// container friction-free sandbox writes, no location ceremony"; 02-architecture.md
/// § Per-board app state, "App-wide state has the same home").
///
/// ** one-app collapse phase 2**: the cross-edition half of this rationale retires with the App
/// Group (12-editions.md App-side state, re-ruled 2026-07-30); the store simply moves to the
/// ordinary sandbox container and keeps every property below, since it never had bookmarks or
/// grants to lose.
///
/// > **templates cross editions**: a template saved in base appears in Pro's chooser, honoring 12's
/// > never-an-empty-home-screen promise (templates are plain board folders no per-edition
/// > semantics, no bookmark grant ceremony; the group container is directly writable by every
/// > edition).
///
/// That last clause is why this store needs none of the machinery the registry does: a template is
/// a folder inside a container both editions can write, so there is no bookmark to mint and nothing
/// to grant the cross-sandbox caveat that gives `BoardRecord` its per-edition grant slots simply
/// does not arise. Spelled through `AppGroup.stateDirectory` like every other app-wide store
/// (`ClipboardStore.defaultStagingRoot`, `BoardRegistry.defaultStorageURL`), so all three move
/// together if the home ever does.
/// This store needs none of the machinery the registry does: a template is a folder inside the
/// app's own container, so there is no bookmark to mint and nothing to grant. Spelled through
/// `AppStateHome` like every other app-wide store (`ClipboardStore.defaultStagingRoot`,
/// `BoardRegistry.defaultStorageURL`), so all three move together if the home ever does.
///
/// **Named, never created here.** Discovery of a store that does not exist is an empty list, not
/// a directory the app made on the off-chance: the store is minted by the first Save as Template,
/// and by Reveal in Finder, both of which are 09's other cards.
static var userStore: URL {
AppGroup.stateDirectory.appendingPathComponent(storeFolderName, isDirectory: true)
AppStateHome.directory.appendingPathComponent(storeFolderName, isDirectory: true)
}
// MARK: - Discovery
+10 -15
View File
@@ -90,18 +90,15 @@ enum LaunchPlan: Equatable, Sendable {
/// Without that, every audit run would stamp a temp folder into the user's real recents list
/// (`BoardRegistry.defaultStorageURL`), where it would sit for good as an unavailable row pointing at a
/// directory that no longer exists and its launch sweep would collect the user's real staged copy
/// (`ClipboardStore.defaultStagingRoot`). Both of those homes are now the **shared App Group
/// container** (12-editions.md Distribution, ruled 2026-07-29), so each of those side effects would
/// land on the sibling edition as well as this one. Tying them to the same flag rather than to separate
/// (`ClipboardStore.defaultStagingRoot`). Tying them to the same flag rather than to separate
/// arguments is deliberate: they are one decision "this launch is synthetic" and a second argument
/// is a second chance to apply only half of it.
///
/// The honest residual: `UserDefaults` is **not** redirected, so an audit run can still write the
/// three app-wide scalars (`AppPreferences`) into the real domain the group's shared suite since the
/// same ruling. They are a window size, a restore toggle this launch never consults, and the
/// quick-style recents list no documents, nothing destructive, and redirecting a defaults domain from
/// inside the process is not something the platform actually supports. It is stated rather than
/// fixed.
/// three app-wide scalars (`AppPreferences`) into the real domain. They are a window size, a restore
/// toggle this launch never consults, and the quick-style recents list no documents, nothing
/// destructive, and redirecting a defaults domain from inside the process is not something the
/// platform actually supports. It is stated rather than fixed.
enum UITestLaunch {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "ui-test-launch")
@@ -206,17 +203,15 @@ enum UITestLaunch {
.appendingPathComponent("LaneworkUITestFixture", isDirectory: true)
}
/// Where the fixture launch's registry lives beside the board rather than in the shared App
/// Group container, which is the whole point (see the type's note).
/// Where the fixture launch's registry lives beside the board rather than in Application
/// Support, which is the whole point (see the type's note).
static var registryStorageURL: URL {
scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false)
}
/// Where the fixture launch's clipboard snapshots live, on the registry's terms and now for a
/// sharper reason: the real staging root moved into the **shared** App Group container
/// (12-editions.md Both editions installed), so an audit run's launch sweep would otherwise
/// collect the developer's own staged copy and the sibling edition's, since there is only one
/// store now. Redirected by the same flag, because it is the same one decision.
/// Where the fixture launch's clipboard snapshots live, on the registry's terms: an audit run's
/// launch sweep would otherwise collect the developer's own staged copy. Redirected by the same
/// flag, because it is the same one decision.
static var clipboardStagingRoot: URL {
scratchRoot.appendingPathComponent("Clipboard", isDirectory: true)
}
+5 -26
View File
@@ -40,18 +40,10 @@ struct WelcomeRow: Identifiable, Equatable {
let icon: String?
let iconColor: String?
/// Where the board is **now**, or `nil` when this edition cannot reach it. The single source
/// Where the board is **now**, or `nil` when its bookmark no longer resolves. The single source
/// of the row's availability: Open and Reveal need a URL, and an orphan has none.
let url: URL?
/// Where the re-grant panel starts for a row whose only grant another edition minted
/// (`RecentBoard.needsReopen`; 12-editions.md Distribution) `nil` on every other row.
///
/// It is what makes this row's Open live while `url` is `nil`: the board is *there*, and one click
/// plus Grant is all that stands between the user and it. Reveal in Finder stays disabled, because
/// revealing a folder is a read this app has not been granted either.
let regrantAnchor: URL?
/// The containing folder, for the row's location line Xcode's welcome shows where a project
/// lives, not its own path repeated under its name. Home-abbreviated where it can be.
let location: String
@@ -66,14 +58,10 @@ struct WelcomeRow: Identifiable, Equatable {
var isAvailable: Bool { url != nil }
/// Whether this row is waiting for this edition's grant rather than being genuinely orphaned.
var needsReopen: Bool { regrantAnchor != nil }
/// Open needs somewhere to go **or something to grant**; Reveal in Finder needs the former only.
/// Forget is deliberately not here, because it is enabled on every row an orphan the user can
/// never open is exactly the row that most needs erasing (02 § Graceful orphaning: "recents
/// surface it as unavailable with Forget").
var canOpen: Bool { isAvailable || needsReopen }
/// Open and Reveal in Finder both need somewhere to go; Forget is deliberately not here, because
/// it is enabled on every row an orphan the user can never open is exactly the row that most
/// needs erasing (02 § Graceful orphaning: "recents surface it as unavailable with Forget").
var canOpen: Bool { isAvailable }
var canReveal: Bool { isAvailable }
/// The row's third line one line, so the three states are alternatives rather than a stack.
@@ -86,20 +74,12 @@ struct WelcomeRow: Identifiable, Equatable {
case counts(lanes: Int?, cards: Int?)
/// The bookmark no longer resolves (02 § Graceful orphaning).
case unavailable
/// This edition has never been granted the board another edition minted the record for
/// (12-editions.md Distribution) the *reopen* state, which is not orphaning: the board is
/// there, and one click opens the panel that grants it.
case needsReopen
/// Fail-fast's specifics, from the open or restore that failed.
case failed(String)
}
var caption: Caption {
if let failure { return .failed(failure) }
// Before `unavailable`, because the two are told apart by *why* there is no URL and this one
// is the reachable case: an orphan's caption offering to grant access would be a promise the
// app cannot keep, and this row wearing the orphan's caption would read as a loss it is not.
if needsReopen { return .needsReopen }
if url == nil { return .unavailable }
return .counts(lanes: laneCount, cards: cardCount)
}
@@ -163,7 +143,6 @@ struct WelcomeRow: Identifiable, Equatable {
icon: record.icon,
iconColor: record.iconColor,
url: recent.url,
regrantAnchor: recent.regrantAnchor,
location: location(of: recent.url?.path ?? record.lastKnownPath),
laneCount: record.laneCount,
cardCount: record.cardCount,
+8 -21
View File
@@ -244,13 +244,12 @@ struct WelcomeView: View {
// MARK: Actions
/// Opens a row's board through `AppModel.open(_:)`, which owns the two ways a row can lead to
/// one (an available URL, or the re-grant panel a cross-edition row needs first). Welcome closes
/// itself on the way in that is the board window host's job ("Opening a board from welcome
/// closes welcome"), not this view's, because the close has to wait for the load to actually
/// succeed.
/// Opens a row's board. Welcome closes itself on the way in that is the board window host's
/// job ("Opening a board from welcome closes welcome"), not this view's, because the close has to
/// wait for the load to actually succeed.
private func open(_ row: WelcomeRow) {
appModel.open(row)
guard let url = row.url else { return }
appModel.openBoard(at: url)
}
private func reveal(_ row: WelcomeRow) {
@@ -321,9 +320,8 @@ private struct RecentBoardRow: View {
}
.padding(.vertical, BoardMetrics.em(0.3, bodyPointSize: WelcomeView.pointSize))
// Dimmed when the board cannot be reached the row stays, with Forget, rather than
// disappearing (02 § Graceful orphaning). A cross-edition row is *not* dimmed: it opens on one
// click like any other, and dimming it would advertise a loss that has not happened.
.opacity(row.canOpen ? 1 : 0.55)
// disappearing (02 § Graceful orphaning).
.opacity(row.isAvailable ? 1 : 0.55)
.accessibilityElement(children: .combine)
}
@@ -365,14 +363,6 @@ private struct RecentBoardRow: View {
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
case .needsReopen:
// Not a warning tone: nothing is wrong and nothing is lost this board came from the
// other edition's list and needs one grant (12-editions.md Distribution). The words say
// what the click will do, since the click is the whole remedy.
Label("Open once to grant access", systemImage: "hand.raised")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
case let .failed(message):
// The warning tint, and the whole of fail-fast's specifics this row *is* the failure
// surface (02 § Launch and window lifecycle).
@@ -395,10 +385,7 @@ private struct RecentBoardRow: View {
/// turned off and then turns it back on.
struct SettingsView: View {
/// `store:` named explicitly, and it has to be: the key lives in the group's shared suite
/// (`AppPreferences`), and `@AppStorage`'s default domain is `.standard` a different one. A
/// toggle bound to the wrong domain would write a preference the launch flow never reads.
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey, store: AppGroup.defaults)
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
private var restoreOpenBoardsAtLaunch = true
var body: some View {
+4 -10
View File
@@ -18,15 +18,9 @@
credentials go to the sandbox's own keychain, which needs no key at all. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- The App Group, on borrowed time. 12-editions.md ▸ App-side state (re-ruled 2026-07-30)
removes it wholesale — the board registry and its Application Support peers home in the
ordinary sandbox container once there is one app — but the removal is the *code's* to
make, not this file's: `AppGroup.swift` still resolves its container through this key,
and pulling it first would silently drop the app into the fallback home. It goes with
that code. -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.rzen.indie.Kanban</string>
</array>
<!-- No `com.apple.security.application-groups`: a group exists to share a container *between*
apps, and there is one app (12-editions.md ▸ App-side state, re-ruled 2026-07-30). The
board registry and its Application Support peers home in the ordinary sandbox container
(`AppStateHome`), which the sandbox already scopes to this app alone. -->
</dict>
</plist>
+2 -3
View File
@@ -54,7 +54,7 @@ struct KanbanApp: App {
// Read first, because it decides *which app-side state the model is built over* a fixture
// launch keeps its recents and its clipboard snapshots in the scratch directory rather than in
// the shared App Group container.
// the app's ordinary Application Support home.
let isUITestFixtureLaunch = UITestLaunch.isFixtureLaunch
if isUITestFixtureLaunch {
UITestLaunch.prepareScratchDirectory()
@@ -124,8 +124,7 @@ struct KanbanApp: App {
// way to put a window on screen. Welcome's `.automatic` above is a request the system is free
// to decline, and on macOS 26 it does: a launch with nothing to restore presented *no* scene
// at all, which left `windowOpener` uncaptured and the app a windowless shell no menu action
// could revive (observed 2026-07-29; the per-edition open-now flags exposed it, because before
// them a flagged board almost always routed launches through this window). The pass itself
// could revive (observed 2026-07-29). The pass itself
// still dispatches on the plan a `.welcome` launch restores nothing and shows welcome
// and this window stays invisible and dismisses itself either way.
.defaultLaunchBehavior(.presented)
-198
View File
@@ -1,198 +0,0 @@
import Foundation
import os
/// ** Retired by the one-app collapse one-app collapse phase 2.** 12-editions.md App-side
/// state (re-ruled 2026-07-30) removes the App Group wholesale: with one app there is no sibling
/// to share a container *with*, so the registry and its peers home in the ordinary sandbox
/// container, records carry one grant and one open-now flag, and this type goes away with the
/// entitlement. Everything below still describes the shipping code, and the code still works
/// it is simply describing a world that is being dismantled in a later phase, so the two-app
/// reasoning is left standing rather than half-rewritten into a fiction.
///
/// **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"
/// The bundle id the retired Pro *app* would have carried (12 Targets, ruled 2026-07-27)
/// **one-app collapse phase 2**: no app has ever shipped under it, and nothing will now that Pro
/// is a subscription rather than a second binary (12 Distribution, re-ruled 2026-07-30). It
/// stays only because the awareness line and the grant-slot keying still read it; both go in the
/// same phase, and this constant with them.
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` is a hosted bundle), 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 developer's
/// own 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
}
}
+88
View File
@@ -0,0 +1,88 @@
import Foundation
/// **Where app-side state lives** the board registry, the clipboard's staging snapshots, the user
/// template store (02-architecture.md § Per-board app state, "App-wide state has the same home").
///
/// ### One app, one sandbox, one home
///
/// There is one application (12-editions.md The target, re-ruled 2026-07-30), so there is one
/// sandbox container and nothing to share state *with*. `Library/Application Support` inside that
/// container is the whole answer: the sandbox already scopes it per app, which is why there is **no
/// bundle-id subfolder** a subfolder inside a container that is already this app's alone would be
/// ceremony naming the app twice.
///
/// Scalars are not here. A window size or a toggle goes to `UserDefaults.standard`
/// (`AppPreferences`), which the sandbox scopes on exactly the same terms; this type is only about
/// the *file* stores.
///
/// ### Why the type exists at all rather than three copies of four lines
///
/// Three stores answer "where do I live" and they must answer it identically: the registry, the
/// clipboard staging root and the template store are neighbours by design, and a test that asserts
/// they are neighbours (`BoardRegistryTests`) is asserting something real. One name for the home is
/// what keeps them moving together the day it moves.
public enum AppStateHome {
/// The directory every app-side file store lives in.
///
/// In a shipped app this is `productionDirectory`. **In a unit-test host it is a scratch
/// directory** (`isUnitTestHost`) see that property for why the default has to move for a test
/// host when everything else about testing is injection.
public static var directory: URL {
isUnitTestHost ? unitTestDirectory : productionDirectory
}
/// What a shipped app uses: `Library/Application Support` inside the sandbox container.
///
/// The `NSHomeDirectory()` fallback covers the case where `FileManager` answers with no domain
/// at all not a state a shipped app is in, but this file must not be the thing that throws.
public static var productionDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
}
// MARK: - The unit-test host
/// Whether this process is hosting a unit-test bundle.
///
/// ### Why the app has to know
///
/// The unit-test host **is the app** (`KanbanTests` is a hosted bundle), 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 the thing it reaches for
/// is the developer's own state: its launch sweep would collect real staged clipboard trees, and
/// 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 unitTestDirectory: URL {
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("LaneworkUnitTestState", isDirectory: true)
}
}
+69 -356
View File
@@ -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)")
}
+2 -4
View File
@@ -46,10 +46,8 @@ 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. 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) {
/// its storage URL: a test must be able to hold its own without touching the user's.
public init(defaults: UserDefaults = .standard) {
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
+4 -105
View File
@@ -68,15 +68,6 @@ struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
/// The cross-edition awareness line's *fact*, asked afresh each time the popover is built see
/// `BoardEditionPresence` for why it is a closure rather than a value: both halves of it (the
/// registry's flags and whether the other app is alive) can change while a board window sits
/// there, and neither is a thing to observe.
///
/// Defaulted to "no line" so the two surfaces that build this widget without a registry the
/// titlebar tests keep saying nothing rather than needing one.
var otherEditionNote: () -> String? = { nil }
@Bindable var presentation: BoardInfoPresentation
var body: some View {
@@ -96,7 +87,7 @@ struct BoardInfoWidget: View {
.help("Board Info")
.accessibilityLabel("Board Info")
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents, otherEditionNote: otherEditionNote())
BoardInfoView(store: store, recents: recents)
}
}
}
@@ -112,14 +103,12 @@ struct BoardInfoWidget: View {
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
presentation: BoardInfoPresentation,
otherEditionNote: @escaping () -> String? = { nil }
presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
store: store,
recents: recents,
otherEditionNote: otherEditionNote,
presentation: presentation
)
)
@@ -152,10 +141,6 @@ struct BoardInfoView: View {
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
private let hasGitDirectory: Bool
/// The cross-edition awareness line, or `nil` for no line resolved once when the view is built,
/// on `hasGitDirectory`'s terms and for its reason (`BoardEditionPresence`).
private let otherEditionNote: String?
/// The style editor brings its own padding, so the sections around it carry the same number by
/// hand instead of an outer padding that would double up on it **the editor's own figure**
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
@@ -164,11 +149,10 @@ struct BoardInfoView: View {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
init(store: BoardStore, recents: StyleRecents, otherEditionNote: String? = nil) {
init(store: BoardStore, recents: StyleRecents) {
self.store = store
self.recents = recents
self.hasGitDirectory = BoardGitNote.hasGitDirectory(at: store.rootURL)
self.otherEditionNote = otherEditionNote
}
var body: some View {
@@ -196,20 +180,9 @@ struct BoardInfoView: View {
// nothing here at all no header, no divider, no placeholder and the popover ends at
// Styling, complete in itself. Only a board that actually carries an inert `.git` earns
// this closing note.
//
// The awareness line is its neighbour on the same terms, and the two stack in one section
// when both are true a `.git`-bearing board open in Pro is exactly the household where
// both sentences apply, and each answers a different question.
if hasGitDirectory || otherEditionNote != nil {
Divider()
VStack(alignment: .leading, spacing: 4) {
if hasGitDirectory {
Divider()
BoardGitNote()
}
if let otherEditionNote {
BoardEditionPresenceNote(text: otherEditionNote)
}
}
.padding(inset)
}
}
@@ -344,77 +317,3 @@ struct BoardGitNote: View {
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
}
}
// MARK: - The other edition
/// ** Retired by the one-app collapse one-app collapse phase 2.** There is no other edition to be
/// aware of: 12-editions.md App-side state (re-ruled 2026-07-30) removes the App Group and with it
/// the cross-edition flags this line reads, and 12 Distribution retires the second app outright.
/// This type, `BoardInfoView.otherEditionNote` and `BoardRecord.openNow`'s keying go together in that
/// phase; until then the line is simply never non-`nil` in practice, since no sibling exists to set a
/// flag. Kept functioning, and documented as it was built, rather than half-unwound here.
///
/// **The cross-edition awareness line** "Also open in Lanework Pro" (12-editions.md Both editions
/// installed, ruled 2026-07-29).
///
/// > Two conveniences ride the shared registry: open-now flags are per-edition , and the board
/// > popover carries a contextual awareness line ("Also open in Lanework Pro") read from the other
/// > edition's flag, **pid-liveness-checked so crash residue never lies** a line, never a gate.
///
/// It is `BoardGitNote`'s posture applied to a different fact: contextual, absent when untrue, and
/// never a control. Nothing about it gates anything the same board open in both apps is the designed
/// foreign-writer story (12), so this line exists to *explain* what the user is looking at, not to
/// warn them off it.
///
/// ### Why liveness matters, and why it is a parameter
///
/// The flag is a live open marker that a crash deliberately leaves standing (02-architecture.md
/// § Launch and window lifecycle that residue is what makes crash recovery free). So a flag alone
/// would claim "also open in Lanework Pro" about an app that died last Tuesday. `NSRunningApplication`
/// settles it, and the check is injected so `note(otherEditions:isRunning:)` is a pure function the
/// tests pin directly the same seam `BoardGitNote.hasGitDirectory(at:)` is.
enum BoardEditionPresence {
/// Whether an edition is running right now, by bundle id the pid-liveness half.
///
/// `NSRunningApplication.runningApplications(withBundleIdentifier:)` rather than a stored pid: the
/// registry records *which* edition had the board open, never a process id, and it should not
/// start a pid is a fact with a shelf life of milliseconds, and the bundle id is the question
/// actually being asked.
@MainActor
static func isRunning(_ bundleID: String) -> Bool {
!NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).isEmpty
}
/// The line, or `nil` for no line at all.
///
/// Three ways to get `nil`, and each is the honest answer: no other edition has the board flagged;
/// the flagged edition is not running (crash residue); or the flagged bundle id is one this build
/// cannot name (`AppGroup.editionDisplayName`) a future edition, where inventing a name would be
/// worse than saying nothing.
///
/// **One line even when several editions qualify**, taking the first by the sorted order the
/// registry hands over: the popover has room for a sentence, not a roster, and with Teams
/// deferred there is no case today where two others are open at once.
static func note(otherEditions: [String], isRunning: (String) -> Bool) -> String? {
for bundleID in otherEditions {
guard isRunning(bundleID), let name = AppGroup.editionDisplayName(bundleID) else { continue }
return "Also open in \(name)"
}
return nil
}
}
/// The awareness line as a view, beside `BoardGitNote` and styled identically the two contextual
/// notes are one register, so a popover carrying both reads as one surface.
struct BoardEditionPresenceNote: View {
let text: String
var body: some View {
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
-499
View File
@@ -1,499 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// ** Retired by the one-app collapse one-app collapse phase 2.** 12-editions.md App-side
/// state (re-ruled 2026-07-30) removes the App Group wholesale: one app, one sandbox, one grant, one
/// open-now flag. This whole file is the two-app arrangement's proof, and it retires with the code
/// it pins it is kept green in the meantime rather than deleted ahead of the machinery, because
/// the machinery is what still ships.
///
/// **App-side state in the shared App Group container** (12-editions.md Distribution and Both
/// editions installed, ruled 2026-07-29; 02-architecture.md § Per-board app state).
///
/// One container, one registry, one clipboard staging store, one defaults suite and exactly two
/// fields that cannot be shared, both keyed by bundle id: the security-scoped **grant slot** (a
/// bookmark never crosses a sandbox, group or not) and the **open-now flag** (an edition restores only
/// the boards it had open). Everything here is about those two seams and what they buy.
///
/// **Nothing in this file touches the real group container.** Every registry gets a temp storage file
/// and every edition is a *string*, injected which is the only way "base's record, read by Pro" is
/// expressible inside one process at all.
// MARK: - Fixtures
/// A temp registry file, `BoardRegistryTests`' own shape restated rather than shared because that
/// file's copy is `private` to it and a test fixture is not worth a seam.
@MainActor
private struct GroupStorage {
let folder: URL
var url: URL { folder.appendingPathComponent("board-registry.json", isDirectory: false) }
init() throws {
folder = FileManager.default.temporaryDirectory
.appendingPathComponent("AppGroupStateTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
}
func tearDown() {
try? FileManager.default.removeItem(at: folder)
}
}
@MainActor
private func makeGroupBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
return fixture
}
private let base = AppGroup.baseEditionID
private let pro = AppGroup.proEditionID
// MARK: - The container
@MainActor
@Suite("App Group container")
struct AppGroupContainerTests {
@Test("Every app-side store resolves under one state directory, provisioned or not")
func stateDirectoryIsOneHomeAndAlwaysUsable() async throws {
// Diagnostic, not an assertion: whether this test host has the capability is a fact about
// provisioning, and a suite that *required* it could not run on a machine where the group is
// not yet registered on the team. Printed for the same reason `BoardRegistryTests` prints its
// bookmark flavor the answer matters and cannot be asserted.
let container = AppGroup.containerURL
print("AppGroupStateTests: group container in this host = \(container?.path ?? "nil (unprovisioned — per-edition fallback)")")
let production = AppGroup.productionStateDirectory
if let container {
#expect(production.path.hasPrefix(container.path), "the shared home is inside the group container")
// No bundle-id **subfolder** that subfolder is what kept the editions apart. Compared by
// path component, not by substring: the group id itself contains base's bundle id, which is
// the family name showing through and not a per-edition directory.
#expect(
!production.pathComponents.contains(AppGroup.editionID),
"the shared home must not be nested under a per-edition folder"
)
} else {
#expect(
production == AppGroup.perEditionSupportDirectory,
"the fallback is the pre-2.0 per-edition home, unshared but working"
)
}
// The registry and the clipboard's staging store share one home, which is the whole point:
// "the clipboard staging store homes in the group container **beside the registry**".
let state = AppGroup.stateDirectory
#expect(BoardRegistry.defaultStorageURL.deletingLastPathComponent() == state)
#expect(ClipboardStore.defaultStagingRoot.deletingLastPathComponent() == state)
// And the template store, re-homed here on the same day (09-templates.md Storage): "templates
// cross editions".
#expect(TemplateEngine.userStore.deletingLastPathComponent() == state)
// And it is a directory the app can actually create, which is the only property that has to
// hold on both sides of the provisioning question.
try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true)
#expect(FileManager.default.fileExists(atPath: state.path))
}
@Test("A unit-test host never resolves to the real shared container")
func aTestHostIsRedirected() {
// This suite is running, so this *is* a test host and the point of the check is that the two
// defaults every launch reaches for (the registry file, the staging root) cannot land in a
// container shared with the sibling edition and with the developer's own running copy. There is
// no injection point in `KanbanApp.init()` to fix that from the outside.
#expect(AppGroup.isUnitTestHost)
#expect(AppGroup.stateDirectory == AppGroup.unitTestStateDirectory)
#expect(AppGroup.stateDirectory != AppGroup.productionStateDirectory)
#expect(AppGroup.defaults != UserDefaults(suiteName: AppGroup.identifier))
}
@Test("The group id is the family's, and each edition names itself")
func editionIdentityIsReadFromTheBundle() {
#expect(AppGroup.identifier == "group.dev.rzen.indie.Kanban")
#expect(AppGroup.perEditionSupportDirectory.lastPathComponent == AppGroup.editionID)
#expect(AppGroup.editionDisplayName(base) == "Lanework")
#expect(AppGroup.editionDisplayName(pro) == "Lanework Pro")
// A bundle id this build cannot name gets no name invented for it the awareness line's whole
// posture is that it never says anything it does not know.
#expect(AppGroup.editionDisplayName("dev.rzen.indie.KanbanTeams") == nil)
}
}
// MARK: - Grant slots
@MainActor
@Suite("Per-edition grant slots")
struct GrantSlotTests {
@Test("A grant is minted into this edition's slot and round-trips through the file")
func grantSlotsRoundTrip() async throws {
let storage = try GroupStorage()
defer { storage.tearDown() }
let fixture = try makeGroupBoard()
defer { fixture.tearDown() }
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
let id = asBase.recordOpen(of: fixture.root, displayName: "Work")
let reloaded = try #require(BoardRegistry(storageURL: storage.url, editionID: base).record(id: id))
#expect(reloaded.grant(forEdition: base) != nil)
#expect(reloaded.grant(forEdition: pro) == nil, "an edition mints its own slot and nobody else's")
}
@Test("A record the other edition minted resolves unavailable-until-reopened, anchored at its path")
func otherEditionsGrantNeedsReopening() async throws {
let storage = try GroupStorage()
defer { storage.tearDown() }
let fixture = try makeGroupBoard()
defer { fixture.tearDown() }
BoardRegistry(storageURL: storage.url, editionID: base)
.recordOpen(of: fixture.root, displayName: "Work")
// Pro, over the very same shared file. The board is *there* nothing was deleted but the
// only bookmark on the record was minted in another sandbox, so this edition cannot resolve it.
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
let rows = asPro.recents()
#expect(rows.count == 1, "the record is shared, not duplicated")
guard case let .needsReopen(record, anchor) = rows[0] else {
Issue.record("expected needsReopen, got \(rows[0])")
return
}
#expect(record.displayName == "Work", "every other field is common — the list transfers, only access re-grants")
#expect(anchor.standardizedFileURL.path == URL(fileURLWithPath: fixture.root.path).standardizedFileURL.path)
#expect(rows[0].url == nil, "there is nothing to open until the grant exists")
#expect(rows[0].regrantAnchor != nil)
}
@Test("A genuine orphan is not a re-grant candidate")
func aRecordNobodyCanReachStaysUnavailable() async throws {
let storage = try GroupStorage()
defer { storage.tearDown() }
// No grants at all the born-orphaned record (`recordOpen`'s degenerate case). The two states
// are told apart by whether *somebody* holds a grant, so this one must stay `unavailable`: an
// open panel cannot help a board nothing knows the whereabouts of.
let id = UUID()
let json = """
[
{
"displayName" : "Ghost",
"grants" : {},
"id" : "\(id.uuidString)",
"lastKnownPath" : "/Volumes/Gone/Ghost.kanban",
"lastOpened" : "2026-01-01T09:00:00.000Z",
"openNow" : {},
"pushOnCommit" : false,
"remoteLocationWarned" : false
}
]
"""
try Data(json.utf8).write(to: storage.url)
let rows = BoardRegistry(storageURL: storage.url, editionID: pro).recents()
#expect(rows.count == 1)
guard case .unavailable = rows[0] else {
Issue.record("expected unavailable, got \(rows[0])")
return
}
}
@Test("Re-granting mints this edition's slot onto the shared record and leaves the other's alone")
func regrantingDoesNotForkTheRecord() async throws {
let storage = try GroupStorage()
defer { storage.tearDown() }
let fixture = try makeGroupBoard()
defer { fixture.tearDown() }
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
let id = asBase.recordOpen(of: fixture.root, displayName: "Work")
asBase.updateWindowFrame(id: id, frame: WindowFrame(x: 10, y: 20, width: 300, height: 400))
let baseGrant = try #require(asBase.record(id: id)?.grant(forEdition: base))
// The re-grant: the panel handed Pro the same folder, and the open goes through the ordinary
// `recordOpen` door. Nothing about it is a special API that is the design.
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
let proID = asPro.recordOpen(of: fixture.root)
#expect(proID == id, "the shared record is matched, never forked")
let shared = try #require(BoardRegistry(storageURL: storage.url, editionID: base).record(id: id))
#expect(shared.grant(forEdition: pro) != nil, "Pro now holds its own grant")
#expect(shared.grant(forEdition: base) == baseGrant, "base's grant is untouched — it is still that app's key")
#expect(shared.windowFrame?.width == 300, "and every common field survived the second edition's open")
#expect(BoardRegistry(storageURL: storage.url, editionID: pro).recents().count == 1)
// Both editions can now reach it.
guard case .available = BoardRegistry(storageURL: storage.url, editionID: pro).recents()[0] else {
Issue.record("expected Pro to see the board as available after re-granting")
return
}
}
@Test("A registry file written before the grant slots existed adopts its one bookmark as this edition's")
func legacySingleGrantRecordsAreReadable() async throws {
let storage = try GroupStorage()
defer { storage.tearDown() }
// Byte-for-byte the pre-App-Group shape: one `bookmark`, one `isOpenNow`, no keyed slots at
// all. Only base existed then, so adopting the bookmark as the *running* edition's slot is the
// coherent reading and the reachable case is the unprovisioned fallback, where
// `AppGroup.stateDirectory` still points at the old per-edition home.
let id = UUID()
let garbage = Data("not a bookmark".utf8).base64EncodedString()
let json = """
[
{
"bookmark" : "\(garbage)",
"cardCount" : 9,
"displayName" : "Archive",
"id" : "\(id.uuidString)",
"isOpenNow" : true,
"laneCount" : 4,
"lastKnownPath" : "/Volumes/Archive/Boards/Archive",
"lastOpened" : "2026-01-01T09:00:00.000Z",
"pushOnCommit" : true,
"remoteLocationWarned" : true
}
]
"""
try Data(json.utf8).write(to: storage.url)
let registry = BoardRegistry(storageURL: storage.url, editionID: base)
let record = try #require(registry.record(id: id))
#expect(record.grant(forEdition: base) != nil, "the one bookmark became this edition's grant")
#expect(record.isOpen(inEdition: base), "and the one flag became this edition's flag")
#expect(record.laneCount == 4, "every other field survived — nothing was quarantined")
// Its bookmark is unresolvable garbage, so it classifies as the orphan it is never as a
// re-grant candidate, which would be this edition offering to grant a board it already holds
// the (dead) key to.
guard case .unavailable = registry.recents()[0] else {
Issue.record("expected unavailable, got \(registry.recents()[0])")
return
}
// Tolerate-and-upgrade **on first write**: reading changed nothing on disk, and the next
// ordinary save emits the keyed shape and drops the legacy keys for good.
#expect(try String(data: Data(contentsOf: storage.url), encoding: .utf8)?.contains("\"bookmark\"") == true)
registry.setRemoteLocationWarned(id: id)
let upgraded = try #require(String(data: Data(contentsOf: storage.url), encoding: .utf8))
#expect(!upgraded.contains("\"bookmark\""))
#expect(!upgraded.contains("\"isOpenNow\""))
#expect(upgraded.contains("\"grants\""))
#expect(BoardRegistry(storageURL: storage.url, editionID: base).record(id: id)?.grant(forEdition: base) != nil)
}
}
// MARK: - Open-now flags
@MainActor
@Suite("Per-edition open-now flags")
struct OpenNowPerEditionTests {
@Test("An edition restores only the boards it had open")
func restorationIsFilteredByEdition() async throws {
let storage = try GroupStorage()
defer { storage.tearDown() }
let mine = try makeGroupBoard()
defer { mine.tearDown() }
let theirs = try makeGroupBoard()
defer { theirs.tearDown() }
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
let mineID = asBase.recordOpen(of: mine.root, displayName: "Mine")
let theirsID = asBase.recordOpen(of: theirs.root, displayName: "Theirs")
asBase.setOpenNow(id: mineID)
// Pro flags the other board same shared records, its own slot.
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
asPro.recordOpen(of: theirs.root)
asPro.setOpenNow(id: theirsID)
let baseRestores = BoardRegistry(storageURL: storage.url, editionID: base).restorables()
let proRestores = BoardRegistry(storageURL: storage.url, editionID: pro).restorables()
#expect(baseRestores.map(\.record.id) == [mineID])
#expect(proRestores.map(\.record.id) == [theirsID])
// And a user close in one edition leaves the other's flag standing.
BoardRegistry(storageURL: storage.url, editionID: base).clearOpenNow(id: mineID)
let after = try #require(BoardRegistry(storageURL: storage.url, editionID: pro).record(id: theirsID))
#expect(after.isOpen(inEdition: pro))
#expect(!after.isOpen(inEdition: base))
}
@Test("The other edition's flags are reported raw, never this edition's own")
func flaggedEditionsExcludeSelf() async throws {
let storage = try GroupStorage()
defer { storage.tearDown() }
let fixture = try makeGroupBoard()
defer { fixture.tearDown() }
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
let id = asBase.recordOpen(of: fixture.root, displayName: "Shared")
asBase.setOpenNow(id: id)
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
asPro.setOpenNow(id: id)
#expect(asPro.otherEditionsFlaggedOpen(id: id) == [base])
#expect(BoardRegistry(storageURL: storage.url, editionID: base).otherEditionsFlaggedOpen(id: id) == [pro])
#expect(asPro.otherEditionsFlaggedOpen(id: UUID()).isEmpty, "an unknown id says nothing")
}
}
// MARK: - Two editions, one file
@MainActor
@Suite("One registry file, two live editions")
struct SharedRegistryFileTests {
@Test("Neither edition's write erases the other's")
func writesFromBothEditionsSurvive() async throws {
let storage = try GroupStorage()
defer { storage.tearDown() }
let mine = try makeGroupBoard()
defer { mine.tearDown() }
let theirs = try makeGroupBoard()
defer { theirs.tearDown() }
// Both **live at once**, which is the steady state 12-editions.md blesses ("a supported steady
// state, not a transition to hurry past") and the case a whole-file writer over a cached array
// gets wrong by default: base's next window-frame save would rewrite the file from an array that
// never heard of Pro's board.
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
let mineID = asBase.recordOpen(of: mine.root, displayName: "Base's")
let theirsID = asPro.recordOpen(of: theirs.root, displayName: "Pro's")
// An ordinary convenience write from the edition that has not looked at the file since.
asBase.updateWindowFrame(id: mineID, frame: WindowFrame(x: 1, y: 2, width: 3, height: 4))
let onDisk = BoardRegistry(storageURL: storage.url, editionID: base)
#expect(Set(onDisk.recents().map(\.record.id)) == [mineID, theirsID], "both records are in the file")
#expect(onDisk.record(id: mineID)?.windowFrame?.width == 3)
#expect(onDisk.record(id: theirsID)?.grant(forEdition: pro) != nil, "and Pro's grant was not rewritten away")
}
@Test("A flag the other edition sets is visible without relaunching")
func theOtherEditionsFlagIsPickedUpLive() async throws {
let storage = try GroupStorage()
defer { storage.tearDown() }
let fixture = try makeGroupBoard()
defer { fixture.tearDown() }
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
let id = asBase.recordOpen(of: fixture.root, displayName: "Shared")
// Pro opens the same board afterwards. Base is still running and has not re-read anything and
// the popover's awareness line is worthless if it can only see flags that predate this launch.
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
asPro.recordOpen(of: fixture.root)
asPro.setOpenNow(id: id)
#expect(asBase.otherEditionsFlaggedOpen(id: id) == [pro])
#expect(
BoardEditionPresence.note(
otherEditions: asBase.otherEditionsFlaggedOpen(id: id),
isRunning: { $0 == pro }
) == "Also open in Lanework Pro"
)
// And it goes away again when Pro closes the board, still without a relaunch.
asPro.clearOpenNow(id: id)
#expect(asBase.otherEditionsFlaggedOpen(id: id).isEmpty)
}
}
// MARK: - The awareness line
@Suite("The board popover's awareness line")
struct BoardEditionPresenceTests {
@Test("A live other edition earns the line")
func aLiveEditionIsNamed() {
#expect(
BoardEditionPresence.note(otherEditions: [pro], isRunning: { $0 == pro })
== "Also open in Lanework Pro"
)
#expect(
BoardEditionPresence.note(otherEditions: [base], isRunning: { _ in true })
== "Also open in Lanework"
)
}
@Test("A flag with no live process shows nothing — crash residue never lies")
func staleFlagsSayNothing() {
// The flag is deliberately left standing by a crash (02-architecture.md § Launch and window
// lifecycle that residue is what makes crash recovery free), so the flag alone would claim
// an app that died last week is looking at this board right now.
#expect(BoardEditionPresence.note(otherEditions: [pro], isRunning: { _ in false }) == nil)
#expect(BoardEditionPresence.note(otherEditions: [], isRunning: { _ in true }) == nil)
}
@Test("An edition this build cannot name shows nothing")
func unknownEditionsSayNothing() {
#expect(
BoardEditionPresence.note(otherEditions: ["dev.rzen.indie.KanbanTeams"], isRunning: { _ in true })
== nil
)
// and a nameable live one behind it still wins, so one unknown neighbour does not silence the
// line altogether.
#expect(
BoardEditionPresence.note(
otherEditions: ["dev.rzen.indie.KanbanTeams", pro],
isRunning: { _ in true }
) == "Also open in Lanework Pro"
)
}
}
// MARK: - The welcome row
@MainActor
@Suite("The cross-edition welcome row")
struct CrossEditionWelcomeRowTests {
@Test("A re-grant row opens on one click, reveals on none, and says what the click will do")
func theRowIsOpenableButNotRevealable() throws {
let record = BoardRecord(
grants: [pro: Data("pro's key".utf8)],
displayName: "Roadmap",
lastKnownPath: "/Boards/Roadmap.kanban",
lastOpened: Date()
)
let recent = RecentBoard.needsReopen(record, recordedAt: URL(fileURLWithPath: record.lastKnownPath))
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
#expect(row.needsReopen)
#expect(row.canOpen, "the board is there — one click plus Grant is the whole remedy")
#expect(!row.canReveal, "revealing a folder is a read this app has not been granted either")
#expect(row.caption == .needsReopen)
#expect(row.regrantAnchor?.path == "/Boards/Roadmap.kanban")
#expect(row.location == "/Boards")
}
@Test("Fail-fast's specifics still outrank the re-grant caption")
func aFailureStillWinsTheCaption() throws {
let record = BoardRecord(
grants: [pro: Data("pro's key".utf8)],
displayName: "Roadmap",
lastKnownPath: "/Boards/Roadmap.kanban",
lastOpened: Date()
)
let recent = RecentBoard.needsReopen(record, recordedAt: URL(fileURLWithPath: record.lastKnownPath))
let failure = LaunchFailure(path: "/Boards/Roadmap.kanban", message: "index.md is unparseable.")
let row = try #require(WelcomeRow.derive(recents: [recent], failures: [failure]).rows.first)
// The precedence 02 § Launch and window lifecycle fixes: a failure is what the row is *for* at
// that moment, and it is still true that this board needs granting but the message the user
// has to read first is the one about the file.
#expect(row.caption == .failed("index.md is unparseable."))
#expect(row.canOpen, "and the retry is still one click")
}
}
+5 -6
View File
@@ -33,10 +33,9 @@ private func makeMixedBoard() throws -> WriterFixture {
return fixture
}
/// An `AppModel` whose app-side state lives in temp rather than in the shared App Group container
/// both halves of it: the registry file, and the clipboard's staging store, whose launch sweep would
/// otherwise collect the developer's own staged copy (and the sibling edition's, since there is one
/// store now).
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
/// Support home both halves of it: the registry file, and the clipboard's staging store, whose
/// launch sweep would otherwise collect the developer's own staged copy.
@MainActor
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
@@ -219,13 +218,13 @@ struct AppModelTests {
let record = try #require(model.boardRegistry.record(id: recordID))
#expect(record.laneCount == 2, "the counts the welcome row will show are the working ones")
#expect(record.cardCount == 2)
#expect(!record.isOpen(inEdition: model.boardRegistry.editionID))
#expect(!record.isOpenNow)
#expect(model.boardRegistry.restorables().isEmpty)
// Twice is a no-op, which is what lets the window's close interception and its disappear both
// call this without the sequence running twice.
await model.closeBoard(ref: ref, cause: .userClose)
#expect(model.boardRegistry.record(id: recordID)?.isOpen(inEdition: model.boardRegistry.editionID) == false)
#expect(model.boardRegistry.record(id: recordID)?.isOpenNow == false)
}
@Test("Quit closes every board and leaves them all flagged for the next launch")
+54
View File
@@ -0,0 +1,54 @@
import Foundation
import Testing
@testable import Kanban
/// **Where app-side state lives** (`AppStateHome`; 02-architecture.md § Per-board app state,
/// "App-wide state has the same home"; 12-editions.md App-side state, re-ruled 2026-07-30 one
/// app, one sandbox, one home).
///
/// Two claims, and they are the only two this type makes: the three file stores are neighbours under
/// one directory the app can actually create, and a **unit-test host never resolves to the real
/// one**. The second is not a nicety the test host *is* the app, so `KanbanApp.init()` runs for
/// real on every test launch, and a home that pointed at the developer's own state would have the
/// host's launch sweep collecting real staged clipboard trees and its recents refresh rewriting real
/// records. There is no injection point in `App.init` to fix that from outside.
@MainActor
@Suite("App state home")
struct AppStateHomeTests {
@Test("Every app-side store resolves under one state directory, and it is creatable")
func stateDirectoryIsOneHomeAndAlwaysUsable() throws {
let home = AppStateHome.directory
// The registry, the clipboard's staging store and the template store are one another's
// neighbours by design, and each names the home rather than spelling a path so this is the
// assertion that keeps them moving together the day the home moves.
#expect(BoardRegistry.defaultStorageURL.deletingLastPathComponent() == home)
#expect(ClipboardStore.defaultStagingRoot.deletingLastPathComponent() == home)
#expect(TemplateEngine.userStore.deletingLastPathComponent() == home)
// And it is a directory the app can actually create, which is the one property that has to
// hold whichever side of the test-host redirect this is running on.
try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
#expect(FileManager.default.fileExists(atPath: home.path))
}
@Test("A unit-test host never resolves to the real Application Support home")
func aTestHostIsRedirected() {
// This suite is running, so this *is* a test host and the point of the check is that the
// two defaults every launch reaches for (the registry file, the staging root) cannot land in
// the home the developer's own running copy uses.
#expect(AppStateHome.isUnitTestHost)
#expect(AppStateHome.directory == AppStateHome.unitTestDirectory)
#expect(AppStateHome.directory != AppStateHome.productionDirectory)
}
@Test("The production home is Application Support itself, with no bundle-id subfolder appended")
func productionHomeHasNoBundleIDSubfolder() {
// The sandbox already scopes `Application Support` to this app that container path is the
// one place the bundle id belongs so a subfolder appended *inside* it would name the app
// twice. The last component is therefore the check: what this type adds is nothing.
#expect(AppStateHome.productionDirectory.lastPathComponent == "Application Support")
}
}
+45 -9
View File
@@ -91,7 +91,7 @@ struct BoardRegistryTests {
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
let created = try #require(registry.record(id: id))
#expect(created.grant(forEdition: registry.editionID) != nil)
#expect(!created.bookmark.isEmpty)
#expect(created.displayName == "Todo Board")
#expect(created.lastKnownPath == fixture.root.path)
#expect(created.laneCount == nil, "counts are stamped at close, never guessed at open")
@@ -135,7 +135,7 @@ struct BoardRegistryTests {
let id = registry.recordOpen(of: fixture.root)
let record = try #require(registry.record(id: id))
#expect(record.displayName == fixture.root.deletingPathExtension().lastPathComponent)
#expect(record.grant(forEdition: registry.editionID) != nil, "the bookmark still mints on the before-load call")
#expect(!record.bookmark.isEmpty, "the bookmark still mints on the before-load call")
#expect(record.icon == nil)
#expect(record.iconColor == nil)
}
@@ -163,7 +163,7 @@ struct BoardRegistryTests {
#expect(record.icon == "star")
#expect(record.iconColor == "fern")
#expect(record.lastOpened > firstOpened, "the bookmark and lastOpened still refresh on every open attempt")
#expect(record.grant(forEdition: registry.editionID) != nil)
#expect(!record.bookmark.isEmpty)
}
// MARK: Counts
@@ -314,6 +314,42 @@ struct BoardRegistryTests {
#expect(updated?.iconColor == "aluminum")
}
@Test("A record with no bookmark key at all is a born orphan, not a quarantine")
func aRecordWithNoBookmarkKeyDecodesAsAnOrphan() async throws {
let storage = try RegistryStorage()
defer { storage.tearDown() }
// `bookmark` carries a decoding default like every key past the four founding ones, and this
// is why: a record the system refused to mint a key for is a recents row with Forget the
// born-orphaned case `recordOpen` already writes and quarantining the user's whole list
// over one keyless entry would be the cure being worse than the disease.
let id = UUID()
let json = """
[
{
"displayName" : "Ghost",
"id" : "\(id.uuidString)",
"lastKnownPath" : "/Volumes/Gone/Ghost.kanban",
"lastOpened" : "2026-01-01T09:00:00.000Z"
}
]
"""
try Data(json.utf8).write(to: storage.url)
let registry = BoardRegistry(storageURL: storage.url)
let rows = registry.recents()
#expect(rows.count == 1, "the file decoded; nothing was quarantined")
guard case .unavailable = rows[0] else {
Issue.record("expected unavailable, got \(rows[0])")
return
}
#expect(registry.record(id: id)?.bookmark.isEmpty == true)
// The other defaulted keys came through as their defaults too, rather than as a throw.
#expect(registry.record(id: id)?.isOpenNow == false)
#expect(registry.record(id: id)?.pushOnCommit == false)
#expect(registry.record(id: id)?.remoteLocationWarned == false)
}
@Test("A registry file with icon and iconColor present decodes them")
func registryFileWithIconKeysDecodes() async throws {
let storage = try RegistryStorage()
@@ -620,16 +656,16 @@ struct BoardRegistryTests {
let registry = BoardRegistry(storageURL: storage.url)
let id = registry.recordOpen(of: fixture.root, displayName: "Work")
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == false, "recording an open is not opening a window")
#expect(registry.record(id: id)?.isOpenNow == false, "recording an open is not opening a window")
#expect(registry.restorables().isEmpty)
registry.setOpenNow(id: id)
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == true)
#expect(registry.record(id: id)?.isOpenNow == true)
#expect(ids(registry.restorables()) == [id])
// A user close. The flag goes, and with it the board's place in the next launch.
registry.clearOpenNow(id: id)
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == false)
#expect(registry.record(id: id)?.isOpenNow == false)
#expect(registry.restorables().isEmpty)
// A quit. The teardown stamps counts and does *not* clear the flag that omission is the
@@ -639,7 +675,7 @@ struct BoardRegistryTests {
registry.recordClose(id: id, displayName: "Work", laneCount: 2, cardCount: 5)
let afterRelaunch = BoardRegistry(storageURL: storage.url)
#expect(afterRelaunch.record(id: id)?.isOpen(inEdition: registry.editionID) == true, "the flags describe what was open at quit")
#expect(afterRelaunch.record(id: id)?.isOpenNow == true, "the flags describe what was open at quit")
#expect(ids(afterRelaunch.restorables()) == [id])
#expect(afterRelaunch.record(id: id)?.laneCount == 2)
}
@@ -712,13 +748,13 @@ struct BoardRegistryTests {
let registry = BoardRegistry(storageURL: storage.url)
#expect(registry.recents().count == 1, "the file decoded; nothing was quarantined")
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == false, "a missing key reads as 'not open'")
#expect(registry.record(id: id)?.isOpenNow == false, "a missing key reads as 'not open'")
#expect(registry.restorables().isEmpty)
#expect(registry.record(id: id)?.laneCount == 4, "and every other field survived")
// And the key writes through from here on.
registry.setOpenNow(id: id)
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpen(inEdition: registry.editionID) == true)
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpenNow == true)
}
// MARK: Bookmarks in a sandboxed host
+2 -2
View File
@@ -229,8 +229,8 @@ struct TemplateChooserRowTests {
// MARK: - Fixture
/// A temp store holding hand-written template board folders the user store's shape, minus
/// the shared App Group container (which no test may touch).
/// A temp store holding hand-written template board folders the user store's shape, somewhere no
/// test can disturb the real one.
struct TemplateFixture {
let store: URL
+1 -1
View File
@@ -43,7 +43,7 @@ private func subtitle(forCard cardID: String, boardNamed board: String, in model
return CardWindowHost.subtitle(board: board, lane: placement.lane.title.value)
}
/// A registry whose file lives in temp rather than in the shared App Group container.
/// A registry whose file lives in temp rather than in the app's real Application Support home.
@MainActor
private struct RegistryStorage {
let folder: URL
+17 -19
View File
@@ -8,9 +8,8 @@ import Testing
///
/// Every suite here drives a real store over a real temp board, with two things injected: a fake
/// pasteboard (so a test never races the machine's one real pasteboard, nor every other test in the
/// run) and a temp staging directory (so nothing goes near the shared App Group container, which is now
/// the sibling edition's staging store too). Both seams exist exactly because those two claims are the
/// ones worth pinning.
/// run) and a temp staging directory (so nothing goes near the app's real Application Support home).
/// Both seams exist exactly because those two claims are the ones worth pinning.
// MARK: - Test doubles
@@ -116,8 +115,8 @@ struct ClipboardHarness {
/// The staged copy directories, sorted "at most the current copy" is a claim about this list.
///
/// Hidden entries are excluded because the sweep keeps its own bookkeeping folder among them
/// (`ClipboardStore.prune`'s claim-then-delete, which is what makes a concurrent sweep by the
/// sibling edition safe). A staged copy is never hidden its name is a lowercased UUID.
/// (`ClipboardStore.prune`'s claim-then-delete). A staged copy is never hidden its name is a
/// lowercased UUID.
func stagedCopyIDs() throws -> [String] {
try FileManager.default.contentsOfDirectory(atPath: staging.path)
.filter { !$0.hasPrefix(".") }
@@ -416,17 +415,16 @@ struct ClipboardSweepTests {
#expect(try harness.stagedCopyIDs().isEmpty)
}
// MARK: The sibling edition's sweep
// MARK: Two sweeps over one store
//
// The staging store now lives in the shared App Group container (12-editions.md Both editions
// installed), so base and Pro sweep the same directory on their own launches, activations, copies
// and pastes. Both compute the *same* answer the keep set is the one `copyID` the machine-wide
// pasteboard names so they never disagree about what should go; what they can do is arrive at the
// same doomed tree together. These two tests are the ruling's two clauses: atomic removals, and
// One app, so the ordinary case is one sweeper but the sweep is written claim-then-delete
// anyway (`ClipboardStore.prune`), which is what makes a second sweeper a non-event: a second
// copy of the app launched with `open -n` shares this container, and so does the next sweep after
// a crash mid-delete. These two tests are that property's two halves: atomic removals, and
// missing-entry = already swept.
@Test("Two editions sweeping the same store at once agree, and neither errors")
func concurrentSweepsFromBothEditionsAgree() async throws {
@Test("Two stores sweeping the same staging root at once agree, and neither errors")
func concurrentSweepsAgree() async throws {
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
@@ -443,9 +441,9 @@ struct ClipboardSweepTests {
try Data("bytes".utf8).write(to: tree.appendingPathComponent("nested/file.txt", isDirectory: false))
}
// Both editions read the *same* pasteboard, which is why both keep sets are `keep`. Modelled as
// two stores over one staging root with pasteboards holding the same manifest, since two
// processes are not something a unit test can have.
// Both sweepers read the *same* machine-wide pasteboard, which is why both keep sets are
// `keep`. Modelled as two stores over one staging root with pasteboards holding the same
// manifest, since two processes are not something a unit test can have.
let manifest = ClipboardManifest(
copyID: "keep",
boardRoot: URL(fileURLWithPath: "/Boards/Shared.kanban", isDirectory: true),
@@ -500,7 +498,7 @@ struct ClipboardSweepTests {
stagingRoot: staging,
observesActivation: false
)
// The sibling got there first which from this store's side is indistinguishable from the
// Something got there first which from this store's side is indistinguishable from the
// directory listing simply being stale by the time it is walked.
try FileManager.default.removeItem(at: doomed)
clipboard.sweep()
@@ -508,8 +506,8 @@ struct ClipboardSweepTests {
#expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).isEmpty)
// And a staging root that has gone altogether the sibling swept, then something removed the
// shared folder is nothing to do either, rather than a throw on the way to a no-op.
// And a staging root that has gone altogether is nothing to do either, rather than a throw on
// the way to a no-op.
try FileManager.default.removeItem(at: staging)
clipboard.sweep()
await clipboard.stagingSettled()
+10 -11
View File
@@ -19,9 +19,8 @@ import Testing
///
/// Plus the promise that makes the copy safe to run at all: a cancelled save leaves nothing behind.
///
/// Every test drives an explicit store URL. **No test may touch the real store** which since the
/// 2026-07-29 re-homing is in the shared App Group container, so it would be the sibling edition's
/// template store too. `userStore` is named here only to prove the engine never creates it on its own.
/// Every test drives an explicit store URL. **No test may touch the real store** it is the
/// developer's own. `userStore` is named here only to prove the engine never creates it on its own.
// MARK: - Helpers
@@ -439,18 +438,18 @@ struct SaveAsTemplateAtomicityTests {
@Suite("Save as Template — the user store")
struct UserTemplateStoreTests {
@Test("The user store is named beside the registry in the shared home, and is never created by naming it")
@Test("The user store is named beside the registry in the app's state home, and is never created by naming it")
func theStoreIsNamedNotCreated() {
let store = TemplateEngine.userStore
#expect(store.lastPathComponent == TemplateEngine.storeFolderName)
// Beside the registry and the clipboard's staging store, in the App Group container
// (09-templates.md Storage, re-homed 2026-07-29 templates cross editions). Compared against
// the one shared home rather than spelled out, so the assertion follows it wherever it goes
// including the scratch redirect a test host gets (`AppGroup.isUnitTestHost`). `AppGroup` rather
// than the two stores' own defaults because those are `@MainActor` and this suite is not; that
// the three agree is `AppGroupContainerTests`' assertion.
#expect(store.deletingLastPathComponent() == AppGroup.stateDirectory)
// Beside the registry and the clipboard's staging store (09-templates.md Storage;
// 02-architecture.md § Per-board app state, "App-wide state has the same home"). Compared
// against the one home rather than spelled out, so the assertion follows it wherever it goes
// including the scratch redirect a test host gets (`AppStateHome.isUnitTestHost`).
// `AppStateHome` rather than the two stores' own defaults because those are `@MainActor` and
// this suite is not; that the three agree is `AppStateHomeTests`' assertion.
#expect(store.deletingLastPathComponent() == AppStateHome.directory)
// Nothing here creates it, and no test may: `createUserStore(at:)` is Save as Template's and
// Reveal in Finder's, and both are driven with an explicit store in this suite.
}
+4 -3
View File
@@ -15,9 +15,9 @@ import Testing
// MARK: - Fixtures
/// A record with nothing in it that matters except what a given test is about. It holds **no grant
/// slot** because this function never resolves one `RecentBoard` is constructed directly here, so the
/// availability classification is an input rather than a filesystem outcome.
/// A record with nothing in it that matters except what a given test is about. Its bookmark is
/// **empty** because this function never resolves one `RecentBoard` is constructed directly here,
/// so the availability classification is an input rather than a filesystem outcome.
private func record(
name: String,
at path: String,
@@ -28,6 +28,7 @@ private func record(
iconColor: String? = nil
) -> BoardRecord {
BoardRecord(
bookmark: Data(),
displayName: name,
lastKnownPath: path,
lastOpened: opened,
+4 -4
View File
@@ -86,12 +86,12 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Kanban
MARKETING_VERSION: "2.0"
INFOPLIST_FILE: Kanban/Info.plist
# `Kanban/Kanban.entitlements` — sandbox, user-selected files, app-scope bookmarks, the
# App Group (removed with the app-side-state collapse, a later phase), and
# `Kanban/Kanban.entitlements` — sandbox, user-selected files, app-scope bookmarks, and
# `com.apple.security.network.client`, which is **declared now and dormant until Pro
# ships**: the one binary carries the key, and nothing exercises it until the git
# provider's remotes do under an active subscription (12 ▸ The target). No keychain
# access group — groups exist to share items *between* apps, and there is one app.
# provider's remotes do under an active subscription (12 ▸ The target). No App Group and
# no keychain access group — groups exist to share *between* apps, and there is one app,
# so app-side state homes in the ordinary sandbox container (`AppStateHome`).
CODE_SIGN_ENTITLEMENTS: Kanban/Kanban.entitlements
GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete