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:
@@ -79,8 +79,7 @@ struct OpenRecentMenu: View {
|
|||||||
Menu("Open Recent") {
|
Menu("Open Recent") {
|
||||||
ForEach(rows) { row in
|
ForEach(rows) { row in
|
||||||
Button(row.displayName) {
|
Button(row.displayName) {
|
||||||
guard let url = row.url else { return }
|
appModel.open(row)
|
||||||
appModel.openBoard(at: url)
|
|
||||||
}
|
}
|
||||||
.disabled(!row.canOpen)
|
.disabled(!row.canOpen)
|
||||||
}
|
}
|
||||||
@@ -291,7 +290,7 @@ struct DuplicateBoardCommand: View {
|
|||||||
/// rule covers File ▸ Duplicate". So the shape here is `DuplicateBoardCommand`'s, step for step —
|
/// rule covers File ▸ Duplicate". So the shape here is `DuplicateBoardCommand`'s, step for step —
|
||||||
/// flush, then a cancellable copy off the main actor under an in-progress row — and the differences
|
/// flush, then a cancellable copy off the main actor under an in-progress row — and the differences
|
||||||
/// are all in the engine (`TemplateEngine.saveAsTemplate(boardAt:titled:into:)`): the destination is
|
/// are all in the engine (`TemplateEngine.saveAsTemplate(boardAt:titled:into:)`): the destination is
|
||||||
/// Application Support rather than a sibling, `.git` and `.trash/` are dropped rather than forked, a
|
/// the app-side template store rather than a sibling, `.git` and `.trash/` are dropped rather than forked, a
|
||||||
/// collision auto-renames rather than failing, and a `template:` key lands on the copy.
|
/// collision auto-renames rather than failing, and a `template:` key lands on the copy.
|
||||||
///
|
///
|
||||||
/// **No save panel, ever.** The store is the app's own container — "friction-free sandbox writes, no
|
/// **No save panel, ever.** The store is the app's own container — "friction-free sandbox writes, no
|
||||||
@@ -313,7 +312,7 @@ struct DuplicateBoardCommand: View {
|
|||||||
///
|
///
|
||||||
/// Board window only, and disabled under the read-only lock — with the exception 09 spells out and
|
/// Board window only, and disabled under the read-only lock — with the exception 09 spells out and
|
||||||
/// 02-architecture.md ▸ Live-reload resilience scopes: **under the unwritable-location lock alone it
|
/// 02-architecture.md ▸ Live-reload resilience scopes: **under the unwritable-location lock alone it
|
||||||
/// stays live**, because it "reads the board and writes Application Support" (archiving the
|
/// stays live**, because it "reads the board and writes the app-side store" (archiving the
|
||||||
/// read-only DMG board being inspected is a legitimate errand), *unless* an open Edit or raw-source
|
/// read-only DMG board being inspected is a legitimate errand), *unless* an open Edit or raw-source
|
||||||
/// session holds unsaved content — content that lock's suspended saves cannot flush, and which the
|
/// session holds unsaved content — content that lock's suspended saves cannot flush, and which the
|
||||||
/// template would therefore silently miss. The other two locks disable it outright: a vanished root
|
/// template would therefore silently miss. The other two locks disable it outright: a vanished root
|
||||||
|
|||||||
@@ -28,8 +28,23 @@ public enum WindowID {
|
|||||||
///
|
///
|
||||||
/// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason
|
/// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason
|
||||||
/// `WindowID` exists.
|
/// `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 {
|
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.**
|
/// "Restore open boards at launch" (Settings, ⌘, — 11-command-nexus.md). **Default on.**
|
||||||
public static let restoreOpenBoardsAtLaunchKey = "restoreOpenBoardsAtLaunch"
|
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
|
/// any scene exists. `object(forKey:)` rather than `bool(forKey:)` because the latter cannot
|
||||||
/// tell "off" from "never set", and this preference defaults to *on*.
|
/// tell "off" from "never set", and this preference defaults to *on*.
|
||||||
public static var restoreOpenBoardsAtLaunch: Bool {
|
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 —
|
/// 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 let lastCardWindowSizeKey = "lastCardWindowSize"
|
||||||
|
|
||||||
public static var lastCardWindowSize: CGSize? {
|
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)
|
let size = NSSizeFromString(text)
|
||||||
guard size.width > 0, size.height > 0 else { return nil }
|
guard size.width > 0, size.height > 0 else { return nil }
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func setLastCardWindowSize(_ size: CGSize) {
|
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,
|
/// 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
|
/// 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
|
/// app's — and reached by the context menus through the environment, since a `BoardStore` is
|
||||||
/// board-scoped and this list deliberately is not.
|
/// 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).
|
/// 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`
|
/// 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.
|
/// 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
|
// 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")
|
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
|
/// The app builds one of these with the real state home; a test passes its own for the reason
|
||||||
/// same reason `BoardRegistry` takes one at all — "injecting it is how a test stays out of the
|
/// `BoardRegistry` takes a storage URL at all — "injecting it is how a test stays out of the real
|
||||||
/// real Application Support directory".
|
/// Application Support directory", which after the 2026-07-29 ruling means **out of the shared App
|
||||||
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
|
/// 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)
|
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
|
// 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
|
// 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
|
// 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)
|
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,
|
/// 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.
|
/// which is identity-keyed, rather than through the path.
|
||||||
private func boardRef(forBoardAt url: URL) -> BoardWindowRef? {
|
private func boardRef(forBoardAt url: URL) -> BoardWindowRef? {
|
||||||
|
|||||||
@@ -284,7 +284,22 @@ struct BoardWindowHost: View {
|
|||||||
// load rather than at attach because it carries the store; the controller installs it once,
|
// load rather than at attach because it carries the store; the controller installs it once,
|
||||||
// whichever of the two arrives second.
|
// whichever of the two arrives second.
|
||||||
windowController.installTitlebarAccessory(
|
windowController.installTitlebarAccessory(
|
||||||
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo)
|
boardInfoTitlebarAccessory(
|
||||||
|
store: store,
|
||||||
|
recents: appModel.styleRecents,
|
||||||
|
presentation: boardInfo,
|
||||||
|
// The cross-edition awareness line (12-editions.md ▸ Both editions installed), asked
|
||||||
|
// at each popover build rather than captured as a value: both facts behind it — the
|
||||||
|
// other edition's flag on the shared record, and whether that edition is still
|
||||||
|
// running — change while this window sits here, and neither is observable.
|
||||||
|
otherEditionNote: { [weak appModel] in
|
||||||
|
guard let appModel else { return nil }
|
||||||
|
return BoardEditionPresence.note(
|
||||||
|
otherEditions: appModel.boardRegistry.otherEditionsFlaggedOpen(id: recordID),
|
||||||
|
isRunning: BoardEditionPresence.isRunning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// The board's customizable toolbar (03-board-ui.md ▸ Toolbar) — installed here for the
|
// The board's customizable toolbar (03-board-ui.md ▸ Toolbar) — installed here for the
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ extension UTType {
|
|||||||
///
|
///
|
||||||
/// **It is self-describing twice over**, and both halves earn their keep:
|
/// **It is self-describing twice over**, and both halves earn their keep:
|
||||||
///
|
///
|
||||||
/// - `copyID` ties the pasteboard to a staging directory — `<Application Support>/…/Clipboard/<copyID>/`,
|
/// - `copyID` ties the pasteboard to a staging directory — `<group container>/…/Clipboard/<copyID>/`,
|
||||||
/// the full folder snapshots a paste reproduces byte-for-byte from — and to a pending cut. It is
|
/// the full folder snapshots a paste reproduces byte-for-byte from — and to a pending cut. It is
|
||||||
/// also the whole of "the snapshot survives relaunch exactly as long as the pasteboard still points
|
/// also the whole of "the snapshot survives relaunch exactly as long as the pasteboard still points
|
||||||
/// at it": a sweep keeps the one directory this id names and collects every other.
|
/// at it": a sweep keeps the one directory this id names and collects every other.
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ import os
|
|||||||
///
|
///
|
||||||
/// The pasteboard carries a small JSON manifest plus a plain-text rendering; the *content* — whole
|
/// 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
|
/// 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
|
/// 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**:
|
/// `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.
|
/// 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
|
/// ### The staging lifecycle, settled
|
||||||
///
|
///
|
||||||
/// - **Eager**: the snapshot is taken at ⌘C/⌘X time, so a copy captures the source as it was at the
|
/// - **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")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard")
|
||||||
|
|
||||||
/// `~/Library/Application Support/<bundle id>/Clipboard/`, beside the board registry — the same
|
/// `<group container>/Library/Application Support/Clipboard/`, beside the board registry — the
|
||||||
/// container convention, for the same reason (02-architecture.md § Per-board app state, "App-wide
|
/// same home, for the same reason, and now the same *shared* home (12-editions.md ▸ Both editions
|
||||||
/// state has the same home").
|
/// 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 {
|
public static var defaultStagingRoot: URL {
|
||||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
AppGroup.stateDirectory.appendingPathComponent("Clipboard", isDirectory: true)
|
||||||
?? 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The app builds one of these with the system pasteboard and the real staging directory; a test
|
/// 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
|
/// 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 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
|
/// 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) }
|
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 {
|
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(
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||||
at: root,
|
at: root,
|
||||||
includingPropertiesForKeys: nil,
|
includingPropertiesForKeys: nil,
|
||||||
options: [.skipsHiddenFiles]
|
options: [.skipsHiddenFiles]
|
||||||
) else { return }
|
) else { return }
|
||||||
|
|
||||||
|
var claimed: [URL] = []
|
||||||
for entry in entries where entry.lastPathComponent != keep {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,17 @@ struct RestoreBootstrapView: View {
|
|||||||
path: record.lastKnownPath,
|
path: record.lastKnownPath,
|
||||||
message: "This board is unavailable. Its volume may be offline, or it may have been moved or deleted."
|
message: "This board is unavailable. Its volume may be offline, or it may have been moved or deleted."
|
||||||
)
|
)
|
||||||
|
case .needsReopen:
|
||||||
|
// Effectively unreachable — this edition can only have flagged a board open by having
|
||||||
|
// opened it, which needed a grant — and deliberately quiet if it ever happens.
|
||||||
|
//
|
||||||
|
// **No launch failure and no panel.** A modal grant panel at launch is exactly the
|
||||||
|
// "launch-time modal chain" 02 § Launch and window lifecycle rules out, and a failure
|
||||||
|
// row would put fail-fast's warning tone over a board that is *fine*: welcome appears
|
||||||
|
// (nothing restored), and this board's own row already carries the re-grant caption
|
||||||
|
// and the one click that resolves it (12-editions.md ▸ Distribution). That row is the
|
||||||
|
// surface, so nothing is silently dropped.
|
||||||
|
Self.logger.error("a flagged board is awaiting this edition's grant; left for its welcome row")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import os
|
|||||||
///
|
///
|
||||||
/// ### Reveal in Finder, and how fresh the list is
|
/// ### Reveal in Finder, and how fresh the list is
|
||||||
///
|
///
|
||||||
/// 09 keeps the Application Support store honest with "a **Reveal in Finder** affordance in the
|
/// 09 keeps the app-side store honest with "a **Reveal in Finder** affordance in the
|
||||||
/// template chooser": the button beside the header, which **creates the store and then reveals it**
|
/// template chooser": the button beside the header, which **creates the store and then reveals it**
|
||||||
/// (`TemplateEngine.createUserStore`) — the store's two minters are Save as Template and this, so a
|
/// (`TemplateEngine.createUserStore`) — the store's two minters are Save as Template and this, so a
|
||||||
/// user who has never saved one still gets a folder to drop a board into rather than a Finder window
|
/// user who has never saved one still gets a folder to drop a board into rather than a Finder window
|
||||||
@@ -285,7 +285,7 @@ struct TemplateChooserView: View {
|
|||||||
///
|
///
|
||||||
/// A store that cannot be created is logged and *still* revealed at its parent by
|
/// A store that cannot be created is logged and *still* revealed at its parent by
|
||||||
/// `activateFileViewerSelecting`, which is the honest failure: something is wrong with
|
/// `activateFileViewerSelecting`, which is the honest failure: something is wrong with
|
||||||
/// Application Support, and the user is standing where they can see it.
|
/// the app-side store, and the user is standing where they can see it.
|
||||||
private func revealUserStore() {
|
private func revealUserStore() {
|
||||||
let store = TemplateEngine.userStore
|
let store = TemplateEngine.userStore
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ enum TemplateEngine {
|
|||||||
|
|
||||||
// MARK: - Where templates live
|
// MARK: - Where templates live
|
||||||
|
|
||||||
/// The store folder's name in both locations — the bundle's and Application Support's.
|
/// The store folder's name in both locations — the bundle's and the App Group container's.
|
||||||
static let storeFolderName = "Templates"
|
static let storeFolderName = "Templates"
|
||||||
|
|
||||||
/// The bundled store: `<app bundle>/Contents/Resources/Templates/`, holding one board folder per
|
/// The bundled store: `<app bundle>/Contents/Resources/Templates/`, holding one board folder per
|
||||||
@@ -105,23 +105,27 @@ enum TemplateEngine {
|
|||||||
Bundle.main.resourceURL?.appendingPathComponent(storeFolderName, isDirectory: true)
|
Bundle.main.resourceURL?.appendingPathComponent(storeFolderName, isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user store: `<Application Support>/<bundle id>/Templates/`, beside the board registry and
|
/// The user store: `Templates/` in the **shared App Group container**, beside the board registry
|
||||||
/// the clipboard staging directory — 09's settled location ("Application Support … inside the
|
/// and the clipboard's staging store (09-templates.md ▸ Save as Template ▸ Storage, re-homed
|
||||||
/// app container — friction-free sandbox writes, no location ceremony"), spelled the way every
|
/// 2026-07-29; 02-architecture.md § Per-board app state, "App-wide state has the same home").
|
||||||
/// other app-wide store in this app spells it (`ClipboardStore.defaultStagingRoot`,
|
///
|
||||||
/// `BoardRegistry`; 02-architecture.md § Per-board app state, "App-wide state has the same home").
|
/// > **templates cross editions**: a template saved in base appears in Pro's chooser, honoring 12's
|
||||||
|
/// > never-an-empty-home-screen promise (templates are plain board folders — no per-edition
|
||||||
|
/// > semantics, no bookmark grant ceremony; the group container is directly writable by every
|
||||||
|
/// > edition).
|
||||||
|
///
|
||||||
|
/// That last clause is why this store needs none of the machinery the registry does: a template is
|
||||||
|
/// a folder inside a container both editions can write, so there is no bookmark to mint and nothing
|
||||||
|
/// to grant — the cross-sandbox caveat that gives `BoardRecord` its per-edition grant slots simply
|
||||||
|
/// does not arise. Spelled through `AppGroup.stateDirectory` like every other app-wide store
|
||||||
|
/// (`ClipboardStore.defaultStagingRoot`, `BoardRegistry.defaultStorageURL`), so all three move
|
||||||
|
/// together if the home ever does.
|
||||||
///
|
///
|
||||||
/// **Named, never created here.** Discovery of a store that does not exist is an empty list, not
|
/// **Named, never created here.** Discovery of a store that does not exist is an empty list, not
|
||||||
/// a directory the app made on the off-chance: the store is minted by the first Save as Template,
|
/// a directory the app made on the off-chance: the store is minted by the first Save as Template,
|
||||||
/// and by Reveal in Finder, both of which are 09's other cards.
|
/// and by Reveal in Finder, both of which are 09's other cards.
|
||||||
static var userStore: URL {
|
static var userStore: URL {
|
||||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
AppGroup.stateDirectory.appendingPathComponent(storeFolderName, isDirectory: true)
|
||||||
?? 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(storeFolderName, isDirectory: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Discovery
|
// MARK: - Discovery
|
||||||
@@ -445,7 +449,7 @@ enum TemplateEngine {
|
|||||||
///
|
///
|
||||||
/// **The store's two minters are Save as Template and Reveal in Finder** (see `userStore`, which
|
/// **The store's two minters are Save as Template and Reveal in Finder** (see `userStore`, which
|
||||||
/// only names it): a store that exists because the app made it on the off-chance would be an
|
/// only names it): a store that exists because the app made it on the off-chance would be an
|
||||||
/// empty folder in Application Support for a user who never used the feature, while a Reveal
|
/// empty folder in the shared container for a user who never used the feature, while a Reveal
|
||||||
/// that opened nothing — or a save that failed because its own home was missing — would be the
|
/// that opened nothing — or a save that failed because its own home was missing — would be the
|
||||||
/// app being pedantic about a directory it owns.
|
/// app being pedantic about a directory it owns.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@@ -92,18 +92,22 @@ enum LaunchPlan: Equatable, Sendable {
|
|||||||
///
|
///
|
||||||
/// ### What the flag also switches off
|
/// ### What the flag also switches off
|
||||||
///
|
///
|
||||||
/// **The registry moves into the scratch directory** with the board. Without that, every audit run
|
/// **The registry and the clipboard's staging store move into the scratch directory** with the board.
|
||||||
/// would stamp a temp folder into the user's real recents list (`BoardRegistry.defaultStorageURL`,
|
/// Without that, every audit run would stamp a temp folder into the user's real recents list
|
||||||
/// in Application Support), where it would sit for good as an unavailable row pointing at a
|
/// (`BoardRegistry.defaultStorageURL`), where it would sit for good as an unavailable row pointing at a
|
||||||
/// directory that no longer exists. Tying it to the same flag rather than to a second argument is
|
/// directory that no longer exists — and its launch sweep would collect the user's real staged copy
|
||||||
/// deliberate: the two are one decision — "this launch is synthetic" — and a second argument is a
|
/// (`ClipboardStore.defaultStagingRoot`). Both of those homes are now the **shared App Group
|
||||||
/// second chance to apply only half of it.
|
/// container** (12-editions.md ▸ Distribution, ruled 2026-07-29), so each of those side effects would
|
||||||
|
/// land on the sibling edition as well as this one. Tying them to the same flag rather than to separate
|
||||||
|
/// arguments is deliberate: they are one decision — "this launch is synthetic" — and a second argument
|
||||||
|
/// is a second chance to apply only half of it.
|
||||||
///
|
///
|
||||||
/// The honest residual: `UserDefaults` is **not** redirected, so an audit run can still write the
|
/// The honest residual: `UserDefaults` is **not** redirected, so an audit run can still write the
|
||||||
/// three app-wide scalars (`AppPreferences`) into the real domain. They are a window size, a restore
|
/// three app-wide scalars (`AppPreferences`) into the real domain — the group's shared suite since the
|
||||||
/// toggle this launch never consults, and the quick-style recents list — no documents, nothing
|
/// same ruling. They are a window size, a restore toggle this launch never consults, and the
|
||||||
/// destructive, and redirecting a defaults domain from inside the process is not something the
|
/// quick-style recents list — no documents, nothing destructive, and redirecting a defaults domain from
|
||||||
/// platform actually supports. It is stated rather than fixed.
|
/// inside the process is not something the platform actually supports. It is stated rather than
|
||||||
|
/// fixed.
|
||||||
enum UITestLaunch {
|
enum UITestLaunch {
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "ui-test-launch")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "ui-test-launch")
|
||||||
@@ -208,12 +212,21 @@ enum UITestLaunch {
|
|||||||
.appendingPathComponent("LaneworkUITestFixture", isDirectory: true)
|
.appendingPathComponent("LaneworkUITestFixture", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the fixture launch's registry lives — beside the board rather than in Application
|
/// Where the fixture launch's registry lives — beside the board rather than in the shared App
|
||||||
/// Support, which is the whole point (see the type's note).
|
/// Group container, which is the whole point (see the type's note).
|
||||||
static var registryStorageURL: URL {
|
static var registryStorageURL: URL {
|
||||||
scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false)
|
scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the fixture launch's clipboard snapshots live, on the registry's terms and now for a
|
||||||
|
/// sharper reason: the real staging root moved into the **shared** App Group container
|
||||||
|
/// (12-editions.md ▸ Both editions installed), so an audit run's launch sweep would otherwise
|
||||||
|
/// collect the developer's own staged copy — and the sibling edition's, since there is only one
|
||||||
|
/// store now. Redirected by the same flag, because it is the same one decision.
|
||||||
|
static var clipboardStagingRoot: URL {
|
||||||
|
scratchRoot.appendingPathComponent("Clipboard", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// A fixture board's own folder. `.kanban`-suffixed because a board the app made through the
|
/// A fixture board's own folder. `.kanban`-suffixed because a board the app made through the
|
||||||
/// ordinary create path is a document, and the audit should be looking at the shape a user's
|
/// ordinary create path is a document, and the audit should be looking at the shape a user's
|
||||||
/// board actually has (01-storage-format.md § Document packaging).
|
/// board actually has (01-storage-format.md § Document packaging).
|
||||||
@@ -230,8 +243,10 @@ enum UITestLaunch {
|
|||||||
fixtureBoardURL(for: .standard)
|
fixtureBoardURL(for: .standard)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wipes and recreates the scratch root, and answers the registry URL to build the app model
|
/// Wipes and recreates the scratch root, and answers the registry URL for the caller's convenience.
|
||||||
/// with. Called once, from `KanbanApp.init()`, **before** the model reads its registry.
|
/// Called once, from `KanbanApp.init()`, **before** the model reads its registry — which is also why
|
||||||
|
/// the return value is discardable: that caller now names both redirected homes explicitly
|
||||||
|
/// (`registryStorageURL`, `clipboardStagingRoot`) rather than taking one of them from here.
|
||||||
///
|
///
|
||||||
/// **Wiped rather than reused**: every audit test launches its own app instance, and an audit is
|
/// **Wiped rather than reused**: every audit test launches its own app instance, and an audit is
|
||||||
/// only meaningful against a board whose contents the test knows — a previous run's leftovers
|
/// only meaningful against a board whose contents the test knows — a previous run's leftovers
|
||||||
|
|||||||
@@ -40,10 +40,18 @@ struct WelcomeRow: Identifiable, Equatable {
|
|||||||
let icon: String?
|
let icon: String?
|
||||||
let iconColor: String?
|
let iconColor: String?
|
||||||
|
|
||||||
/// Where the board is **now**, or `nil` when its bookmark no longer resolves. The single source
|
/// Where the board is **now**, or `nil` when this edition cannot reach it. The single source
|
||||||
/// of the row's availability: Open and Reveal need a URL, and an orphan has none.
|
/// of the row's availability: Open and Reveal need a URL, and an orphan has none.
|
||||||
let url: URL?
|
let url: URL?
|
||||||
|
|
||||||
|
/// Where the re-grant panel starts for a row whose only grant another edition minted
|
||||||
|
/// (`RecentBoard.needsReopen`; 12-editions.md ▸ Distribution) — `nil` on every other row.
|
||||||
|
///
|
||||||
|
/// It is what makes this row's Open live while `url` is `nil`: the board is *there*, and one click
|
||||||
|
/// plus Grant is all that stands between the user and it. Reveal in Finder stays disabled, because
|
||||||
|
/// revealing a folder is a read this app has not been granted either.
|
||||||
|
let regrantAnchor: URL?
|
||||||
|
|
||||||
/// The containing folder, for the row's location line — Xcode's welcome shows where a project
|
/// The containing folder, for the row's location line — Xcode's welcome shows where a project
|
||||||
/// lives, not its own path repeated under its name. Home-abbreviated where it can be.
|
/// lives, not its own path repeated under its name. Home-abbreviated where it can be.
|
||||||
let location: String
|
let location: String
|
||||||
@@ -58,10 +66,14 @@ struct WelcomeRow: Identifiable, Equatable {
|
|||||||
|
|
||||||
var isAvailable: Bool { url != nil }
|
var isAvailable: Bool { url != nil }
|
||||||
|
|
||||||
/// Open and Reveal in Finder both need somewhere to go; Forget is deliberately not here, because
|
/// Whether this row is waiting for this edition's grant rather than being genuinely orphaned.
|
||||||
/// it is enabled on every row — an orphan the user can never open is exactly the row that most
|
var needsReopen: Bool { regrantAnchor != nil }
|
||||||
/// needs erasing (02 § Graceful orphaning: "recents surface it as unavailable with Forget").
|
|
||||||
var canOpen: Bool { isAvailable }
|
/// Open needs somewhere to go **or something to grant**; Reveal in Finder needs the former only.
|
||||||
|
/// Forget is deliberately not here, because it is enabled on every row — an orphan the user can
|
||||||
|
/// never open is exactly the row that most needs erasing (02 § Graceful orphaning: "recents
|
||||||
|
/// surface it as unavailable with Forget").
|
||||||
|
var canOpen: Bool { isAvailable || needsReopen }
|
||||||
var canReveal: Bool { isAvailable }
|
var canReveal: Bool { isAvailable }
|
||||||
|
|
||||||
/// The row's third line — one line, so the three states are alternatives rather than a stack.
|
/// The row's third line — one line, so the three states are alternatives rather than a stack.
|
||||||
@@ -74,12 +86,20 @@ struct WelcomeRow: Identifiable, Equatable {
|
|||||||
case counts(lanes: Int?, cards: Int?)
|
case counts(lanes: Int?, cards: Int?)
|
||||||
/// The bookmark no longer resolves (02 § Graceful orphaning).
|
/// The bookmark no longer resolves (02 § Graceful orphaning).
|
||||||
case unavailable
|
case unavailable
|
||||||
|
/// This edition has never been granted the board another edition minted the record for
|
||||||
|
/// (12-editions.md ▸ Distribution) — the *reopen* state, which is not orphaning: the board is
|
||||||
|
/// there, and one click opens the panel that grants it.
|
||||||
|
case needsReopen
|
||||||
/// Fail-fast's specifics, from the open or restore that failed.
|
/// Fail-fast's specifics, from the open or restore that failed.
|
||||||
case failed(String)
|
case failed(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
var caption: Caption {
|
var caption: Caption {
|
||||||
if let failure { return .failed(failure) }
|
if let failure { return .failed(failure) }
|
||||||
|
// Before `unavailable`, because the two are told apart by *why* there is no URL and this one
|
||||||
|
// is the reachable case: an orphan's caption offering to grant access would be a promise the
|
||||||
|
// app cannot keep, and this row wearing the orphan's caption would read as a loss it is not.
|
||||||
|
if needsReopen { return .needsReopen }
|
||||||
if url == nil { return .unavailable }
|
if url == nil { return .unavailable }
|
||||||
return .counts(lanes: laneCount, cards: cardCount)
|
return .counts(lanes: laneCount, cards: cardCount)
|
||||||
}
|
}
|
||||||
@@ -143,6 +163,7 @@ struct WelcomeRow: Identifiable, Equatable {
|
|||||||
icon: record.icon,
|
icon: record.icon,
|
||||||
iconColor: record.iconColor,
|
iconColor: record.iconColor,
|
||||||
url: recent.url,
|
url: recent.url,
|
||||||
|
regrantAnchor: recent.regrantAnchor,
|
||||||
location: location(of: recent.url?.path ?? record.lastKnownPath),
|
location: location(of: recent.url?.path ?? record.lastKnownPath),
|
||||||
laneCount: record.laneCount,
|
laneCount: record.laneCount,
|
||||||
cardCount: record.cardCount,
|
cardCount: record.cardCount,
|
||||||
|
|||||||
@@ -244,12 +244,13 @@ struct WelcomeView: View {
|
|||||||
|
|
||||||
// MARK: Actions
|
// MARK: Actions
|
||||||
|
|
||||||
/// Opens a row's board. Welcome closes itself on the way in — that is the board window host's
|
/// Opens a row's board — through `AppModel.open(_:)`, which owns the two ways a row can lead to
|
||||||
/// job ("Opening a board from welcome closes welcome"), not this view's, because the close has to
|
/// one (an available URL, or the re-grant panel a cross-edition row needs first). Welcome closes
|
||||||
/// wait for the load to actually succeed.
|
/// itself on the way in — that is the board window host's job ("Opening a board from welcome
|
||||||
|
/// closes welcome"), not this view's, because the close has to wait for the load to actually
|
||||||
|
/// succeed.
|
||||||
private func open(_ row: WelcomeRow) {
|
private func open(_ row: WelcomeRow) {
|
||||||
guard let url = row.url else { return }
|
appModel.open(row)
|
||||||
appModel.openBoard(at: url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reveal(_ row: WelcomeRow) {
|
private func reveal(_ row: WelcomeRow) {
|
||||||
@@ -320,8 +321,9 @@ private struct RecentBoardRow: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, BoardMetrics.em(0.3, bodyPointSize: WelcomeView.pointSize))
|
.padding(.vertical, BoardMetrics.em(0.3, bodyPointSize: WelcomeView.pointSize))
|
||||||
// Dimmed when the board cannot be reached — the row stays, with Forget, rather than
|
// Dimmed when the board cannot be reached — the row stays, with Forget, rather than
|
||||||
// disappearing (02 § Graceful orphaning).
|
// disappearing (02 § Graceful orphaning). A cross-edition row is *not* dimmed: it opens on one
|
||||||
.opacity(row.isAvailable ? 1 : 0.55)
|
// click like any other, and dimming it would advertise a loss that has not happened.
|
||||||
|
.opacity(row.canOpen ? 1 : 0.55)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +365,14 @@ private struct RecentBoardRow: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
case .needsReopen:
|
||||||
|
// Not a warning tone: nothing is wrong and nothing is lost — this board came from the
|
||||||
|
// other edition's list and needs one grant (12-editions.md ▸ Distribution). The words say
|
||||||
|
// what the click will do, since the click is the whole remedy.
|
||||||
|
Label("Open once to grant access", systemImage: "hand.raised")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
case let .failed(message):
|
case let .failed(message):
|
||||||
// The warning tint, and the whole of fail-fast's specifics — this row *is* the failure
|
// The warning tint, and the whole of fail-fast's specifics — this row *is* the failure
|
||||||
// surface (02 § Launch and window lifecycle).
|
// surface (02 § Launch and window lifecycle).
|
||||||
@@ -385,7 +395,10 @@ private struct RecentBoardRow: View {
|
|||||||
/// turned off and then turns it back on.
|
/// turned off and then turns it back on.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
|
|
||||||
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
|
/// `store:` named explicitly, and it has to be: the key lives in the group's shared suite
|
||||||
|
/// (`AppPreferences`), and `@AppStorage`'s default domain is `.standard` — a different one. A
|
||||||
|
/// toggle bound to the wrong domain would write a preference the launch flow never reads.
|
||||||
|
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey, store: AppGroup.defaults)
|
||||||
private var restoreOpenBoardsAtLaunch = true
|
private var restoreOpenBoardsAtLaunch = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -8,5 +8,17 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- The family App Group (12-editions.md ▸ Distribution, ruled 2026-07-29). Every edition
|
||||||
|
declares this same group id, and the board registry with its app-wide peers — the
|
||||||
|
clipboard's staging store, the shared defaults suite — homes in its container from day
|
||||||
|
one, so a paying upgrader launches Pro onto their own recents rather than an empty
|
||||||
|
screen. **It must be in base's shipped entitlements**: base ships first, and an edition
|
||||||
|
that joined the group later would have a migration to do. Security-scoped bookmarks are
|
||||||
|
the stated exception — they never cross sandboxes, group or not, which is why each
|
||||||
|
edition holds its own grant slot on the shared record. -->
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.rzen.indie.Kanban</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+14
-7
@@ -41,14 +41,21 @@ struct KanbanApp: App {
|
|||||||
private let launchPlan: LaunchPlan
|
private let launchPlan: LaunchPlan
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Read first, because it decides *which registry file the model is built with* — a fixture
|
// Read first, because it decides *which app-side state the model is built over* — a fixture
|
||||||
// launch keeps its recents in the scratch directory rather than in the user's real one.
|
// launch keeps its recents and its clipboard snapshots in the scratch directory rather than in
|
||||||
|
// the shared App Group container.
|
||||||
let isUITestFixtureLaunch = UITestLaunch.isFixtureLaunch
|
let isUITestFixtureLaunch = UITestLaunch.isFixtureLaunch
|
||||||
let registryStorageURL = isUITestFixtureLaunch
|
if isUITestFixtureLaunch {
|
||||||
? UITestLaunch.prepareScratchDirectory()
|
UITestLaunch.prepareScratchDirectory()
|
||||||
: BoardRegistry.defaultStorageURL
|
}
|
||||||
|
let model = AppModel(
|
||||||
let model = AppModel(registryStorageURL: registryStorageURL)
|
registryStorageURL: isUITestFixtureLaunch
|
||||||
|
? UITestLaunch.registryStorageURL
|
||||||
|
: BoardRegistry.defaultStorageURL,
|
||||||
|
clipboardStagingRoot: isUITestFixtureLaunch
|
||||||
|
? UITestLaunch.clipboardStagingRoot
|
||||||
|
: ClipboardStore.defaultStagingRoot
|
||||||
|
)
|
||||||
_appModel = State(initialValue: model)
|
_appModel = State(initialValue: model)
|
||||||
launchPlan = LaunchPlan.decide(
|
launchPlan = LaunchPlan.decide(
|
||||||
isUITestFixtureLaunch: isUITestFixtureLaunch,
|
isUITestFixtureLaunch: isUITestFixtureLaunch,
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// **The family App Group** — where every edition's app-side state lives (12-editions.md
|
||||||
|
/// § Distribution, ruled 2026-07-29; 02-architecture.md § Per-board app state).
|
||||||
|
///
|
||||||
|
/// ### Why a shared container at all
|
||||||
|
///
|
||||||
|
/// Boards are files, so they need no migration between editions — but the *app-side* state around
|
||||||
|
/// them (the recents list, per-board frames, the quick-style row, the clipboard's staged snapshot)
|
||||||
|
/// is app-private, and per-app-private means an upgrader lands on an empty home screen. So every
|
||||||
|
/// edition declares one group — `group.dev.rzen.indie.Kanban` — and homes that state in its
|
||||||
|
/// container **from day one**: base 2.0 ships with the entitlement, Pro's first release joins the
|
||||||
|
/// same group, Teams later does too, and at no point is there a migration or an ordering dependency
|
||||||
|
/// between them.
|
||||||
|
///
|
||||||
|
/// ### The one thing that cannot be shared
|
||||||
|
///
|
||||||
|
/// **Security-scoped bookmarks never cross sandboxes** — App Group or not, a bookmark is minted for
|
||||||
|
/// one app's sandbox and resolves in that one only. So the registry record is *common* except for
|
||||||
|
/// a per-edition **grant slot** keyed by bundle id (`BoardRecord.grants`), and a record whose only
|
||||||
|
/// grant another edition minted reads as unavailable-until-reopened. Open-now flags are keyed the
|
||||||
|
/// same way, for the same shape of reason: an edition restores the boards *it* had open.
|
||||||
|
///
|
||||||
|
/// ### It degrades rather than fails
|
||||||
|
///
|
||||||
|
/// `containerURL(forSecurityApplicationGroupIdentifier:)` answers `nil` when the group is not
|
||||||
|
/// provisioned for the running binary — a unit-test host without the capability, a locally signed
|
||||||
|
/// build before the group is registered on the team. Every path here falls back to the *previous*
|
||||||
|
/// per-edition Application Support home in that case, so nothing depends on provisioning to work:
|
||||||
|
/// state simply stops being shared, which is exactly the old behaviour.
|
||||||
|
public enum AppGroup {
|
||||||
|
|
||||||
|
/// The group id every edition declares, verbatim (12-editions.md ▸ Distribution). It is
|
||||||
|
/// deliberately the *family* name rather than an edition's: Pro and Teams declare this same
|
||||||
|
/// string, and a future edition's bundle id joins with no further ceremony.
|
||||||
|
public static let identifier = "group.dev.rzen.indie.Kanban"
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-group")
|
||||||
|
|
||||||
|
// MARK: - Edition identity
|
||||||
|
|
||||||
|
/// Base's bundle id — also the fallback when `Bundle.main` has none, which is a test host's
|
||||||
|
/// case and never a shipped app's.
|
||||||
|
public static let baseEditionID = "dev.rzen.indie.Kanban"
|
||||||
|
|
||||||
|
/// Pro's bundle id (12 ▸ Targets, ruled 2026-07-27). Named here as well as in
|
||||||
|
/// `ProEdition.bundleIdentifier` because *base* has to know it: the popover's awareness line and
|
||||||
|
/// the grant-slot keying are shared-tree code that must be able to name the other edition
|
||||||
|
/// without compiling any of it.
|
||||||
|
public static let proEditionID = "dev.rzen.indie.KanbanPro"
|
||||||
|
|
||||||
|
/// Which edition is running — the key every per-edition slot on a shared record is stored under.
|
||||||
|
///
|
||||||
|
/// Read from `Bundle.main` rather than declared per target, which is what keeps this file free of
|
||||||
|
/// any edition conditional: the binary already knows which app it is.
|
||||||
|
public static var editionID: String {
|
||||||
|
Bundle.main.bundleIdentifier ?? baseEditionID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user-facing name of an edition, for the popover's awareness line ("Also open in Lanework
|
||||||
|
/// Pro"). `nil` for a bundle id this build has never heard of — a future edition's, or a stale
|
||||||
|
/// slot left by something else — because inventing a name for it would be worse than saying
|
||||||
|
/// nothing, and the line's whole posture is that it never lies.
|
||||||
|
public static func editionDisplayName(_ bundleID: String) -> String? {
|
||||||
|
switch bundleID {
|
||||||
|
case baseEditionID: "Lanework"
|
||||||
|
case proEditionID: "Lanework Pro"
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The container
|
||||||
|
|
||||||
|
/// The group container, or `nil` when the running binary has no such capability.
|
||||||
|
///
|
||||||
|
/// Not cached: the answer is a property of the process's entitlements and cannot change within
|
||||||
|
/// a launch, but the call is a cheap lookup and a cached `nil` from an early read (before the
|
||||||
|
/// container has been created for the first time) is the sort of staleness this file should not
|
||||||
|
/// invent.
|
||||||
|
public static var containerURL: URL? {
|
||||||
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where every app-side file store lives — the registry, the clipboard's staging snapshots, and
|
||||||
|
/// whatever joins them.
|
||||||
|
///
|
||||||
|
/// In a shipped app this is `productionStateDirectory`. **In a unit-test host it is a scratch
|
||||||
|
/// directory** (`isUnitTestHost`), which is not a nicety: the real container is shared with the
|
||||||
|
/// sibling edition and with the developer's own running copy, so a suite that used it would be
|
||||||
|
/// sweeping real staged clipboard copies and rewriting a real recents list.
|
||||||
|
public static var stateDirectory: URL {
|
||||||
|
isUnitTestHost ? unitTestStateDirectory : productionStateDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a shipped app uses: `<group container>/Library/Application Support/`.
|
||||||
|
///
|
||||||
|
/// **No bundle-id subfolder**, unlike the per-edition home this replaces — that subfolder was
|
||||||
|
/// exactly what kept two editions from seeing one list, and its absence is the whole feature.
|
||||||
|
/// `Library/Application Support` is kept as the path *inside* the container for Apple's
|
||||||
|
/// convention rather than for any behaviour: the container is the app's either way.
|
||||||
|
///
|
||||||
|
/// Falls back to `perEditionSupportDirectory` when there is no group container (see the type's
|
||||||
|
/// note): unshared, but working.
|
||||||
|
public static var productionStateDirectory: URL {
|
||||||
|
guard let containerURL else {
|
||||||
|
logger.debug("no group container for \(identifier, privacy: .public); using the per-edition home")
|
||||||
|
return perEditionSupportDirectory
|
||||||
|
}
|
||||||
|
return containerURL
|
||||||
|
.appendingPathComponent("Library", isDirectory: true)
|
||||||
|
.appendingPathComponent("Application Support", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pre-2.0 home — `~/Library/Application Support/<bundle id>/`, inside this edition's own
|
||||||
|
/// sandbox container. Kept as the fallback above and as the home of anything deliberately *not*
|
||||||
|
/// shared.
|
||||||
|
public static var perEditionSupportDirectory: URL {
|
||||||
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||||
|
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
||||||
|
.appendingPathComponent("Library/Application Support", isDirectory: true)
|
||||||
|
return support.appendingPathComponent(editionID, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keeping the suites out of it
|
||||||
|
|
||||||
|
/// Whether this process is hosting a unit-test bundle.
|
||||||
|
///
|
||||||
|
/// ### Why the app has to know
|
||||||
|
///
|
||||||
|
/// The unit-test host **is the app** (`KanbanTests` and `KanbanProTests` are hosted bundles), so
|
||||||
|
/// `KanbanApp.init()` runs for real on every test launch and builds an `AppModel` over whatever
|
||||||
|
/// the defaults resolve to. Every *object* a test constructs takes its storage by injection — that
|
||||||
|
/// is the seam, and it is untouched — but the host's own launch has no injection point, and after
|
||||||
|
/// the 2026-07-29 ruling the thing it would reach for is a container shared with the sibling
|
||||||
|
/// edition and with the developer's running copy. Its launch sweep would collect real staged
|
||||||
|
/// clipboard trees; its `refreshRecents()` would resolve, refresh and rewrite real records.
|
||||||
|
///
|
||||||
|
/// So the *default* moves for a test host, which is the one place a default can be wrong in a way
|
||||||
|
/// injection cannot fix.
|
||||||
|
///
|
||||||
|
/// ### Why this variable and not a launch flag
|
||||||
|
///
|
||||||
|
/// `UITestLaunch.fixtureFlag` is the flag-shaped answer and remains the right one for the UI
|
||||||
|
/// suites, which launch the app themselves and can pass arguments. A *unit*-test host is launched
|
||||||
|
/// by the test runner, which passes nothing of ours — but it does set these variables, and it has
|
||||||
|
/// set them for as long as XCTest has existed. Three spellings are checked because Apple has used
|
||||||
|
/// each at some point and a missed one would silently mean "not a test".
|
||||||
|
///
|
||||||
|
/// It cannot fire in a shipped app: nothing sets these but a test runner.
|
||||||
|
public static var isUnitTestHost: Bool {
|
||||||
|
let environment = ProcessInfo.processInfo.environment
|
||||||
|
return environment["XCTestConfigurationFilePath"] != nil
|
||||||
|
|| environment["XCTestBundlePath"] != nil
|
||||||
|
|| environment["XCTestSessionIdentifier"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scratch home a test host uses instead. Inside the app's own container (`NSTemporaryDirectory`
|
||||||
|
/// sandboxes there), so nothing outside this app can see it and the OS reclaims it.
|
||||||
|
///
|
||||||
|
/// One fixed folder rather than one per run: the suites do not depend on it being empty — they
|
||||||
|
/// inject their own paths for anything they assert on — and a stable name keeps it inspectable when
|
||||||
|
/// something writes there that should not have.
|
||||||
|
public static var unitTestStateDirectory: URL {
|
||||||
|
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
|
.appendingPathComponent("LaneworkUnitTestState", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The shared defaults suite
|
||||||
|
|
||||||
|
/// The group's shared `UserDefaults` suite — where app-side state that is a *scalar* lives
|
||||||
|
/// (02-architecture.md § Per-board app state: "or the group's shared `UserDefaults` suite where
|
||||||
|
/// a scalar fits").
|
||||||
|
///
|
||||||
|
/// `UserDefaults(suiteName:)` answers `nil` only for a suite name equal to the app's own bundle
|
||||||
|
/// id, which this never is; `.standard` is the fallback anyway, for the reason every fallback
|
||||||
|
/// here exists — an unshared preference is a papercut, an unreadable one is a bug.
|
||||||
|
///
|
||||||
|
/// Without the entitlement the suite is an ordinary named domain rather than a shared one, so
|
||||||
|
/// this works unprovisioned too: the values are simply this edition's alone.
|
||||||
|
///
|
||||||
|
/// A **test host gets its own suite name** for `isUnitTestHost`'s reason applied to preferences: a
|
||||||
|
/// suite that read and wrote the real one would be reading and writing the developer's quick-style
|
||||||
|
/// row and window size, which is the objection `StyleModelTests` already states about
|
||||||
|
/// `UserDefaults.standard`.
|
||||||
|
public static var defaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: isUnitTestHost ? "\(identifier).unit-tests" : identifier) ?? .standard
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,35 +55,80 @@ public struct WindowFrame: Codable, Sendable, Equatable {
|
|||||||
/// One known board: everything the app keeps *about* a board that must never be written *into* it.
|
/// One known board: everything the app keeps *about* a board that must never be written *into* it.
|
||||||
///
|
///
|
||||||
/// **Files-first is absolute** (02-architecture.md § Per-board app state): no frontmatter key, no
|
/// **Files-first is absolute** (02-architecture.md § Per-board app state): no frontmatter key, no
|
||||||
/// sidecar, no xattr — this record lives wholly in Application Support, which is also why two
|
/// sidecar, no xattr — this record lives wholly in the shared App Group container, which is also why
|
||||||
/// machines sharing a board through a remote each keep their own (push-on-commit and window frames
|
/// two machines sharing a board through a remote each keep their own (push-on-commit and window
|
||||||
/// are genuinely per-machine choices).
|
/// frames are genuinely per-machine choices).
|
||||||
///
|
///
|
||||||
/// The **bookmark is the identity**; `lastKnownPath` is display text and nothing else. Matching an
|
/// The **bookmark is the identity**; `lastKnownPath` is display text and nothing else. Matching an
|
||||||
/// opened folder to its record resolves the bookmark and compares file identity, so a board renamed
|
/// opened folder to its record resolves the bookmark and compares file identity, so a board renamed
|
||||||
/// or moved on the same volume keeps its settings and its place in recents.
|
/// or moved on the same volume keeps its settings and its place in recents.
|
||||||
///
|
///
|
||||||
|
/// ### One record, shared by every edition — with two per-edition fields
|
||||||
|
///
|
||||||
|
/// The record lives in the family App Group container (`AppGroup`), so base, Pro and later Teams all
|
||||||
|
/// read and write the same one: an upgrader's recents, frames and settings are simply *there*
|
||||||
|
/// (12-editions.md ▸ Distribution, ruled 2026-07-29). Two fields cannot be common, and both are
|
||||||
|
/// keyed by bundle id for the same reason:
|
||||||
|
///
|
||||||
|
/// - **`grants`** — security-scoped bookmarks never cross sandboxes (12: "minted per sandbox, App
|
||||||
|
/// Group or not"), so each edition holds its own. A record whose only grant another edition minted
|
||||||
|
/// resolves *unavailable-until-reopened* (`RecentBoard.needsReopen`) and its first click runs an
|
||||||
|
/// open panel pre-anchored at `lastKnownPath`.
|
||||||
|
/// - **`openNow`** — "an edition restores only the boards *it* had open" (12; 02 § Launch and window
|
||||||
|
/// lifecycle), which is also what lets the board popover say "Also open in Lanework Pro" from the
|
||||||
|
/// *other* edition's flag.
|
||||||
|
///
|
||||||
|
/// Everything else here is common, deliberately: a window frame, a cached title, a lane count and a
|
||||||
|
/// push-on-commit choice are facts about the board and this machine, not about which app is looking.
|
||||||
|
///
|
||||||
/// ### Evolving this struct
|
/// ### Evolving this struct
|
||||||
///
|
///
|
||||||
/// The synthesized `Codable` conformance rejects a file missing any non-optional key, and
|
/// `BoardRegistry` responds to a file it cannot decode by quarantining it — every record lost. So
|
||||||
/// `BoardRegistry` responds to a rejected file by quarantining it — every record lost. So **a key
|
/// **a key added here must be optional or have a decoding default**, or the addition silently
|
||||||
/// added here must be optional or have a decoding default**, or the addition silently empties every
|
/// empties every existing user's recents on upgrade. That policy, not a version field, is what keeps
|
||||||
/// existing user's recents on upgrade. That policy, not a version field, is what keeps this format
|
/// this format readable across releases; `init(from:)` below applies it by hand for the keys that
|
||||||
/// readable across releases.
|
/// arrived with the App Group, and keeps the four founding keys required exactly as they were.
|
||||||
public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
|
|
||||||
/// The security-scoped bookmark — **the board's identity**, refreshed on every open. Empty only
|
/// The per-edition security-scoped grant slots — **the board's identity, per sandbox** — keyed by
|
||||||
/// in the degenerate case where the system refused to make one at all, which is a record born
|
/// bundle id and refreshed on every open *by that edition*. Empty for an edition means "this app
|
||||||
/// orphaned: it shows in recents with Forget and never matches an open.
|
/// has never been granted this board", which is the unavailable-until-reopened state and not an
|
||||||
public var bookmark: Data
|
/// error.
|
||||||
|
///
|
||||||
|
/// A record with no grants at all is the degenerate case where the system refused to mint one:
|
||||||
|
/// born orphaned, shown in recents with Forget, never matching an open.
|
||||||
|
public var grants: [String: Data]
|
||||||
|
|
||||||
|
/// Per-edition open-now flags, keyed by bundle id — see `isOpen(inEdition:)` for what the flag
|
||||||
|
/// *means*, which is unchanged; only its keying is new.
|
||||||
|
public var openNow: [String: Bool]
|
||||||
|
|
||||||
|
/// The pre-App-Group single-grant key (`bookmark`), decoded and **never re-encoded**.
|
||||||
|
///
|
||||||
|
/// Its whole life is `BoardRegistry.adoptLegacyKeys()`: a record written before the grant slots
|
||||||
|
/// existed has one bookmark, minted by whichever edition wrote it — and since only base existed
|
||||||
|
/// then, adopting it as the *running* edition's slot is the coherent reading. Adopted in memory
|
||||||
|
/// at load and gone from the file on the first save, which is why nothing else here ever looks
|
||||||
|
/// at it.
|
||||||
|
public private(set) var legacyBookmark: Data?
|
||||||
|
|
||||||
|
/// The pre-App-Group single open-now key (`isOpenNow`), on the same terms as `legacyBookmark`.
|
||||||
|
public private(set) var legacyOpenNow: Bool?
|
||||||
|
|
||||||
/// Last-known title or folder name, for the recents row. Display only.
|
/// Last-known title or folder name, for the recents row. Display only.
|
||||||
public var displayName: String
|
public var displayName: String
|
||||||
|
|
||||||
/// Where the board was last seen, for the recents row when the bookmark no longer resolves and
|
/// Where the board was last seen, for the recents row when the bookmark no longer resolves and
|
||||||
/// there is nothing else to show. **Never used for matching** — that is the bookmark's job, and
|
/// there is nothing else to show.
|
||||||
/// a path that matched would reintroduce exactly the identity-by-string bug this design excludes.
|
///
|
||||||
|
/// **Not how a board is identified** — that is the grant slot's job, and a path used as the primary
|
||||||
|
/// key would reintroduce exactly the identity-by-string bug this design excludes. It has precisely
|
||||||
|
/// one narrow other use, which 12-editions.md itself nominates: for a record only *another* edition
|
||||||
|
/// holds a grant for, this is the open panel's anchor and the only locator this app has, so
|
||||||
|
/// `BoardRegistry.indexOfRecord(matching:)` falls back to it — after identity has failed, and only
|
||||||
|
/// against records holding no grant of ours. Without that, granting a board in the second edition
|
||||||
|
/// would fork the shared record in two.
|
||||||
public var lastKnownPath: String
|
public var lastKnownPath: String
|
||||||
|
|
||||||
public var lastOpened: Date
|
public var lastOpened: Date
|
||||||
@@ -136,21 +181,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
/// time — never here.
|
/// time — never here.
|
||||||
public var iconColor: String?
|
public var iconColor: String?
|
||||||
|
|
||||||
/// Whether this board's window is open **right now** — the restoration set, as a live marker
|
|
||||||
/// rather than an at-quit write (02-architecture.md § Launch and window lifecycle, settled).
|
|
||||||
///
|
|
||||||
/// Set when the board's window opens, cleared on *user-initiated* close; quit's teardown
|
|
||||||
/// deliberately leaves it standing, because the boards open at quit are by definition the ones
|
|
||||||
/// to restore. **Crash recovery falls out for free**: after a crash the flags describe what was
|
|
||||||
/// open at crash time, so the next launch restores exactly that — no separate recovery logic, no
|
|
||||||
/// once-at-quit stamp to race teardown or miss when the app dies.
|
|
||||||
///
|
|
||||||
/// **Optional because every key here must be** (see Evolving this struct above): a registry file
|
|
||||||
/// written before this key existed decodes with `nil`, which reads as "not open" and costs the
|
|
||||||
/// user nothing. A non-optional `Bool` would have quarantined every existing file on upgrade and
|
|
||||||
/// emptied everyone's recents.
|
|
||||||
public var isOpenNow: Bool?
|
|
||||||
|
|
||||||
/// Whether committing also pushes (07-sync-collab.md). Off by default: pushing is a decision,
|
/// Whether committing also pushes (07-sync-collab.md). Off by default: pushing is a decision,
|
||||||
/// not a side effect.
|
/// not a side effect.
|
||||||
public var pushOnCommit: Bool
|
public var pushOnCommit: Bool
|
||||||
@@ -162,7 +192,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
bookmark: Data,
|
grants: [String: Data] = [:],
|
||||||
displayName: String,
|
displayName: String,
|
||||||
lastKnownPath: String,
|
lastKnownPath: String,
|
||||||
lastOpened: Date,
|
lastOpened: Date,
|
||||||
@@ -170,14 +200,14 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
cardCount: Int? = nil,
|
cardCount: Int? = nil,
|
||||||
windowFrame: WindowFrame? = nil,
|
windowFrame: WindowFrame? = nil,
|
||||||
cardWindowFrames: [String: WindowFrame]? = nil,
|
cardWindowFrames: [String: WindowFrame]? = nil,
|
||||||
isOpenNow: Bool? = nil,
|
openNow: [String: Bool] = [:],
|
||||||
pushOnCommit: Bool = false,
|
pushOnCommit: Bool = false,
|
||||||
remoteLocationWarned: Bool = false,
|
remoteLocationWarned: Bool = false,
|
||||||
icon: String? = nil,
|
icon: String? = nil,
|
||||||
iconColor: String? = nil
|
iconColor: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.bookmark = bookmark
|
self.grants = grants
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.lastKnownPath = lastKnownPath
|
self.lastKnownPath = lastKnownPath
|
||||||
self.lastOpened = lastOpened
|
self.lastOpened = lastOpened
|
||||||
@@ -185,12 +215,153 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
self.cardCount = cardCount
|
self.cardCount = cardCount
|
||||||
self.windowFrame = windowFrame
|
self.windowFrame = windowFrame
|
||||||
self.cardWindowFrames = cardWindowFrames
|
self.cardWindowFrames = cardWindowFrames
|
||||||
self.isOpenNow = isOpenNow
|
self.openNow = openNow
|
||||||
self.pushOnCommit = pushOnCommit
|
self.pushOnCommit = pushOnCommit
|
||||||
self.remoteLocationWarned = remoteLocationWarned
|
self.remoteLocationWarned = remoteLocationWarned
|
||||||
self.icon = icon
|
self.icon = icon
|
||||||
self.iconColor = iconColor
|
self.iconColor = iconColor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: The per-edition slots
|
||||||
|
|
||||||
|
/// This edition's grant, or `nil` when it holds none — the unavailable-until-reopened case.
|
||||||
|
///
|
||||||
|
/// An **empty** `Data` answers `nil` too: `recordOpen` stores one when the system refused to mint
|
||||||
|
/// a bookmark at all, and a slot holding nothing is indistinguishable from no slot for every
|
||||||
|
/// purpose this type has.
|
||||||
|
public func grant(forEdition editionID: String) -> Data? {
|
||||||
|
guard let grant = grants[editionID], !grant.isEmpty else { return nil }
|
||||||
|
return grant
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func setGrant(_ grant: Data, forEdition editionID: String) {
|
||||||
|
grants[editionID] = grant
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this board is open in `editionID` right now — the restoration set, as a live marker
|
||||||
|
/// rather than an at-quit write (02-architecture.md § Launch and window lifecycle, settled).
|
||||||
|
///
|
||||||
|
/// Set when the board's window opens, cleared on *user-initiated* close; quit's teardown
|
||||||
|
/// deliberately leaves it standing, because the boards open at quit are by definition the ones
|
||||||
|
/// to restore. **Crash recovery falls out for free**: after a crash the flags describe what was
|
||||||
|
/// open at crash time, so the next launch restores exactly that — no separate recovery logic, no
|
||||||
|
/// once-at-quit stamp to race teardown or miss when the app dies.
|
||||||
|
///
|
||||||
|
/// **Per edition** (12-editions.md ▸ Both editions installed): an edition restores only the
|
||||||
|
/// boards *it* had open, so a board Pro has open never reopens in base — and the other direction
|
||||||
|
/// of the same fact is the popover's awareness line.
|
||||||
|
public func isOpen(inEdition editionID: String) -> Bool {
|
||||||
|
openNow[editionID] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func setOpen(_ isOpen: Bool, inEdition editionID: String) {
|
||||||
|
openNow[editionID] = isOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The editions other than `editionID` that have this board flagged open, sorted so the answer is
|
||||||
|
/// stable rather than a dictionary's order.
|
||||||
|
///
|
||||||
|
/// **Liveness is not checked here** — that is the caller's, because a flag is a *claim* and a
|
||||||
|
/// crashed edition leaves its claims standing by design. See `BoardEditionPresence` for the rule
|
||||||
|
/// that turns this list into a line the popover can honestly show.
|
||||||
|
public func otherEditionsOpen(besides editionID: String) -> [String] {
|
||||||
|
openNow.filter { $0.key != editionID && $0.value }.keys.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any edition at all holds a grant for this board — what tells
|
||||||
|
/// unavailable-until-reopened (some other edition minted the only grant) apart from a genuine
|
||||||
|
/// orphan (nobody can reach it).
|
||||||
|
public var isGrantedByAnyEdition: Bool {
|
||||||
|
grants.contains { !$0.value.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Codable
|
||||||
|
|
||||||
|
/// Hand-written for exactly one reason: the two pre-App-Group keys have to keep decoding while
|
||||||
|
/// never being written again (see `legacyBookmark`). Everything else is the synthesized
|
||||||
|
/// behaviour restated — required for the four founding keys, so a genuinely broken file still
|
||||||
|
/// quarantines, and defaulted for every key added since, which is § Evolving this struct's policy
|
||||||
|
/// spelled out rather than implied by an `Optional`.
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case grants
|
||||||
|
case openNow
|
||||||
|
case displayName
|
||||||
|
case lastKnownPath
|
||||||
|
case lastOpened
|
||||||
|
case laneCount
|
||||||
|
case cardCount
|
||||||
|
case windowFrame
|
||||||
|
case cardWindowFrames
|
||||||
|
case icon
|
||||||
|
case iconColor
|
||||||
|
case pushOnCommit
|
||||||
|
case remoteLocationWarned
|
||||||
|
/// Pre-App-Group. Read, never written.
|
||||||
|
case bookmark
|
||||||
|
/// Pre-App-Group. Read, never written.
|
||||||
|
case isOpenNow
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(UUID.self, forKey: .id)
|
||||||
|
displayName = try container.decode(String.self, forKey: .displayName)
|
||||||
|
lastKnownPath = try container.decode(String.self, forKey: .lastKnownPath)
|
||||||
|
lastOpened = try container.decode(Date.self, forKey: .lastOpened)
|
||||||
|
grants = try container.decodeIfPresent([String: Data].self, forKey: .grants) ?? [:]
|
||||||
|
openNow = try container.decodeIfPresent([String: Bool].self, forKey: .openNow) ?? [:]
|
||||||
|
laneCount = try container.decodeIfPresent(Int.self, forKey: .laneCount)
|
||||||
|
cardCount = try container.decodeIfPresent(Int.self, forKey: .cardCount)
|
||||||
|
windowFrame = try container.decodeIfPresent(WindowFrame.self, forKey: .windowFrame)
|
||||||
|
cardWindowFrames = try container.decodeIfPresent([String: WindowFrame].self, forKey: .cardWindowFrames)
|
||||||
|
icon = try container.decodeIfPresent(String.self, forKey: .icon)
|
||||||
|
iconColor = try container.decodeIfPresent(String.self, forKey: .iconColor)
|
||||||
|
pushOnCommit = try container.decodeIfPresent(Bool.self, forKey: .pushOnCommit) ?? false
|
||||||
|
remoteLocationWarned = try container.decodeIfPresent(Bool.self, forKey: .remoteLocationWarned) ?? false
|
||||||
|
legacyBookmark = try container.decodeIfPresent(Data.self, forKey: .bookmark)
|
||||||
|
legacyOpenNow = try container.decodeIfPresent(Bool.self, forKey: .isOpenNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(grants, forKey: .grants)
|
||||||
|
try container.encode(openNow, forKey: .openNow)
|
||||||
|
try container.encode(displayName, forKey: .displayName)
|
||||||
|
try container.encode(lastKnownPath, forKey: .lastKnownPath)
|
||||||
|
try container.encode(lastOpened, forKey: .lastOpened)
|
||||||
|
try container.encodeIfPresent(laneCount, forKey: .laneCount)
|
||||||
|
try container.encodeIfPresent(cardCount, forKey: .cardCount)
|
||||||
|
try container.encodeIfPresent(windowFrame, forKey: .windowFrame)
|
||||||
|
try container.encodeIfPresent(cardWindowFrames, forKey: .cardWindowFrames)
|
||||||
|
try container.encodeIfPresent(icon, forKey: .icon)
|
||||||
|
try container.encodeIfPresent(iconColor, forKey: .iconColor)
|
||||||
|
try container.encode(pushOnCommit, forKey: .pushOnCommit)
|
||||||
|
try container.encode(remoteLocationWarned, forKey: .remoteLocationWarned)
|
||||||
|
// `bookmark` and `isOpenNow` are deliberately absent: this is the tolerate-and-upgrade half
|
||||||
|
// of backward compatibility. An unknown key is dropped on the same terms — the synthesized
|
||||||
|
// conformance dropped them too, so the file's forward tolerance is exactly what it was.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds the two pre-App-Group keys into `editionID`'s slots and forgets them.
|
||||||
|
///
|
||||||
|
/// **Only when the modern slot is empty**, in both cases: a file carrying both shapes was written
|
||||||
|
/// by a build that already knew about grants, and the legacy key is then stale by definition.
|
||||||
|
///
|
||||||
|
/// **In memory only, at load.** The upgraded shape reaches disk on the next ordinary save —
|
||||||
|
/// "tolerate-and-upgrade on first write" — so merely *reading* a registry never rewrites it, and
|
||||||
|
/// a launch that opens nothing leaves the file exactly as it found it.
|
||||||
|
mutating func adoptLegacyKeys(as editionID: String) {
|
||||||
|
if let legacyBookmark, !legacyBookmark.isEmpty, grants.isEmpty {
|
||||||
|
grants[editionID] = legacyBookmark
|
||||||
|
}
|
||||||
|
if legacyOpenNow == true, openNow.isEmpty {
|
||||||
|
openNow[editionID] = true
|
||||||
|
}
|
||||||
|
legacyBookmark = nil
|
||||||
|
legacyOpenNow = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A recents row: the record, plus whether its board can be reached right now.
|
/// A recents row: the record, plus whether its board can be reached right now.
|
||||||
@@ -206,27 +377,48 @@ public enum RecentBoard: Sendable, Equatable {
|
|||||||
/// The bookmark no longer resolves: deleted, or moved across a volume boundary a bookmark
|
/// The bookmark no longer resolves: deleted, or moved across a volume boundary a bookmark
|
||||||
/// cannot follow. Orphaned — "its settings are conveniences and die with it".
|
/// cannot follow. Orphaned — "its settings are conveniences and die with it".
|
||||||
case unavailable(BoardRecord)
|
case unavailable(BoardRecord)
|
||||||
|
/// **Unavailable-until-reopened**: this edition holds no grant, but another edition does
|
||||||
|
/// (12-editions.md ▸ Distribution — "a record another edition minted resolves
|
||||||
|
/// unavailable-until-reopened, and the first click runs an open panel pre-anchored at the
|
||||||
|
/// recorded path: one click + Grant per board, once per edition").
|
||||||
|
///
|
||||||
|
/// `recordedAt` is `lastKnownPath` as a URL — the panel's anchor, and the one place that field is
|
||||||
|
/// used for anything but display. It is **not** a claim that the board is there: nothing here
|
||||||
|
/// touches the filesystem, and a board that has since moved simply opens the panel at its parent.
|
||||||
|
case needsReopen(BoardRecord, recordedAt: URL)
|
||||||
|
|
||||||
public var record: BoardRecord {
|
public var record: BoardRecord {
|
||||||
switch self {
|
switch self {
|
||||||
case let .available(record, _): record
|
case let .available(record, _): record
|
||||||
case let .unavailable(record): record
|
case let .unavailable(record): record
|
||||||
|
case let .needsReopen(record, _): record
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the board is now, or `nil` for an orphan.
|
/// Where the board is now, or `nil` when this edition cannot reach it — an orphan, or a record
|
||||||
|
/// awaiting this edition's grant. Both answer `nil` because both need something to happen before
|
||||||
|
/// a board can be opened; which something is `regrantAnchor`'s question.
|
||||||
public var url: URL? {
|
public var url: URL? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .available(_, url): url
|
case let .available(_, url): url
|
||||||
case .unavailable: nil
|
case .unavailable, .needsReopen: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an open panel should start when this row is clicked, or `nil` for a row a panel cannot
|
||||||
|
/// help — an available board (nothing to grant) or a genuine orphan (nothing to grant *to*).
|
||||||
|
public var regrantAnchor: URL? {
|
||||||
|
switch self {
|
||||||
|
case let .needsReopen(_, url): url
|
||||||
|
case .available, .unavailable: nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BoardRegistry
|
// MARK: - BoardRegistry
|
||||||
|
|
||||||
/// The persistent side of per-board app state: one record per known board, in Application Support
|
/// The persistent side of per-board app state: one record per known board, in the shared App Group
|
||||||
/// (02-architecture.md § Per-board app state).
|
/// container (02-architecture.md § Per-board app state; `AppGroup`).
|
||||||
///
|
///
|
||||||
/// ### Three rules do most of the work
|
/// ### Three rules do most of the work
|
||||||
///
|
///
|
||||||
@@ -249,25 +441,31 @@ public enum RecentBoard: Sendable, Equatable {
|
|||||||
public final class BoardRegistry {
|
public final class BoardRegistry {
|
||||||
|
|
||||||
/// The JSON file this registry is. Public because a diagnostic ("Reveal registry in Finder") and
|
/// The JSON file this registry is. Public because a diagnostic ("Reveal registry in Finder") and
|
||||||
/// every test want to name it, and because injecting it is how a test stays out of the real
|
/// every test want to name it, and because injecting it is how a test stays out of the real shared
|
||||||
/// Application Support directory.
|
/// App Group container (`AppGroup`) — which after the 2026-07-29 ruling is the sibling edition's
|
||||||
|
/// registry too, so a suite writing there would be editing two apps' state.
|
||||||
public let storageURL: URL
|
public let storageURL: URL
|
||||||
|
|
||||||
|
/// **Which edition's slots this registry reads and writes** — the bundle id keying `grants` and
|
||||||
|
/// `openNow` on every shared record.
|
||||||
|
///
|
||||||
|
/// Injected rather than read from `Bundle.main` at each use for the reason `storageURL` is
|
||||||
|
/// injected: it is how a test can be Pro looking at base's records (and back again) inside one
|
||||||
|
/// process, which is the only way the cross-edition rules are checkable at all.
|
||||||
|
public let editionID: String
|
||||||
|
|
||||||
private var records: [BoardRecord]
|
private var records: [BoardRecord]
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-registry")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-registry")
|
||||||
|
|
||||||
/// `~/Library/Application Support/<bundle id>/board-registry.json`, inside the sandbox
|
/// `<group container>/Library/Application Support/board-registry.json` — the **shared** home
|
||||||
/// container. The bundle-id subfolder is Apple's convention and keeps the app's files together
|
/// (12-editions.md ▸ Distribution, ruled 2026-07-29), with no bundle-id subfolder, because the
|
||||||
/// as the app-wide neighbours arrive.
|
/// absence of that subfolder is what makes one list serve every edition.
|
||||||
|
///
|
||||||
|
/// `AppGroup.stateDirectory` falls back to the old per-edition path when the group is not
|
||||||
|
/// provisioned, so this is also the answer in a test host and in a locally signed build.
|
||||||
public static var defaultStorageURL: URL {
|
public static var defaultStorageURL: URL {
|
||||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
AppGroup.stateDirectory.appendingPathComponent("board-registry.json", isDirectory: false)
|
||||||
?? 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("board-registry.json", isDirectory: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the registry, tolerating everything a file on disk can be.
|
/// Loads the registry, tolerating everything a file on disk can be.
|
||||||
@@ -277,10 +475,85 @@ public final class BoardRegistry {
|
|||||||
/// because the file may be the only trace of a user's board list, and a support request can read
|
/// because the file may be the only trace of a user's board list, and a support request can read
|
||||||
/// it even when this app cannot; empty rather than fatal because a truncated convenience file
|
/// it even when this app cannot; empty rather than fatal because a truncated convenience file
|
||||||
/// must not stand between the user and their boards.
|
/// must not stand between the user and their boards.
|
||||||
public init(storageURL: URL) {
|
///
|
||||||
|
/// A file written before the per-edition slots existed is upgraded **in memory** on the way in
|
||||||
|
/// (`BoardRecord.adoptLegacyKeys(as:)`) — its one bookmark becomes this edition's grant — and
|
||||||
|
/// reaches disk in the new shape on the next ordinary save.
|
||||||
|
public init(storageURL: URL, editionID: String = AppGroup.editionID) {
|
||||||
self.storageURL = storageURL
|
self.storageURL = storageURL
|
||||||
|
self.editionID = editionID
|
||||||
self.records = []
|
self.records = []
|
||||||
self.records = loadFromDisk()
|
self.records = loadRecords()
|
||||||
|
self.fileStamp = Self.fileStamp(of: storageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Two editions, one file
|
||||||
|
|
||||||
|
/// What the file looked like as of this registry's last read or write.
|
||||||
|
///
|
||||||
|
/// The whole of the cross-edition freshness mechanism (see `syncFromDiskIfChanged`). `nil` means
|
||||||
|
/// "there is no file", which is the first-launch state and not a stale one.
|
||||||
|
private var fileStamp: FileStamp?
|
||||||
|
|
||||||
|
private struct FileStamp: Equatable {
|
||||||
|
let modified: Date
|
||||||
|
let size: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **`FileManager.attributesOfItem` and deliberately not `URL.resourceValues`.** A `URL` *caches*
|
||||||
|
/// resource values on the instance it was asked through, and this registry holds one `storageURL`
|
||||||
|
/// for its whole life — so the second question would be answered with the first question's answer,
|
||||||
|
/// and a stamp that never changes is a freshness check that never fires. (Measured, not assumed:
|
||||||
|
/// with `resourceValues` here, each edition kept its own pre-write view and the last save won
|
||||||
|
/// wholesale, which is exactly the bug this mechanism exists to prevent.)
|
||||||
|
private static func fileStamp(of url: URL) -> FileStamp? {
|
||||||
|
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
|
let modified = attributes[.modificationDate] as? Date,
|
||||||
|
let size = attributes[.size] as? Int
|
||||||
|
else { return nil }
|
||||||
|
return FileStamp(modified: modified, size: size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-reads the file when somebody else has written it — **the sibling edition**.
|
||||||
|
///
|
||||||
|
/// ### Why this is needed at all
|
||||||
|
///
|
||||||
|
/// The registry is one shared file now (12-editions.md ▸ Distribution) and "the same board open in
|
||||||
|
/// both apps at once is fine … a supported steady state, not a transition to hurry past" (▸ Both
|
||||||
|
/// editions installed). But this type holds its records in memory for the life of a launch and
|
||||||
|
/// `save()` writes the array *wholesale* — so without this, base running for an hour would, on its
|
||||||
|
/// next window-frame save, silently erase every record Pro wrote in that hour. That is not a narrow
|
||||||
|
/// race; it is the ordinary outcome of the steady state the design blesses.
|
||||||
|
///
|
||||||
|
/// **Disk is the truth and this cache is only a cache**, which is what makes the fix this small:
|
||||||
|
/// there is no in-memory state to reconcile, because every mutation here saves immediately. So a
|
||||||
|
/// reload is a plain replacement, and the merge problem never arises.
|
||||||
|
///
|
||||||
|
/// ### Why a stamp rather than an unconditional read
|
||||||
|
///
|
||||||
|
/// One `stat` instead of a file read and a JSON parse, and — the part that matters — **exactly zero
|
||||||
|
/// behavioural change when one edition is running**: the stamp is recorded on every save, so our own
|
||||||
|
/// writes never look like somebody else's. Sub-second `contentModificationDate` plus the size makes
|
||||||
|
/// a missed change vanishingly unlikely, and the cost of missing one is the pre-existing behaviour.
|
||||||
|
///
|
||||||
|
/// The honest residual, stated: the window between this check and the write that follows it is still
|
||||||
|
/// last-writer-wins. It is microseconds rather than hours, and what it can cost is one convenience
|
||||||
|
/// field — 02 § Per-board app state's "its settings are conveniences" already accepts exactly that.
|
||||||
|
private func syncFromDiskIfChanged() {
|
||||||
|
let current = Self.fileStamp(of: storageURL)
|
||||||
|
guard current != fileStamp else { return }
|
||||||
|
Self.logger.debug("the registry file changed underneath us — re-reading the sibling edition's writes")
|
||||||
|
fileStamp = current
|
||||||
|
records = loadRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The load, plus the legacy-key adoption every load applies (see `init`).
|
||||||
|
private func loadRecords() -> [BoardRecord] {
|
||||||
|
loadFromDisk().map { record in
|
||||||
|
var record = record
|
||||||
|
record.adoptLegacyKeys(as: editionID)
|
||||||
|
return record
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Opening and closing
|
// MARK: - Opening and closing
|
||||||
@@ -335,6 +608,8 @@ public final class BoardRegistry {
|
|||||||
icon: String? = nil,
|
icon: String? = nil,
|
||||||
iconColor: String? = nil
|
iconColor: String? = nil
|
||||||
) -> UUID {
|
) -> UUID {
|
||||||
|
syncFromDiskIfChanged()
|
||||||
|
|
||||||
let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data()
|
let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data()
|
||||||
if bookmark.isEmpty {
|
if bookmark.isEmpty {
|
||||||
// Both the security-scoped and the plain attempt failed — vanishingly unlikely for a
|
// Both the security-scoped and the plain attempt failed — vanishingly unlikely for a
|
||||||
@@ -344,7 +619,11 @@ public final class BoardRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let index = indexOfRecord(matching: rootURL) {
|
if let index = indexOfRecord(matching: rootURL) {
|
||||||
records[index].bookmark = bookmark
|
// **This edition's slot only.** A match found through the cross-edition fallback below is
|
||||||
|
// precisely the re-grant flow (12-editions.md: "one click + Grant per board, once per
|
||||||
|
// edition") — it mints *this* edition's grant onto the shared record and leaves the other
|
||||||
|
// edition's untouched, so the board stays reachable from both.
|
||||||
|
records[index].setGrant(bookmark, forEdition: editionID)
|
||||||
if let displayName {
|
if let displayName {
|
||||||
records[index].displayName = displayName
|
records[index].displayName = displayName
|
||||||
records[index].icon = icon
|
records[index].icon = icon
|
||||||
@@ -357,7 +636,7 @@ public final class BoardRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let record = BoardRecord(
|
let record = BoardRecord(
|
||||||
bookmark: bookmark,
|
grants: [editionID: bookmark],
|
||||||
displayName: displayName ?? Self.folderName(of: rootURL),
|
displayName: displayName ?? Self.folderName(of: rootURL),
|
||||||
lastKnownPath: rootURL.path,
|
lastKnownPath: rootURL.path,
|
||||||
lastOpened: Self.stamp(),
|
lastOpened: Self.stamp(),
|
||||||
@@ -410,7 +689,7 @@ public final class BoardRegistry {
|
|||||||
/// Marks this board as open — called when its window has actually opened, not when the open was
|
/// Marks this board as open — called when its window has actually opened, not when the open was
|
||||||
/// merely attempted (02-architecture.md § Launch and window lifecycle).
|
/// merely attempted (02-architecture.md § Launch and window lifecycle).
|
||||||
public func setOpenNow(id: UUID) {
|
public func setOpenNow(id: UUID) {
|
||||||
update(id) { $0.isOpenNow = true }
|
update(id) { [editionID] in $0.setOpen(true, inEdition: editionID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the marker — **user-initiated close only**.
|
/// Clears the marker — **user-initiated close only**.
|
||||||
@@ -421,7 +700,20 @@ public final class BoardRegistry {
|
|||||||
/// whole mechanism)". A crash calls nothing at all, which is why crash recovery needs no code of
|
/// whole mechanism)". A crash calls nothing at all, which is why crash recovery needs no code of
|
||||||
/// its own — the flags already describe what was open when the app died.
|
/// its own — the flags already describe what was open when the app died.
|
||||||
public func clearOpenNow(id: UUID) {
|
public func clearOpenNow(id: UUID) {
|
||||||
update(id) { $0.isOpenNow = false }
|
update(id) { [editionID] in $0.setOpen(false, inEdition: editionID) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The **other** editions with this board flagged open — the raw flags, what the board popover's
|
||||||
|
/// awareness line is derived from (12-editions.md ▸ Both editions installed: "the board popover
|
||||||
|
/// carries a contextual awareness line … read from the other edition's flag,
|
||||||
|
/// pid-liveness-checked so crash residue never lies").
|
||||||
|
///
|
||||||
|
/// **Deliberately unfiltered.** Liveness is `BoardEditionPresence`'s half and lives there once: a
|
||||||
|
/// flag is a claim this file records faithfully, including the stale claim a crashed edition
|
||||||
|
/// leaves behind, and deciding which claims are still true needs `NSRunningApplication` — which
|
||||||
|
/// this `Foundation`-only file has no business reaching for.
|
||||||
|
public func otherEditionsFlaggedOpen(id: UUID) -> [String] {
|
||||||
|
record(id: id)?.otherEditionsOpen(besides: editionID) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The boards to reopen at launch: every record still flagged open, **oldest `lastOpened`
|
/// The boards to reopen at launch: every record still flagged open, **oldest `lastOpened`
|
||||||
@@ -435,10 +727,15 @@ public final class BoardRegistry {
|
|||||||
/// (§ "A restored board that fails surfaces on welcome, row-level") instead of hanging on a
|
/// (§ "A restored board that fails surfaces on welcome, row-level") instead of hanging on a
|
||||||
/// mount.
|
/// mount.
|
||||||
///
|
///
|
||||||
|
/// **This edition's flags only** (12-editions.md ▸ Both editions installed): "an edition restores
|
||||||
|
/// only the boards *it* had open". A board Pro has open is Pro's to reopen, and base opening it
|
||||||
|
/// too at launch would be the app deciding for the user that two windows onto one board is what
|
||||||
|
/// they meant.
|
||||||
|
///
|
||||||
/// The preference gates only whether this is *consulted*; the flags are maintained regardless.
|
/// The preference gates only whether this is *consulted*; the flags are maintained regardless.
|
||||||
public func restorables() -> [RecentBoard] {
|
public func restorables() -> [RecentBoard] {
|
||||||
recents()
|
recents()
|
||||||
.filter { $0.record.isOpenNow == true }
|
.filter { $0.record.isOpen(inEdition: editionID) }
|
||||||
// Ascending, with the same id tie-break `recents()` uses inverted, so two boards opened
|
// Ascending, with the same id tie-break `recents()` uses inverted, so two boards opened
|
||||||
// in the same millisecond still come back in one stable order rather than whichever
|
// in the same millisecond still come back in one stable order rather than whichever
|
||||||
// `sorted(by:)` felt like.
|
// `sorted(by:)` felt like.
|
||||||
@@ -493,6 +790,7 @@ public final class BoardRegistry {
|
|||||||
/// An unknown id is `update`'s own no-op (a board closed and forgotten mid-reload), for the
|
/// An unknown id is `update`'s own no-op (a board closed and forgotten mid-reload), for the
|
||||||
/// same reason every other setter here tolerates one.
|
/// same reason every other setter here tolerates one.
|
||||||
public func syncDisplayState(id: UUID, title: String, icon: String?, iconColor: String?) {
|
public func syncDisplayState(id: UUID, title: String, icon: String?, iconColor: String?) {
|
||||||
|
syncFromDiskIfChanged()
|
||||||
guard let index = indexOfRecord(id) else {
|
guard let index = indexOfRecord(id) else {
|
||||||
Self.logger.debug("syncDisplayState: no record for this id — ignored")
|
Self.logger.debug("syncDisplayState: no record for this id — ignored")
|
||||||
return
|
return
|
||||||
@@ -535,18 +833,27 @@ public final class BoardRegistry {
|
|||||||
/// refreshed: it is the display fallback for a record that *cannot* be resolved, so the resolved
|
/// refreshed: it is the display fallback for a record that *cannot* be resolved, so the resolved
|
||||||
/// URL, not the record, is what an available row shows.
|
/// URL, not the record, is what an available row shows.
|
||||||
///
|
///
|
||||||
|
/// **Three states, not two** (12-editions.md ▸ Distribution): a record this edition holds no grant
|
||||||
|
/// for, but some other edition does, is `needsReopen` — unavailable-until-reopened, with the
|
||||||
|
/// recorded path as the open panel's anchor. A record nobody can reach is the orphan it always
|
||||||
|
/// was. Only this edition's slot is ever resolved or refreshed; another edition's bookmark is not
|
||||||
|
/// this app's to resolve and would fail if it tried.
|
||||||
|
///
|
||||||
/// The sort is total — `lastOpened` descending, then id — because `sorted(by:)` is not stable
|
/// The sort is total — `lastOpened` descending, then id — because `sorted(by:)` is not stable
|
||||||
/// and two rows that tie should still come back in the same order every call.
|
/// and two rows that tie should still come back in the same order every call.
|
||||||
public func recents() -> [RecentBoard] {
|
public func recents() -> [RecentBoard] {
|
||||||
|
syncFromDiskIfChanged()
|
||||||
|
|
||||||
var resolvedURLs: [UUID: URL] = [:]
|
var resolvedURLs: [UUID: URL] = [:]
|
||||||
var refreshedAny = false
|
var refreshedAny = false
|
||||||
|
|
||||||
for index in records.indices {
|
for index in records.indices {
|
||||||
guard let resolution = Self.resolve(records[index].bookmark) else { continue }
|
guard let grant = records[index].grant(forEdition: editionID),
|
||||||
|
let resolution = Self.resolve(grant) else { continue }
|
||||||
resolvedURLs[records[index].id] = resolution.url
|
resolvedURLs[records[index].id] = resolution.url
|
||||||
guard resolution.isStale else { continue }
|
guard resolution.isStale else { continue }
|
||||||
if let refreshed = Self.withScopedAccess(to: resolution.url, { Self.makeBookmark(for: $0) }) {
|
if let refreshed = Self.withScopedAccess(to: resolution.url, { Self.makeBookmark(for: $0) }) {
|
||||||
records[index].bookmark = refreshed.data
|
records[index].setGrant(refreshed.data, forEdition: editionID)
|
||||||
refreshedAny = true
|
refreshedAny = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,20 +870,30 @@ public final class BoardRegistry {
|
|||||||
.map { record in
|
.map { record in
|
||||||
if let url = resolvedURLs[record.id] {
|
if let url = resolvedURLs[record.id] {
|
||||||
.available(record, at: url)
|
.available(record, at: url)
|
||||||
|
} else if record.grant(forEdition: editionID) == nil, record.isGrantedByAnyEdition {
|
||||||
|
// No grant of ours, but somebody's — the cross-edition row. Note the order: a
|
||||||
|
// grant of ours that *failed to resolve* falls through to `unavailable` below,
|
||||||
|
// which is right. That is Graceful orphaning's case (the board is gone), not the
|
||||||
|
// re-grant case (the board is there and we were never given it).
|
||||||
|
.needsReopen(record, recordedAt: URL(fileURLWithPath: record.lastKnownPath, isDirectory: true))
|
||||||
} else {
|
} else {
|
||||||
.unavailable(record)
|
.unavailable(record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One record, fresh — the sibling edition's writes included, which is what lets the popover's
|
||||||
|
/// awareness line see a flag Pro set after this app launched.
|
||||||
public func record(id: UUID) -> BoardRecord? {
|
public func record(id: UUID) -> BoardRecord? {
|
||||||
records.first { $0.id == id }
|
syncFromDiskIfChanged()
|
||||||
|
return records.first { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a record — the Forget action on an orphaned recents row, and the only way a record
|
/// Drops a record — the Forget action on an orphaned recents row, and the only way a record
|
||||||
/// leaves. Nothing else prunes: a board that is merely unavailable today may be a remounted
|
/// leaves. Nothing else prunes: a board that is merely unavailable today may be a remounted
|
||||||
/// volume tomorrow, so forgetting is always the user's call.
|
/// volume tomorrow, so forgetting is always the user's call.
|
||||||
public func forget(id: UUID) {
|
public func forget(id: UUID) {
|
||||||
|
syncFromDiskIfChanged()
|
||||||
guard let index = indexOfRecord(id) else { return }
|
guard let index = indexOfRecord(id) else { return }
|
||||||
records.remove(at: index)
|
records.remove(at: index)
|
||||||
save()
|
save()
|
||||||
@@ -592,6 +909,7 @@ public final class BoardRegistry {
|
|||||||
/// One save rather than one per record: the file is rewritten wholesale anyway, and forgetting
|
/// One save rather than one per record: the file is rewritten wholesale anyway, and forgetting
|
||||||
/// twenty boards should not be twenty writes.
|
/// twenty boards should not be twenty writes.
|
||||||
public func forgetAll() {
|
public func forgetAll() {
|
||||||
|
syncFromDiskIfChanged()
|
||||||
guard !records.isEmpty else { return }
|
guard !records.isEmpty else { return }
|
||||||
records.removeAll()
|
records.removeAll()
|
||||||
save()
|
save()
|
||||||
@@ -599,11 +917,22 @@ public final class BoardRegistry {
|
|||||||
|
|
||||||
// MARK: - Matching
|
// MARK: - Matching
|
||||||
|
|
||||||
/// The index of the record whose bookmark resolves to the same file as `url`, if any.
|
/// The index of the record whose grant resolves to the same file as `url`, if any — falling back
|
||||||
|
/// to the recorded path for records this edition has no grant for at all.
|
||||||
|
///
|
||||||
|
/// **File identity is still the rule.** The fallback is not a second identity mechanism; it is the
|
||||||
|
/// only locator available for a record only *another* edition can resolve, and it is the same
|
||||||
|
/// locator the design already nominates for that case — "an open panel pre-anchored at the
|
||||||
|
/// recorded path" (12-editions.md). It is consulted only after identity has failed, and only
|
||||||
|
/// against records holding no grant of ours, so a board this edition knows can never be matched by
|
||||||
|
/// its path. Without it, granting a board in the second edition would fork the shared record into
|
||||||
|
/// two and undo the whole point of sharing it.
|
||||||
private func indexOfRecord(matching url: URL) -> Int? {
|
private func indexOfRecord(matching url: URL) -> Int? {
|
||||||
guard let target = FileIdentity(of: url) else { return nil }
|
guard let target = FileIdentity(of: url) else { return nil }
|
||||||
return records.firstIndex { record in
|
|
||||||
guard let resolution = Self.resolve(record.bookmark) else { return false }
|
let byIdentity = records.firstIndex { record in
|
||||||
|
guard let grant = record.grant(forEdition: editionID),
|
||||||
|
let resolution = Self.resolve(grant) else { return false }
|
||||||
// Scope is started around the identity read and stopped immediately. Resolving a
|
// Scope is started around the identity read and stopped immediately. Resolving a
|
||||||
// security-scoped bookmark grants nothing by itself, and in the sandbox an unscoped
|
// security-scoped bookmark grants nothing by itself, and in the sandbox an unscoped
|
||||||
// `resourceValues` call on some *other* board's folder is exactly the read that gets
|
// `resourceValues` call on some *other* board's folder is exactly the read that gets
|
||||||
@@ -612,6 +941,21 @@ public final class BoardRegistry {
|
|||||||
// unaffected.
|
// unaffected.
|
||||||
return Self.withScopedAccess(to: resolution.url) { FileIdentity(of: $0) == target }
|
return Self.withScopedAccess(to: resolution.url) { FileIdentity(of: $0) == target }
|
||||||
}
|
}
|
||||||
|
if let byIdentity { return byIdentity }
|
||||||
|
|
||||||
|
let key = Self.pathKey(url.path)
|
||||||
|
return records.firstIndex { record in
|
||||||
|
record.grant(forEdition: editionID) == nil
|
||||||
|
&& record.isGrantedByAnyEdition
|
||||||
|
&& Self.pathKey(record.lastKnownPath) == key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How two paths are compared for "the same board" in the fallback above — standardized, never
|
||||||
|
/// resolved through symlinks, which is `WelcomeRow.pathKey`'s rule restated rather than imported
|
||||||
|
/// (this file is `Foundation`-only and must not reach into the app layer).
|
||||||
|
private static func pathKey(_ path: String) -> String {
|
||||||
|
URL(fileURLWithPath: path).standardizedFileURL.path
|
||||||
}
|
}
|
||||||
|
|
||||||
private func indexOfRecord(_ id: UUID) -> Int? {
|
private func indexOfRecord(_ id: UUID) -> Int? {
|
||||||
@@ -621,6 +965,7 @@ public final class BoardRegistry {
|
|||||||
/// Mutates a record and saves. An unknown id is a no-op: a window that outlived its record —
|
/// Mutates a record and saves. An unknown id is a no-op: a window that outlived its record —
|
||||||
/// the user pressed Forget while the board was open — must not crash on its way out.
|
/// the user pressed Forget while the board was open — must not crash on its way out.
|
||||||
private func update(_ id: UUID, _ mutate: (inout BoardRecord) -> Void) {
|
private func update(_ id: UUID, _ mutate: (inout BoardRecord) -> Void) {
|
||||||
|
syncFromDiskIfChanged()
|
||||||
guard let index = indexOfRecord(id) else {
|
guard let index = indexOfRecord(id) else {
|
||||||
Self.logger.debug("update: no record for this id — ignored")
|
Self.logger.debug("update: no record for this id — ignored")
|
||||||
return
|
return
|
||||||
@@ -761,6 +1106,10 @@ public final class BoardRegistry {
|
|||||||
withIntermediateDirectories: true
|
withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try encoder.encode(ordered).write(to: storageURL, options: .atomic)
|
try encoder.encode(ordered).write(to: storageURL, options: .atomic)
|
||||||
|
// Recorded *after* the write, so our own bytes never read as the sibling's on the next
|
||||||
|
// `syncFromDiskIfChanged` — which is what keeps a single-edition launch behaving exactly as
|
||||||
|
// it did before that mechanism existed.
|
||||||
|
fileStamp = Self.fileStamp(of: storageURL)
|
||||||
} catch {
|
} catch {
|
||||||
Self.logger.error("could not save the board registry: \(error.localizedDescription, privacy: .public)")
|
Self.logger.error("could not save the board registry: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ public final class StyleRecents {
|
|||||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "style-recents")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "style-recents")
|
||||||
|
|
||||||
/// - Parameter defaults: the domain to persist in. Injected for the reason `BoardRegistry` takes
|
/// - Parameter defaults: the domain to persist in. Injected for the reason `BoardRegistry` takes
|
||||||
/// its storage URL: a test must be able to hold its own without touching the user's.
|
/// its storage URL: a test must be able to hold its own without touching the user's. The app's
|
||||||
public init(defaults: UserDefaults = .standard) {
|
/// own is the **group's shared suite** (02-architecture.md § Per-board app state, ruled
|
||||||
|
/// 2026-07-29), so the row an upgrader built up in base is the row Pro offers.
|
||||||
|
public init(defaults: UserDefaults = AppGroup.defaults) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
// Anything but an array of strings is treated as an empty list rather than as an error: this
|
// Anything but an array of strings is treated as an empty list rather than as an error: this
|
||||||
// is a convenience, and a hand-edited or truncated preference must never be the reason a
|
// is a convenience, and a hand-edited or truncated preference must never be the reason a
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ struct BoardInfoWidget: View {
|
|||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
let recents: StyleRecents
|
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
|
@Bindable var presentation: BoardInfoPresentation
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -87,7 +96,7 @@ struct BoardInfoWidget: View {
|
|||||||
.help("Board Info")
|
.help("Board Info")
|
||||||
.accessibilityLabel("Board Info")
|
.accessibilityLabel("Board Info")
|
||||||
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
|
.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(
|
func boardInfoTitlebarAccessory(
|
||||||
store: BoardStore,
|
store: BoardStore,
|
||||||
recents: StyleRecents,
|
recents: StyleRecents,
|
||||||
presentation: BoardInfoPresentation
|
presentation: BoardInfoPresentation,
|
||||||
|
otherEditionNote: @escaping () -> String? = { nil }
|
||||||
) -> NSTitlebarAccessoryViewController {
|
) -> NSTitlebarAccessoryViewController {
|
||||||
let hosting = NSHostingView(
|
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
|
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
|
||||||
// as zero would be an invisible, unclickable widget.
|
// 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.
|
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
|
||||||
private let hasGitDirectory: Bool
|
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
|
/// 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**
|
/// 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
|
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
|
||||||
@@ -145,10 +164,11 @@ struct BoardInfoView: View {
|
|||||||
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(store: BoardStore, recents: StyleRecents) {
|
init(store: BoardStore, recents: StyleRecents, otherEditionNote: String? = nil) {
|
||||||
self.store = store
|
self.store = store
|
||||||
self.recents = recents
|
self.recents = recents
|
||||||
self.hasGitDirectory = BoardGitNote.hasGitDirectory(at: store.rootURL)
|
self.hasGitDirectory = BoardGitNote.hasGitDirectory(at: store.rootURL)
|
||||||
|
self.otherEditionNote = otherEditionNote
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -176,9 +196,20 @@ struct BoardInfoView: View {
|
|||||||
// nothing here at all — no header, no divider, no placeholder — and the popover ends at
|
// 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
|
// Styling, complete in itself. Only a board that actually carries an inert `.git` earns
|
||||||
// this closing note.
|
// 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()
|
Divider()
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if hasGitDirectory {
|
||||||
BoardGitNote()
|
BoardGitNote()
|
||||||
|
}
|
||||||
|
if let otherEditionNote {
|
||||||
|
BoardEditionPresenceNote(text: otherEditionNote)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(inset)
|
.padding(inset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,3 +341,70 @@ struct BoardGitNote: View {
|
|||||||
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- The family App Group, **verbatim** the same id base declares (12-editions.md ▸
|
||||||
|
Distribution, ruled 2026-07-29): one container, one registry, one clipboard staging store,
|
||||||
|
one defaults suite. The whole upgrade story is this key matching base's — an edition
|
||||||
|
declaring a group of its own would share nothing while looking as though it did. Teams
|
||||||
|
joins by declaring this same string. -->
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.rzen.indie.Kanban</string>
|
||||||
|
</array>
|
||||||
<!-- Pro only (12-editions.md ▸ Targets): remotes. Base ships without this key at all, which
|
<!-- Pro only (12-editions.md ▸ Targets): remotes. Base ships without this key at all, which
|
||||||
is what makes "base cannot talk to a network" a checkable property of the signed binary
|
is what makes "base cannot talk to a network" a checkable property of the signed binary
|
||||||
rather than a promise about the source (scripts/verify-editions.sh). -->
|
rather than a promise about the source (scripts/verify-editions.sh). -->
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// **App-side state in the shared App Group container** (12-editions.md ▸ Distribution and ▸ Both
|
||||||
|
/// editions installed, ruled 2026-07-29; 02-architecture.md § Per-board app state).
|
||||||
|
///
|
||||||
|
/// One container, one registry, one clipboard staging store, one defaults suite — and exactly two
|
||||||
|
/// fields that cannot be shared, both keyed by bundle id: the security-scoped **grant slot** (a
|
||||||
|
/// bookmark never crosses a sandbox, group or not) and the **open-now flag** (an edition restores only
|
||||||
|
/// the boards it had open). Everything here is about those two seams and what they buy.
|
||||||
|
///
|
||||||
|
/// **Nothing in this file touches the real group container.** Every registry gets a temp storage file
|
||||||
|
/// and every edition is a *string*, injected — which is the only way "base's record, read by Pro" is
|
||||||
|
/// expressible inside one process at all.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// A temp registry file, `BoardRegistryTests`' own shape — restated rather than shared because that
|
||||||
|
/// file's copy is `private` to it and a test fixture is not worth a seam.
|
||||||
|
@MainActor
|
||||||
|
private struct GroupStorage {
|
||||||
|
let folder: URL
|
||||||
|
|
||||||
|
var url: URL { folder.appendingPathComponent("board-registry.json", isDirectory: false) }
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
folder = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("AppGroupStateTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeGroupBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
private let base = AppGroup.baseEditionID
|
||||||
|
private let pro = AppGroup.proEditionID
|
||||||
|
|
||||||
|
// MARK: - The container
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("App Group container")
|
||||||
|
struct AppGroupContainerTests {
|
||||||
|
|
||||||
|
@Test("Every app-side store resolves under one state directory, provisioned or not")
|
||||||
|
func stateDirectoryIsOneHomeAndAlwaysUsable() async throws {
|
||||||
|
// Diagnostic, not an assertion: whether this test host has the capability is a fact about
|
||||||
|
// provisioning, and a suite that *required* it could not run on a machine where the group is
|
||||||
|
// not yet registered on the team. Printed for the same reason `BoardRegistryTests` prints its
|
||||||
|
// bookmark flavor — the answer matters and cannot be asserted.
|
||||||
|
let container = AppGroup.containerURL
|
||||||
|
print("AppGroupStateTests: group container in this host = \(container?.path ?? "nil (unprovisioned — per-edition fallback)")")
|
||||||
|
|
||||||
|
let production = AppGroup.productionStateDirectory
|
||||||
|
if let container {
|
||||||
|
#expect(production.path.hasPrefix(container.path), "the shared home is inside the group container")
|
||||||
|
// No bundle-id **subfolder** — that subfolder is what kept the editions apart. Compared by
|
||||||
|
// path component, not by substring: the group id itself contains base's bundle id, which is
|
||||||
|
// the family name showing through and not a per-edition directory.
|
||||||
|
#expect(
|
||||||
|
!production.pathComponents.contains(AppGroup.editionID),
|
||||||
|
"the shared home must not be nested under a per-edition folder"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
#expect(
|
||||||
|
production == AppGroup.perEditionSupportDirectory,
|
||||||
|
"the fallback is the pre-2.0 per-edition home, unshared but working"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The registry and the clipboard's staging store share one home, which is the whole point:
|
||||||
|
// "the clipboard staging store homes in the group container **beside the registry**".
|
||||||
|
let state = AppGroup.stateDirectory
|
||||||
|
#expect(BoardRegistry.defaultStorageURL.deletingLastPathComponent() == state)
|
||||||
|
#expect(ClipboardStore.defaultStagingRoot.deletingLastPathComponent() == state)
|
||||||
|
// And the template store, re-homed here on the same day (09-templates.md ▸ Storage): "templates
|
||||||
|
// cross editions".
|
||||||
|
#expect(TemplateEngine.userStore.deletingLastPathComponent() == state)
|
||||||
|
|
||||||
|
// And it is a directory the app can actually create, which is the only property that has to
|
||||||
|
// hold on both sides of the provisioning question.
|
||||||
|
try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: state.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A unit-test host never resolves to the real shared container")
|
||||||
|
func aTestHostIsRedirected() {
|
||||||
|
// This suite is running, so this *is* a test host — and the point of the check is that the two
|
||||||
|
// defaults every launch reaches for (the registry file, the staging root) cannot land in a
|
||||||
|
// container shared with the sibling edition and with the developer's own running copy. There is
|
||||||
|
// no injection point in `KanbanApp.init()` to fix that from the outside.
|
||||||
|
#expect(AppGroup.isUnitTestHost)
|
||||||
|
#expect(AppGroup.stateDirectory == AppGroup.unitTestStateDirectory)
|
||||||
|
#expect(AppGroup.stateDirectory != AppGroup.productionStateDirectory)
|
||||||
|
#expect(AppGroup.defaults != UserDefaults(suiteName: AppGroup.identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The group id is the family's, and each edition names itself")
|
||||||
|
func editionIdentityIsReadFromTheBundle() {
|
||||||
|
#expect(AppGroup.identifier == "group.dev.rzen.indie.Kanban")
|
||||||
|
#expect(AppGroup.perEditionSupportDirectory.lastPathComponent == AppGroup.editionID)
|
||||||
|
#expect(AppGroup.editionDisplayName(base) == "Lanework")
|
||||||
|
#expect(AppGroup.editionDisplayName(pro) == "Lanework Pro")
|
||||||
|
// A bundle id this build cannot name gets no name invented for it — the awareness line's whole
|
||||||
|
// posture is that it never says anything it does not know.
|
||||||
|
#expect(AppGroup.editionDisplayName("dev.rzen.indie.KanbanTeams") == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grant slots
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Per-edition grant slots")
|
||||||
|
struct GrantSlotTests {
|
||||||
|
|
||||||
|
@Test("A grant is minted into this edition's slot and round-trips through the file")
|
||||||
|
func grantSlotsRoundTrip() async throws {
|
||||||
|
let storage = try GroupStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeGroupBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
|
||||||
|
let id = asBase.recordOpen(of: fixture.root, displayName: "Work")
|
||||||
|
|
||||||
|
let reloaded = try #require(BoardRegistry(storageURL: storage.url, editionID: base).record(id: id))
|
||||||
|
#expect(reloaded.grant(forEdition: base) != nil)
|
||||||
|
#expect(reloaded.grant(forEdition: pro) == nil, "an edition mints its own slot and nobody else's")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A record the other edition minted resolves unavailable-until-reopened, anchored at its path")
|
||||||
|
func otherEditionsGrantNeedsReopening() async throws {
|
||||||
|
let storage = try GroupStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeGroupBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
BoardRegistry(storageURL: storage.url, editionID: base)
|
||||||
|
.recordOpen(of: fixture.root, displayName: "Work")
|
||||||
|
|
||||||
|
// Pro, over the very same shared file. The board is *there* — nothing was deleted — but the
|
||||||
|
// only bookmark on the record was minted in another sandbox, so this edition cannot resolve it.
|
||||||
|
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
|
||||||
|
let rows = asPro.recents()
|
||||||
|
#expect(rows.count == 1, "the record is shared, not duplicated")
|
||||||
|
|
||||||
|
guard case let .needsReopen(record, anchor) = rows[0] else {
|
||||||
|
Issue.record("expected needsReopen, got \(rows[0])")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(record.displayName == "Work", "every other field is common — the list transfers, only access re-grants")
|
||||||
|
#expect(anchor.standardizedFileURL.path == URL(fileURLWithPath: fixture.root.path).standardizedFileURL.path)
|
||||||
|
#expect(rows[0].url == nil, "there is nothing to open until the grant exists")
|
||||||
|
#expect(rows[0].regrantAnchor != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A genuine orphan is not a re-grant candidate")
|
||||||
|
func aRecordNobodyCanReachStaysUnavailable() async throws {
|
||||||
|
let storage = try GroupStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
|
||||||
|
// No grants at all — the born-orphaned record (`recordOpen`'s degenerate case). The two states
|
||||||
|
// are told apart by whether *somebody* holds a grant, so this one must stay `unavailable`: an
|
||||||
|
// open panel cannot help a board nothing knows the whereabouts of.
|
||||||
|
let id = UUID()
|
||||||
|
let json = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"displayName" : "Ghost",
|
||||||
|
"grants" : {},
|
||||||
|
"id" : "\(id.uuidString)",
|
||||||
|
"lastKnownPath" : "/Volumes/Gone/Ghost.kanban",
|
||||||
|
"lastOpened" : "2026-01-01T09:00:00.000Z",
|
||||||
|
"openNow" : {},
|
||||||
|
"pushOnCommit" : false,
|
||||||
|
"remoteLocationWarned" : false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
try Data(json.utf8).write(to: storage.url)
|
||||||
|
|
||||||
|
let rows = BoardRegistry(storageURL: storage.url, editionID: pro).recents()
|
||||||
|
#expect(rows.count == 1)
|
||||||
|
guard case .unavailable = rows[0] else {
|
||||||
|
Issue.record("expected unavailable, got \(rows[0])")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Re-granting mints this edition's slot onto the shared record and leaves the other's alone")
|
||||||
|
func regrantingDoesNotForkTheRecord() async throws {
|
||||||
|
let storage = try GroupStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeGroupBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
|
||||||
|
let id = asBase.recordOpen(of: fixture.root, displayName: "Work")
|
||||||
|
asBase.updateWindowFrame(id: id, frame: WindowFrame(x: 10, y: 20, width: 300, height: 400))
|
||||||
|
let baseGrant = try #require(asBase.record(id: id)?.grant(forEdition: base))
|
||||||
|
|
||||||
|
// The re-grant: the panel handed Pro the same folder, and the open goes through the ordinary
|
||||||
|
// `recordOpen` door. Nothing about it is a special API — that is the design.
|
||||||
|
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
|
||||||
|
let proID = asPro.recordOpen(of: fixture.root)
|
||||||
|
#expect(proID == id, "the shared record is matched, never forked")
|
||||||
|
|
||||||
|
let shared = try #require(BoardRegistry(storageURL: storage.url, editionID: base).record(id: id))
|
||||||
|
#expect(shared.grant(forEdition: pro) != nil, "Pro now holds its own grant")
|
||||||
|
#expect(shared.grant(forEdition: base) == baseGrant, "base's grant is untouched — it is still that app's key")
|
||||||
|
#expect(shared.windowFrame?.width == 300, "and every common field survived the second edition's open")
|
||||||
|
#expect(BoardRegistry(storageURL: storage.url, editionID: pro).recents().count == 1)
|
||||||
|
|
||||||
|
// Both editions can now reach it.
|
||||||
|
guard case .available = BoardRegistry(storageURL: storage.url, editionID: pro).recents()[0] else {
|
||||||
|
Issue.record("expected Pro to see the board as available after re-granting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A registry file written before the grant slots existed adopts its one bookmark as this edition's")
|
||||||
|
func legacySingleGrantRecordsAreReadable() async throws {
|
||||||
|
let storage = try GroupStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
|
||||||
|
// Byte-for-byte the pre-App-Group shape: one `bookmark`, one `isOpenNow`, no keyed slots at
|
||||||
|
// all. Only base existed then, so adopting the bookmark as the *running* edition's slot is the
|
||||||
|
// coherent reading — and the reachable case is the unprovisioned fallback, where
|
||||||
|
// `AppGroup.stateDirectory` still points at the old per-edition home.
|
||||||
|
let id = UUID()
|
||||||
|
let garbage = Data("not a bookmark".utf8).base64EncodedString()
|
||||||
|
let json = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"bookmark" : "\(garbage)",
|
||||||
|
"cardCount" : 9,
|
||||||
|
"displayName" : "Archive",
|
||||||
|
"id" : "\(id.uuidString)",
|
||||||
|
"isOpenNow" : true,
|
||||||
|
"laneCount" : 4,
|
||||||
|
"lastKnownPath" : "/Volumes/Archive/Boards/Archive",
|
||||||
|
"lastOpened" : "2026-01-01T09:00:00.000Z",
|
||||||
|
"pushOnCommit" : true,
|
||||||
|
"remoteLocationWarned" : true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
try Data(json.utf8).write(to: storage.url)
|
||||||
|
|
||||||
|
let registry = BoardRegistry(storageURL: storage.url, editionID: base)
|
||||||
|
let record = try #require(registry.record(id: id))
|
||||||
|
#expect(record.grant(forEdition: base) != nil, "the one bookmark became this edition's grant")
|
||||||
|
#expect(record.isOpen(inEdition: base), "and the one flag became this edition's flag")
|
||||||
|
#expect(record.laneCount == 4, "every other field survived — nothing was quarantined")
|
||||||
|
|
||||||
|
// Its bookmark is unresolvable garbage, so it classifies as the orphan it is — never as a
|
||||||
|
// re-grant candidate, which would be this edition offering to grant a board it already holds
|
||||||
|
// the (dead) key to.
|
||||||
|
guard case .unavailable = registry.recents()[0] else {
|
||||||
|
Issue.record("expected unavailable, got \(registry.recents()[0])")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tolerate-and-upgrade **on first write**: reading changed nothing on disk, and the next
|
||||||
|
// ordinary save emits the keyed shape and drops the legacy keys for good.
|
||||||
|
#expect(try String(data: Data(contentsOf: storage.url), encoding: .utf8)?.contains("\"bookmark\"") == true)
|
||||||
|
registry.setRemoteLocationWarned(id: id)
|
||||||
|
let upgraded = try #require(String(data: Data(contentsOf: storage.url), encoding: .utf8))
|
||||||
|
#expect(!upgraded.contains("\"bookmark\""))
|
||||||
|
#expect(!upgraded.contains("\"isOpenNow\""))
|
||||||
|
#expect(upgraded.contains("\"grants\""))
|
||||||
|
#expect(BoardRegistry(storageURL: storage.url, editionID: base).record(id: id)?.grant(forEdition: base) != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Open-now flags
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Per-edition open-now flags")
|
||||||
|
struct OpenNowPerEditionTests {
|
||||||
|
|
||||||
|
@Test("An edition restores only the boards it had open")
|
||||||
|
func restorationIsFilteredByEdition() async throws {
|
||||||
|
let storage = try GroupStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let mine = try makeGroupBoard()
|
||||||
|
defer { mine.tearDown() }
|
||||||
|
let theirs = try makeGroupBoard()
|
||||||
|
defer { theirs.tearDown() }
|
||||||
|
|
||||||
|
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
|
||||||
|
let mineID = asBase.recordOpen(of: mine.root, displayName: "Mine")
|
||||||
|
let theirsID = asBase.recordOpen(of: theirs.root, displayName: "Theirs")
|
||||||
|
asBase.setOpenNow(id: mineID)
|
||||||
|
|
||||||
|
// Pro flags the other board — same shared records, its own slot.
|
||||||
|
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
|
||||||
|
asPro.recordOpen(of: theirs.root)
|
||||||
|
asPro.setOpenNow(id: theirsID)
|
||||||
|
|
||||||
|
let baseRestores = BoardRegistry(storageURL: storage.url, editionID: base).restorables()
|
||||||
|
let proRestores = BoardRegistry(storageURL: storage.url, editionID: pro).restorables()
|
||||||
|
#expect(baseRestores.map(\.record.id) == [mineID])
|
||||||
|
#expect(proRestores.map(\.record.id) == [theirsID])
|
||||||
|
|
||||||
|
// And a user close in one edition leaves the other's flag standing.
|
||||||
|
BoardRegistry(storageURL: storage.url, editionID: base).clearOpenNow(id: mineID)
|
||||||
|
let after = try #require(BoardRegistry(storageURL: storage.url, editionID: pro).record(id: theirsID))
|
||||||
|
#expect(after.isOpen(inEdition: pro))
|
||||||
|
#expect(!after.isOpen(inEdition: base))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The other edition's flags are reported raw, never this edition's own")
|
||||||
|
func flaggedEditionsExcludeSelf() async throws {
|
||||||
|
let storage = try GroupStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeGroupBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
|
||||||
|
let id = asBase.recordOpen(of: fixture.root, displayName: "Shared")
|
||||||
|
asBase.setOpenNow(id: id)
|
||||||
|
|
||||||
|
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
|
||||||
|
asPro.setOpenNow(id: id)
|
||||||
|
|
||||||
|
#expect(asPro.otherEditionsFlaggedOpen(id: id) == [base])
|
||||||
|
#expect(BoardRegistry(storageURL: storage.url, editionID: base).otherEditionsFlaggedOpen(id: id) == [pro])
|
||||||
|
#expect(asPro.otherEditionsFlaggedOpen(id: UUID()).isEmpty, "an unknown id says nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Two editions, one file
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("One registry file, two live editions")
|
||||||
|
struct SharedRegistryFileTests {
|
||||||
|
|
||||||
|
@Test("Neither edition's write erases the other's")
|
||||||
|
func writesFromBothEditionsSurvive() async throws {
|
||||||
|
let storage = try GroupStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let mine = try makeGroupBoard()
|
||||||
|
defer { mine.tearDown() }
|
||||||
|
let theirs = try makeGroupBoard()
|
||||||
|
defer { theirs.tearDown() }
|
||||||
|
|
||||||
|
// Both **live at once**, which is the steady state 12-editions.md blesses ("a supported steady
|
||||||
|
// state, not a transition to hurry past") — and the case a whole-file writer over a cached array
|
||||||
|
// gets wrong by default: base's next window-frame save would rewrite the file from an array that
|
||||||
|
// never heard of Pro's board.
|
||||||
|
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
|
||||||
|
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
|
||||||
|
|
||||||
|
let mineID = asBase.recordOpen(of: mine.root, displayName: "Base's")
|
||||||
|
let theirsID = asPro.recordOpen(of: theirs.root, displayName: "Pro's")
|
||||||
|
|
||||||
|
// An ordinary convenience write from the edition that has not looked at the file since.
|
||||||
|
asBase.updateWindowFrame(id: mineID, frame: WindowFrame(x: 1, y: 2, width: 3, height: 4))
|
||||||
|
|
||||||
|
let onDisk = BoardRegistry(storageURL: storage.url, editionID: base)
|
||||||
|
#expect(Set(onDisk.recents().map(\.record.id)) == [mineID, theirsID], "both records are in the file")
|
||||||
|
#expect(onDisk.record(id: mineID)?.windowFrame?.width == 3)
|
||||||
|
#expect(onDisk.record(id: theirsID)?.grant(forEdition: pro) != nil, "and Pro's grant was not rewritten away")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A flag the other edition sets is visible without relaunching")
|
||||||
|
func theOtherEditionsFlagIsPickedUpLive() async throws {
|
||||||
|
let storage = try GroupStorage()
|
||||||
|
defer { storage.tearDown() }
|
||||||
|
let fixture = try makeGroupBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let asBase = BoardRegistry(storageURL: storage.url, editionID: base)
|
||||||
|
let id = asBase.recordOpen(of: fixture.root, displayName: "Shared")
|
||||||
|
|
||||||
|
// Pro opens the same board afterwards. Base is still running and has not re-read anything — and
|
||||||
|
// the popover's awareness line is worthless if it can only see flags that predate this launch.
|
||||||
|
let asPro = BoardRegistry(storageURL: storage.url, editionID: pro)
|
||||||
|
asPro.recordOpen(of: fixture.root)
|
||||||
|
asPro.setOpenNow(id: id)
|
||||||
|
|
||||||
|
#expect(asBase.otherEditionsFlaggedOpen(id: id) == [pro])
|
||||||
|
#expect(
|
||||||
|
BoardEditionPresence.note(
|
||||||
|
otherEditions: asBase.otherEditionsFlaggedOpen(id: id),
|
||||||
|
isRunning: { $0 == pro }
|
||||||
|
) == "Also open in Lanework Pro"
|
||||||
|
)
|
||||||
|
|
||||||
|
// And it goes away again when Pro closes the board, still without a relaunch.
|
||||||
|
asPro.clearOpenNow(id: id)
|
||||||
|
#expect(asBase.otherEditionsFlaggedOpen(id: id).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The awareness line
|
||||||
|
|
||||||
|
@Suite("The board popover's awareness line")
|
||||||
|
struct BoardEditionPresenceTests {
|
||||||
|
|
||||||
|
@Test("A live other edition earns the line")
|
||||||
|
func aLiveEditionIsNamed() {
|
||||||
|
#expect(
|
||||||
|
BoardEditionPresence.note(otherEditions: [pro], isRunning: { $0 == pro })
|
||||||
|
== "Also open in Lanework Pro"
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
BoardEditionPresence.note(otherEditions: [base], isRunning: { _ in true })
|
||||||
|
== "Also open in Lanework"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A flag with no live process shows nothing — crash residue never lies")
|
||||||
|
func staleFlagsSayNothing() {
|
||||||
|
// The flag is deliberately left standing by a crash (02-architecture.md § Launch and window
|
||||||
|
// lifecycle — that residue is what makes crash recovery free), so the flag alone would claim
|
||||||
|
// an app that died last week is looking at this board right now.
|
||||||
|
#expect(BoardEditionPresence.note(otherEditions: [pro], isRunning: { _ in false }) == nil)
|
||||||
|
#expect(BoardEditionPresence.note(otherEditions: [], isRunning: { _ in true }) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An edition this build cannot name shows nothing")
|
||||||
|
func unknownEditionsSayNothing() {
|
||||||
|
#expect(
|
||||||
|
BoardEditionPresence.note(otherEditions: ["dev.rzen.indie.KanbanTeams"], isRunning: { _ in true })
|
||||||
|
== nil
|
||||||
|
)
|
||||||
|
// …and a nameable live one behind it still wins, so one unknown neighbour does not silence the
|
||||||
|
// line altogether.
|
||||||
|
#expect(
|
||||||
|
BoardEditionPresence.note(
|
||||||
|
otherEditions: ["dev.rzen.indie.KanbanTeams", pro],
|
||||||
|
isRunning: { _ in true }
|
||||||
|
) == "Also open in Lanework Pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The welcome row
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("The cross-edition welcome row")
|
||||||
|
struct CrossEditionWelcomeRowTests {
|
||||||
|
|
||||||
|
@Test("A re-grant row opens on one click, reveals on none, and says what the click will do")
|
||||||
|
func theRowIsOpenableButNotRevealable() throws {
|
||||||
|
let record = BoardRecord(
|
||||||
|
grants: [pro: Data("pro's key".utf8)],
|
||||||
|
displayName: "Roadmap",
|
||||||
|
lastKnownPath: "/Boards/Roadmap.kanban",
|
||||||
|
lastOpened: Date()
|
||||||
|
)
|
||||||
|
let recent = RecentBoard.needsReopen(record, recordedAt: URL(fileURLWithPath: record.lastKnownPath))
|
||||||
|
|
||||||
|
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
|
||||||
|
#expect(row.needsReopen)
|
||||||
|
#expect(row.canOpen, "the board is there — one click plus Grant is the whole remedy")
|
||||||
|
#expect(!row.canReveal, "revealing a folder is a read this app has not been granted either")
|
||||||
|
#expect(row.caption == .needsReopen)
|
||||||
|
#expect(row.regrantAnchor?.path == "/Boards/Roadmap.kanban")
|
||||||
|
#expect(row.location == "/Boards")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Fail-fast's specifics still outrank the re-grant caption")
|
||||||
|
func aFailureStillWinsTheCaption() throws {
|
||||||
|
let record = BoardRecord(
|
||||||
|
grants: [pro: Data("pro's key".utf8)],
|
||||||
|
displayName: "Roadmap",
|
||||||
|
lastKnownPath: "/Boards/Roadmap.kanban",
|
||||||
|
lastOpened: Date()
|
||||||
|
)
|
||||||
|
let recent = RecentBoard.needsReopen(record, recordedAt: URL(fileURLWithPath: record.lastKnownPath))
|
||||||
|
let failure = LaunchFailure(path: "/Boards/Roadmap.kanban", message: "index.md is unparseable.")
|
||||||
|
|
||||||
|
let row = try #require(WelcomeRow.derive(recents: [recent], failures: [failure]).rows.first)
|
||||||
|
// The precedence 02 § Launch and window lifecycle fixes: a failure is what the row is *for* at
|
||||||
|
// that moment, and it is still true that this board needs granting — but the message the user
|
||||||
|
// has to read first is the one about the file.
|
||||||
|
#expect(row.caption == .failed("index.md is unparseable."))
|
||||||
|
#expect(row.canOpen, "and the retry is still one click")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,14 +33,19 @@ private func makeMixedBoard() throws -> WriterFixture {
|
|||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An `AppModel` whose registry file lives in temp rather than in the test host's real Application
|
/// An `AppModel` whose app-side state lives in temp rather than in the shared App Group container —
|
||||||
/// Support directory.
|
/// both halves of it: the registry file, and the clipboard's staging store, whose launch sweep would
|
||||||
|
/// otherwise collect the developer's own staged copy (and the sibling edition's, since there is one
|
||||||
|
/// store now).
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
||||||
let folder = FileManager.default.temporaryDirectory
|
let folder = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("AppModelTests-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("AppModelTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
let model = AppModel(registryStorageURL: folder.appendingPathComponent("board-registry.json"))
|
let model = AppModel(
|
||||||
|
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
||||||
|
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||||
|
)
|
||||||
return (model, { try? FileManager.default.removeItem(at: folder) })
|
return (model, { try? FileManager.default.removeItem(at: folder) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,13 +219,13 @@ struct AppModelTests {
|
|||||||
let record = try #require(model.boardRegistry.record(id: recordID))
|
let record = try #require(model.boardRegistry.record(id: recordID))
|
||||||
#expect(record.laneCount == 2, "the counts the welcome row will show are the working ones")
|
#expect(record.laneCount == 2, "the counts the welcome row will show are the working ones")
|
||||||
#expect(record.cardCount == 2)
|
#expect(record.cardCount == 2)
|
||||||
#expect(record.isOpenNow == false)
|
#expect(!record.isOpen(inEdition: model.boardRegistry.editionID))
|
||||||
#expect(model.boardRegistry.restorables().isEmpty)
|
#expect(model.boardRegistry.restorables().isEmpty)
|
||||||
|
|
||||||
// Twice is a no-op, which is what lets the window's close interception and its disappear both
|
// Twice is a no-op, which is what lets the window's close interception and its disappear both
|
||||||
// call this without the sequence running twice.
|
// call this without the sequence running twice.
|
||||||
await model.closeBoard(ref: ref, cause: .userClose)
|
await model.closeBoard(ref: ref, cause: .userClose)
|
||||||
#expect(model.boardRegistry.record(id: recordID)?.isOpenNow == false)
|
#expect(model.boardRegistry.record(id: recordID)?.isOpen(inEdition: model.boardRegistry.editionID) == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Quit closes every board and leaves them all flagged for the next launch")
|
@Test("Quit closes every board and leaves them all flagged for the next launch")
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ struct BoardRegistryTests {
|
|||||||
|
|
||||||
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
|
let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board")
|
||||||
let created = try #require(registry.record(id: id))
|
let created = try #require(registry.record(id: id))
|
||||||
#expect(!created.bookmark.isEmpty)
|
#expect(created.grant(forEdition: registry.editionID) != nil)
|
||||||
#expect(created.displayName == "Todo Board")
|
#expect(created.displayName == "Todo Board")
|
||||||
#expect(created.lastKnownPath == fixture.root.path)
|
#expect(created.lastKnownPath == fixture.root.path)
|
||||||
#expect(created.laneCount == nil, "counts are stamped at close, never guessed at open")
|
#expect(created.laneCount == nil, "counts are stamped at close, never guessed at open")
|
||||||
@@ -135,7 +135,7 @@ struct BoardRegistryTests {
|
|||||||
let id = registry.recordOpen(of: fixture.root)
|
let id = registry.recordOpen(of: fixture.root)
|
||||||
let record = try #require(registry.record(id: id))
|
let record = try #require(registry.record(id: id))
|
||||||
#expect(record.displayName == fixture.root.deletingPathExtension().lastPathComponent)
|
#expect(record.displayName == fixture.root.deletingPathExtension().lastPathComponent)
|
||||||
#expect(!record.bookmark.isEmpty, "the bookmark still mints on the before-load call")
|
#expect(record.grant(forEdition: registry.editionID) != nil, "the bookmark still mints on the before-load call")
|
||||||
#expect(record.icon == nil)
|
#expect(record.icon == nil)
|
||||||
#expect(record.iconColor == nil)
|
#expect(record.iconColor == nil)
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ struct BoardRegistryTests {
|
|||||||
#expect(record.icon == "star")
|
#expect(record.icon == "star")
|
||||||
#expect(record.iconColor == "fern")
|
#expect(record.iconColor == "fern")
|
||||||
#expect(record.lastOpened > firstOpened, "the bookmark and lastOpened still refresh on every open attempt")
|
#expect(record.lastOpened > firstOpened, "the bookmark and lastOpened still refresh on every open attempt")
|
||||||
#expect(!record.bookmark.isEmpty)
|
#expect(record.grant(forEdition: registry.editionID) != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Counts
|
// MARK: Counts
|
||||||
@@ -620,16 +620,16 @@ struct BoardRegistryTests {
|
|||||||
let registry = BoardRegistry(storageURL: storage.url)
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
|
|
||||||
let id = registry.recordOpen(of: fixture.root, displayName: "Work")
|
let id = registry.recordOpen(of: fixture.root, displayName: "Work")
|
||||||
#expect(registry.record(id: id)?.isOpenNow == nil, "recording an open is not opening a window")
|
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == false, "recording an open is not opening a window")
|
||||||
#expect(registry.restorables().isEmpty)
|
#expect(registry.restorables().isEmpty)
|
||||||
|
|
||||||
registry.setOpenNow(id: id)
|
registry.setOpenNow(id: id)
|
||||||
#expect(registry.record(id: id)?.isOpenNow == true)
|
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == true)
|
||||||
#expect(ids(registry.restorables()) == [id])
|
#expect(ids(registry.restorables()) == [id])
|
||||||
|
|
||||||
// A user close. The flag goes, and with it the board's place in the next launch.
|
// A user close. The flag goes, and with it the board's place in the next launch.
|
||||||
registry.clearOpenNow(id: id)
|
registry.clearOpenNow(id: id)
|
||||||
#expect(registry.record(id: id)?.isOpenNow == false)
|
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == false)
|
||||||
#expect(registry.restorables().isEmpty)
|
#expect(registry.restorables().isEmpty)
|
||||||
|
|
||||||
// A quit. The teardown stamps counts and does *not* clear the flag — that omission is the
|
// A quit. The teardown stamps counts and does *not* clear the flag — that omission is the
|
||||||
@@ -639,7 +639,7 @@ struct BoardRegistryTests {
|
|||||||
registry.recordClose(id: id, displayName: "Work", laneCount: 2, cardCount: 5)
|
registry.recordClose(id: id, displayName: "Work", laneCount: 2, cardCount: 5)
|
||||||
|
|
||||||
let afterRelaunch = BoardRegistry(storageURL: storage.url)
|
let afterRelaunch = BoardRegistry(storageURL: storage.url)
|
||||||
#expect(afterRelaunch.record(id: id)?.isOpenNow == true, "the flags describe what was open at quit")
|
#expect(afterRelaunch.record(id: id)?.isOpen(inEdition: registry.editionID) == true, "the flags describe what was open at quit")
|
||||||
#expect(ids(afterRelaunch.restorables()) == [id])
|
#expect(ids(afterRelaunch.restorables()) == [id])
|
||||||
#expect(afterRelaunch.record(id: id)?.laneCount == 2)
|
#expect(afterRelaunch.record(id: id)?.laneCount == 2)
|
||||||
}
|
}
|
||||||
@@ -712,13 +712,13 @@ struct BoardRegistryTests {
|
|||||||
|
|
||||||
let registry = BoardRegistry(storageURL: storage.url)
|
let registry = BoardRegistry(storageURL: storage.url)
|
||||||
#expect(registry.recents().count == 1, "the file decoded; nothing was quarantined")
|
#expect(registry.recents().count == 1, "the file decoded; nothing was quarantined")
|
||||||
#expect(registry.record(id: id)?.isOpenNow == nil, "a missing key reads as 'not open'")
|
#expect(registry.record(id: id)?.isOpen(inEdition: registry.editionID) == false, "a missing key reads as 'not open'")
|
||||||
#expect(registry.restorables().isEmpty)
|
#expect(registry.restorables().isEmpty)
|
||||||
#expect(registry.record(id: id)?.laneCount == 4, "and every other field survived")
|
#expect(registry.record(id: id)?.laneCount == 4, "and every other field survived")
|
||||||
|
|
||||||
// And the key writes through from here on.
|
// And the key writes through from here on.
|
||||||
registry.setOpenNow(id: id)
|
registry.setOpenNow(id: id)
|
||||||
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpenNow == true)
|
#expect(BoardRegistry(storageURL: storage.url).record(id: id)?.isOpen(inEdition: registry.editionID) == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Bookmarks in a sandboxed host
|
// MARK: Bookmarks in a sandboxed host
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ struct TemplateChooserRowTests {
|
|||||||
// MARK: - Fixture
|
// MARK: - Fixture
|
||||||
|
|
||||||
/// A temp store holding hand-written template board folders — the user store's shape, minus
|
/// A temp store holding hand-written template board folders — the user store's shape, minus
|
||||||
/// Application Support (which no test may touch).
|
/// the shared App Group container (which no test may touch).
|
||||||
struct TemplateFixture {
|
struct TemplateFixture {
|
||||||
|
|
||||||
let store: URL
|
let store: URL
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ private func subtitle(forCard cardID: String, boardNamed board: String, in model
|
|||||||
return CardWindowHost.subtitle(board: board, lane: placement.lane.title.value)
|
return CardWindowHost.subtitle(board: board, lane: placement.lane.title.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A registry whose file lives in temp rather than in the test host's Application Support.
|
/// A registry whose file lives in temp rather than in the shared App Group container.
|
||||||
@MainActor
|
@MainActor
|
||||||
private struct RegistryStorage {
|
private struct RegistryStorage {
|
||||||
let folder: URL
|
let folder: URL
|
||||||
@@ -264,7 +264,10 @@ struct CardWindowIdentityTests {
|
|||||||
let storage = try RegistryStorage()
|
let storage = try RegistryStorage()
|
||||||
defer { storage.tearDown() }
|
defer { storage.tearDown() }
|
||||||
|
|
||||||
let model = AppModel(registryStorageURL: storage.url)
|
let model = AppModel(
|
||||||
|
registryStorageURL: storage.url,
|
||||||
|
clipboardStagingRoot: storage.folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||||
|
)
|
||||||
let board = BoardWindowRef(url: fixture.root)
|
let board = BoardWindowRef(url: fixture.root)
|
||||||
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
|
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
|
||||||
let store = try model.storeRegistry.acquire(fixture.root)
|
let store = try model.storeRegistry.acquire(fixture.root)
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import Testing
|
|||||||
///
|
///
|
||||||
/// Every suite here drives a real store over a real temp board, with two things injected: a fake
|
/// 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
|
/// 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
|
/// run) and a temp staging directory (so nothing goes near the shared App Group container, which is now
|
||||||
/// exactly because those two claims are the ones worth pinning.
|
/// the sibling edition's staging store too). Both seams exist exactly because those two claims are the
|
||||||
|
/// ones worth pinning.
|
||||||
|
|
||||||
// MARK: - Test doubles
|
// 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.
|
/// 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] {
|
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)
|
#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
|
// MARK: - Takeover
|
||||||
|
|||||||
@@ -464,7 +464,10 @@ private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
|||||||
let folder = FileManager.default.temporaryDirectory
|
let folder = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("HistoryProviderTests-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("HistoryProviderTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
let model = AppModel(registryStorageURL: folder.appendingPathComponent("board-registry.json"))
|
let model = AppModel(
|
||||||
|
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
||||||
|
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||||
|
)
|
||||||
return (model, { try? FileManager.default.removeItem(at: folder) })
|
return (model, { try? FileManager.default.removeItem(at: folder) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import Testing
|
|||||||
///
|
///
|
||||||
/// Plus the promise that makes the copy safe to run at all: a cancelled save leaves nothing behind.
|
/// Plus the promise that makes the copy safe to run at all: a cancelled save leaves nothing behind.
|
||||||
///
|
///
|
||||||
/// Every test drives an explicit store URL. **No test may touch Application Support** — `userStore`
|
/// Every test drives an explicit store URL. **No test may touch the real store** — which since the
|
||||||
/// is named here only to prove the engine never creates it on its own.
|
/// 2026-07-29 re-homing is in the shared App Group container, so it would be the sibling edition's
|
||||||
|
/// template store too. `userStore` is named here only to prove the engine never creates it on its own.
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
@@ -438,14 +439,18 @@ struct SaveAsTemplateAtomicityTests {
|
|||||||
@Suite("Save as Template — the user store")
|
@Suite("Save as Template — the user store")
|
||||||
struct UserTemplateStoreTests {
|
struct UserTemplateStoreTests {
|
||||||
|
|
||||||
@Test("The user store is named inside the app container and is never created by naming it")
|
@Test("The user store is named beside the registry in the shared home, and is never created by naming it")
|
||||||
func theStoreIsNamedNotCreated() {
|
func theStoreIsNamedNotCreated() {
|
||||||
let store = TemplateEngine.userStore
|
let store = TemplateEngine.userStore
|
||||||
|
|
||||||
#expect(store.lastPathComponent == TemplateEngine.storeFolderName)
|
#expect(store.lastPathComponent == TemplateEngine.storeFolderName)
|
||||||
#expect(store.deletingLastPathComponent().lastPathComponent
|
// Beside the registry and the clipboard's staging store, in the App Group container
|
||||||
== (Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"))
|
// (09-templates.md ▸ Storage, re-homed 2026-07-29 — templates cross editions). Compared against
|
||||||
#expect(store.path.contains("Application Support"))
|
// the one shared home rather than spelled out, so the assertion follows it wherever it goes —
|
||||||
|
// including the scratch redirect a test host gets (`AppGroup.isUnitTestHost`). `AppGroup` rather
|
||||||
|
// than the two stores' own defaults because those are `@MainActor` and this suite is not; that
|
||||||
|
// the three agree is `AppGroupContainerTests`' assertion.
|
||||||
|
#expect(store.deletingLastPathComponent() == AppGroup.stateDirectory)
|
||||||
// Nothing here creates it, and no test may: `createUserStore(at:)` is Save as Template's and
|
// Nothing here creates it, and no test may: `createUserStore(at:)` is Save as Template's and
|
||||||
// Reveal in Finder's, and both are driven with an explicit store in this suite.
|
// Reveal in Finder's, and both are driven with an explicit store in this suite.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import Testing
|
|||||||
|
|
||||||
// MARK: - Fixtures
|
// MARK: - Fixtures
|
||||||
|
|
||||||
/// A record with nothing in it that matters except what a given test is about. The bookmark is empty
|
/// A record with nothing in it that matters except what a given test is about. It holds **no grant
|
||||||
/// because this function never resolves one — `RecentBoard` is constructed directly here, so the
|
/// slot** because this function never resolves one — `RecentBoard` is constructed directly here, so the
|
||||||
/// availability classification is an input rather than a filesystem outcome.
|
/// availability classification is an input rather than a filesystem outcome.
|
||||||
private func record(
|
private func record(
|
||||||
name: String,
|
name: String,
|
||||||
@@ -28,7 +28,6 @@ private func record(
|
|||||||
iconColor: String? = nil
|
iconColor: String? = nil
|
||||||
) -> BoardRecord {
|
) -> BoardRecord {
|
||||||
BoardRecord(
|
BoardRecord(
|
||||||
bookmark: Data(),
|
|
||||||
displayName: name,
|
displayName: name,
|
||||||
lastKnownPath: path,
|
lastKnownPath: path,
|
||||||
lastOpened: opened,
|
lastOpened: opened,
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ fi
|
|||||||
|
|
||||||
for key in com.apple.security.app-sandbox \
|
for key in com.apple.security.app-sandbox \
|
||||||
com.apple.security.files.user-selected.read-write \
|
com.apple.security.files.user-selected.read-write \
|
||||||
com.apple.security.files.bookmarks.app-scope; do
|
com.apple.security.files.bookmarks.app-scope \
|
||||||
|
com.apple.security.application-groups; do
|
||||||
if has_entitlement "$BASE_APP" "$key"; then
|
if has_entitlement "$BASE_APP" "$key"; then
|
||||||
pass "base carries $key"
|
pass "base carries $key"
|
||||||
else
|
else
|
||||||
@@ -164,7 +165,7 @@ done
|
|||||||
# archive — which is why they are named out loud below rather
|
# archive — which is why they are named out loud below rather
|
||||||
# than quietly filtered.
|
# than quietly filtered.
|
||||||
toolchain_injected='^(com\.apple\.security\.get-task-allow|com\.apple\.application-identifier|com\.apple\.developer\.team-identifier|com\.apple\.security\.temporary-exception\.[a-z.-]*)$'
|
toolchain_injected='^(com\.apple\.security\.get-task-allow|com\.apple\.application-identifier|com\.apple\.developer\.team-identifier|com\.apple\.security\.temporary-exception\.[a-z.-]*)$'
|
||||||
settled_minimum='^(com\.apple\.security\.app-sandbox|com\.apple\.security\.files\.user-selected\.read-write|com\.apple\.security\.files\.bookmarks\.app-scope)$'
|
settled_minimum='^(com\.apple\.security\.app-sandbox|com\.apple\.security\.files\.user-selected\.read-write|com\.apple\.security\.files\.bookmarks\.app-scope|com\.apple\.security\.application-groups)$'
|
||||||
|
|
||||||
base_extra="$(entitlement_keys "$BASE_APP" | grep -vE "$settled_minimum" | grep -vE "$toolchain_injected")"
|
base_extra="$(entitlement_keys "$BASE_APP" | grep -vE "$settled_minimum" | grep -vE "$toolchain_injected")"
|
||||||
if [ -n "$base_extra" ]; then
|
if [ -n "$base_extra" ]; then
|
||||||
@@ -187,6 +188,7 @@ head2 "Pro — entitlements"
|
|||||||
for key in com.apple.security.app-sandbox \
|
for key in com.apple.security.app-sandbox \
|
||||||
com.apple.security.files.user-selected.read-write \
|
com.apple.security.files.user-selected.read-write \
|
||||||
com.apple.security.files.bookmarks.app-scope \
|
com.apple.security.files.bookmarks.app-scope \
|
||||||
|
com.apple.security.application-groups \
|
||||||
com.apple.security.network.client \
|
com.apple.security.network.client \
|
||||||
keychain-access-groups; do
|
keychain-access-groups; do
|
||||||
if has_entitlement "$PRO_APP" "$key"; then
|
if has_entitlement "$PRO_APP" "$key"; then
|
||||||
@@ -196,6 +198,45 @@ for key in com.apple.security.app-sandbox \
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------ the App Group
|
||||||
|
#
|
||||||
|
# 12-editions.md ▸ Distribution (ruled 2026-07-29): "every edition declares the family group —
|
||||||
|
# `group.dev.rzen.indie.Kanban` — and the board registry with its Application Support peers homes in
|
||||||
|
# the group container **from day one**". The upgrade story is exactly this string matching across the
|
||||||
|
# two signatures: an edition declaring a group of its own would share nothing while looking as though
|
||||||
|
# it did, and nothing in the app could detect the difference — the container would simply be empty.
|
||||||
|
|
||||||
|
head2 "The App Group — one container, either app"
|
||||||
|
|
||||||
|
# The group ids the signature declares, one per line. Read from the signature rather than the source
|
||||||
|
# `.entitlements` for the reason every check here does: the shipped binary is the claim.
|
||||||
|
#
|
||||||
|
# `sed` over the whole stream rather than a line-oriented pass, because `codesign --xml` emits the
|
||||||
|
# entire plist on **one line** — which is also why `entitlement_keys` above reaches for `grep -o`
|
||||||
|
# instead of PlistBuddy. The two substitutions trim everything before this key's `<array>` and
|
||||||
|
# everything after its close, leaving only its own `<string>`s to unwrap.
|
||||||
|
app_groups() {
|
||||||
|
entitlements_of "$1" |
|
||||||
|
sed -e 's/.*<key>com\.apple\.security\.application-groups<\/key><array>//' \
|
||||||
|
-e 's/<\/array>.*//' |
|
||||||
|
grep -o '<string>[^<]*</string>' |
|
||||||
|
sed 's/<[^>]*>//g'
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_group="group.dev.rzen.indie.Kanban"
|
||||||
|
base_groups="$(app_groups "$BASE_APP")"
|
||||||
|
pro_groups="$(app_groups "$PRO_APP")"
|
||||||
|
|
||||||
|
for app_label in "base:$base_groups" "pro:$pro_groups"; do
|
||||||
|
label="${app_label%%:*}"
|
||||||
|
groups="${app_label#*:}"
|
||||||
|
if [ "$groups" = "$expected_group" ]; then
|
||||||
|
pass "$label declares exactly $expected_group"
|
||||||
|
else
|
||||||
|
fail "$label declares '$groups', expected '$expected_group'"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------ bundle identity
|
# ------------------------------------------------------------------------------ bundle identity
|
||||||
|
|
||||||
head2 "Bundle identity"
|
head2 "Bundle identity"
|
||||||
|
|||||||
Reference in New Issue
Block a user