Files
lanework/Kanban/LiveStore/AppGroup.swift
T
rzen 092300c7d2 Collapse the edition split — one target, twins merged, verify-editions retired
Phase 1 of the 2026-07-30 one-app pivot (DESIGN 0bec9a6, card c3a3ddd5):
the KanbanPro target, LaneworkPro scheme, KanbanProTests module-alias
bundle, KanbanPro/ source root and scripts/verify-editions.sh retire
wholesale. project.yml reads as a single-target file again (anchors
inlined, header rewritten in tier vocabulary).

The edition twins merge: EditionTypes -> PasteboardTypes (one
UTType(exportedAs:) home — the one app owns the family types),
EditionAbout -> AboutBox (the quiet Pro signpost survives as the About
box's one line; "…in Settings" deferred until the StoreKit phase gives
it somewhere to point). InertGitTests drops its Base prefix — the
inert-.git posture is unconditional app behavior, unsubscribed and
lapsed being one state.

Entitlements gain com.apple.security.network.client, declared now and
dormant until Pro's remotes use it; no keychain access group. The App
Group key deliberately stays — it goes with AppGroup.swift in phase 2,
since pulling it first would silently drop the app into the fallback
container. README/RELEASE.md build-and-pipeline prose updated to the
one-record world; the subscription story lands with phase 3.

1893 tests in 322 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 17:25:50 -04:00

199 lines
11 KiB
Swift

import Foundation
import os
/// **⚠ Retired by the one-app collapse — one-app collapse phase 2.** 12-editions.md ▸ App-side
/// state (re-ruled 2026-07-30) removes the App Group wholesale: with one app there is no sibling
/// to share a container *with*, so the registry and its peers home in the ordinary sandbox
/// container, records carry one grant and one open-now flag, and this type goes away with the
/// entitlement. Everything below still describes the shipping code, and the code still works —
/// it is simply describing a world that is being dismantled in a later phase, so the two-app
/// reasoning is left standing rather than half-rewritten into a fiction.
///
/// **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"
/// The bundle id the retired Pro *app* would have carried (12 ▸ Targets, ruled 2026-07-27) —
/// **one-app collapse phase 2**: no app has ever shipped under it, and nothing will now that Pro
/// is a subscription rather than a second binary (12 ▸ Distribution, re-ruled 2026-07-30). It
/// stays only because the awareness line and the grant-slot keying still read it; both go in the
/// same phase, and this constant with them.
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` is a hosted bundle), 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 developer's
/// own 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
}
}