Files
rzen 2c6b8fe63a Remove the App Group wholesale — one sandbox, one bookmark, one flag
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
2026-07-30 17:46:32 -04:00

182 lines
9.1 KiB
Swift

import Foundation
// MARK: - WelcomeRow
/// One row of the welcome window's recents list: a `RecentBoard` joined with whatever launch failure
/// names the same board.
///
/// ### Why the join is a value, derived by a pure function
///
/// 02-architecture.md § Launch and window lifecycle makes the *row* the failure surface:
///
/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome
/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's
/// > specifics (load error) or the unavailable state per Graceful orphaning. Other restorations
/// > proceed unaffected — never a launch-time modal chain, never a silent drop.
///
/// That is a rule about *data*: which of three captions a row wears, which of its actions are live,
/// and — the clause a list of rows cannot express by itself — what happens to a failure that matches
/// no row at all. Derived here, every one of those cases is stateable in a test without a window,
/// which is the only way the "never a silent drop" half is checkable at all.
///
/// The same derivation feeds File ▸ Open Recent, so the submenu and the list can never disagree
/// about a board's name or about whether it can be opened.
struct WelcomeRow: Identifiable, Equatable {
/// The registry record's id — the row's identity, the selection's value, and what Forget names.
let id: UUID
/// The board's name: the record's cached `displayName`, falling back to the folder name for a
/// record that somehow carries none (01-storage-format.md § Board naming's fallback, applied to
/// the cached string rather than to a board this window must never open).
let displayName: String
/// The board's cached `icon`/`iconColor` (02-architecture.md § Per-board app state: "The row's
/// title and icon are registry-cached too — with live write-through"). `nil` reads as "draw the
/// board-default glyph" — the same lenient fallback the board window's own icon field gets, and
/// exactly what an unstamped or override-free record means. The row never resolves these itself;
/// that is the renderer's job (`ItemSymbol`, `Palette`), so the same record decodes into one
/// answer for both the welcome row and File ▸ Open Recent.
let icon: String?
let iconColor: String?
/// Where the board is **now**, or `nil` when its bookmark no longer resolves. The single source
/// of the row's availability: Open and Reveal need a URL, and an orphan has none.
let url: URL?
/// The containing folder, for the row's location line — Xcode's welcome shows where a project
/// lives, not its own path repeated under its name. Home-abbreviated where it can be.
let location: String
/// The counts stamped at last close, `nil` until a close has stamped them
/// (02 § Per-board app state: registry-cached, never a directory scan at welcome time).
let laneCount: Int?
let cardCount: Int?
/// The message of the launch failure naming this board, if one does.
let failure: String?
var isAvailable: Bool { url != nil }
/// Open and Reveal in Finder both need somewhere to go; Forget is deliberately not here, because
/// it is enabled on every row — an orphan the user can never open is exactly the row that most
/// needs erasing (02 § Graceful orphaning: "recents surface it as unavailable with Forget").
var canOpen: Bool { isAvailable }
var canReveal: Bool { isAvailable }
/// The row's third line — one line, so the three states are alternatives rather than a stack.
///
/// The precedence is 02's sentence read in order: a failure is what the row is *for* at that
/// moment and outranks both the orphan state (which the failure message already describes in
/// better words) and the counts (facts about a board the user cannot currently get into).
enum Caption: Equatable {
/// The ordinary row: "3 lanes · 12 cards", or an em dash where nothing has been stamped.
case counts(lanes: Int?, cards: Int?)
/// The bookmark no longer resolves (02 § Graceful orphaning).
case unavailable
/// Fail-fast's specifics, from the open or restore that failed.
case failed(String)
}
var caption: Caption {
if let failure { return .failed(failure) }
if url == nil { return .unavailable }
return .counts(lanes: laneCount, cards: cardCount)
}
/// "3 lanes · 12 cards" — or "—" when the record has never been closed and so carries nothing.
///
/// A single em dash rather than "0 lanes · 0 cards": an unstamped record knows nothing about the
/// board's size, and zero is a claim.
var countsSummary: String {
guard let laneCount, let cardCount else { return "—" }
let lanes = "\(laneCount) lane\(laneCount == 1 ? "" : "s")"
let cards = "\(cardCount) card\(cardCount == 1 ? "" : "s")"
return "\(lanes) · \(cards)"
}
// MARK: - Derivation
/// The rows, and the failures no row could carry.
struct Derivation: Equatable {
var rows: [WelcomeRow]
/// Failures naming no record — a first open of a folder that turned out not to be a board,
/// which fails before anything is registered and so has no row to render on. They keep a
/// list of their own on welcome, because the alternative is the silent drop 02 rules out.
var unmatched: [LaunchFailure]
}
/// Joins the recents list with the launch failures, in `recents`' order (which is the registry's
/// `lastOpened` descending — this function never re-sorts, so the sort rule keeps living in
/// exactly one place).
///
/// **Matching is by path, and by both of a record's paths.** A record knows where it was last
/// seen (`lastKnownPath`) and, when its bookmark resolves, where it lives now; a bookmark follows
/// a move, so a failure recorded before one names the older spelling. Paths are standardized
/// before comparison — `/tmp/b/../b` and `/tmp/b` are one board — but never resolved through
/// symlinks: that would be a filesystem round trip per row, which is the cost the registry's
/// whole design is built to avoid at welcome time.
///
/// **The newest failure wins a row's caption** when several name it (a board that failed, was
/// retried, and failed again), because it is the one describing the state the file is in now.
/// All of them are consumed either way — a row carries one message, and the older attempts must
/// not resurface in the unmatched list as if nothing had shown them.
static func derive(recents: [RecentBoard], failures: [LaunchFailure]) -> Derivation {
var claimed: Set<UUID> = []
let rows = recents.map { recent -> WelcomeRow in
let record = recent.record
var keys: Set<String> = [pathKey(record.lastKnownPath)]
if let url = recent.url {
keys.insert(pathKey(url.path))
}
let matches = failures.filter { keys.contains(pathKey($0.path)) }
claimed.formUnion(matches.map(\.id))
return WelcomeRow(
id: record.id,
displayName: record.displayName.isEmpty
? URL(fileURLWithPath: record.lastKnownPath).deletingPathExtension().lastPathComponent
: record.displayName,
icon: record.icon,
iconColor: record.iconColor,
url: recent.url,
location: location(of: recent.url?.path ?? record.lastKnownPath),
laneCount: record.laneCount,
cardCount: record.cardCount,
failure: matches.last?.message
)
}
return Derivation(rows: rows, unmatched: failures.filter { !claimed.contains($0.id) })
}
/// How two paths are compared for "the same board" on this screen. Standardized only — see
/// `derive` for why nothing here touches the filesystem.
static func pathKey(_ path: String) -> String {
URL(fileURLWithPath: path).standardizedFileURL.path
}
/// The board's containing folder, with the user's home written as `~`.
static func location(of path: String) -> String {
let parent = URL(fileURLWithPath: path).deletingLastPathComponent().path
guard let home = realHomeDirectory, parent == home || parent.hasPrefix(home + "/") else {
return parent
}
return "~" + parent.dropFirst(home.count)
}
/// The user's **real** home directory.
///
/// `NSHomeDirectory()` and `FileManager.homeDirectoryForCurrentUser` both answer with the sandbox
/// container, which no board is ever inside — abbreviating against either would never once fire.
/// The password database is where the real path still lives, and this is display text only:
/// nothing is opened, resolved, or written relative to it, so being wrong costs a longer row.
private static let realHomeDirectory: String? = {
guard let entry = getpwuid(getuid()), let directory = entry.pointee.pw_dir else { return nil }
return String(cString: directory)
}()
}