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:
@@ -8,8 +8,9 @@ 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 Application Support). Both seams exist
|
||||
/// exactly because those two claims are the ones worth pinning.
|
||||
/// 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.
|
||||
|
||||
// MARK: - Test doubles
|
||||
|
||||
@@ -109,8 +110,14 @@ 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.
|
||||
func stagedCopyIDs() throws -> [String] {
|
||||
try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted()
|
||||
try FileManager.default.contentsOfDirectory(atPath: staging.path)
|
||||
.filter { !$0.hasPrefix(".") }
|
||||
.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +396,106 @@ struct ClipboardSweepTests {
|
||||
|
||||
#expect(try harness.stagedCopyIDs().isEmpty)
|
||||
}
|
||||
|
||||
// MARK: The sibling edition's sweep
|
||||
//
|
||||
// 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
|
||||
// missing-entry = already swept.
|
||||
|
||||
@Test("Two editions sweeping the same store at once agree, and neither errors")
|
||||
func concurrentSweepsFromBothEditionsAgree() async throws {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
|
||||
// Six trees, each with a file in it so a removal is a real recursive delete rather than an
|
||||
// empty-directory unlink — the case where two sweepers walking one tree could see it half gone.
|
||||
for name in ["a", "b", "c", "d", "e", "keep"] {
|
||||
let tree = staging.appendingPathComponent(name, isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: tree.appendingPathComponent("nested", isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
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.
|
||||
let manifest = ClipboardManifest(
|
||||
copyID: "keep",
|
||||
boardRoot: URL(fileURLWithPath: "/Boards/Shared.kanban", isDirectory: true),
|
||||
kind: .card,
|
||||
container: .board,
|
||||
// One entry, because a manifest with none is refused outright (`init?(data:)`) — and a
|
||||
// refused manifest is a keep set of nothing, which is a different test.
|
||||
entries: [
|
||||
ClipboardManifest.Entry(
|
||||
id: Ident.card1,
|
||||
folder: Ident.card1,
|
||||
title: "First",
|
||||
index: "---\nschema: 1\ntitle: First\norder: 1024\n---\n",
|
||||
attachmentCount: 0
|
||||
)
|
||||
]
|
||||
)
|
||||
let data = try #require(manifest.encoded())
|
||||
|
||||
let onePasteboard = FakePasteboard()
|
||||
onePasteboard.write(manifest: data, text: manifest.plainText)
|
||||
let otherPasteboard = FakePasteboard()
|
||||
otherPasteboard.write(manifest: data, text: manifest.plainText)
|
||||
|
||||
// Each `init` sweeps — the launch sweep — so the two are already racing before either explicit
|
||||
// call below.
|
||||
let one = ClipboardStore(pasteboard: onePasteboard, stagingRoot: staging, observesActivation: false)
|
||||
let other = ClipboardStore(pasteboard: otherPasteboard, stagingRoot: staging, observesActivation: false)
|
||||
one.sweep()
|
||||
other.sweep()
|
||||
one.sweep()
|
||||
await one.stagingSettled()
|
||||
await other.stagingSettled()
|
||||
|
||||
// The answer both computed, arrived at exactly once: the named tree intact, everything else
|
||||
// gone, and no bookkeeping left behind.
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted() == ["keep"])
|
||||
#expect(FileManager.default.fileExists(atPath: staging.appendingPathComponent("keep/nested/file.txt").path))
|
||||
}
|
||||
|
||||
@Test("A tree that vanished before the sweep reached it is already swept, not an error")
|
||||
func aVanishedEntryIsANoOp() async throws {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
let doomed = staging.appendingPathComponent("gone", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: doomed, withIntermediateDirectories: true)
|
||||
|
||||
let clipboard = ClipboardStore(
|
||||
pasteboard: FakePasteboard(),
|
||||
stagingRoot: staging,
|
||||
observesActivation: false
|
||||
)
|
||||
// The sibling 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()
|
||||
await clipboard.stagingSettled()
|
||||
|
||||
#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.
|
||||
try FileManager.default.removeItem(at: staging)
|
||||
clipboard.sweep()
|
||||
await clipboard.stagingSettled()
|
||||
#expect(!FileManager.default.fileExists(atPath: staging.path), "a vanished store is not recreated by a sweep")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Takeover
|
||||
|
||||
Reference in New Issue
Block a user