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: `/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` 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 } }