Home app-side state in the shared App Group container

Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):

- AppGroup namespace: container resolution with per-edition fallback
  when unprovisioned, shared UserDefaults suite, edition identity, and
  a unit-test-host redirect (the test host IS the app — its launch
  sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
  openNow keyed by bundle id; hand-written Codable keeps legacy keys
  decoding (adopted in memory as the running edition's slots, upgraded
  on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
  first click runs an open panel pre-anchored at the recorded path,
  prompt "Grant"; recordOpen mints this edition's slot onto the
  matched shared record (path fallback only after identity fails and
  only against records holding no grant of ours, so re-granting never
  forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
  registry when the sibling edition wrote it, so one edition's save
  never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
  popover gains BoardEditionPresence ("Also open in Lanework Pro"),
  pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
  claims doomed trees by atomic rename into .sweeping/ then deletes,
  so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
  re-ruling; scalars (quick-style recents, window size) move to the
  shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
  family group). No pathfinder 1.x migrator: 1.x predates the
  registry; state starts fresh in the group container.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 20:18:15 -04:00
parent a99e1a52f0
commit 566deab506
28 changed files with 1733 additions and 181 deletions
+189
View File
@@ -0,0 +1,189 @@
import Foundation
import os
/// **The family App Group** where every edition's app-side state lives (12-editions.md
/// § Distribution, ruled 2026-07-29; 02-architecture.md § Per-board app state).
///
/// ### Why a shared container at all
///
/// Boards are files, so they need no migration between editions but the *app-side* state around
/// them (the recents list, per-board frames, the quick-style row, the clipboard's staged snapshot)
/// is app-private, and per-app-private means an upgrader lands on an empty home screen. So every
/// edition declares one group `group.dev.rzen.indie.Kanban` and homes that state in its
/// container **from day one**: base 2.0 ships with the entitlement, Pro's first release joins the
/// same group, Teams later does too, and at no point is there a migration or an ordering dependency
/// between them.
///
/// ### The one thing that cannot be shared
///
/// **Security-scoped bookmarks never cross sandboxes** App Group or not, a bookmark is minted for
/// one app's sandbox and resolves in that one only. So the registry record is *common* except for
/// a per-edition **grant slot** keyed by bundle id (`BoardRecord.grants`), and a record whose only
/// grant another edition minted reads as unavailable-until-reopened. Open-now flags are keyed the
/// same way, for the same shape of reason: an edition restores the boards *it* had open.
///
/// ### It degrades rather than fails
///
/// `containerURL(forSecurityApplicationGroupIdentifier:)` answers `nil` when the group is not
/// provisioned for the running binary a unit-test host without the capability, a locally signed
/// build before the group is registered on the team. Every path here falls back to the *previous*
/// per-edition Application Support home in that case, so nothing depends on provisioning to work:
/// state simply stops being shared, which is exactly the old behaviour.
public enum AppGroup {
/// The group id every edition declares, verbatim (12-editions.md Distribution). It is
/// deliberately the *family* name rather than an edition's: Pro and Teams declare this same
/// string, and a future edition's bundle id joins with no further ceremony.
public static let identifier = "group.dev.rzen.indie.Kanban"
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-group")
// MARK: - Edition identity
/// Base's bundle id also the fallback when `Bundle.main` has none, which is a test host's
/// case and never a shipped app's.
public static let baseEditionID = "dev.rzen.indie.Kanban"
/// Pro's bundle id (12 Targets, ruled 2026-07-27). Named here as well as in
/// `ProEdition.bundleIdentifier` because *base* has to know it: the popover's awareness line and
/// the grant-slot keying are shared-tree code that must be able to name the other edition
/// without compiling any of it.
public static let proEditionID = "dev.rzen.indie.KanbanPro"
/// Which edition is running the key every per-edition slot on a shared record is stored under.
///
/// Read from `Bundle.main` rather than declared per target, which is what keeps this file free of
/// any edition conditional: the binary already knows which app it is.
public static var editionID: String {
Bundle.main.bundleIdentifier ?? baseEditionID
}
/// The user-facing name of an edition, for the popover's awareness line ("Also open in Lanework
/// Pro"). `nil` for a bundle id this build has never heard of a future edition's, or a stale
/// slot left by something else because inventing a name for it would be worse than saying
/// nothing, and the line's whole posture is that it never lies.
public static func editionDisplayName(_ bundleID: String) -> String? {
switch bundleID {
case baseEditionID: "Lanework"
case proEditionID: "Lanework Pro"
default: nil
}
}
// MARK: - The container
/// The group container, or `nil` when the running binary has no such capability.
///
/// Not cached: the answer is a property of the process's entitlements and cannot change within
/// a launch, but the call is a cheap lookup and a cached `nil` from an early read (before the
/// container has been created for the first time) is the sort of staleness this file should not
/// invent.
public static var containerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
}
/// Where every app-side file store lives the registry, the clipboard's staging snapshots, and
/// whatever joins them.
///
/// In a shipped app this is `productionStateDirectory`. **In a unit-test host it is a scratch
/// directory** (`isUnitTestHost`), which is not a nicety: the real container is shared with the
/// sibling edition and with the developer's own running copy, so a suite that used it would be
/// sweeping real staged clipboard copies and rewriting a real recents list.
public static var stateDirectory: URL {
isUnitTestHost ? unitTestStateDirectory : productionStateDirectory
}
/// What a shipped app uses: `<group container>/Library/Application Support/`.
///
/// **No bundle-id subfolder**, unlike the per-edition home this replaces that subfolder was
/// exactly what kept two editions from seeing one list, and its absence is the whole feature.
/// `Library/Application Support` is kept as the path *inside* the container for Apple's
/// convention rather than for any behaviour: the container is the app's either way.
///
/// Falls back to `perEditionSupportDirectory` when there is no group container (see the type's
/// note): unshared, but working.
public static var productionStateDirectory: URL {
guard let containerURL else {
logger.debug("no group container for \(identifier, privacy: .public); using the per-edition home")
return perEditionSupportDirectory
}
return containerURL
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Application Support", isDirectory: true)
}
/// The pre-2.0 home `~/Library/Application Support/<bundle id>/`, inside this edition's own
/// sandbox container. Kept as the fallback above and as the home of anything deliberately *not*
/// shared.
public static var perEditionSupportDirectory: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
return support.appendingPathComponent(editionID, isDirectory: true)
}
// MARK: - Keeping the suites out of it
/// Whether this process is hosting a unit-test bundle.
///
/// ### Why the app has to know
///
/// The unit-test host **is the app** (`KanbanTests` and `KanbanProTests` are hosted bundles), so
/// `KanbanApp.init()` runs for real on every test launch and builds an `AppModel` over whatever
/// the defaults resolve to. Every *object* a test constructs takes its storage by injection that
/// is the seam, and it is untouched but the host's own launch has no injection point, and after
/// the 2026-07-29 ruling the thing it would reach for is a container shared with the sibling
/// edition and with the developer's running copy. Its launch sweep would collect real staged
/// clipboard trees; its `refreshRecents()` would resolve, refresh and rewrite real records.
///
/// So the *default* moves for a test host, which is the one place a default can be wrong in a way
/// injection cannot fix.
///
/// ### Why this variable and not a launch flag
///
/// `UITestLaunch.fixtureFlag` is the flag-shaped answer and remains the right one for the UI
/// suites, which launch the app themselves and can pass arguments. A *unit*-test host is launched
/// by the test runner, which passes nothing of ours but it does set these variables, and it has
/// set them for as long as XCTest has existed. Three spellings are checked because Apple has used
/// each at some point and a missed one would silently mean "not a test".
///
/// It cannot fire in a shipped app: nothing sets these but a test runner.
public static var isUnitTestHost: Bool {
let environment = ProcessInfo.processInfo.environment
return environment["XCTestConfigurationFilePath"] != nil
|| environment["XCTestBundlePath"] != nil
|| environment["XCTestSessionIdentifier"] != nil
}
/// The scratch home a test host uses instead. Inside the app's own container (`NSTemporaryDirectory`
/// sandboxes there), so nothing outside this app can see it and the OS reclaims it.
///
/// One fixed folder rather than one per run: the suites do not depend on it being empty they
/// inject their own paths for anything they assert on and a stable name keeps it inspectable when
/// something writes there that should not have.
public static var unitTestStateDirectory: URL {
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("LaneworkUnitTestState", isDirectory: true)
}
// MARK: - The shared defaults suite
/// The group's shared `UserDefaults` suite where app-side state that is a *scalar* lives
/// (02-architecture.md § Per-board app state: "or the group's shared `UserDefaults` suite where
/// a scalar fits").
///
/// `UserDefaults(suiteName:)` answers `nil` only for a suite name equal to the app's own bundle
/// id, which this never is; `.standard` is the fallback anyway, for the reason every fallback
/// here exists an unshared preference is a papercut, an unreadable one is a bug.
///
/// Without the entitlement the suite is an ordinary named domain rather than a shared one, so
/// this works unprovisioned too: the values are simply this edition's alone.
///
/// A **test host gets its own suite name** for `isUnitTestHost`'s reason applied to preferences: a
/// suite that read and wrote the real one would be reading and writing the developer's quick-style
/// row and window size, which is the objection `StyleModelTests` already states about
/// `UserDefaults.standard`.
public static var defaults: UserDefaults {
UserDefaults(suiteName: isUnitTestHost ? "\(identifier).unit-tests" : identifier) ?? .standard
}
}