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
+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)
}
}