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
199 lines
11 KiB
Swift
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
|
|
}
|
|
}
|