Home app-side state in the shared App Group container

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

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

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 20:18:15 -04:00
parent a99e1a52f0
commit 566deab506
28 changed files with 1733 additions and 181 deletions
+3 -4
View File
@@ -79,8 +79,7 @@ struct OpenRecentMenu: View {
Menu("Open Recent") {
ForEach(rows) { row in
Button(row.displayName) {
guard let url = row.url else { return }
appModel.openBoard(at: url)
appModel.open(row)
}
.disabled(!row.canOpen)
}
@@ -291,7 +290,7 @@ struct DuplicateBoardCommand: View {
/// rule covers File Duplicate". So the shape here is `DuplicateBoardCommand`'s, step for step
/// flush, then a cancellable copy off the main actor under an in-progress row and the differences
/// are all in the engine (`TemplateEngine.saveAsTemplate(boardAt:titled:into:)`): the destination is
/// Application Support rather than a sibling, `.git` and `.trash/` are dropped rather than forked, a
/// the app-side template store rather than a sibling, `.git` and `.trash/` are dropped rather than forked, a
/// collision auto-renames rather than failing, and a `template:` key lands on the copy.
///
/// **No save panel, ever.** The store is the app's own container "friction-free sandbox writes, no
@@ -313,7 +312,7 @@ struct DuplicateBoardCommand: View {
///
/// Board window only, and disabled under the read-only lock with the exception 09 spells out and
/// 02-architecture.md Live-reload resilience scopes: **under the unwritable-location lock alone it
/// stays live**, because it "reads the board and writes Application Support" (archiving the
/// stays live**, because it "reads the board and writes the app-side store" (archiving the
/// read-only DMG board being inspected is a legitimate errand), *unless* an open Edit or raw-source
/// session holds unsaved content content that lock's suspended saves cannot flush, and which the
/// template would therefore silently miss. The other two locks disable it outright: a vanished root
+88 -9
View File
@@ -28,8 +28,23 @@ 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`).
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"
@@ -37,7 +52,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 {
UserDefaults.standard.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
defaults.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
@@ -46,14 +61,14 @@ public enum AppPreferences {
public static let lastCardWindowSizeKey = "lastCardWindowSize"
public static var lastCardWindowSize: CGSize? {
guard let text = UserDefaults.standard.string(forKey: lastCardWindowSizeKey) else { return nil }
guard let text = defaults.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) {
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
defaults.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
}
/// The quick-style row's recently-used backgrounds an array of palette names / hex strings,
@@ -163,7 +178,7 @@ public final class AppModel {
/// the registries' reason app-scoped, and a test holds its own rather than colliding with the
/// app's and reached by the context menus through the environment, since a `BoardStore` is
/// board-scoped and this list deliberately is not.
public let styleRecents = StyleRecents()
public let styleRecents: StyleRecents
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
@@ -187,7 +202,7 @@ public final class AppModel {
///
/// Building it here is also the **launch sweep** (04: "a sweep at launch"): the store's `init`
/// reads the pasteboard once and collects every staged tree it no longer names.
public let clipboard = ClipboardStore()
public let clipboard: ClipboardStore
// MARK: The provider seam
@@ -411,11 +426,25 @@ public final class AppModel {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
/// The app builds one of these with the real registry file; a test passes its own path for the
/// same reason `BoardRegistry` takes one at all "injecting it is how a test stays out of the
/// real Application Support directory".
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
/// 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.
///
/// `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
/// fixture launch redirects both into one scratch root (`UITestLaunch`), a unit test usually wants
/// only one of them, and deriving would silently move a test's staging directory the day it moved
/// its registry file.
public init(
registryStorageURL: URL = BoardRegistry.defaultStorageURL,
clipboardStagingRoot: URL = ClipboardStore.defaultStagingRoot,
preferences: UserDefaults = AppGroup.defaults
) {
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
styleRecents = StyleRecents(defaults: preferences)
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
// Read once here rather than lazily, so File Open Recent is populated from the app's first
// menu pass a launch that restores boards never shows welcome, and a submenu that filled
// in only after the first close would look broken. It costs one bookmark-resolution sweep at
@@ -518,6 +547,56 @@ 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? {
+16 -1
View File
@@ -284,7 +284,22 @@ struct BoardWindowHost: View {
// load rather than at attach because it carries the store; the controller installs it once,
// whichever of the two arrives second.
windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo)
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
)
}
)
)
// The board's customizable toolbar (03-board-ui.md Toolbar) installed here for the
+1 -1
View File
@@ -19,7 +19,7 @@ extension UTType {
///
/// **It is self-describing twice over**, and both halves earn their keep:
///
/// - `copyID` ties the pasteboard to a staging directory `<Application Support>//Clipboard/<copyID>/`,
/// - `copyID` ties the pasteboard to a staging directory `<group container>//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.
+86 -13
View File
@@ -12,11 +12,17 @@ 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
/// `<Application Support>/<bundle id>/Clipboard/<copyID>/`, so a paste reproduces the item
/// `<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 the fallback when a snapshot is missing, and a fallback paste is **loud**:
/// a banner names exactly what was lost.
///
/// **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`).
///
/// ### The staging lifecycle, settled
///
/// - **Eager**: the snapshot is taken at C/X time, so a copy captures the source as it was at the
@@ -102,22 +108,24 @@ public final class ClipboardStore {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard")
/// `~/Library/Application Support/<bundle id>/Clipboard/`, beside the board registry the same
/// container convention, for the same reason (02-architecture.md § Per-board app state, "App-wide
/// state has the same home").
/// `<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, and the degraded embedded-`index.md` fallback stays
/// for genuinely missing snapshots rather than being the structural cross-edition outcome.
public static var defaultStagingRoot: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"
return support
.appendingPathComponent(bundleIdentifier, isDirectory: true)
.appendingPathComponent("Clipboard", isDirectory: true)
AppGroup.stateDirectory.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 Application Support *and* off the machine's one pasteboard.
/// them is how a suite stays out of the shared App Group container *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
/// the pasteboard once and collects every staged tree it no longer names, which is exactly the
@@ -508,14 +516,79 @@ public final class ClipboardStore {
enqueue { await Self.prune(root, keeping: keep) }
}
/// Where a tree goes to die: a hidden sibling inside the staging root, so a removal is **two
/// steps, the first of them atomic**.
///
/// Hidden (`.`-prefixed) on purpose `prune` lists with `.skipsHiddenFiles`, so this folder is
/// invisible to the sweep that owns it and can never be mistaken for a staged copy.
///
/// `nonisolated` because `prune` is: the sweep runs off the main actor by design, and a constant
/// 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 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:
///
/// 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.
/// 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.
///
/// Leftovers in `.sweeping/` are collected on the next pass. A crash between the rename and the
/// delete therefore costs disk until the next sweep, which is the same guarantee the staging store
/// already gives about its own orphans.
private nonisolated static func prune(_ root: URL, keeping keep: String?) async {
let sweepFolder = root.appendingPathComponent(sweepFolderName, isDirectory: true)
guard let entries = try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return }
var claimed: [URL] = []
for entry in entries where entry.lastPathComponent != keep {
try? FileManager.default.removeItem(at: entry)
// Created lazily: a sweep with nothing to collect must not leave a folder behind as proof
// it ran.
if claimed.isEmpty {
try? FileManager.default.createDirectory(at: sweepFolder, withIntermediateDirectories: true)
}
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.
continue
}
claimed.append(claim)
}
for claim in claimed {
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.
if let stragglers = try? FileManager.default.contentsOfDirectory(
at: sweepFolder,
includingPropertiesForKeys: nil,
options: []
) {
for straggler in stragglers {
try? FileManager.default.removeItem(at: straggler)
}
try? FileManager.default.removeItem(at: sweepFolder)
}
}
+11
View File
@@ -92,6 +92,17 @@ 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")
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ import os
///
/// ### Reveal in Finder, and how fresh the list is
///
/// 09 keeps the Application Support store honest with "a **Reveal in Finder** affordance in the
/// 09 keeps the app-side store honest with "a **Reveal in Finder** affordance in the
/// template chooser": the button beside the header, which **creates the store and then reveals it**
/// (`TemplateEngine.createUserStore`) the store's two minters are Save as Template and this, so a
/// user who has never saved one still gets a folder to drop a board into rather than a Finder window
@@ -285,7 +285,7 @@ struct TemplateChooserView: View {
///
/// A store that cannot be created is logged and *still* revealed at its parent by
/// `activateFileViewerSelecting`, which is the honest failure: something is wrong with
/// Application Support, and the user is standing where they can see it.
/// the app-side store, and the user is standing where they can see it.
private func revealUserStore() {
let store = TemplateEngine.userStore
do {
+18 -14
View File
@@ -95,7 +95,7 @@ enum TemplateEngine {
// MARK: - Where templates live
/// The store folder's name in both locations the bundle's and Application Support's.
/// The store folder's name in both locations the bundle's and the App Group container's.
static let storeFolderName = "Templates"
/// The bundled store: `<app bundle>/Contents/Resources/Templates/`, holding one board folder per
@@ -105,23 +105,27 @@ enum TemplateEngine {
Bundle.main.resourceURL?.appendingPathComponent(storeFolderName, isDirectory: true)
}
/// The user store: `<Application Support>/<bundle id>/Templates/`, beside the board registry and
/// the clipboard staging directory 09's settled location ("Application Support inside the
/// app container friction-free sandbox writes, no location ceremony"), spelled the way every
/// other app-wide store in this app spells it (`ClipboardStore.defaultStagingRoot`,
/// `BoardRegistry`; 02-architecture.md § Per-board app state, "App-wide state has the same home").
/// 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").
///
/// > **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.
///
/// **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 {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"
return support
.appendingPathComponent(bundleIdentifier, isDirectory: true)
.appendingPathComponent(storeFolderName, isDirectory: true)
AppGroup.stateDirectory.appendingPathComponent(storeFolderName, isDirectory: true)
}
// MARK: - Discovery
@@ -445,7 +449,7 @@ enum TemplateEngine {
///
/// **The store's two minters are Save as Template and Reveal in Finder** (see `userStore`, which
/// only names it): a store that exists because the app made it on the off-chance would be an
/// empty folder in Application Support for a user who never used the feature, while a Reveal
/// empty folder in the shared container for a user who never used the feature, while a Reveal
/// that opened nothing or a save that failed because its own home was missing would be the
/// app being pedantic about a directory it owns.
@discardableResult
+29 -14
View File
@@ -92,18 +92,22 @@ enum LaunchPlan: Equatable, Sendable {
///
/// ### What the flag also switches off
///
/// **The registry moves into the scratch directory** with the board. Without that, every audit run
/// would stamp a temp folder into the user's real recents list (`BoardRegistry.defaultStorageURL`,
/// in Application Support), where it would sit for good as an unavailable row pointing at a
/// directory that no longer exists. Tying it to the same flag rather than to a second argument is
/// deliberate: the two are one decision "this launch is synthetic" and a second argument is a
/// second chance to apply only half of it.
/// **The registry and the clipboard's staging store move into the scratch directory** with the board.
/// 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
/// 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. 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 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.
enum UITestLaunch {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "ui-test-launch")
@@ -208,12 +212,21 @@ enum UITestLaunch {
.appendingPathComponent("LaneworkUITestFixture", isDirectory: true)
}
/// 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).
/// 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).
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.
static var clipboardStagingRoot: URL {
scratchRoot.appendingPathComponent("Clipboard", isDirectory: true)
}
/// A fixture board's own folder. `.kanban`-suffixed because a board the app made through the
/// ordinary create path is a document, and the audit should be looking at the shape a user's
/// board actually has (01-storage-format.md § Document packaging).
@@ -230,8 +243,10 @@ enum UITestLaunch {
fixtureBoardURL(for: .standard)
}
/// Wipes and recreates the scratch root, and answers the registry URL to build the app model
/// with. Called once, from `KanbanApp.init()`, **before** the model reads its registry.
/// Wipes and recreates the scratch root, and answers the registry URL for the caller's convenience.
/// Called once, from `KanbanApp.init()`, **before** the model reads its registry which is also why
/// the return value is discardable: that caller now names both redirected homes explicitly
/// (`registryStorageURL`, `clipboardStagingRoot`) rather than taking one of them from here.
///
/// **Wiped rather than reused**: every audit test launches its own app instance, and an audit is
/// only meaningful against a board whose contents the test knows a previous run's leftovers
+26 -5
View File
@@ -40,10 +40,18 @@ struct WelcomeRow: Identifiable, Equatable {
let icon: String?
let iconColor: String?
/// Where the board is **now**, or `nil` when its bookmark no longer resolves. The single source
/// Where the board is **now**, or `nil` when this edition cannot reach it. 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
@@ -58,10 +66,14 @@ struct WelcomeRow: Identifiable, Equatable {
var isAvailable: Bool { url != nil }
/// 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 }
/// 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 }
var canReveal: Bool { isAvailable }
/// The row's third line one line, so the three states are alternatives rather than a stack.
@@ -74,12 +86,20 @@ 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)
}
@@ -143,6 +163,7 @@ 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,
+21 -8
View File
@@ -244,12 +244,13 @@ struct WelcomeView: View {
// MARK: Actions
/// 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.
/// 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.
private func open(_ row: WelcomeRow) {
guard let url = row.url else { return }
appModel.openBoard(at: url)
appModel.open(row)
}
private func reveal(_ row: WelcomeRow) {
@@ -320,8 +321,9 @@ 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).
.opacity(row.isAvailable ? 1 : 0.55)
// 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)
.accessibilityElement(children: .combine)
}
@@ -363,6 +365,14 @@ 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).
@@ -385,7 +395,10 @@ private struct RecentBoardRow: View {
/// turned off and then turns it back on.
struct SettingsView: View {
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
/// `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)
private var restoreOpenBoardsAtLaunch = true
var body: some View {