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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user