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