From 566deab5066d03557b06ee355f9455670b014a37 Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 29 Jul 2026 20:18:15 -0400 Subject: [PATCH] Home app-side state in the shared App Group container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/AppCommands.swift | 7 +- Kanban/App/AppModel.swift | 97 ++++- Kanban/App/BoardWindowHost.swift | 17 +- Kanban/App/ClipboardManifest.swift | 2 +- Kanban/App/ClipboardStore.swift | 99 ++++- Kanban/App/RestoreBootstrapView.swift | 11 + Kanban/App/TemplateChooserView.swift | 4 +- Kanban/App/TemplateEngine.swift | 32 +- Kanban/App/UITestLaunch.swift | 43 ++- Kanban/App/WelcomeRow.swift | 31 +- Kanban/App/WelcomeView.swift | 29 +- Kanban/Kanban.entitlements | 12 + Kanban/KanbanApp.swift | 21 +- Kanban/LiveStore/AppGroup.swift | 189 ++++++++++ Kanban/LiveStore/BoardRegistry.swift | 473 ++++++++++++++++++++---- Kanban/LiveStore/StyleRecents.swift | 6 +- Kanban/UI/Board/BoardInfoPopover.swift | 112 +++++- KanbanPro/KanbanPro.entitlements | 9 + KanbanTests/AppGroupStateTests.swift | 493 +++++++++++++++++++++++++ KanbanTests/AppModelTests.swift | 15 +- KanbanTests/BoardRegistryTests.swift | 18 +- KanbanTests/BoardTemplateTests.swift | 2 +- KanbanTests/CardWindowShellTests.swift | 7 +- KanbanTests/ClipboardTests.swift | 113 +++++- KanbanTests/HistoryProviderTests.swift | 5 +- KanbanTests/SaveAsTemplateTests.swift | 17 +- KanbanTests/WelcomeRowTests.swift | 5 +- scripts/verify-editions.sh | 45 ++- 28 files changed, 1733 insertions(+), 181 deletions(-) create mode 100644 Kanban/LiveStore/AppGroup.swift create mode 100644 KanbanTests/AppGroupStateTests.swift diff --git a/Kanban/App/AppCommands.swift b/Kanban/App/AppCommands.swift index 6311322..6745a21 100644 --- a/Kanban/App/AppCommands.swift +++ b/Kanban/App/AppCommands.swift @@ -79,8 +79,7 @@ struct OpenRecentMenu: View { Menu("Open Recent") { ForEach(rows) { row in Button(row.displayName) { - guard let url = row.url else { return } - appModel.openBoard(at: url) + appModel.open(row) } .disabled(!row.canOpen) } @@ -291,7 +290,7 @@ struct DuplicateBoardCommand: View { /// 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 /// 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. /// /// **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 /// 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 /// 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 diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 756c21c..9f37968 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -28,8 +28,23 @@ public enum WindowID { /// /// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason /// `WindowID` exists. +/// +/// ### The domain is the group's, not `.standard` +/// +/// 02 § Per-board app state sends these to "the group's shared `UserDefaults` suite where a scalar +/// fits" (ruled 2026-07-29; 12-editions.md), for the registry's reason exactly: a paying upgrader +/// launches Pro onto their own settings rather than onto defaults. `AppGroup.defaults` is that suite, +/// and it degrades to a plain named domain when the group is not provisioned — unshared, but working. +/// +/// **Every reader and writer of these keys must name that suite.** A `@AppStorage` left to its own +/// devices reads `.standard`, which after this ruling is a *different* domain — so the two views that +/// bind one of these keys pass `store:` explicitly (`SettingsView`). public enum AppPreferences { + /// The domain every key here lives in. A stored `let` would capture a suite at type-load time; + /// this is a lookup of an object `UserDefaults` itself caches. + public static var defaults: UserDefaults { AppGroup.defaults } + /// "Restore open boards at launch" (Settings, ⌘, — 11-command-nexus.md). **Default on.** public static let restoreOpenBoardsAtLaunchKey = "restoreOpenBoardsAtLaunch" @@ -37,7 +52,7 @@ public enum AppPreferences { /// any scene exists. `object(forKey:)` rather than `bool(forKey:)` because the latter cannot /// tell "off" from "never set", and this preference defaults to *on*. public static var restoreOpenBoardsAtLaunch: Bool { - UserDefaults.standard.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true + defaults.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true } /// The last-used card-window size (05-card-window.md; 02 files it as app-wide, not per-board — @@ -46,14 +61,14 @@ public enum AppPreferences { public static let lastCardWindowSizeKey = "lastCardWindowSize" public static var lastCardWindowSize: CGSize? { - guard let text = UserDefaults.standard.string(forKey: lastCardWindowSizeKey) else { return nil } + guard let text = defaults.string(forKey: lastCardWindowSizeKey) else { return nil } let size = NSSizeFromString(text) guard size.width > 0, size.height > 0 else { return nil } return size } public static func setLastCardWindowSize(_ size: CGSize) { - UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey) + defaults.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey) } /// The quick-style row's recently-used backgrounds — an array of palette names / hex strings, @@ -163,7 +178,7 @@ public final class AppModel { /// the registries' reason — app-scoped, and a test holds its own rather than colliding with the /// app's — and reached by the context menus through the environment, since a `BoardStore` is /// board-scoped and this list deliberately is not. - public let styleRecents = StyleRecents() + public let styleRecents: StyleRecents /// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop). /// @@ -187,7 +202,7 @@ public final class AppModel { /// /// Building it here is also the **launch sweep** (04: "a sweep at launch"): the store's `init` /// reads the pasteboard once and collects every staged tree it no longer names. - public let clipboard = ClipboardStore() + public let clipboard: ClipboardStore // MARK: The provider seam @@ -411,11 +426,25 @@ public final class AppModel { private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model") - /// The app builds one of these with the real registry file; a test passes its own path for the - /// same reason `BoardRegistry` takes one at all — "injecting it is how a test stays out of the - /// real Application Support directory". - public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) { + /// The app builds one of these with the real state home; a test passes its own for the reason + /// `BoardRegistry` takes a storage URL at all — "injecting it is how a test stays out of the real + /// Application Support directory", which after the 2026-07-29 ruling means **out of the shared App + /// Group container** (`AppGroup`). A suite that swept the real staging root would be sweeping the + /// developer's own clipboard, and now the sibling edition's too. + /// + /// `clipboardStagingRoot` is a separate parameter rather than derived from `registryStorageURL`'s + /// folder because the two are injected for different reasons and by different callers: the UI-test + /// fixture launch redirects both into one scratch root (`UITestLaunch`), a unit test usually wants + /// only one of them, and deriving would silently move a test's staging directory the day it moved + /// its registry file. + public init( + registryStorageURL: URL = BoardRegistry.defaultStorageURL, + clipboardStagingRoot: URL = ClipboardStore.defaultStagingRoot, + preferences: UserDefaults = AppGroup.defaults + ) { boardRegistry = BoardRegistry(storageURL: registryStorageURL) + styleRecents = StyleRecents(defaults: preferences) + clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot) // Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first // menu pass — a launch that restores boards never shows welcome, and a submenu that filled // in only after the first close would look broken. It costs one bookmark-resolution sweep at @@ -518,6 +547,56 @@ public final class AppModel { openBoard(at: url) } + /// Opens a recents row — the one door for welcome's double-click, its Open item, and File ▸ Open + /// Recent, because a row has **two** ways of leading to a board now. + /// + /// An ordinary available row opens its URL. A row awaiting this edition's grant + /// (`RecentBoard.needsReopen` — a board the other edition minted the only bookmark for) runs the + /// re-grant panel first: "the first click runs an open panel pre-anchored at the recorded path: + /// one click + Grant per board, once per edition" (12-editions.md ▸ Distribution). + /// + /// Nothing else about the open differs. The granted URL goes through `openBoard(at:)` exactly as a + /// File ▸ Open… pick would, and `BoardRegistry.recordOpen` matches the *existing* shared record and + /// mints this edition's slot onto it — the other edition's grant, the frames, the counts and the + /// cached title all stay where they are. + /// Internal rather than `public` only because `WelcomeRow` is — the row derivation is a UI-layer + /// value, and nothing outside this module opens boards by row. + func open(_ row: WelcomeRow) { + if let url = row.url { + openBoard(at: url) + return + } + guard let anchor = row.regrantAnchor, + let granted = presentRegrantPanel(anchoredAt: anchor, boardName: row.displayName) else { return } + openBoard(at: granted) + } + + /// The re-grant panel: an ordinary open panel, pre-anchored at the board's recorded path. + /// + /// **A panel and not an alert**, because the panel *is* the mechanism: a sandboxed app gains access + /// to a folder by the user choosing it, so there is nothing an intermediate explanation could add + /// that the panel's own message does not say better while doing the job. + /// + /// `directoryURL` is the recorded path itself, per the ruling. For a board that is a `.kanban` + /// package the panel therefore opens *inside* it — `treatsFilePackagesAsDirectories` is on for + /// `presentOpenPanel`'s reason (boards are packages *and* plain folders) — and Open with nothing + /// selected chooses the folder on display, which is the board. A board that has since moved leaves + /// the panel at the nearest surviving ancestor, which is the Finder behaviour and the honest one: + /// the user knows where their board went, and this app does not. + private func presentRegrantPanel(anchoredAt anchor: URL, boardName: String) -> URL? { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.treatsFilePackagesAsDirectories = true + panel.allowsMultipleSelection = false + panel.directoryURL = anchor + panel.prompt = "Grant" + panel.message = "Choose “\(boardName)” to let this app open it." + + guard panel.runModal() == .OK else { return nil } + return panel.url + } + /// The ref of the window already showing the board at `url`, if any — matched through the store, /// which is identity-keyed, rather than through the path. private func boardRef(forBoardAt url: URL) -> BoardWindowRef? { diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index c86c725..7392e3f 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -284,7 +284,22 @@ struct BoardWindowHost: View { // load rather than at attach because it carries the store; the controller installs it once, // whichever of the two arrives second. 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 diff --git a/Kanban/App/ClipboardManifest.swift b/Kanban/App/ClipboardManifest.swift index 8c596a9..c06ac20 100644 --- a/Kanban/App/ClipboardManifest.swift +++ b/Kanban/App/ClipboardManifest.swift @@ -19,7 +19,7 @@ extension UTType { /// /// **It is self-describing twice over**, and both halves earn their keep: /// -/// - `copyID` ties the pasteboard to a staging directory — `/…/Clipboard//`, +/// - `copyID` ties the pasteboard to a staging directory — `/…/Clipboard//`, /// 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 /// at it": a sweep keeps the one directory this id names and collects every other. diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index 30f090b..959054d 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -12,11 +12,17 @@ import os /// /// 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 -/// `//Clipboard//`, so a paste reproduces the item +/// `/Library/Application Support/Clipboard//`, so a paste reproduces the item /// 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**: /// 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 /// /// - **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") - /// `~/Library/Application Support//Clipboard/`, beside the board registry — the same - /// container convention, for the same reason (02-architecture.md § Per-board app state, "App-wide - /// state has the same home"). + /// `/Library/Application Support/Clipboard/`, beside the board registry — the + /// same home, for the same reason, and now the same *shared* home (12-editions.md ▸ Both editions + /// 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 { - let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? 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) + AppGroup.stateDirectory.appendingPathComponent("Clipboard", isDirectory: true) } /// 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 - /// 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 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) } } + /// 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 { + let sweepFolder = root.appendingPathComponent(sweepFolderName, isDirectory: true) + guard let entries = try? FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) else { return } + + var claimed: [URL] = [] 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) } } diff --git a/Kanban/App/RestoreBootstrapView.swift b/Kanban/App/RestoreBootstrapView.swift index 2fd4bf9..dfa3fcf 100644 --- a/Kanban/App/RestoreBootstrapView.swift +++ b/Kanban/App/RestoreBootstrapView.swift @@ -92,6 +92,17 @@ struct RestoreBootstrapView: View { path: record.lastKnownPath, 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") } } diff --git a/Kanban/App/TemplateChooserView.swift b/Kanban/App/TemplateChooserView.swift index f2aeb07..875deef 100644 --- a/Kanban/App/TemplateChooserView.swift +++ b/Kanban/App/TemplateChooserView.swift @@ -27,7 +27,7 @@ import os /// /// ### 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** /// (`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 @@ -285,7 +285,7 @@ struct TemplateChooserView: View { /// /// 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 - /// 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() { let store = TemplateEngine.userStore do { diff --git a/Kanban/App/TemplateEngine.swift b/Kanban/App/TemplateEngine.swift index c7c760f..25c08c3 100644 --- a/Kanban/App/TemplateEngine.swift +++ b/Kanban/App/TemplateEngine.swift @@ -95,7 +95,7 @@ enum TemplateEngine { // 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" /// The bundled store: `/Contents/Resources/Templates/`, holding one board folder per @@ -105,23 +105,27 @@ enum TemplateEngine { Bundle.main.resourceURL?.appendingPathComponent(storeFolderName, isDirectory: true) } - /// The user store: `//Templates/`, beside the board registry and - /// the clipboard staging directory — 09's settled location ("Application Support … inside the - /// app container — friction-free sandbox writes, no location ceremony"), spelled the way every - /// 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"). + /// The user store: `Templates/` in the **shared App Group container**, beside the board registry + /// and the clipboard's staging store (09-templates.md ▸ Save as Template ▸ Storage, re-homed + /// 2026-07-29; 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 /// 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. static var userStore: URL { - let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? 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) + AppGroup.stateDirectory.appendingPathComponent(storeFolderName, isDirectory: true) } // MARK: - Discovery @@ -445,7 +449,7 @@ enum TemplateEngine { /// /// **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 - /// 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 /// app being pedantic about a directory it owns. @discardableResult diff --git a/Kanban/App/UITestLaunch.swift b/Kanban/App/UITestLaunch.swift index ea8907a..10359c1 100644 --- a/Kanban/App/UITestLaunch.swift +++ b/Kanban/App/UITestLaunch.swift @@ -92,18 +92,22 @@ enum LaunchPlan: Equatable, Sendable { /// /// ### What the flag also switches off /// -/// **The registry moves into the scratch directory** with the board. Without that, every audit run -/// would stamp a temp folder into the user's real recents list (`BoardRegistry.defaultStorageURL`, -/// in Application Support), 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 -/// deliberate: the two are one decision — "this launch is synthetic" — and a second argument is a -/// second chance to apply only half of it. +/// **The registry and the clipboard's staging store move into the scratch directory** with the board. +/// Without that, every audit run would stamp a temp folder into the user's real recents list +/// (`BoardRegistry.defaultStorageURL`), where it would sit for good as an unavailable row pointing at a +/// directory that no longer exists — and its launch sweep would collect the user's real staged copy +/// (`ClipboardStore.defaultStagingRoot`). Both of those homes are now the **shared App Group +/// 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 -/// three app-wide scalars (`AppPreferences`) into the real domain. They are a window size, a restore -/// toggle this launch never consults, and the quick-style recents list — no documents, nothing -/// destructive, and redirecting a defaults domain from inside the process is not something the -/// platform actually supports. It is stated rather than fixed. +/// three app-wide scalars (`AppPreferences`) into the real domain — the group's shared suite since the +/// same ruling. They are a window size, a restore toggle this launch never consults, and the +/// quick-style recents list — no documents, nothing destructive, and redirecting a defaults domain from +/// inside the process is not something the platform actually supports. It is stated rather than +/// fixed. enum UITestLaunch { private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "ui-test-launch") @@ -208,12 +212,21 @@ enum UITestLaunch { .appendingPathComponent("LaneworkUITestFixture", isDirectory: true) } - /// Where the fixture launch's registry lives — beside the board rather than in Application - /// Support, which is the whole point (see the type's note). + /// Where the fixture launch's registry lives — beside the board rather than in the shared App + /// Group container, which is the whole point (see the type's note). static var registryStorageURL: URL { 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 /// 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). @@ -230,8 +243,10 @@ enum UITestLaunch { fixtureBoardURL(for: .standard) } - /// Wipes and recreates the scratch root, and answers the registry URL to build the app model - /// with. Called once, from `KanbanApp.init()`, **before** the model reads its registry. + /// Wipes and recreates the scratch root, and answers the registry URL for the caller's convenience. + /// 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 /// only meaningful against a board whose contents the test knows — a previous run's leftovers diff --git a/Kanban/App/WelcomeRow.swift b/Kanban/App/WelcomeRow.swift index 2a72c79..42c5d8a 100644 --- a/Kanban/App/WelcomeRow.swift +++ b/Kanban/App/WelcomeRow.swift @@ -40,10 +40,18 @@ struct WelcomeRow: Identifiable, Equatable { let icon: 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. 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 /// lives, not its own path repeated under its name. Home-abbreviated where it can be. let location: String @@ -58,10 +66,14 @@ struct WelcomeRow: Identifiable, Equatable { var isAvailable: Bool { url != nil } - /// Open and Reveal in Finder both need somewhere to go; 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 } + /// Whether this row is waiting for this edition's grant rather than being genuinely orphaned. + var needsReopen: Bool { regrantAnchor != nil } + + /// 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 } /// 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?) /// The bookmark no longer resolves (02 § Graceful orphaning). 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. case failed(String) } var caption: Caption { 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 } return .counts(lanes: laneCount, cards: cardCount) } @@ -143,6 +163,7 @@ struct WelcomeRow: Identifiable, Equatable { icon: record.icon, iconColor: record.iconColor, url: recent.url, + regrantAnchor: recent.regrantAnchor, location: location(of: recent.url?.path ?? record.lastKnownPath), laneCount: record.laneCount, cardCount: record.cardCount, diff --git a/Kanban/App/WelcomeView.swift b/Kanban/App/WelcomeView.swift index 69f3ab5..fe24771 100644 --- a/Kanban/App/WelcomeView.swift +++ b/Kanban/App/WelcomeView.swift @@ -244,12 +244,13 @@ struct WelcomeView: View { // MARK: Actions - /// Opens a row's board. Welcome closes 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. + /// Opens a row's board — through `AppModel.open(_:)`, which owns the two ways a row can lead to + /// one (an available URL, or the re-grant panel a cross-edition row needs first). Welcome closes + /// 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) { - guard let url = row.url else { return } - appModel.openBoard(at: url) + appModel.open(row) } private func reveal(_ row: WelcomeRow) { @@ -320,8 +321,9 @@ private struct RecentBoardRow: View { } .padding(.vertical, BoardMetrics.em(0.3, bodyPointSize: WelcomeView.pointSize)) // Dimmed when the board cannot be reached — the row stays, with Forget, rather than - // disappearing (02 § Graceful orphaning). - .opacity(row.isAvailable ? 1 : 0.55) + // disappearing (02 § Graceful orphaning). A cross-edition row is *not* dimmed: it opens on one + // click like any other, and dimming it would advertise a loss that has not happened. + .opacity(row.canOpen ? 1 : 0.55) .accessibilityElement(children: .combine) } @@ -363,6 +365,14 @@ private struct RecentBoardRow: View { .font(.caption) .foregroundStyle(.secondary) .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): // The warning tint, and the whole of fail-fast's specifics — this row *is* the failure // surface (02 § Launch and window lifecycle). @@ -385,7 +395,10 @@ private struct RecentBoardRow: View { /// turned off and then turns it back on. 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 var body: some View { diff --git a/Kanban/Kanban.entitlements b/Kanban/Kanban.entitlements index 05cf7fc..e6eea50 100644 --- a/Kanban/Kanban.entitlements +++ b/Kanban/Kanban.entitlements @@ -8,5 +8,17 @@ com.apple.security.files.bookmarks.app-scope + + com.apple.security.application-groups + + group.dev.rzen.indie.Kanban + diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index a41ebc7..0471eb8 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -41,14 +41,21 @@ struct KanbanApp: App { private let launchPlan: LaunchPlan init() { - // Read first, because it decides *which registry file the model is built with* — a fixture - // launch keeps its recents in the scratch directory rather than in the user's real one. + // Read first, because it decides *which app-side state the model is built over* — a fixture + // 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 registryStorageURL = isUITestFixtureLaunch - ? UITestLaunch.prepareScratchDirectory() - : BoardRegistry.defaultStorageURL - - let model = AppModel(registryStorageURL: registryStorageURL) + if isUITestFixtureLaunch { + UITestLaunch.prepareScratchDirectory() + } + let model = AppModel( + registryStorageURL: isUITestFixtureLaunch + ? UITestLaunch.registryStorageURL + : BoardRegistry.defaultStorageURL, + clipboardStagingRoot: isUITestFixtureLaunch + ? UITestLaunch.clipboardStagingRoot + : ClipboardStore.defaultStagingRoot + ) _appModel = State(initialValue: model) launchPlan = LaunchPlan.decide( isUITestFixtureLaunch: isUITestFixtureLaunch, diff --git a/Kanban/LiveStore/AppGroup.swift b/Kanban/LiveStore/AppGroup.swift new file mode 100644 index 0000000..82f53cc --- /dev/null +++ b/Kanban/LiveStore/AppGroup.swift @@ -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: `/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//`, 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 + } +} diff --git a/Kanban/LiveStore/BoardRegistry.swift b/Kanban/LiveStore/BoardRegistry.swift index f2dd8c2..9c265a4 100644 --- a/Kanban/LiveStore/BoardRegistry.swift +++ b/Kanban/LiveStore/BoardRegistry.swift @@ -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. /// /// **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 -/// machines sharing a board through a remote each keep their own (push-on-commit and window frames -/// are genuinely per-machine choices). +/// sidecar, no xattr — this record lives wholly in the shared App Group container, which is also why +/// two machines sharing a board through a remote each keep their own (push-on-commit and window +/// frames are genuinely per-machine choices). /// /// 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 /// 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 /// -/// The synthesized `Codable` conformance rejects a file missing any non-optional key, and -/// `BoardRegistry` responds to a rejected file by quarantining it — every record lost. So **a key -/// added here must be optional or have a decoding default**, or the addition silently empties every -/// existing user's recents on upgrade. That policy, not a version field, is what keeps this format -/// readable across releases. +/// `BoardRegistry` responds to a file it cannot decode by quarantining it — every record lost. So +/// **a key added here must be optional or have a decoding default**, or the addition silently +/// empties every existing user's recents on upgrade. That policy, not a version field, is what keeps +/// this format readable across releases; `init(from:)` below applies it by hand for the keys that +/// arrived with the App Group, and keeps the four founding keys required exactly as they were. public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { public let id: UUID - /// The security-scoped bookmark — **the board's identity**, refreshed on every open. Empty only - /// in the degenerate case where the system refused to make one at all, which is a record born - /// orphaned: it shows in recents with Forget and never matches an open. - public var bookmark: Data + /// The per-edition security-scoped grant slots — **the board's identity, per sandbox** — keyed by + /// bundle id and refreshed on every open *by that edition*. Empty for an edition means "this app + /// has never been granted this board", which is the unavailable-until-reopened state and not an + /// 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. public var displayName: String /// 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 - /// a path that matched would reintroduce exactly the identity-by-string bug this design excludes. + /// there is nothing else to show. + /// + /// **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 lastOpened: Date @@ -136,21 +181,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { /// time — never here. 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, /// not a side effect. public var pushOnCommit: Bool @@ -162,7 +192,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { public init( id: UUID = UUID(), - bookmark: Data, + grants: [String: Data] = [:], displayName: String, lastKnownPath: String, lastOpened: Date, @@ -170,14 +200,14 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { cardCount: Int? = nil, windowFrame: WindowFrame? = nil, cardWindowFrames: [String: WindowFrame]? = nil, - isOpenNow: Bool? = nil, + openNow: [String: Bool] = [:], pushOnCommit: Bool = false, remoteLocationWarned: Bool = false, icon: String? = nil, iconColor: String? = nil ) { self.id = id - self.bookmark = bookmark + self.grants = grants self.displayName = displayName self.lastKnownPath = lastKnownPath self.lastOpened = lastOpened @@ -185,12 +215,153 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { self.cardCount = cardCount self.windowFrame = windowFrame self.cardWindowFrames = cardWindowFrames - self.isOpenNow = isOpenNow + self.openNow = openNow self.pushOnCommit = pushOnCommit self.remoteLocationWarned = remoteLocationWarned self.icon = icon 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. @@ -206,27 +377,48 @@ public enum RecentBoard: Sendable, Equatable { /// 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". 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 { switch self { case let .available(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? { switch self { 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 -/// The persistent side of per-board app state: one record per known board, in Application Support -/// (02-architecture.md § Per-board app state). +/// The persistent side of per-board app state: one record per known board, in the shared App Group +/// container (02-architecture.md § Per-board app state; `AppGroup`). /// /// ### Three rules do most of the work /// @@ -249,25 +441,31 @@ public enum RecentBoard: Sendable, Equatable { public final class BoardRegistry { /// 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 - /// Application Support directory. + /// every test want to name it, and because injecting it is how a test stays out of the real shared + /// 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 + /// **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 static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-registry") - /// `~/Library/Application Support//board-registry.json`, inside the sandbox - /// container. The bundle-id subfolder is Apple's convention and keeps the app's files together - /// as the app-wide neighbours arrive. + /// `/Library/Application Support/board-registry.json` — the **shared** home + /// (12-editions.md ▸ Distribution, ruled 2026-07-29), with no bundle-id subfolder, because the + /// 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 { - let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? 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) + AppGroup.stateDirectory.appendingPathComponent("board-registry.json", isDirectory: false) } /// 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 /// it even when this app cannot; empty rather than fatal because a truncated convenience file /// 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.editionID = editionID 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 @@ -335,6 +608,8 @@ public final class BoardRegistry { icon: String? = nil, iconColor: String? = nil ) -> UUID { + syncFromDiskIfChanged() + let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data() if bookmark.isEmpty { // 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) { - 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 { records[index].displayName = displayName records[index].icon = icon @@ -357,7 +636,7 @@ public final class BoardRegistry { } let record = BoardRecord( - bookmark: bookmark, + grants: [editionID: bookmark], displayName: displayName ?? Self.folderName(of: rootURL), lastKnownPath: rootURL.path, 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 /// merely attempted (02-architecture.md § Launch and window lifecycle). 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**. @@ -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 /// its own — the flags already describe what was open when the app died. 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` @@ -435,10 +727,15 @@ public final class BoardRegistry { /// (§ "A restored board that fails surfaces on welcome, row-level") instead of hanging on a /// 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. public func restorables() -> [RecentBoard] { 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 // in the same millisecond still come back in one stable order rather than whichever // `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 /// same reason every other setter here tolerates one. public func syncDisplayState(id: UUID, title: String, icon: String?, iconColor: String?) { + syncFromDiskIfChanged() guard let index = indexOfRecord(id) else { Self.logger.debug("syncDisplayState: no record for this id — ignored") 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 /// 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 /// and two rows that tie should still come back in the same order every call. public func recents() -> [RecentBoard] { + syncFromDiskIfChanged() + var resolvedURLs: [UUID: URL] = [:] var refreshedAny = false 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 guard resolution.isStale else { continue } 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 } } @@ -563,20 +870,30 @@ public final class BoardRegistry { .map { record in if let url = resolvedURLs[record.id] { .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 { .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? { - 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 /// 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. public func forget(id: UUID) { + syncFromDiskIfChanged() guard let index = indexOfRecord(id) else { return } records.remove(at: index) save() @@ -592,6 +909,7 @@ public final class BoardRegistry { /// One save rather than one per record: the file is rewritten wholesale anyway, and forgetting /// twenty boards should not be twenty writes. public func forgetAll() { + syncFromDiskIfChanged() guard !records.isEmpty else { return } records.removeAll() save() @@ -599,11 +917,22 @@ public final class BoardRegistry { // 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? { 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 // 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 @@ -612,6 +941,21 @@ public final class BoardRegistry { // unaffected. 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? { @@ -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 — /// 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) { + syncFromDiskIfChanged() guard let index = indexOfRecord(id) else { Self.logger.debug("update: no record for this id — ignored") return @@ -761,6 +1106,10 @@ public final class BoardRegistry { withIntermediateDirectories: true ) 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 { Self.logger.error("could not save the board registry: \(error.localizedDescription, privacy: .public)") } diff --git a/Kanban/LiveStore/StyleRecents.swift b/Kanban/LiveStore/StyleRecents.swift index 0ad52d2..b3a0835 100644 --- a/Kanban/LiveStore/StyleRecents.swift +++ b/Kanban/LiveStore/StyleRecents.swift @@ -46,8 +46,10 @@ public final class StyleRecents { 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 - /// its storage URL: a test must be able to hold its own without touching the user's. - public init(defaults: UserDefaults = .standard) { + /// its storage URL: a test must be able to hold its own without touching the user's. The app's + /// 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 // 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 diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index 94defc6..a26fb0a 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -68,6 +68,15 @@ struct BoardInfoWidget: View { let store: BoardStore let recents: StyleRecents + /// The cross-edition awareness line's *fact*, asked afresh each time the popover is built — see + /// `BoardEditionPresence` for why it is a closure rather than a value: both halves of it (the + /// registry's flags and whether the other app is alive) can change while a board window sits + /// there, and neither is a thing to observe. + /// + /// Defaulted to "no line" so the two surfaces that build this widget without a registry — the + /// titlebar tests — keep saying nothing rather than needing one. + var otherEditionNote: () -> String? = { nil } + @Bindable var presentation: BoardInfoPresentation var body: some View { @@ -87,7 +96,7 @@ struct BoardInfoWidget: View { .help("Board Info") .accessibilityLabel("Board Info") .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) { - BoardInfoView(store: store, recents: recents) + BoardInfoView(store: store, recents: recents, otherEditionNote: otherEditionNote()) } } } @@ -103,10 +112,16 @@ struct BoardInfoWidget: View { func boardInfoTitlebarAccessory( store: BoardStore, recents: StyleRecents, - presentation: BoardInfoPresentation + presentation: BoardInfoPresentation, + otherEditionNote: @escaping () -> String? = { nil } ) -> NSTitlebarAccessoryViewController { let hosting = NSHostingView( - rootView: BoardInfoWidget(store: store, recents: recents, presentation: presentation) + rootView: BoardInfoWidget( + store: store, + recents: recents, + otherEditionNote: otherEditionNote, + presentation: presentation + ) ) // The titlebar lays its accessories out by fitting size, and a hosting view that measured itself // as zero would be an invisible, unclickable widget. @@ -137,6 +152,10 @@ struct BoardInfoView: View { /// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here. private let hasGitDirectory: Bool + /// The cross-edition awareness line, or `nil` for no line — resolved once when the view is built, + /// on `hasGitDirectory`'s terms and for its reason (`BoardEditionPresence`). + private let otherEditionNote: String? + /// The style editor brings its own padding, so the sections around it carry the same number by /// hand instead of an outer padding that would double up on it — **the editor's own figure** /// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales @@ -145,10 +164,11 @@ struct BoardInfoView: View { StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize) } - init(store: BoardStore, recents: StyleRecents) { + init(store: BoardStore, recents: StyleRecents, otherEditionNote: String? = nil) { self.store = store self.recents = recents self.hasGitDirectory = BoardGitNote.hasGitDirectory(at: store.rootURL) + self.otherEditionNote = otherEditionNote } var body: some View { @@ -176,10 +196,21 @@ struct BoardInfoView: View { // nothing here at all — no header, no divider, no placeholder — and the popover ends at // Styling, complete in itself. Only a board that actually carries an inert `.git` earns // this closing note. - if hasGitDirectory { + // + // The awareness line is its neighbour on the same terms, and the two stack in one section + // when both are true — a `.git`-bearing board open in Pro is exactly the household where + // both sentences apply, and each answers a different question. + if hasGitDirectory || otherEditionNote != nil { Divider() - BoardGitNote() - .padding(inset) + VStack(alignment: .leading, spacing: 4) { + if hasGitDirectory { + BoardGitNote() + } + if let otherEditionNote { + BoardEditionPresenceNote(text: otherEditionNote) + } + } + .padding(inset) } } // The style editor's popover width, taken from the editor rather than restated: the embed @@ -310,3 +341,70 @@ struct BoardGitNote: View { FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path) } } + +// MARK: - The other edition + +/// **The cross-edition awareness line** — "Also open in Lanework Pro" (12-editions.md ▸ Both editions +/// installed, ruled 2026-07-29). +/// +/// > Two conveniences ride the shared registry: open-now flags are per-edition …, and the board +/// > popover carries a contextual awareness line ("Also open in Lanework Pro") read from the other +/// > edition's flag, **pid-liveness-checked so crash residue never lies** — a line, never a gate. +/// +/// It is `BoardGitNote`'s posture applied to a different fact: contextual, absent when untrue, and +/// never a control. Nothing about it gates anything — the same board open in both apps is the designed +/// foreign-writer story (12), so this line exists to *explain* what the user is looking at, not to +/// warn them off it. +/// +/// ### Why liveness matters, and why it is a parameter +/// +/// The flag is a live open marker that a crash deliberately leaves standing (02-architecture.md +/// § Launch and window lifecycle — that residue is what makes crash recovery free). So a flag alone +/// would claim "also open in Lanework Pro" about an app that died last Tuesday. `NSRunningApplication` +/// settles it, and the check is injected so `note(otherEditions:isRunning:)` is a pure function the +/// tests pin directly — the same seam `BoardGitNote.hasGitDirectory(at:)` is. +enum BoardEditionPresence { + + /// Whether an edition is running right now, by bundle id — the pid-liveness half. + /// + /// `NSRunningApplication.runningApplications(withBundleIdentifier:)` rather than a stored pid: the + /// registry records *which* edition had the board open, never a process id, and it should not + /// start — a pid is a fact with a shelf life of milliseconds, and the bundle id is the question + /// actually being asked. + @MainActor + static func isRunning(_ bundleID: String) -> Bool { + !NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).isEmpty + } + + /// The line, or `nil` for no line at all. + /// + /// Three ways to get `nil`, and each is the honest answer: no other edition has the board flagged; + /// the flagged edition is not running (crash residue); or the flagged bundle id is one this build + /// cannot name (`AppGroup.editionDisplayName`) — a future edition, where inventing a name would be + /// worse than saying nothing. + /// + /// **One line even when several editions qualify**, taking the first by the sorted order the + /// registry hands over: the popover has room for a sentence, not a roster, and with Teams + /// deferred there is no case today where two others are open at once. + static func note(otherEditions: [String], isRunning: (String) -> Bool) -> String? { + for bundleID in otherEditions { + guard isRunning(bundleID), let name = AppGroup.editionDisplayName(bundleID) else { continue } + return "Also open in \(name)" + } + return nil + } +} + +/// The awareness line as a view, beside `BoardGitNote` and styled identically — the two contextual +/// notes are one register, so a popover carrying both reads as one surface. +struct BoardEditionPresenceNote: View { + + let text: String + + var body: some View { + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/KanbanPro/KanbanPro.entitlements b/KanbanPro/KanbanPro.entitlements index 51d27c0..663f0c8 100644 --- a/KanbanPro/KanbanPro.entitlements +++ b/KanbanPro/KanbanPro.entitlements @@ -10,6 +10,15 @@ com.apple.security.files.bookmarks.app-scope + + com.apple.security.application-groups + + group.dev.rzen.indie.Kanban + diff --git a/KanbanTests/AppGroupStateTests.swift b/KanbanTests/AppGroupStateTests.swift new file mode 100644 index 0000000..4ef6422 --- /dev/null +++ b/KanbanTests/AppGroupStateTests.swift @@ -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") + } +} diff --git a/KanbanTests/AppModelTests.swift b/KanbanTests/AppModelTests.swift index ac213b1..31dfe2c 100644 --- a/KanbanTests/AppModelTests.swift +++ b/KanbanTests/AppModelTests.swift @@ -33,14 +33,19 @@ private func makeMixedBoard() throws -> WriterFixture { return fixture } -/// An `AppModel` whose registry file lives in temp rather than in the test host's real Application -/// Support directory. +/// An `AppModel` whose app-side state lives in temp rather than in the shared App Group container — +/// 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 private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { let folder = FileManager.default.temporaryDirectory .appendingPathComponent("AppModelTests-\(UUID().uuidString)", isDirectory: 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) }) } @@ -214,13 +219,13 @@ struct AppModelTests { 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.cardCount == 2) - #expect(record.isOpenNow == false) + #expect(!record.isOpen(inEdition: model.boardRegistry.editionID)) #expect(model.boardRegistry.restorables().isEmpty) // 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. 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") diff --git a/KanbanTests/BoardRegistryTests.swift b/KanbanTests/BoardRegistryTests.swift index f72957c..50ac393 100644 --- a/KanbanTests/BoardRegistryTests.swift +++ b/KanbanTests/BoardRegistryTests.swift @@ -91,7 +91,7 @@ struct BoardRegistryTests { let id = registry.recordOpen(of: fixture.root, displayName: "Todo Board") 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.lastKnownPath == fixture.root.path) #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 record = try #require(registry.record(id: id)) #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.iconColor == nil) } @@ -163,7 +163,7 @@ struct BoardRegistryTests { #expect(record.icon == "star") #expect(record.iconColor == "fern") #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 @@ -620,16 +620,16 @@ struct BoardRegistryTests { let registry = BoardRegistry(storageURL: storage.url) 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) 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]) // A user close. The flag goes, and with it the board's place in the next launch. 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) // 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) 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(afterRelaunch.record(id: id)?.laneCount == 2) } @@ -712,13 +712,13 @@ struct BoardRegistryTests { let registry = BoardRegistry(storageURL: storage.url) #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.record(id: id)?.laneCount == 4, "and every other field survived") // And the key writes through from here on. 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 diff --git a/KanbanTests/BoardTemplateTests.swift b/KanbanTests/BoardTemplateTests.swift index b132c94..4d54f93 100644 --- a/KanbanTests/BoardTemplateTests.swift +++ b/KanbanTests/BoardTemplateTests.swift @@ -230,7 +230,7 @@ struct TemplateChooserRowTests { // MARK: - Fixture /// 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 { let store: URL diff --git a/KanbanTests/CardWindowShellTests.swift b/KanbanTests/CardWindowShellTests.swift index a14b113..e54446d 100644 --- a/KanbanTests/CardWindowShellTests.swift +++ b/KanbanTests/CardWindowShellTests.swift @@ -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) } -/// 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 private struct RegistryStorage { let folder: URL @@ -264,7 +264,10 @@ struct CardWindowIdentityTests { let storage = try RegistryStorage() 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 recordID = model.boardRegistry.recordOpen(of: fixture.root) let store = try model.storeRegistry.acquire(fixture.root) diff --git a/KanbanTests/ClipboardTests.swift b/KanbanTests/ClipboardTests.swift index 7c0e973..da695c1 100644 --- a/KanbanTests/ClipboardTests.swift +++ b/KanbanTests/ClipboardTests.swift @@ -8,8 +8,9 @@ import Testing /// /// Every suite here drives a real store over a real temp board, with two things injected: a fake /// pasteboard (so a test never races the machine's one real pasteboard, nor every other test in the -/// run) and a temp staging directory (so nothing goes near Application Support). Both seams exist -/// exactly because those two claims are the ones worth pinning. +/// run) and a temp staging directory (so nothing goes near the shared App Group container, which is now +/// the sibling edition's staging store too). Both seams exist exactly because those two claims are the +/// ones worth pinning. // MARK: - Test doubles @@ -109,8 +110,14 @@ struct ClipboardHarness { } /// The staged copy directories, sorted — "at most the current copy" is a claim about this list. + /// + /// Hidden entries are excluded because the sweep keeps its own bookkeeping folder among them + /// (`ClipboardStore.prune`'s claim-then-delete, which is what makes a concurrent sweep by the + /// sibling edition safe). A staged copy is never hidden — its name is a lowercased UUID. func stagedCopyIDs() throws -> [String] { - try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted() + try FileManager.default.contentsOfDirectory(atPath: staging.path) + .filter { !$0.hasPrefix(".") } + .sorted() } } @@ -389,6 +396,106 @@ struct ClipboardSweepTests { #expect(try harness.stagedCopyIDs().isEmpty) } + + // MARK: The sibling edition's sweep + // + // The staging store now lives in the shared App Group container (12-editions.md ▸ Both editions + // installed), so base and Pro sweep the same directory on their own launches, activations, copies + // and pastes. Both compute the *same* answer — the keep set is the one `copyID` the machine-wide + // pasteboard names — so they never disagree about what should go; what they can do is arrive at the + // same doomed tree together. These two tests are the ruling's two clauses: atomic removals, and + // missing-entry = already swept. + + @Test("Two editions sweeping the same store at once agree, and neither errors") + func concurrentSweepsFromBothEditionsAgree() async throws { + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: staging) } + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + + // Six trees, each with a file in it so a removal is a real recursive delete rather than an + // empty-directory unlink — the case where two sweepers walking one tree could see it half gone. + for name in ["a", "b", "c", "d", "e", "keep"] { + let tree = staging.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory( + at: tree.appendingPathComponent("nested", isDirectory: true), + withIntermediateDirectories: true + ) + try Data("bytes".utf8).write(to: tree.appendingPathComponent("nested/file.txt", isDirectory: false)) + } + + // Both editions read the *same* pasteboard, which is why both keep sets are `keep`. Modelled as + // two stores over one staging root with pasteboards holding the same manifest, since two + // processes are not something a unit test can have. + let manifest = ClipboardManifest( + copyID: "keep", + boardRoot: URL(fileURLWithPath: "/Boards/Shared.kanban", isDirectory: true), + kind: .card, + container: .board, + // One entry, because a manifest with none is refused outright (`init?(data:)`) — and a + // refused manifest is a keep set of nothing, which is a different test. + entries: [ + ClipboardManifest.Entry( + id: Ident.card1, + folder: Ident.card1, + title: "First", + index: "---\nschema: 1\ntitle: First\norder: 1024\n---\n", + attachmentCount: 0 + ) + ] + ) + let data = try #require(manifest.encoded()) + + let onePasteboard = FakePasteboard() + onePasteboard.write(manifest: data, text: manifest.plainText) + let otherPasteboard = FakePasteboard() + otherPasteboard.write(manifest: data, text: manifest.plainText) + + // Each `init` sweeps — the launch sweep — so the two are already racing before either explicit + // call below. + let one = ClipboardStore(pasteboard: onePasteboard, stagingRoot: staging, observesActivation: false) + let other = ClipboardStore(pasteboard: otherPasteboard, stagingRoot: staging, observesActivation: false) + one.sweep() + other.sweep() + one.sweep() + await one.stagingSettled() + await other.stagingSettled() + + // The answer both computed, arrived at exactly once: the named tree intact, everything else + // gone, and no bookkeeping left behind. + #expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted() == ["keep"]) + #expect(FileManager.default.fileExists(atPath: staging.appendingPathComponent("keep/nested/file.txt").path)) + } + + @Test("A tree that vanished before the sweep reached it is already swept, not an error") + func aVanishedEntryIsANoOp() async throws { + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("ClipboardTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: staging) } + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + let doomed = staging.appendingPathComponent("gone", isDirectory: true) + try FileManager.default.createDirectory(at: doomed, withIntermediateDirectories: true) + + let clipboard = ClipboardStore( + pasteboard: FakePasteboard(), + stagingRoot: staging, + observesActivation: false + ) + // The sibling got there first — which from this store's side is indistinguishable from the + // directory listing simply being stale by the time it is walked. + try FileManager.default.removeItem(at: doomed) + clipboard.sweep() + await clipboard.stagingSettled() + + #expect(try FileManager.default.contentsOfDirectory(atPath: staging.path).isEmpty) + + // And a staging root that has gone altogether — the sibling swept, then something removed the + // shared folder — is nothing to do either, rather than a throw on the way to a no-op. + try FileManager.default.removeItem(at: staging) + clipboard.sweep() + await clipboard.stagingSettled() + #expect(!FileManager.default.fileExists(atPath: staging.path), "a vanished store is not recreated by a sweep") + } } // MARK: - Takeover diff --git a/KanbanTests/HistoryProviderTests.swift b/KanbanTests/HistoryProviderTests.swift index 7f7f80c..a60a414 100644 --- a/KanbanTests/HistoryProviderTests.swift +++ b/KanbanTests/HistoryProviderTests.swift @@ -464,7 +464,10 @@ private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { let folder = FileManager.default.temporaryDirectory .appendingPathComponent("HistoryProviderTests-\(UUID().uuidString)", isDirectory: 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) }) } diff --git a/KanbanTests/SaveAsTemplateTests.swift b/KanbanTests/SaveAsTemplateTests.swift index 1ebc16e..321b652 100644 --- a/KanbanTests/SaveAsTemplateTests.swift +++ b/KanbanTests/SaveAsTemplateTests.swift @@ -19,8 +19,9 @@ import Testing /// /// 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` -/// is named here only to prove the engine never creates it on its own. +/// Every test drives an explicit store URL. **No test may touch the real store** — which since the +/// 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 @@ -438,14 +439,18 @@ struct SaveAsTemplateAtomicityTests { @Suite("Save as Template — the user store") 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() { let store = TemplateEngine.userStore #expect(store.lastPathComponent == TemplateEngine.storeFolderName) - #expect(store.deletingLastPathComponent().lastPathComponent - == (Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban")) - #expect(store.path.contains("Application Support")) + // Beside the registry and the clipboard's staging store, in the App Group container + // (09-templates.md ▸ Storage, re-homed 2026-07-29 — templates cross editions). Compared against + // 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 // Reveal in Finder's, and both are driven with an explicit store in this suite. } diff --git a/KanbanTests/WelcomeRowTests.swift b/KanbanTests/WelcomeRowTests.swift index ea763aa..e04ebf5 100644 --- a/KanbanTests/WelcomeRowTests.swift +++ b/KanbanTests/WelcomeRowTests.swift @@ -15,8 +15,8 @@ import Testing // MARK: - Fixtures -/// A record with nothing in it that matters except what a given test is about. The bookmark is empty -/// because this function never resolves one — `RecentBoard` is constructed directly here, so the +/// A record with nothing in it that matters except what a given test is about. It holds **no grant +/// slot** because this function never resolves one — `RecentBoard` is constructed directly here, so the /// availability classification is an input rather than a filesystem outcome. private func record( name: String, @@ -28,7 +28,6 @@ private func record( iconColor: String? = nil ) -> BoardRecord { BoardRecord( - bookmark: Data(), displayName: name, lastKnownPath: path, lastOpened: opened, diff --git a/scripts/verify-editions.sh b/scripts/verify-editions.sh index 70b4d81..a3b160f 100755 --- a/scripts/verify-editions.sh +++ b/scripts/verify-editions.sh @@ -144,7 +144,8 @@ fi for key in com.apple.security.app-sandbox \ 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 pass "base carries $key" else @@ -164,7 +165,7 @@ done # archive — which is why they are named out loud below rather # 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.-]*)$' -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")" if [ -n "$base_extra" ]; then @@ -187,6 +188,7 @@ head2 "Pro — entitlements" for key in 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 \ com.apple.security.network.client \ keychain-access-groups; do if has_entitlement "$PRO_APP" "$key"; then @@ -196,6 +198,45 @@ for key in com.apple.security.app-sandbox \ fi 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 `` and +# everything after its close, leaving only its own ``s to unwrap. +app_groups() { + entitlements_of "$1" | + sed -e 's/.*com\.apple\.security\.application-groups<\/key>//' \ + -e 's/<\/array>.*//' | + grep -o '[^<]*' | + 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 head2 "Bundle identity"