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:
@@ -28,8 +28,23 @@ public enum WindowID {
|
||||
///
|
||||
/// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason
|
||||
/// `WindowID` exists.
|
||||
///
|
||||
/// ### The domain is the group's, not `.standard`
|
||||
///
|
||||
/// 02 § Per-board app state sends these to "the group's shared `UserDefaults` suite where a scalar
|
||||
/// fits" (ruled 2026-07-29; 12-editions.md), for the registry's reason exactly: a paying upgrader
|
||||
/// launches Pro onto their own settings rather than onto defaults. `AppGroup.defaults` is that suite,
|
||||
/// and it degrades to a plain named domain when the group is not provisioned — unshared, but working.
|
||||
///
|
||||
/// **Every reader and writer of these keys must name that suite.** A `@AppStorage` left to its own
|
||||
/// devices reads `.standard`, which after this ruling is a *different* domain — so the two views that
|
||||
/// bind one of these keys pass `store:` explicitly (`SettingsView`).
|
||||
public enum AppPreferences {
|
||||
|
||||
/// The domain every key here lives in. A stored `let` would capture a suite at type-load time;
|
||||
/// this is a lookup of an object `UserDefaults` itself caches.
|
||||
public static var defaults: UserDefaults { AppGroup.defaults }
|
||||
|
||||
/// "Restore open boards at launch" (Settings, ⌘, — 11-command-nexus.md). **Default on.**
|
||||
public static let restoreOpenBoardsAtLaunchKey = "restoreOpenBoardsAtLaunch"
|
||||
|
||||
@@ -37,7 +52,7 @@ public enum AppPreferences {
|
||||
/// any scene exists. `object(forKey:)` rather than `bool(forKey:)` because the latter cannot
|
||||
/// tell "off" from "never set", and this preference defaults to *on*.
|
||||
public static var restoreOpenBoardsAtLaunch: Bool {
|
||||
UserDefaults.standard.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
|
||||
defaults.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// The last-used card-window size (05-card-window.md; 02 files it as app-wide, not per-board —
|
||||
@@ -46,14 +61,14 @@ public enum AppPreferences {
|
||||
public static let lastCardWindowSizeKey = "lastCardWindowSize"
|
||||
|
||||
public static var lastCardWindowSize: CGSize? {
|
||||
guard let text = UserDefaults.standard.string(forKey: lastCardWindowSizeKey) else { return nil }
|
||||
guard let text = defaults.string(forKey: lastCardWindowSizeKey) else { return nil }
|
||||
let size = NSSizeFromString(text)
|
||||
guard size.width > 0, size.height > 0 else { return nil }
|
||||
return size
|
||||
}
|
||||
|
||||
public static func setLastCardWindowSize(_ size: CGSize) {
|
||||
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
|
||||
defaults.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
|
||||
}
|
||||
|
||||
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
||||
@@ -163,7 +178,7 @@ public final class AppModel {
|
||||
/// the registries' reason — app-scoped, and a test holds its own rather than colliding with the
|
||||
/// app's — and reached by the context menus through the environment, since a `BoardStore` is
|
||||
/// board-scoped and this list deliberately is not.
|
||||
public let styleRecents = StyleRecents()
|
||||
public let styleRecents: StyleRecents
|
||||
|
||||
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
@@ -187,7 +202,7 @@ public final class AppModel {
|
||||
///
|
||||
/// Building it here is also the **launch sweep** (04: "a sweep at launch"): the store's `init`
|
||||
/// reads the pasteboard once and collects every staged tree it no longer names.
|
||||
public let clipboard = ClipboardStore()
|
||||
public let clipboard: ClipboardStore
|
||||
|
||||
// MARK: The provider seam
|
||||
|
||||
@@ -411,11 +426,25 @@ public final class AppModel {
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
|
||||
|
||||
/// The app builds one of these with the real registry file; a test passes its own path for the
|
||||
/// same reason `BoardRegistry` takes one at all — "injecting it is how a test stays out of the
|
||||
/// real Application Support directory".
|
||||
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
|
||||
/// The app builds one of these with the real state home; a test passes its own for the reason
|
||||
/// `BoardRegistry` takes a storage URL at all — "injecting it is how a test stays out of the real
|
||||
/// Application Support directory", which after the 2026-07-29 ruling means **out of the shared App
|
||||
/// Group container** (`AppGroup`). A suite that swept the real staging root would be sweeping the
|
||||
/// developer's own clipboard, and now the sibling edition's too.
|
||||
///
|
||||
/// `clipboardStagingRoot` is a separate parameter rather than derived from `registryStorageURL`'s
|
||||
/// folder because the two are injected for different reasons and by different callers: the UI-test
|
||||
/// fixture launch redirects both into one scratch root (`UITestLaunch`), a unit test usually wants
|
||||
/// only one of them, and deriving would silently move a test's staging directory the day it moved
|
||||
/// its registry file.
|
||||
public init(
|
||||
registryStorageURL: URL = BoardRegistry.defaultStorageURL,
|
||||
clipboardStagingRoot: URL = ClipboardStore.defaultStagingRoot,
|
||||
preferences: UserDefaults = AppGroup.defaults
|
||||
) {
|
||||
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
||||
styleRecents = StyleRecents(defaults: preferences)
|
||||
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
|
||||
// Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first
|
||||
// menu pass — a launch that restores boards never shows welcome, and a submenu that filled
|
||||
// in only after the first close would look broken. It costs one bookmark-resolution sweep at
|
||||
@@ -518,6 +547,56 @@ public final class AppModel {
|
||||
openBoard(at: url)
|
||||
}
|
||||
|
||||
/// Opens a recents row — the one door for welcome's double-click, its Open item, and File ▸ Open
|
||||
/// Recent, because a row has **two** ways of leading to a board now.
|
||||
///
|
||||
/// An ordinary available row opens its URL. A row awaiting this edition's grant
|
||||
/// (`RecentBoard.needsReopen` — a board the other edition minted the only bookmark for) runs the
|
||||
/// re-grant panel first: "the first click runs an open panel pre-anchored at the recorded path:
|
||||
/// one click + Grant per board, once per edition" (12-editions.md ▸ Distribution).
|
||||
///
|
||||
/// Nothing else about the open differs. The granted URL goes through `openBoard(at:)` exactly as a
|
||||
/// File ▸ Open… pick would, and `BoardRegistry.recordOpen` matches the *existing* shared record and
|
||||
/// mints this edition's slot onto it — the other edition's grant, the frames, the counts and the
|
||||
/// cached title all stay where they are.
|
||||
/// Internal rather than `public` only because `WelcomeRow` is — the row derivation is a UI-layer
|
||||
/// value, and nothing outside this module opens boards by row.
|
||||
func open(_ row: WelcomeRow) {
|
||||
if let url = row.url {
|
||||
openBoard(at: url)
|
||||
return
|
||||
}
|
||||
guard let anchor = row.regrantAnchor,
|
||||
let granted = presentRegrantPanel(anchoredAt: anchor, boardName: row.displayName) else { return }
|
||||
openBoard(at: granted)
|
||||
}
|
||||
|
||||
/// The re-grant panel: an ordinary open panel, pre-anchored at the board's recorded path.
|
||||
///
|
||||
/// **A panel and not an alert**, because the panel *is* the mechanism: a sandboxed app gains access
|
||||
/// to a folder by the user choosing it, so there is nothing an intermediate explanation could add
|
||||
/// that the panel's own message does not say better while doing the job.
|
||||
///
|
||||
/// `directoryURL` is the recorded path itself, per the ruling. For a board that is a `.kanban`
|
||||
/// package the panel therefore opens *inside* it — `treatsFilePackagesAsDirectories` is on for
|
||||
/// `presentOpenPanel`'s reason (boards are packages *and* plain folders) — and Open with nothing
|
||||
/// selected chooses the folder on display, which is the board. A board that has since moved leaves
|
||||
/// the panel at the nearest surviving ancestor, which is the Finder behaviour and the honest one:
|
||||
/// the user knows where their board went, and this app does not.
|
||||
private func presentRegrantPanel(anchoredAt anchor: URL, boardName: String) -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.treatsFilePackagesAsDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = anchor
|
||||
panel.prompt = "Grant"
|
||||
panel.message = "Choose “\(boardName)” to let this app open it."
|
||||
|
||||
guard panel.runModal() == .OK else { return nil }
|
||||
return panel.url
|
||||
}
|
||||
|
||||
/// The ref of the window already showing the board at `url`, if any — matched through the store,
|
||||
/// which is identity-keyed, rather than through the path.
|
||||
private func boardRef(forBoardAt url: URL) -> BoardWindowRef? {
|
||||
|
||||
Reference in New Issue
Block a user