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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user