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
+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,