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
+105 -7
View File
@@ -68,6 +68,15 @@ struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
/// The cross-edition awareness line's *fact*, asked afresh each time the popover is built see
/// `BoardEditionPresence` for why it is a closure rather than a value: both halves of it (the
/// registry's flags and whether the other app is alive) can change while a board window sits
/// there, and neither is a thing to observe.
///
/// Defaulted to "no line" so the two surfaces that build this widget without a registry the
/// titlebar tests keep saying nothing rather than needing one.
var otherEditionNote: () -> String? = { nil }
@Bindable var presentation: BoardInfoPresentation
var body: some View {
@@ -87,7 +96,7 @@ struct BoardInfoWidget: View {
.help("Board Info")
.accessibilityLabel("Board Info")
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents)
BoardInfoView(store: store, recents: recents, otherEditionNote: otherEditionNote())
}
}
}
@@ -103,10 +112,16 @@ struct BoardInfoWidget: View {
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
presentation: BoardInfoPresentation
presentation: BoardInfoPresentation,
otherEditionNote: @escaping () -> String? = { nil }
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(store: store, recents: recents, presentation: presentation)
rootView: BoardInfoWidget(
store: store,
recents: recents,
otherEditionNote: otherEditionNote,
presentation: presentation
)
)
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
// as zero would be an invisible, unclickable widget.
@@ -137,6 +152,10 @@ struct BoardInfoView: View {
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
private let hasGitDirectory: Bool
/// The cross-edition awareness line, or `nil` for no line resolved once when the view is built,
/// on `hasGitDirectory`'s terms and for its reason (`BoardEditionPresence`).
private let otherEditionNote: String?
/// The style editor brings its own padding, so the sections around it carry the same number by
/// hand instead of an outer padding that would double up on it **the editor's own figure**
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
@@ -145,10 +164,11 @@ struct BoardInfoView: View {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
init(store: BoardStore, recents: StyleRecents) {
init(store: BoardStore, recents: StyleRecents, otherEditionNote: String? = nil) {
self.store = store
self.recents = recents
self.hasGitDirectory = BoardGitNote.hasGitDirectory(at: store.rootURL)
self.otherEditionNote = otherEditionNote
}
var body: some View {
@@ -176,10 +196,21 @@ struct BoardInfoView: View {
// nothing here at all no header, no divider, no placeholder and the popover ends at
// Styling, complete in itself. Only a board that actually carries an inert `.git` earns
// this closing note.
if hasGitDirectory {
//
// The awareness line is its neighbour on the same terms, and the two stack in one section
// when both are true a `.git`-bearing board open in Pro is exactly the household where
// both sentences apply, and each answers a different question.
if hasGitDirectory || otherEditionNote != nil {
Divider()
BoardGitNote()
.padding(inset)
VStack(alignment: .leading, spacing: 4) {
if hasGitDirectory {
BoardGitNote()
}
if let otherEditionNote {
BoardEditionPresenceNote(text: otherEditionNote)
}
}
.padding(inset)
}
}
// The style editor's popover width, taken from the editor rather than restated: the embed
@@ -310,3 +341,70 @@ struct BoardGitNote: View {
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
}
}
// MARK: - The other edition
/// **The cross-edition awareness line** "Also open in Lanework Pro" (12-editions.md Both editions
/// installed, ruled 2026-07-29).
///
/// > Two conveniences ride the shared registry: open-now flags are per-edition , and the board
/// > popover carries a contextual awareness line ("Also open in Lanework Pro") read from the other
/// > edition's flag, **pid-liveness-checked so crash residue never lies** a line, never a gate.
///
/// It is `BoardGitNote`'s posture applied to a different fact: contextual, absent when untrue, and
/// never a control. Nothing about it gates anything the same board open in both apps is the designed
/// foreign-writer story (12), so this line exists to *explain* what the user is looking at, not to
/// warn them off it.
///
/// ### Why liveness matters, and why it is a parameter
///
/// The flag is a live open marker that a crash deliberately leaves standing (02-architecture.md
/// § Launch and window lifecycle that residue is what makes crash recovery free). So a flag alone
/// would claim "also open in Lanework Pro" about an app that died last Tuesday. `NSRunningApplication`
/// settles it, and the check is injected so `note(otherEditions:isRunning:)` is a pure function the
/// tests pin directly the same seam `BoardGitNote.hasGitDirectory(at:)` is.
enum BoardEditionPresence {
/// Whether an edition is running right now, by bundle id the pid-liveness half.
///
/// `NSRunningApplication.runningApplications(withBundleIdentifier:)` rather than a stored pid: the
/// registry records *which* edition had the board open, never a process id, and it should not
/// start a pid is a fact with a shelf life of milliseconds, and the bundle id is the question
/// actually being asked.
@MainActor
static func isRunning(_ bundleID: String) -> Bool {
!NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).isEmpty
}
/// The line, or `nil` for no line at all.
///
/// Three ways to get `nil`, and each is the honest answer: no other edition has the board flagged;
/// the flagged edition is not running (crash residue); or the flagged bundle id is one this build
/// cannot name (`AppGroup.editionDisplayName`) a future edition, where inventing a name would be
/// worse than saying nothing.
///
/// **One line even when several editions qualify**, taking the first by the sorted order the
/// registry hands over: the popover has room for a sentence, not a roster, and with Teams
/// deferred there is no case today where two others are open at once.
static func note(otherEditions: [String], isRunning: (String) -> Bool) -> String? {
for bundleID in otherEditions {
guard isRunning(bundleID), let name = AppGroup.editionDisplayName(bundleID) else { continue }
return "Also open in \(name)"
}
return nil
}
}
/// The awareness line as a view, beside `BoardGitNote` and styled identically the two contextual
/// notes are one register, so a popover carrying both reads as one surface.
struct BoardEditionPresenceNote: View {
let text: String
var body: some View {
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}