Phase 2 of the one-app pivot (DESIGN 12 ▸ App-side state, re-ruled
2026-07-30; reworks 566deab). AppGroup retires; what remains is
AppStateHome — ordinary sandbox Application Support as the one home for
the registry, clipboard staging and template stores, keeping the
unit-test-host redirect (the test host is the app and would sweep real
state). Scalar defaults return to UserDefaults.standard.
BoardRecord's per-edition grant slots and openNow flags collapse to one
bookmark + one isOpenNow; the legacy-key decode and adopt-in-memory
paths go (nothing shipped with group-era records), while the founding
four-keys-required / defaults-for-everything-since decode policy stays —
a bookmarkless record decodes as the born-orphan row rather than
quarantining the list. needsReopen and the pre-anchored re-grant panel
are removed whole: the only state that flow served — a record granted by
a sibling sandbox — is unrepresentable now, and a dead bookmark of our
own was already the orphan case by explicit comment. The
indexOfRecord path fallback dies with it; path is never a key again.
The cross-process freshness stamp (mtime+size re-read) and
BoardEditionPresence with its popover "Also open in…" line retire; the
clipboard prune keeps its atomic .sweeping/ claim-then-delete, reframed
for crash residue and open -n copies rather than sibling editions. The
application-groups entitlement key is gone.
1880 tests in 317 suites green (13 cross-edition tests retired with
their subject).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
89 lines
4.8 KiB
Swift
89 lines
4.8 KiB
Swift
import Foundation
|
|
|
|
/// **Where app-side state lives** — the board registry, the clipboard's staging snapshots, the user
|
|
/// template store (02-architecture.md § Per-board app state, "App-wide state has the same home").
|
|
///
|
|
/// ### One app, one sandbox, one home
|
|
///
|
|
/// There is one application (12-editions.md ▸ The target, re-ruled 2026-07-30), so there is one
|
|
/// sandbox container and nothing to share state *with*. `Library/Application Support` inside that
|
|
/// container is the whole answer: the sandbox already scopes it per app, which is why there is **no
|
|
/// bundle-id subfolder** — a subfolder inside a container that is already this app's alone would be
|
|
/// ceremony naming the app twice.
|
|
///
|
|
/// Scalars are not here. A window size or a toggle goes to `UserDefaults.standard`
|
|
/// (`AppPreferences`), which the sandbox scopes on exactly the same terms; this type is only about
|
|
/// the *file* stores.
|
|
///
|
|
/// ### Why the type exists at all rather than three copies of four lines
|
|
///
|
|
/// Three stores answer "where do I live" and they must answer it identically: the registry, the
|
|
/// clipboard staging root and the template store are neighbours by design, and a test that asserts
|
|
/// they are neighbours (`BoardRegistryTests`) is asserting something real. One name for the home is
|
|
/// what keeps them moving together the day it moves.
|
|
public enum AppStateHome {
|
|
|
|
/// The directory every app-side file store lives in.
|
|
///
|
|
/// In a shipped app this is `productionDirectory`. **In a unit-test host it is a scratch
|
|
/// directory** (`isUnitTestHost`) — see that property for why the default has to move for a test
|
|
/// host when everything else about testing is injection.
|
|
public static var directory: URL {
|
|
isUnitTestHost ? unitTestDirectory : productionDirectory
|
|
}
|
|
|
|
/// What a shipped app uses: `Library/Application Support` inside the sandbox container.
|
|
///
|
|
/// The `NSHomeDirectory()` fallback covers the case where `FileManager` answers with no domain
|
|
/// at all — not a state a shipped app is in, but this file must not be the thing that throws.
|
|
public static var productionDirectory: URL {
|
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent("Library/Application Support", isDirectory: true)
|
|
}
|
|
|
|
// MARK: - The unit-test host
|
|
|
|
/// 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 the thing it reaches for
|
|
/// is the developer's own state: its launch sweep would collect real staged clipboard trees, and
|
|
/// 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 unitTestDirectory: URL {
|
|
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
|
.appendingPathComponent("LaneworkUnitTestState", isDirectory: true)
|
|
}
|
|
}
|