From 7f40e4e56861ef7a8647f996641a00cf4f06205e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Sun, 12 Jul 2026 21:37:24 +0200 Subject: [PATCH 1/2] =?UTF-8?q?docs(spec):=20traduction=20EN=20des=20specs?= =?UTF-8?q?=20+=20d=C3=A9placement=20vers=20docs/=20+=20liens=20corrig?= =?UTF-8?q?=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Déplace specs/ → docs/specs/ (git mv, historique préservé) - Traduit les 13 fichiers (README + SPEC-01…12) FR → EN, ligne par ligne, structure identique (titres, blocs de code, liens, nb de lignes) - Corrige la profondeur des liens ../ → ../../ (conséquence du déplacement) - Corrige les chemins Sources/MacDirStats/ → Sources/SpaceMatters/ (renommage du projet) Liens vérifiés : tous résolvent depuis docs/specs/ sauf ../../PLAN-ACTION.md (fichier absent du repo, à traiter séparément). Glyphe ■ (SPEC-02) et lien sans cible SUUpdateValidator.m (SPEC-12) conservés tels quels (contenu source). --- docs/specs/README.md | 27 ++++ docs/specs/SPEC-01-keyboard-navigation.md | 72 ++++++++++ docs/specs/SPEC-02-invalidation-rescan.md | 63 +++++++++ docs/specs/SPEC-03-exact-vs-attribution.md | 71 ++++++++++ docs/specs/SPEC-04-fsevents-live.md | 51 +++++++ docs/specs/SPEC-05-file-level-treemap.md | 64 +++++++++ docs/specs/SPEC-06-pluggable-backends.md | 53 +++++++ docs/specs/SPEC-07-distribution.md | 59 ++++++++ docs/specs/SPEC-08-accessibility.md | 51 +++++++ docs/specs/SPEC-09-gpu-treemap-rendering.md | 140 +++++++++++++++++++ docs/specs/SPEC-10-world-treemap.md | 110 +++++++++++++++ docs/specs/SPEC-11-incremental-refresh.md | 76 ++++++++++ docs/specs/SPEC-12-auto-update.md | 147 ++++++++++++++++++++ specs/README.md | 27 ---- specs/SPEC-01-keyboard-navigation.md | 72 ---------- specs/SPEC-02-invalidation-rescan.md | 63 --------- specs/SPEC-03-exact-vs-attribution.md | 71 ---------- specs/SPEC-04-fsevents-live.md | 51 ------- specs/SPEC-05-file-level-treemap.md | 64 --------- specs/SPEC-06-pluggable-backends.md | 53 ------- specs/SPEC-07-distribution.md | 59 -------- specs/SPEC-08-accessibility.md | 51 ------- specs/SPEC-09-gpu-treemap-rendering.md | 140 ------------------- specs/SPEC-10-world-treemap.md | 110 --------------- specs/SPEC-11-incremental-refresh.md | 76 ---------- specs/SPEC-12-auto-update.md | 147 -------------------- 26 files changed, 984 insertions(+), 984 deletions(-) create mode 100644 docs/specs/README.md create mode 100644 docs/specs/SPEC-01-keyboard-navigation.md create mode 100644 docs/specs/SPEC-02-invalidation-rescan.md create mode 100644 docs/specs/SPEC-03-exact-vs-attribution.md create mode 100644 docs/specs/SPEC-04-fsevents-live.md create mode 100644 docs/specs/SPEC-05-file-level-treemap.md create mode 100644 docs/specs/SPEC-06-pluggable-backends.md create mode 100644 docs/specs/SPEC-07-distribution.md create mode 100644 docs/specs/SPEC-08-accessibility.md create mode 100644 docs/specs/SPEC-09-gpu-treemap-rendering.md create mode 100644 docs/specs/SPEC-10-world-treemap.md create mode 100644 docs/specs/SPEC-11-incremental-refresh.md create mode 100644 docs/specs/SPEC-12-auto-update.md delete mode 100644 specs/README.md delete mode 100644 specs/SPEC-01-keyboard-navigation.md delete mode 100644 specs/SPEC-02-invalidation-rescan.md delete mode 100644 specs/SPEC-03-exact-vs-attribution.md delete mode 100644 specs/SPEC-04-fsevents-live.md delete mode 100644 specs/SPEC-05-file-level-treemap.md delete mode 100644 specs/SPEC-06-pluggable-backends.md delete mode 100644 specs/SPEC-07-distribution.md delete mode 100644 specs/SPEC-08-accessibility.md delete mode 100644 specs/SPEC-09-gpu-treemap-rendering.md delete mode 100644 specs/SPEC-10-world-treemap.md delete mode 100644 specs/SPEC-11-incremental-refresh.md delete mode 100644 specs/SPEC-12-auto-update.md diff --git a/docs/specs/README.md b/docs/specs/README.md new file mode 100644 index 0000000..a7a73c9 --- /dev/null +++ b/docs/specs/README.md @@ -0,0 +1,27 @@ +# Specifications by workstream — MacDirStats + +Each `SPEC-*.md` is a self-contained requirements document for a deferred workstream from the [action plan](../../PLAN-ACTION.md), written to be picked up in a dedicated session. Common format: + +1. **Objective & findings covered** — the why, with references to the audits. +2. **Current state of the code (verified)** — what exists, cited `file:line`. +3. **Design axes & tradeoffs** — real options, with a justified recommendation. +4. **Implementation plan** — files, steps. +5. **Verification** — tests + live driving (the screenshot + coordinate-click method is established and works). +6. **Risks & assumptions (🔬)** — what remains to be proven. +7. **Effort & dependencies**. + +Guiding principle (carried over from the plan): **integrity > performance > robustness > simplicity > maintainability**, long-term first. + +| Spec | Workstream | Findings | Effort | Depends on | +|---|---|---|---|---| +| [SPEC-01](SPEC-01-keyboard-navigation.md) | Keyboard navigation & native list | J3.1, J4.2, J3.7 | 1–2 d | — | +| [SPEC-02](SPEC-02-invalidation-rescan.md) | Sub-tree invalidation & re-scan | B1(cure), A6, A7, J4.4, D1 | 1–2 d | — | +| [SPEC-03](SPEC-03-exact-vs-attribution.md) | Exact counting vs attribution + reconciliation | A3, A4, A10, J9 | 2–3 d | — | +| [SPEC-04](SPEC-04-fsevents-live.md) | FSEvents — live dashboard | S3, J5.2, J4.5, D1 | 1–2 d | SPEC-02 | +| [SPEC-05](SPEC-05-file-level-treemap.md) | File-level refinement **on zoom** (per-folder overview) | S5 | 2–3 d | — | +| [SPEC-06](SPEC-06-pluggable-backends.md) | Pluggable backends | S6 | ~1 d/backend | — | +| [SPEC-07](SPEC-07-distribution.md) | **Signed/notarized DMG distribution via GitHub** (v1) | J1.1–J1.5, D4 | 1–2 d | — | +| [SPEC-08](SPEC-08-accessibility.md) | Full accessibility & i18n | J10.1–J10.4, J9.5 | 1–2 d | SPEC-01 (partial) | +| [SPEC-09](SPEC-09-gpu-treemap-rendering.md) | **3D-native** GPU treemap rendering (Metal, ortho 2D projection) | perf-resize (PR #17) | 2–3 d | PR #17 (NSView seam, done) | + +**Recommended order**: SPEC-02 (unblocks SPEC-04 and closes A6/A7 cleanly) → SPEC-01 → SPEC-03 → SPEC-08 → SPEC-04 → SPEC-05/06/07 depending on product priorities. diff --git a/docs/specs/SPEC-01-keyboard-navigation.md b/docs/specs/SPEC-01-keyboard-navigation.md new file mode 100644 index 0000000..b515f0d --- /dev/null +++ b/docs/specs/SPEC-01-keyboard-navigation.md @@ -0,0 +1,72 @@ +# SPEC-01 — Keyboard navigation & native list + +> **Findings**: J3.1 (100% mouse-driven app), J4.2 (no ⌘⌫ nor multi-selection), partially J3.7 (sorting/columns). Refers to D-E of the plan. +> **Status**: ✅ **IMPLEMENTED** (Axis B — `NSTableView` as `NSViewRepresentable`). See [DirectoryTable.swift](../../Sources/SpaceMatters/Views/DirectoryTable.swift). + +## 0. Implementation result + +- `DirectoryTable: NSViewRepresentable` (NSScrollView + `MacDirTableView`), rows = `NSHostingView(OutlineRowView)` → **zero visual regression** (SwiftUI rendering reused as-is). +- **Deterministic** mouse: `MacDirTableView.hitTest` returns the table for any in-bounds point (purely visual cells); selection goes through `NSTableView.mouseDown`/`row(at:)`, not through responder-chain forwarding. Collapse/expand chevron by geometry in `mouseDown`. +- Keyboard: ↑/↓ native, ←/→ collapse/parent & expand/child, ⏎ zoom/open, **⌘⌫ trash the selection (gated on `!isScanning`)**, space Quick Look, native type-select. +- **Multi-selection** (`allowsMultipleSelection`): `ScanController.selectedRowIDs` (set) + `setListSelection(_:primary:)` — the *primary* drives the treemap, the set drives ⌘⌫. Per-row context menu (Quick Look/Open/Reveal/Copy/Trash/Delete) via `menu(for:)`. +- treemap↔list sync: `selectedRowID` remains the single source; `updateNSView` idempotent, `scrollRowToVisible` on `revealTarget`. +- **Verified**: green build; 12 tests (including `listSelectionTracksPrimaryAndSet`, `deletingAncestorClearsStaleMultiSelection`); **live** — the native table renders the outline with full fidelity (chevrons/icons/bars/sizes/sorting), selection drawn, **each row exposed to AX** with a label ("Folder alpha, 1000 KB"), no crash. Driving via synthesized events (click/arrows) was blocked by a *Secure Event Input* panel from another app; logic covered by the ViewModel tests. +- **Remaining optional (outside the 100% scope)**: J3.7 sortable column headers — explicitly "optional" in §5.7. Multi-target context menu (today ⌘⌫ covers bulk trashing). + +## 1. Objective + +Make the directory list keyboard-drivable for the target user (senior dev): ↑/↓ arrows to move through it, ←/→ to collapse/expand, ⏎ to zoom/open, ⌘⌫ to move to trash, space bar for QuickLook, multi-selection (⇧/⌘-click) for bulk cleanup, and type-select (typing a prefix). + +## 2. Current state of the code (verified) + +- [DirectoryListView.swift](../../Sources/SpaceMatters/Views/DirectoryListView.swift): `List { ForEach(rows) { OutlineRowView… } }`, `listStyle(.plain)`, custom rows. Selection via `.onTapGesture { select() }`, zoom via `.simultaneousGesture(TapGesture(count: 2))`, chevron via a dedicated `.onTapGesture`, `.contextMenu`. The selection is `controller.selectedRowID` (derives `selection`). +- `visibleRows()` ([ScanController.swift:398](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L398)) produces a **flat array** (dirs + files interleaved, biggest-first) — already the right model for a table. +- Zoom/selection/expansion are centralized in `ScanController` (good). + +## 3. Lesson learned (verified live) + +`List(selection:)` + `.tag(row.id)` was tried and **driven on screen**: +- the `List` does **not** take keyboard focus (arrows inert); +- native selection does **not** engage on single click as long as the rows carry their own `onTapGesture`/`contentShape`/double-click; +- removing the `onTapGesture` **breaks** single-click without unblocking the arrows. + +→ Keyboard navigation **is not a bolt-on** on the current SwiftUI `List`. + +## 4. Design axes & tradeoffs + +- **Axis A — All-SwiftUI `List(selection:)`**: strip the rows of all their gestures, let the `List` own the selection, re-add chevron/double-click/hover some other way. *Tradeoff*: fights SwiftUI focus quirks (observed), native selection rendering to re-style, arrow behavior not guaranteed on a `List` with custom content. **High risk, uncertain outcome.** +- **Axis B — `NSViewRepresentable` around an `NSTableView` (recommended)**: **deterministic** control of the keyboard (arrows, ⌘⌫, type-select, multi-select), focus (first responder), selection, and scrolling. This is how dense Mac apps (Finder-like) do it. *Tradeoff*: more code (≈250–350 lines), a SwiftUI↔AppKit bridge to maintain. *Benefit*: robust, testable, extensible (sortable columns J3.7, multi-selection J4.2 for free). +- **Axis C — `NSOutlineView`**: handles the tree natively (disclosure), but we already have a *flat* model (`visibleRows()`) and expansion lives in the controller → `NSTableView` fits better, less friction. + +**Recommendation: Axis B.** An `NSTableView` as `NSViewRepresentable`, fed by `visibleRows()`, cells rendered via `NSHostingView(OutlineRowView)` to **reuse the existing SwiftUI design** (zero visual regression). Keyboard/focus/multi-selection become native and reliable. + +## 5. Implementation plan + +1. **`DirectoryTable: NSViewRepresentable`** producing `NSScrollView` + `NSTableView` (one column, fixed `rowHeight`, `usesAlternatingRowBackgroundColors = false`, plain style). +2. **Data source / delegate** (`Coordinator`): rows = `controller.visibleRows()`; `numberOfRows`, `viewFor:` → `NSHostingView(rootView: OutlineRowView(row:…))` reused (pooled via `makeView(withIdentifier:)`). +3. **Selection**: `allowsMultipleSelection = true`. `tableViewSelectionDidChange` → push to `controller.selectedRowID`/`selection`; conversely, `updateNSView` re-selects when `controller.selectedRowID` changes (breadcrumb/treemap → list). +4. **Keyboard** (subclass `NSTableView.keyDown` or `NSResponder`): + - ↑/↓: native. + - ←/→: collapse/expand the selected folder row (`controller.toggleExpanded`). + - ⏎: `controller.zoom(into:)` (folder) / `openItem` (file). + - ⌘⌫: trash the selected row(s), **gated on `!isScanning`** (J4.4), via the existing async `remove(...)` flow. + - space: QuickLook of the selected URLs. + - type-select: native via `tableView(_:typeSelectStringFor:)`. +5. **Multi-selection → bulk actions**: `controller.remove(rows:)` iterating the async flow; equivalent `contextMenu(forSelectionType:)` context menu. +6. Replace `List{…}` with `DirectoryTable(controller:)` in `DirectoryListView`. Keep `revealTarget`/scroll-to (via `scrollRowToVisible`). +7. Optional (J3.7): sortable column headers (name / size / file count / date) → `sortDescriptors` → new sort in the controller. + +## 6. Verification + +- **Live (established method)**: `--open `, screenshot, `key code 125/126` (↓/↑) → the selection moves (verifiable by the highlight); ⌘⌫ on a row → trash + total decremented; ⇧-click → multi-selection; space → QuickLook panel. +- **ViewModel tests**: `controller.selectRow`, multi-remove, gating during scan (extend `NavigationTests`). + +## 7. Risks & assumptions + +- 🔬 **Per-row `NSHostingView` perf** on a very large list: mitigate via view reuse + a lightweight `OutlineRowView`; to be measured on a folder with 100k visible rows (rare — the list is virtualized to O(visible)). +- 🔬 Bidirectional list-selection ↔ treemap synchronization without an event loop: keep `selectedRowID` as the single source, `updateNSView` idempotent. +- Focus ring / selection appearance to match the theme (draw the selection in `OutlineRowView` via `isSelected`, disable the AppKit-drawn selection). + +## 8. Effort & dependencies + +**1–2 days.** No dependencies. Unblocks J3.1, J4.2, and paves the way for J3.7 (columns) and part of SPEC-08 (natively accessible focus/rotor). diff --git a/docs/specs/SPEC-02-invalidation-rescan.md b/docs/specs/SPEC-02-invalidation-rescan.md new file mode 100644 index 0000000..2544959 --- /dev/null +++ b/docs/specs/SPEC-02-invalidation-rescan.md @@ -0,0 +1,63 @@ +# SPEC-02 — Sub-tree invalidation & re-scan + +> **Findings**: B1 (deep fix — the safeguard is already in place), **A6** (types table stale after deletion), A7 (folder counter), J4.4 (delete during scan — gated), D1 (TOCTOU). Refers to D-B/S2 of the plan. +> **Status**: ✅ **IMPLEMENTED** — A6 resolved (deletion path, exact) + `invalidate(subtree:)` building block delivered for SPEC-04. + +## 0. Implementation result + +- **A6 resolved (exact) — deletion**: `remove(directory:)` walks the sub-tree **before** deletion (`DirectoryScanner.subtreeExtensions(path:)`, an exact mirror of the scan tally: same `ExtKey`, same alloc/logical sizes) and subtracts that contribution from the scanner's `extStats` table (`subtractExtensions`, zeroed entries removed). `remove(file:)` subtracts the file's extension. The File-types panel updates. Test: `deletingSubtreeUpdatesFileTypeTable` (deleted `.mp4` disappear, `.txt` intact). +- **`invalidate(subtree:)` building block** (reused by SPEC-04): **in-place** re-scan of the sub-tree (seed on the node → correct parents), **exact** reconciliation of the ancestor aggregates (old→new delta), of the **dirCount** (A7), and **nav re-resolution by path** (`node(at:)`) since the descendant nodes become fresh objects (no dangling `unowned`). Test: `invalidateReflectsExternalChanges` (external file+folder addition reflected, zoom re-bound by path). +- **Approach = recommended hybrid (§4/§7)**: exact aggregate subtraction retained (avoids re-scanning a potentially huge parent); only `extStats` is corrected by delta. +- **Documented limit (extStats in `invalidate` only)**: for an extension **shared across sub-trees AND modified** within the invalidated sub-tree, the delta is not exact (the original sub-contribution is not stored per node — low-RAM stance assumed §3); error bounded by the change's delta, self-corrected at the next full rescan. **The deletion path (the A6 finding) stays exact** (walk before the change). Sizes/counts/dirCount always exact. +- **B1/A7/J4.4**: already in place, unchanged and still valid. **D1**: bounded by the `!isScanning` gating; live detection → SPEC-04. + +## 1. Objective + +Replace the **manual surgery** of the aggregates in `remove()` with a **targeted invalidation + re-scan** of the parent directory. Benefits: removes the entire class of fragile accounting (A6 impossible to fix otherwise — see §3), eliminates any residual risk of inconsistency, and provides the **common building block** reused by FSEvents (SPEC-04). + +## 2. Current state of the code (verified) + +- `remove(directory:)`/`applyDirectoryRemoval` ([ScanController.swift:507+](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L507)): subtracts the aggregates of each ancestor (atomics), moves `zoomRoot`/`selection`/`expanded` up out of the sub-tree (safeguard B1), decrements `dirCount` (A7), detaches the node. +- **What is NOT adjusted**: the scanner's `extStats` (A6). The per-extension breakdown of the deleted sub-tree is **not stored** in the tree (nodes only keep `dominantExt` + aggregates). So subtracting the per-extension contributions of the deleted sub-tree is **infeasible** without re-enumerating. +- The scanner already knows how to scan a sub-tree: `DirectoryScanner(root:, seeds:[Seed(path:node:)])` — a deletion = a new seed on `node.parent`. + +## 3. The crux of the problem (A6) + +`extStats: [ExtKey: ExtStat]` is **global to the scan**, built by accumulation during the scan. After deletion of a sub-tree, it still contains the bytes/■counts of the deleted files. Three ways out: + +- **Store the extensions table per node**: unbounded memory cost (the opposite of the "1 node/directory, low-RAM" stance). ❌ +- **Re-scan the deleted sub-tree *before* deletion to know its contributions and subtract them**: possible but fragile (double work, race with the scan). ⚠️ +- **Invalidation + re-scan of the parent (recommended)**: after a successful deletion, re-scanning the parent directory rebuilds its children **and** re-accumulates `extStats` for that sub-tree. It remains to reconcile with the global table. + +## 4. Design axes & tradeoffs + +- **Axis A — Consolidate the in-place mutation**: add the `extStats` subtraction. *Cleanly infeasible* (§3). ❌ +- **Axis B — Re-scan of the parent, extensions table recomputed globally**: on deletion, (1) detach the node (as today, B1 safeguards retained), (2) relaunch a `DirectoryScanner` seeded on `parent.path` in a **fresh sub-tree**, (3) atomic swap of `parent._children`, (4) **rebuild `extStats` by re-walking the entire remaining tree** (the files are not in memory → disk re-enumeration of the folders, costly) *or* maintain `extStats` as a sum of recomputed per-seed tables. +- **Axis C — Re-scan of the parent + `extStats` maintained per sub-tree**: the scanner keeps `extStats` **per first-level directory** (or per seed), so that invalidating a sub-tree = subtract its sub-table + re-accumulate. More state, but exact O(1) subtraction. + +**Recommendation: Axis B, pragmatic variant.** The re-scan of the parent is the true cure for B1/A6/A7. For `extStats`, **mark the "File types" panel as approximate** just after the deletion *and* recompute it via **lazy** re-enumeration of the re-scanned parent (the parent re-scan already re-accumulates the sub-tree's extensions; it suffices to subtract the parent's old contribution known before the re-scan). Key decision documented below. + +**`extStats` decision**: before the re-scan, **snapshot the extensions table restricted to the parent's sub-tree** (obtainable via a "dry-run" re-scan pass of the parent, or by subtracting post-re-scan). *The simplest and exact*: the parent re-scan produces its **new** extensions sub-table; we keep an `extStats` **per direct-parent-of-seed node** in order to do `global = Σ sub-tables`. 🔬 to validate: the memory overhead of a sub-table per child-of-seed (bounded by the number of distinct extensions, small). + +## 5. Implementation plan + +1. `DirectoryScanner`: expose a **sub-tree re-scan** mode — `rescan(node:path:)` that restarts from a single seed, builds a temporary root `FSNode`, and returns `(children, directFiles, extSubtable, dirCount)`. +2. `ScanController.remove(...)`: on disk success → instead of the aggregate surgery, call `invalidate(subtree: node.parent)`. +3. `invalidate(subtree:)`: launches the re-scan (detached, non-blocking), applies on MainActor: swap `_children`, recompute the ancestor aggregates (delta = new − old sub-total), update `extStats` (Σ sub-tables), `dirCount`, re-resolution of `selection`/`zoomRoot` **by path** (not by node identity, since the nodes are fresh — reuse `path(for:)` + a reverse resolution). +4. Keep the B1 safeguards (they become redundant but harmless) during the migration, then remove them. +5. Gating during scan (J4.4): already in place; the invalidation only runs when `phase != .scanning`. + +## 6. Verification + +- **Tests** (extend `NavigationTests`): after deleting a sub-tree containing `.mp4`, **`extStats` no longer contains the deleted `.mp4`** (locks A6); `dirCount`, aggregates and absence of a dangling node stay correct; `selection`/`zoomRoot` re-resolved by path. +- **Live**: delete `sub2` from the fixture → the "File types" panel loses the `.bin` of `sub2/deep`, total/folders consistent (before/after screenshot). + +## 7. Risks & assumptions + +- 🔬 Re-resolution of `selection`/`zoomRoot` by path after node swap: requires a transient path→node index. +- 🔬 Cost of re-scanning a very large parent (e.g. deleting a folder in `/` re-scans the whole root): bound it by re-scanning — **the deleted node itself no longer exists**, so we re-scan its parent — potentially huge. Mitigation: only re-scan if the parent has few remaining children, otherwise keep the aggregate subtraction (exact) and invalidate ONLY the sub-tree's `extStats` (hybrid). +- The hybrid choice (aggregates by subtraction + extStats by sub-table) is perhaps the best simplicity/cost tradeoff — to be decided at implementation time. + +## 8. Effort & dependencies + +**1–2 days.** No upstream dependency. **Unblocks SPEC-04 (FSEvents)** which reuses `invalidate(subtree:)`. diff --git a/docs/specs/SPEC-03-exact-vs-attribution.md b/docs/specs/SPEC-03-exact-vs-attribution.md new file mode 100644 index 0000000..a013b77 --- /dev/null +++ b/docs/specs/SPEC-03-exact-vs-attribution.md @@ -0,0 +1,71 @@ +# SPEC-03 — Exact counting vs attribution + reconciliation of the figures + +> **Findings**: A3 (hardlinks counted per link), A4 (APFS clones not deducted), A10 (base 1024 labeled KB), J9 ("why doesn't it match Finder?"), J9.5 (mixed locale). Refers to D-G/S4 of the plan. A1 (mounts) is **already fixed**. +> **Status**: ✅ **IMPLEMENTED** — A3 exact (validated against `du`), A10/J9.5, J9 reconciliation. A4 in honest fallback (conforms to §3.b/§6). + +## 0. Implementation result + +- **A3 hardlinks (exact) — validated against `du`**: `CountingMode { attribution | exact }` (toolbar toggle, host). In exact mode, the bulk enumerator requests `ATTR_CMN_FILEID` + `ATTR_FILE_LINKCOUNT` (read in exact bit order; **the default attribution mode packs an identical buffer** — reads guarded by the `returned` mask), and the scanner dedups multi-link inodes (`Set`, only `linkCount > 1` → negligible memory). Golden test `exactModeDedupsHardlinks`: exact ↔ `du -skx`, attribution ↔ `du -sklx`. Switching = re-scan (dedup at scan time). VM = attribution. +- **A10 + J9.5 (format)**: `Format.bytes` → base 1024 with **honest IEC labels** (KiB/MiB/…), **localized** decimal separator. Test `formatBytes` updated. +- **J9 reconciliation**: `Reconciliation` (model) + `ReconciliationButton`/popover (whole-volume scans). Breaks down "used (API)" = scan + trash (`~/.Trash` + `.Trashes`) + purgeable (`importantUsage − available`, with snapshot count via `tmutil`) + unattributed; flags `scanExceedsUsed` (signature of attribution over hardlinks/clones) and the unreadable paths. Test `reconciliationArithmetic`. +- **A4 APFS clones — honest fallback (conforms to the plan)**: §3.b/§6 flagged `ATTR_CMNEXT_PRIVATESIZE` 🔬 "to prototype / honest fallback if unreliable". Parsing extended attributes (`forkattr`) is riskier than the standard buffer; **not adopted**. Instead, an explicit UI note (toggle tooltip + reconciliation panel): "APFS clones are counted full in both modes and may inflate the total beyond `df`". +- **A1 (mounts)**: already fixed. + +## 1. Objective + +Formalize two explicit **counting modes** and settle the first objection of any user ("it doesn't match Finder/df"): +- **Attribution** (current default): "who is responsible for the space" — hardlinks per link, clones full. +- **Exact disk**: dedup hardlinks + private size of clones + bounded to the volume (A1 already done) → **matches `df`**. +Plus a **reconciliation panel** that breaks down the gap with the volume's "used" space. + +## 2. Current state of the code (verified) + +- `physical` = Σ `fileAllocSize` ([FSAttr.swift](../../Sources/SpaceMatters/Scanner/FSAttr.swift), `ATTR_FILE_ALLOCSIZE`). Hardlinks: each link counted (validated = `du -skl`). Clones: counted full. +- A1 (mount status): **fixed and verified** — the scan stays on the volume (`du -skx`). +- `Format.bytes` ([Formatting.swift:5](../../Sources/SpaceMatters/Util/Formatting.swift#L5)): base 1024, "KB/MB" labels (not localized); `Format.count` localized (J9.5, mixed locale). +- The volume already exposes capacities via `Volume` / `URLResourceValues`. + +## 3. Design axes & tradeoffs + +### 3.a Hardlinks (A3) +- Request `ATTR_CMN_FILEID` + `ATTR_CMN_DEVID` + `ATTR_FILE_LINKCOUNT` in the bulk. +- For `nlink > 1` entries only: dedup via `Set` keyed `(dev, ino)` (or `[dev: Set]`). Count blocks only at the **first** occurrence. Memory bounded to the number of multi-link files (marginal). +- *Tradeoff*: +12 bytes/entry in the bulk buffer, a shared `Set` under lock (or per-worker merged). In "Exact" mode only. + +### 3.b APFS clones (A4) +- `ATTR_CMNEXT_PRIVATESIZE` (unshared size per file) via `FSOPT_ATTR_CMN_EXTENDED` + `forkattr`. 🔬 availability/reliability varies across versions, larger buffer cost — **to be measured before adopting**. +- Honest fallback if unreliable: UI note "APFS clones may inflate this total". + +### 3.c Reconciliation (J9) +Panel at the end of a volume scan: +``` +Volume used (API) = scan + trash + local snapshots + purgeable + unreadable + delta +``` +- scan = measured total; trash = size of `~/.Trash` (and the volume's `.Trashes`); snapshots = `tmutil listlocalsnapshots`; purgeable = `volumeAvailableCapacityForImportantUsage − volumeAvailableCapacity`; unreadable = `errorCount` (skipped paths); delta = unexplained remainder. + +### 3.d Base 1024 / locale (A10, J9.5) +- Honest **KiB/MiB** labels *or* a base 10 / 1024 toggle next to the On disk/Logical toggle. +- Unify the locale: `Format.bytes` via localized `MeasurementFormatter`/`ByteCountFormatter`, consistent with `Format.count`. + +## 4. Implementation plan + +1. Enum `CountingMode { .attribution, .exact }` in `ScanController`, exposed by a toggle (next to On disk/Logical). +2. `DirectoryScanner`: in `.exact` mode, request the hardlink attributes + (optional) private-size; dedup `(dev,ino)`; use `privateSize` instead of `allocSize` for clones if available. +3. **Reconciliation** panel: new component under the treemap (or a tab), fed by `Volume` capacities + `tmutil` (via `ProcessRunner`) + trash size + `errorCount`. +4. `Format.bytes`: KiB/MiB labels (or toggle) + localization. + +## 5. Verification + +- **Golden tests**: fixture with hardlinks → **Exact** mode == `du -sk` (deduplicated), **Attribution** mode == `du -skl`. Fixture with a `cp -c` clone → Exact mode ≈ private size. +- **Reconciliation**: on a real volume, `scan + trash + snapshots + purgeable + unreadable + delta ≈ used(API)` with a small delta; compare against `df`. +- **Live**: toggle Exact/Attribution → the total changes by the right amount on a hardlink fixture. + +## 6. Risks & assumptions + +- 🔬 `ATTR_CMNEXT_PRIVATESIZE`: availability, buffer cost, exact semantics on partial clones — **to prototype**. +- 🔬 Packing order of extended attributes (`forkattr`) in the bulk buffer — same caution as for A1 (`dirattr`). +- Reconciliation depends on `tmutil` (access rights to snapshots) and on the accuracy of `purgeable` (Apple approximation). + +## 7. Effort & dependencies + +**2–3 days** (A3 ~½ d, A4 uncertain ~½–1 d, reconciliation ~1 d, formatting ~¼ d). Independent. A **product** decision (two modes) as much as a technical one. diff --git a/docs/specs/SPEC-04-fsevents-live.md b/docs/specs/SPEC-04-fsevents-live.md new file mode 100644 index 0000000..fb14d4b --- /dev/null +++ b/docs/specs/SPEC-04-fsevents-live.md @@ -0,0 +1,51 @@ +# SPEC-04 — FSEvents: from snapshot to live dashboard + +> **Findings**: S3 (product differentiator), J5.2 (stale data with no indicator), J4.5 ("Put Back" invisible), D1 (deletion TOCTOU). **Depends on SPEC-02** (reuses `invalidate(subtree:)`). +> **Status**: ✅ **IMPLEMENTED & LIVE-VERIFIED** (banner + timestamp captured in the app). + +## 0. Implementation result + +- **`FSWatcher`** ([FSWatcher.swift](../../Sources/SpaceMatters/Scanner/FSWatcher.swift)): `FSEventStreamCreate` on the seed paths, **directory** granularity (not `FileEvents`), `latency` debounce (1 s), delivery on a private **dispatch queue** (`kFSEventStreamCreateFlagUseCFTypes | NoDefer`). +- **Lifecycle**: starts at the end of the scan (host), stops on `goHome`/new scan/`deinit`. +- **Dirty tracking by path** (not by identity — `invalidate` recreates the nodes): `handleDiskChanges` → `nearestNode(toPath:)` **symlink-tolerant** (canonicalization: FSEvents reports `/private/var/…`, bug found and fixed), `dirtyPaths` + `diskChanged`. +- **UI**: "The disk changed — [Refresh]" banner (`DiskChangedBanner`), **per-row dot** on dirty folders, **"scanned N ago"** in the stats bar (J5.2, `TimelineView` 30 s). **Live-verified** (banner + "just now" captured). +- **Refresh = `invalidate(subtree:)`** (SPEC-02) on the dirty sub-trees, ancestors subsuming descendants (a single re-scan). **Scope**: host seeds only. +- **D1**: surfaced by the **persistent banner** (the user is warned that the disk changed before acting) rather than a per-deletion modal — simpler, less intrusive; deletion stays gated on `!isScanning`. +- **Test**: `fsEventsMarkDirtyAndRefreshCatchesUp` (end-to-end: external write → `diskChanged` → `refreshDirty` → totals caught up). + +## 1. Objective + +While a result is displayed, watch the disk and **reflect the changes**: "the disk changed" badge, incremental re-scan of the affected sub-trees, visible scan age. This is the differentiator that all WinDirStat-likes lack, and the architecture (seeds, live tree, per-node re-scan from SPEC-02) is well predisposed to it. + +## 2. Current state (verified) + +- No `FSEventStream` (grep). The tree is a frozen snapshot; `path(for:)` reconstructs paths from a potentially old state (D1). +- SPEC-02 will provide `invalidate(subtree:)` — the targeted re-scan building block. + +## 3. Axes & tradeoffs + +- **Granularity**: `kFSEventStreamCreateFlagFileEvents` (per file, precise, more events) vs per directory (coalesced, cheaper). *Recommended*: per directory, ~1 s latency, sufficient to mark a sub-tree dirty. +- **Reaction**: immediate automatic re-scan vs **badge + re-scan on demand**. *Recommended*: "changed" badge + re-scan of the dirty sub-tree on click (respects the intent, avoids untimely work), with an "auto" option. +- **Scope**: the seeds only (host scans; not VM/K8s scans). + +## 4. Implementation plan + +1. `FSWatcher`: `FSEventStreamCreate` on `seedPaths`, callback → set of dirty paths (~1 s debounce), on a dedicated run loop. +2. Map each dirty path → nearest node (path→node index, cf. SPEC-02) → mark "dirty". +3. UI: badge/dot on dirty nodes + "the disk changed — [Refresh]" banner; "scanned N min ago" timestamp in the status bar (J5.2). +4. Refresh = `invalidate(subtree:)` (SPEC-02) on the dirty nodes. +5. Before any deletion, if the target is in a dirty sub-tree → D1 alert "the disk changed, re-run". + +## 5. Verification + +- **Live**: scan the fixture, create/delete a file in it via shell → badge appears; Refresh → the tree reflects the change. +- **Test**: `FSWatcher` does emit a coalescing event for a touched path (short integration test). + +## 6. Risks & assumptions + +- 🔬 Event volume on large active trees (caches): debounce + coalescing indispensable. +- Consistency with the SPEC-02 path→node index (strong dependency). + +## 7. Effort & dependencies + +**1–2 days**, after SPEC-02. diff --git a/docs/specs/SPEC-05-file-level-treemap.md b/docs/specs/SPEC-05-file-level-treemap.md new file mode 100644 index 0000000..29b5f50 --- /dev/null +++ b/docs/specs/SPEC-05-file-level-treemap.md @@ -0,0 +1,64 @@ +# SPEC-05 — File-level refinement on zoom (per-folder overview) + +> **Findings**: S5 (README mentions it), complements A8 (tile color). Optional. +> **Product constraint (imposed)**: the **overview stays strictly per folder** — legibility takes priority. File-level detail is a property of the **current zoom level**, never of the whole map. +> **Status**: ✅ **IMPLEMENTED & LIVE-VERIFIED** (Axis B2 — file-tiles at zoomRoot, sub-folders aggregated). + +## 0. Implementation result + +- **Axis B2**: `TreemapLayout.compute(..., rootFiles:)` — only the direct files of the **zoomRoot** (depth 0) are materialized as individual tiles; the sub-folders stay aggregated (recursion with `files: nil`). The overview stays strictly per folder. +- **`TreemapTile.file: FileTileInfo?`** (name, size at the current metric, extension) carries the file-tile; color by **file extension** (`treemapTypeColor`, consistent with the legend), label = file name, dedicated hover/menu/double-click (Open/Reveal/Copy). **Live-verified**: `demo05` shows video.mp4/archive.zip/photo.png/document.pdf/notes.txt as distinct colored tiles, `subfolder` aggregated. +- **Residual**: if the file list is capped (`maxFilesPerFolder`), the remainder stays an aggregated "other files" block (exact proportions). +- **Memory**: the files come from the existing **bounded** `fileCache` (≤ 2000/folder, per visited directory), filled on zoom-in via `filesIn(zoomRoot)`. Not materialized **during** the scan (avoids re-enumeration at 10 Hz) — refinement once the scan has stabilized. +- **Test**: `zoomRootRefinesIntoFileTiles` (zoomRoot → N file-tiles + aggregated sub-folder; overview → 1 aggregated block, 0 file-tile). + +## 1. Objective + +When you **enter a folder** (click/double-click/zoom), that folder's region **refines down to the files**; conversely, the overview displays **only folders**. File-tiles are never scattered across the whole map: global legibility comes first. + +## 2. Current state (verified) + +- "1 node/directory" model ([FSNode.swift](../../Sources/SpaceMatters/Model/FSNode.swift)): files are not objects; aggregated into `directFiles*`. +- The treemap renders the sub-tree of `zoomRoot` ([TreemapView.swift](../../Sources/SpaceMatters/Views/TreemapView.swift)); the zoom (tile double-click, breadcrumb, ⌘↑) exists and works. +- Today, inside a zoomed folder, sub-folders are tiles but the **direct files are aggregated** into a single leaf-tile (color = direct dominant, cf. A8). On-demand enumeration: `filesIn` ([ScanController.swift:344](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L344)), not persisted. + +## 3. Design principle + +- **Overview level** (`zoomRoot` = scan root, or any "from afar" level): tiles **per folder** only — current behavior **preserved**. +- **Entering a folder** (`zoomRoot` = that folder): the map displays the **direct files** of that folder as individual tiles, interleaved with its sub-folders (still aggregated). +- **Zoom-out**: return to the per-folder aggregate; the file memory of the folder left is **freed**. + +→ The refinement follows the **zoom**, not a global threshold. This is what guarantees a legible overview. + +## 4. Axes & tradeoffs + +- **Axis A — Global size threshold** (show files everywhere they exceed the tile threshold): **rejected** — would scatter files into the overview, contrary to the product constraint. ❌ +- **Axis B — Zoom-driven refinement (recommended)**: materialize file-tiles **only for the current `zoomRoot`**. Overview untouched, memory bounded to the open folder. + - **B1**: files of the `zoomRoot` **and** of its 1st-level sub-folders (one notch of refinement — richer, a bit denser). + - **B2**: direct files of the `zoomRoot` only; the sub-folders stay aggregated tiles until you enter them (more conservative, the most legible). **Recommended default.** +- **Storage**: compact columnar blob per refined directory `[(nameOffset: UInt32, logical: Int64, physical: Int64, extIndex)]` + name arena (~24 B/file), **filled on zoom-in, emptied on zoom-out**. + +**Recommended: Axis B / B2**, toggleable via a "file detail" toggle (default on), with the option to switch to B1. + +## 5. Implementation plan + +1. Compact (columnar) `FileBlock` built on the fly for the `zoomRoot` when you enter it (reuses the enumeration of `filesIn`, but persisted compactly). +2. `TreemapLayout.compute`: if the rendered node **is the `zoomRoot`** (B2) and has a `FileBlock`, subdivide its region into file-tiles (squarify of the file sizes) instead of a single leaf-tile. Beyond this level → per-folder aggregate unchanged. +3. Memory life cycle: fill in `zoom(into:)`, free in `zoomOut`/`resetZoom`/`navigate` when leaving the folder (strict bound: at most the file content of one folder at a time, +1 level if B1). +4. Hit-test / hover / selection at file level **only in the refined zone**; elsewhere, current node behavior. +5. Color per file via `ExtKey`/palette (already consistent with the legend). Take advantage of the pass for A8 (weighted dominant of the sub-tree on the aggregated folder-tiles). + +## 6. Verification + +- **Live (established method)**: overview → **folder** tiles (screenshot, legible); zoom into `sub1` → files of `sub1` as individual tiles (screenshot); ⌘↑ → return to the folder aggregate. Verify that at the overview **no** file-tile appears. +- **Test**: `FileBlock` round-trip (names/sizes/ext); budget ≤ 24 B/file; freeing on zoom-out. + +## 7. Risks & assumptions + +- 🔬 Visual transition zoom-in/out (appearance/disappearance of file-tiles): animate or switch cleanly on relayout (`recompute`). +- B1 vs B2 choice to calibrate on real folders (density vs richness). +- Interaction with the hit-test that today reasons in nodes: introduce a distinct "file" tile type. + +## 8. Effort & dependencies + +**2–3 days.** Independent (synergy A8). The zoom-driven approach reuses the existing `zoomRoot` infrastructure. diff --git a/docs/specs/SPEC-06-pluggable-backends.md b/docs/specs/SPEC-06-pluggable-backends.md new file mode 100644 index 0000000..1f92f50 --- /dev/null +++ b/docs/specs/SPEC-06-pluggable-backends.md @@ -0,0 +1,53 @@ +# SPEC-06 — Pluggable scan backends + +> **Findings**: S6. H1 (Docker) is **already done**. Extends the `ScanBackend` contract. +> **Status**: ✅ **IMPLEMENTED** — enriched contract + generic SSH backend + splash card. + +## 0. Implementation result + +- **Enriched contract** ([ScanBackend.swift](../../Sources/SpaceMatters/Scanner/ScanBackend.swift)): `var source: ScanSource { get }` (`host | vm | remote | archive`, with `isReadOnly`/`label`) + `func diagnostics() -> String`, defaults provided. The UI already gates destructive actions on `!isHostScan` (⇒ remote/vm read-only). +- **Shared `find` command**: `RemoteFind.command(rootPath:sudo:)` + centralized `printf` — VMProbe **and** SSH reuse it (no more format drift). +- **Generic SSH backend**: `SSHTarget` (user/host/port/path/identity/sudo) → `command()` builds `ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new … `. `ScanController.scanRemote` / `AppModel.scanRemote` launch a `CommandScanner` (`source: .remote`) — **reuses 100% of the existing streamed-flow parser**, nearly free. +- **Splash UI**: "Remote" section → `RemoteCard` → `RemoteScanSheet` (host/user/path/port/identity/sudo form, read-only note + key-based auth). +- **Tests**: `sshTargetBuildsFindCommand`, `hostOnlyTargetOmitsUserAndOptionals`, and **`commandScannerParsesFindStream`** (the SSH parser exercised locally via `printf` in the exact NUL format — tree/sizes/dirCount/extensions validated). +- **🔬 Not testable here**: a real SSH scan requires Remote Login **and** GNU `find`/`-printf` on the remote side (macOS's `find` doesn't have it) — exactly the case the D-D error channel diagnoses (J6.3). + +## 1. Objective + +Make `ScanBackend` a true extension point for adding scan sources (generic SSH, archives, Time Machine, another Mac) with minimal effort per backend, building on the existing infrastructure (live tree, error channel, `ProcessRunner`). + +## 2. Current state (verified) + +- `ScanBackend` ([ScanBackend.swift](../../Sources/SpaceMatters/Scanner/ScanBackend.swift)): `start/cancel/isFinished/directoryCount/scanErrorCount/failure/snapshotExtensions`. **`failure` already added** (D-D error channel). +- Two implementations: `DirectoryScanner` (local syscalls) and `CommandScanner` (streamed SSH `find`, NUL framing already fixed). The `CommandScanner` is already **90% of a generic SSH backend**. +- `ProcessRunner` (timeout + cancellation) available. + +## 3. Axes & tradeoffs + +- Enrich the contract with: `var source: ScanSource { get }` (`host | vm(machine) | remote(host) | archive(url)`), `func diagnostics() -> String`. Lets the UI adapt actions (read-only for remote/archive, cf. J6.5). +- New backends, in order of ease: + - **Generic SSH**: `CommandScanner` with a `ssh user@host 'find … -printf …\0'` command — nearly free. + - **Time Machine**: `tmutil` + traversal of a mounted snapshot. + - **Archives** (`tar`/`zip`): list the entries + sizes → feed the tree (no deletion). + - **Another Mac**: generic SSH + auth. + +## 4. Implementation plan + +1. Add `source`/`diagnostics` to the protocol (defaults provided). +2. Generalize `CommandScanner`: accept an arbitrary command (exe+args) + a `rootPath` — already almost the case; extract a `RemoteFindConfig`. +3. `SSHScanBackend`: builds the remote `find` command (reuses a generalized `VMProbe.scanCommand`); key/host handling. +4. Splash UI: "Remote server…" card (host + path); actions gated on `source.isReadOnly`. + +## 5. Verification + +- **Test**: parsing of the remote `find` stream (already covered by the `CommandScanner` tests). +- **Live**: SSH to `localhost` on a folder → populated tree, read-only (no trash). + +## 6. Risks & assumptions + +- 🔬 Availability of GNU `find`/`-printf` on the remote side (busybox) — already diagnosed by the error channel (J6.3). +- SSH auth (key/agent) out of scope for v1: document the prerequisites. + +## 7. Effort & dependencies + +**~1 day per backend.** The enriched contract: ~½ day. Independent. diff --git a/docs/specs/SPEC-07-distribution.md b/docs/specs/SPEC-07-distribution.md new file mode 100644 index 0000000..517f04d --- /dev/null +++ b/docs/specs/SPEC-07-distribution.md @@ -0,0 +1,59 @@ +# SPEC-07 — Distribution: signed + notarized DMG via GitHub + +> **Findings**: J1.1 (no release binary), J1.2 (generic icon), J1.4 (missing TCC usage descriptions), J1.5 (version/About), D4 (signing/notarization). I3 (`bundle.sh` `|| true`) **already fixed**. +> **v1 scope (imposed)**: distribute **only a `.dmg` via GitHub Releases**. No Mac App Store. Homebrew Cask / auto-update pushed out of v1. +> **Status**: ✅ **IMPLEMENTED** (everything codable) — signing/notarization runnable once the Developer ID account is provided (external prerequisite). + +## 0. Implementation result + +- **Icon** (J1.2): [`Packaging/make-icon.sh`](../../Packaging/make-icon.sh) renders `chart.pie.fill` (accent on a panel background) → `Packaging/AppIcon.icns` (generated, committed); `bundle.sh` copies it + `CFBundleIconFile`. Verified visually. +- **TCC usage descriptions** (J1.4): `NSDesktop/Documents/Downloads/RemovableVolumes/NetworkVolumes UsageDescription` added to the plist. `plutil -lint` OK. +- **Version from git** (J1.5): `CFBundleShortVersionString` = `git describe --tags` (fallback `0.1.0`), `CFBundleVersion` = number of commits. Verified (`0.1.0` / build `26`). +- **`release.sh`**: build → `bundle.sh` (Developer ID) → **hardened-runtime sign + verify** → DMG (`hdiutil`, app + `/Applications` alias) → sign DMG → `notarytool submit --wait` → `stapler staple` → `spctl` check → **`gh release create`**. External prerequisites documented at the top (Developer ID cert + `notarytool` profile + `gh auth`). +- **README**: "Download" section pointing to `releases/latest`. +- **`.gitignore`**: `*.dmg` (artifact) excluded; `MacDirStats.app/` already excluded. +- **🔬 Not runnable here**: the Developer ID signing + notarization require an Apple Developer account (external prerequisite assumed by spec §6). The ad-hoc fallback of `bundle.sh` remains for local dev (verified: ad-hoc-signed bundle launches without crash). + +## 1. Objective + +A colleague downloads the `.dmg` from the **GitHub Releases** page, mounts it, drags the app into Applications, and opens it **without a Gatekeeper block** — so a **Developer ID signed + notarized + stapled** DMG. + +## 2. Current state (verified) + +- `bundle.sh`: **ad-hoc** signing (unstable cdhash → FDA to re-grant), **no** notarization, **no** `.icns`/`CFBundleIconFile`, `CFBundleShortVersionString` frozen at "1.0", bundle id `com.macdirstats.app`. Total failure of `codesign` **now unmasked** (I3 done). +- No `NS*UsageDescription` key → bare TCC prompts (J1.4). +- FDA detection via opening `TCC.db` ([FullDiskAccess.swift](../../Sources/SpaceMatters/Util/FullDiskAccess.swift)) — well documented; sensitive to the cdhash → **stable signing (Developer ID) makes the FDA grant persist**, a big gain vs ad-hoc. + +## 3. Implementation plan (v1) + +1. **Icon** (J1.2): `AppIcon.icns` (the `chart.pie.fill` pie chart from the splash works) + `CFBundleIconFile` in `bundle.sh`. +2. **TCC usage descriptions** (J1.4): add to the generated plist `NSDesktopFolderUsageDescription`, `NSDocumentsFolderUsageDescription`, `NSDownloadsFolderUsageDescription`, `NSRemovableVolumesUsageDescription`, `NSNetworkVolumesUsageDescription` — explicit phrasing. +3. **Version from git** (J1.5): `CFBundleShortVersionString`/`CFBundleVersion` derived from `git describe --tags` at build time; small custom About panel. +4. **Developer ID signing + notarization**: + - `codesign --force --options runtime --sign "Developer ID Application: …" MacDirStats.app` + - build the DMG (`hdiutil create` or `create-dmg`) with the app + `/Applications` alias + - `codesign` the DMG, `xcrun notarytool submit MacDirStats.dmg --keychain-profile … --wait`, then `xcrun stapler staple MacDirStats.dmg`. +5. **`release.sh` script**: release build → bundle → sign → dmg → notarize → staple → `gh release create vX.Y.Z MacDirStats.dmg --notes …`. +6. **README**: "Download" section pointing to the latest GitHub Release. + +## 4. Out of v1 scope (to keep in mind) + +- Homebrew Cask (`brew install --cask macdirstats`) — trivial once Releases are in place. +- Auto-update (Sparkle appcast **or** simple "check GitHub releases"). +- Mac App Store (sandbox incompatible with the global disk scan — not relevant). + +## 5. Verification + +- `spctl -a -vv -t open --context context:primary-signature MacDirStats.dmg` → "accepted, source=Notarized Developer ID". +- `codesign -dv --verbose=4 MacDirStats.app` → Developer ID identity, hardened runtime. +- **Real test**: download the DMG from the GitHub Release on **another** session/machine → opens without "unidentified developer". +- Verify that the FDA grant **persists** after a signed rebuild (stable cdhash). + +## 6. Risks & assumptions + +- External prerequisite: Apple Developer account (Developer ID Application) + `notarytool` keychain profile. +- 🔬 Hardened runtime vs local debuggability: keep the ad-hoc fallback of `bundle.sh` for dev builds; the signed/notarized path is reserved for `release.sh`. + +## 7. Effort & dependencies + +**1–2 days** (excluding obtaining the Developer account). Independent. diff --git a/docs/specs/SPEC-08-accessibility.md b/docs/specs/SPEC-08-accessibility.md new file mode 100644 index 0000000..9f15bea --- /dev/null +++ b/docs/specs/SPEC-08-accessibility.md @@ -0,0 +1,51 @@ +# SPEC-08 — Complete accessibility & internationalization + +> **Findings**: J10.1 (VoiceOver), J10.2 (color blindness), J10.3 (fixed text sizes), J10.4 + J9.5 (i18n, mixed locale). +> **Status**: ✅ VoiceOver completed · ✅ color blindness/F6 · ✅ J9.5 · 🔬 J10.3 & complete translation = documented dedicated workstreams. + +## 0. Implementation result + +- **VoiceOver (J10.1 completed)**: labels/values added on — *Size metric* / *Counting mode* pickers, theme toggle, *Storage reconciliation* button, *File types* rows (name + size + count, button/selection trait), *breadcrumb* segments (name + size + "current/zoom"), **volume cards** (label+value "N% full"), **K8s gauge** (`N percent, OK/High/Critical`). The **treemap** (opaque Canvas) now exposes a spoken summary: zoomed folder + its largest children in %. Adds to the 1st pass (stats, list rows, zoom-out). +- **Color blindness (J10.2)**: the **percentage in text** is a safe redundant channel on the volume gauges (live-verified "81% · …"), and a **textual level** `OK/High/Critical` (`Theme.usageLevel`) in accessibility. **F6 fixed**: thresholds **unified 70/90** everywhere (`Theme.usageColor`) — volumes and K8s were 70/90 vs 70/85. +- **i18n — J9.5 fixed** (SPEC-03): `Format.bytes` is **localized** (decimal separator), consistent with `Format.count` — no more mixed locale (live-verified: "85,7 GiB"). + +## 0.b Remaining dedicated workstreams (honest) + +- **🔬 J10.3 (text scaling / Dynamic Type)**: the app uses **fixed** `.system(size:)` sizes by density choice; making them responsive to "Larger Text" is a **global design retrofit** (≈200 sites) that must be calibrated so as not to break the dense layout — dedicated workstream, not a bolt-on (consistent with the "design robustness" stance). Not attempted on the fly. +- **🔬 J10.4 (complete translation)**: the mixed-locale **bug** (J9.5) is fixed; **translation** into other languages (populated String Catalog `.xcstrings`) is a full-fledged localization effort, out of scope for v1. + +## 1. Objective + +Make the app usable with a screen reader, robust to color blindness and text-size preferences, and ready for localization. + +## 2. Current state (verified) + +- **J10.1 (first pass delivered)**: `accessibilityLabel/Value` added on the stats bar, the list rows ("Folder sub1, 979 KB" — **read live via the AX API**), the treemap (root zoom + size) and the zoom-out button. The rest of the content (treemap = `Canvas`, individual tiles) remains opaque. +- Color: "file type" info carried by the **hue alone** (16 hues, collisions); green/orange/red gauges with no fallback shape/text (J10.2). +- Fixed type 9–13 pt, no reaction to the system text size (J10.3). +- Everything hardcoded in English; `Format.count` localized but `Format.bytes` not (J9.5, mixed locale). + +## 3. Implementation plan + +1. **VoiceOver (complete J10.1)**: + - Toolbar buttons (Home/Rescan/theme): `accessibilityLabel`. + - Treemap: expose the tiles as `accessibilityChildren` (or a rotor) with label "name, size, % of parent" — allows navigating the map via keyboard/VoiceOver. + - File types panel, breadcrumb, K8s gauges: labels + values. + - (SPEC-01 synergy: an `NSTableView` provides accessible selection/focus/rotor natively.) +2. **Color blindness (J10.2)**: do not rely on the hue alone — add the **% in text** on the gauges, a light pattern/wave or a type label on the tiles on hover, and check the contrasts. Unify the color thresholds (70/90 % VolumeCard vs 70/85 % K8s, cf. F6). +3. **Text size (J10.3)**: global scale factor in `Theme` driven by the system preference (or a setting), applied to the font sizes. +4. **i18n (J10.4, J9.5)**: String Catalog (`.xcstrings`) for the strings; `Format.bytes` via a **localized** formatter (consistent with `Format.count`); decide KiB/MiB vs base 10 (cf. SPEC-03). + +## 4. Verification + +- **Live (established method)**: read the app's AX tree (`osascript` System Events) → each control exposes a relevant label/value; navigate the treemap via the rotor. +- Manual VoiceOver test; color-blindness simulator (Sim Daltonism); "Larger Text". + +## 5. Risks & assumptions + +- 🔬 Expose the `Canvas` treemap tiles to AX without degrading the rendering (overlay of invisible accessibility elements vs `accessibilityRepresentation`). +- i18n: the current locale mix (J9.5) is the first bug to fix before any translation. + +## 6. Effort & dependencies + +**1–2 days.** The VoiceOver part benefits from SPEC-01 (native list). Independent otherwise. diff --git a/docs/specs/SPEC-09-gpu-treemap-rendering.md b/docs/specs/SPEC-09-gpu-treemap-rendering.md new file mode 100644 index 0000000..1804392 --- /dev/null +++ b/docs/specs/SPEC-09-gpu-treemap-rendering.md @@ -0,0 +1,140 @@ +# SPEC-09 — **3D-native** GPU rendering of the treemap (Metal, orthographic 2D projection) + +> **Findings**: **perf-resize** workstream (continuation of PR #17 "solution C"). Profiling: after removing the SwiftUI tax (`NSHostingView.layout` 5373→101, `dispatchActions` 3395→1), the cost shifted onto the **CPU rasterization** of the tiles — `CGContextFillRect` ≈ 7482 samples during a drag, on the `NSEventThread`. On a pathological tree (`.build/ModuleCache`, thousands of tiny tiles) the per-tile fill remains the floor. +> **Architecture decision (enacted)**: the rendering engine is **3D-native from the start** — real 3D geometry, camera, MVP matrices, depth buffer. The current display is its **top-down orthographic projection**: flat tiles (height = 0) viewed straight-on → **strict iso-visual** with the current 2D visualization. An ortho projection of flat tiles **preserves proportions exactly** (no vanishing lines), so today's 2D is mathematically identical. **Switching to 3D** (later, §9) = changing the **camera** (perspective + orbit) and giving a **height** to the tiles — **no rewrite**, the pipeline is already 3D. +> **Product constraint**: **we keep the current 2D tiles.** The 3D stays **deferred (+3–6 months)** on the product side. +> **Status**: 📋 **PROPOSED** — to be planned. The seam (NSView separating layout/interaction from `draw()`) is already in place (PR #17), which makes the Metal renderer a **local replacement** of the tile drawing only. + +## 1. Objective + +Render the treemap on the **GPU** instead of the CPU, via a **native 3D pipeline** whose current rendering is an **orthographic 2D projection**, so that resize (and eventually zoom, then 3D) is smooth even on tens of thousands of tiles — **without touching the visual or the interaction today**. The reasoning, proven on this project: + +- The old `Canvas` + `.drawingGroup()` was smooth **because it went through the GPU** (Metal offscreen); its real cost was the **SwiftUI reconcile tax** around it, not the drawing. +- Solution C (PR #17) removed that tax (AppKit drives the resize) but **brought the drawing back onto the CPU** (`CALayer.draw` → Core Graphics). +- **Metal = both gains at once**: GPU drawing **and** no SwiftUI tax. + +A treemap is a set of axis-aligned colored quads: the textbook case for **GPU instancing** (a single draw call for N tiles). The sequential filling of thousands of rects on a thread becomes an upload of N instances + **one** draw call, rasterized in parallel. + +## 2. Current state of the code (verified) + +Seam (laid by PR #17) — the drawing is **already isolated** from the rest: + +- [TreemapView.swift:13](../../Sources/SpaceMatters/Views/TreemapView.swift#L13) `struct TreemapView` (SwiftUI wrapper: observation + hover overlay + a11y) → [:70](../../Sources/SpaceMatters/Views/TreemapView.swift#L70) `TreemapRepresentable: NSViewRepresentable` → [:110](../../Sources/SpaceMatters/Views/TreemapView.swift#L110) `final class TreemapNSView: NSView, CALayerDelegate`. +- Two `CALayer`: [:133](../../Sources/SpaceMatters/Views/TreemapView.swift#L133) `tileLayer` (tiles, redrawn on relayout) + [:134](../../Sources/SpaceMatters/Views/TreemapView.swift#L134) `overlayLayer` (hover + selection, redrawn alone). +- **The bottleneck**: [`drawTiles`](../../Sources/SpaceMatters/Views/TreemapView.swift#L387) — per-tile loop with [`ctx.setFillColor` + `ctx.fill(r)`](../../Sources/SpaceMatters/Views/TreemapView.swift#L398-L399), [`drawLinearGradient` per tile](../../Sources/SpaceMatters/Views/TreemapView.swift#L405) (cushion), dim fill, border `stroke`. **All on CPU.** +- Current pixel-cost workaround: [`viewWillStartLiveResize` sets `contentsScale` to 1](../../Sources/SpaceMatters/Views/TreemapView.swift#L208) (¼ of the pixels during the drag, re-sharpened at the end). A band-aid — Metal makes it unnecessary (§4.7). +- Orientation: top-left rects, bottom-left CG context → [`flip(_:_:)` per tile](../../Sources/SpaceMatters/Views/TreemapView.swift#L383). Historical bug (vertically inverted hover) fixed on the hit-test side [:490](../../Sources/SpaceMatters/Views/TreemapView.swift#L490). + +What **does not move** (and is therefore not rewritten): + +- **Layout**: [`TreemapLayout.compute`](../../Sources/SpaceMatters/Views/TreemapLayout.swift#L77) + size-independent [`Cache`](../../Sources/SpaceMatters/Views/TreemapLayout.swift#L49) + [`squarifySorted`](../../Sources/SpaceMatters/Views/TreemapLayout.swift#L192); only the **placement** re-runs per frame, the sorted structure is memoized. Stays CPU (cheap after cache). Driven by [`ScanController.treemapLayout`](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L453), invalidated by [`version`](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L30). +- **Colors**: [`computeColors`](../../Sources/SpaceMatters/Views/TreemapView.swift#L302) + LUT `(hueIndex, bucket luminance)` → today `CGColor` ([`cgColor(for:weight:)`](../../Sources/SpaceMatters/Views/TreemapView.swift#L326)), via [`Theme.treemapTypeColor(hueIndex:weight:)`](../../Sources/SpaceMatters/App/Theme.swift#L61). **Only change**: emit **packed RGBA** (`SIMD4`) instead of `CGColor`. +- **Interaction**: [`tileAt`](../../Sources/SpaceMatters/Views/TreemapView.swift#L490), hover ([:507](../../Sources/SpaceMatters/Views/TreemapView.swift#L507)), `mouseUp` ([:524](../../Sources/SpaceMatters/Views/TreemapView.swift#L524)), `menu(for:)` ([:568](../../Sources/SpaceMatters/Views/TreemapView.swift#L568)) — **unchanged** (CPU hit-test on `tiles`). +- **Current overlay**: [`drawOverlay`](../../Sources/SpaceMatters/Views/TreemapView.swift#L432) (1 spotlight + 1 rim + 1 outline) in CoreGraphics — **migrates to a 2nd Metal pass** (§4.5); the CG code stays as a fallback. + +No Metal usage today (grep: only a residual `.drawingGroup()` in `KubernetesResultView`, off-topic). + +## 3. Retained design + +The renderer is an **instanced-quad Metal pipeline, 3D-native, projected in ortho 2D**. The decisions, each enacted: + +1. **GPU instancing** — a unit quad (generated from `[[vertex_id]]`, no VBO) drawn **N times** via `drawPrimitives(instanceCount: N)`; each instance reads its `{origin, size, color}` from an **instance buffer** indexed by `[[instance_id]]`. A single draw call for all the tiles. *(Equivalent OpenGL model: `glDrawArraysInstanced` + per-instance attribute; MSL instead of GLSL, pre-compiled pipeline state instead of the mutable global state.)* + +2. **3D-native, ortho projection** — 3D vertices, **MVP matrix** driven by a camera, depth buffer active. Today: `height = 0`, top-down orthographic camera → pixel-identical output to the 2D. This choice has **the same per-frame cost** as a frozen 2D pipeline, but makes the 3D (§9) free (camera + height) instead of a rewrite. The MVP matrix replaces the per-tile `flip(_:_:)` (flip encoded once in the camera). + +3. **On-demand rendering** — `CAMetalLayer` driven manually (draw called from `setFrameSize`/`apply`), GPU at rest when nothing changes. No `MTKView` (avoids a subview + delegate; we keep our single `NSView` and its interaction). + +4. **Shared buffer + ring** — `MTLStorageModeShared` (Apple Silicon unified memory: CPU writes, GPU reads, **zero copy/blit**). Ring of 2–3 buffers + `DispatchSemaphore` so that the CPU does not overwrite a buffer still being read by the GPU (useful during the burst of frames of a live-resize). + +5. **Borders via inset gutter** — clear the layer to `treemapBorder`, then draw each fill **inset by 0.6 px** → the border color shows through as gutters. Zero border geometry, a crisp "grid" look (the classic treemap trick). + +6. **Overlay = 2nd Metal pass** — selection + hover rendered by a handful of instances, border/dim in a **fragment shader** (SDF). This is *the* place for future highlight effects (§4.8); putting it in Metal now avoids writing a CPU overlay that we would then tear out. **Phase 1: reproduces the current look identically.** + +## 4. Implementation plan + +Principle: **replace only the guts of the drawing**. Layout, colors (source), hit-test and interaction stay as they are. + +### 4.1 New file `Views/TreemapMetalRenderer.swift` +Encapsulates all the Metal, testable/replaceable, without polluting `TreemapNSView`: +- `device: MTLDevice`, `commandQueue`, `pipelineState: MTLRenderPipelineState`, `depthState: MTLDepthStencilState`, ring of `instanceBuffers: [MTLBuffer]`, `inflightSemaphore`, a `Camera` (§4.2). +- **3D-native** instance struct (compact, aligned): + ``` + struct TileInstance { // 48 B: 3× SIMD4 + var origin: SIMD4 // x, y, z(=0) in world space; w = padding + var size: SIMD4 // width, depth, height(=0 today), w = padding + var color: SIMD4 // linear RGBA; the dim is already folded in + } + ``` + Today `origin.z = 0` and `size.height = 0` → each instance is a **flat quad** laid on the ground plane; tomorrow `size.height = f(size|count|depth)` → an **extruded box**, **same struct**. +- `func render(instances:, drawable:, camera: Camera, borderColor:)`. + +### 4.2 Camera & world convention (the heart of 3D-native) +- **World**: **XZ ground plane** (like a map / a city), **Y = height** axis ("code city": the tiles lie on the ground, the boxes rise in Y). The squarify layout (top-left rects in pixels) is mapped onto the ground plane by a fixed transform (scale + top-left → world flip). +- **Camera** = `view` (position/orientation) × `projection`, encapsulated in `Camera { viewProjection() -> float4x4 }`. **Today**: **orthographic** camera, above, looking straight down (−Y), *up* aligned to reproduce the rects layout → top-down ortho = the 2D rects to the pixel (iso-visual guarantee). +- **Depth buffer** enabled right now (`MTLDepthStencilState`, `.depth32Float`): inert as long as the tiles are flat, **ready** for box occlusion in 3D. Negligible cost. + +### 4.3 Shaders `Views/Treemap.metal` +- **Vertex**: quad vertex (`vertex_id` → 2 triangles; tomorrow 12 box triangles) placed by `instance.origin/size`, transformed into clip space by the **camera MVP** (uniform). Passes `uv∈[0,1]` and the pixel size to the fragment. +- **Fragment**: `fill = instance.color`; **cushion** = reproduction of the current 3 stops ([TreemapView.swift:160-165](../../Sources/SpaceMatters/Views/TreemapView.swift#L160-L165)) — white α .16 at the top → 0 at 0.45 → black α .20 at the bottom, composited over the fill, **skipped** if tile < 6 px (as today); **border** via inset gutter (background pre-cleared to `treemapBorder`, fill inset by 0.6 px). sRGB output (§6). + +### 4.4 Instance buffer — allocation discipline +- Reuse one `MTLBuffer` per ring slot, **grown only** when `tiles.count` exceeds capacity (like `sizeScratch`). Never a per-frame alloc. +- Filling: single loop over `tiles`, sub-pixel culling **on the CPU side before writing** (we don't send a tile ≤ 0.5 px — already filtered [:395](../../Sources/SpaceMatters/Views/TreemapView.swift#L395)). Direct write into the shared buffer. +- Colors: `computeColors` produces `SIMD4` (same values as `treemapTypeColor`, converted once) instead of `CGColor`. The **dim** (highlight/search) is **folded** into the color at fill time → a simple recompute of the buffer on a highlight change (infrequent), no second pass. + +### 4.5 Integration into `TreemapNSView` +- Replace `tileLayer` (CPU CALayer) with a `CAMetalLayer` (`tileMetalLayer`), `device` assigned, `pixelFormat` calibrated (§6), `framebufferOnly = true`, `presentsWithTransaction = true` (§6 resize gotcha). +- `relayout()`/`apply()` unchanged in their logic; at the end, instead of `tileLayer.setNeedsDisplay()` → synchronous `renderer.render(...)`. +- `setFrameSize`: update `tileMetalLayer.drawableSize = bounds.size * scale`, then render. **Remove** the live-resize `contentsScale = 1` hack (§4.7). +- **Overlay** (selection + hover): 2nd Metal pass in the same drawable (spotlight `evenOdd` → inside/outside test + SDF rim; hover outline → SDF). **Phase 1: current look identically.** The CG `drawOverlay` stays as a fallback (§4.6). + +### 4.6 Fallback (defensive) +If `MTLCreateSystemDefaultDevice()` returns `nil` (never on macOS 15, but by-the-book) → keep the current CoreGraphics path (`drawTiles`/`drawOverlay`) as a fallback. The CG drawing code **is not deleted**, it becomes plan B → **no regression possible**. + +### 4.7 Expected bonus: removal of the live-resize band-aid +Metal rasterizes the full retina almost for free → no more need for `contentsScale = 1` during the drag ([:208-217](../../Sources/SpaceMatters/Views/TreemapView.swift#L208-L217)). The treemap stays **sharp during** the resize, not only at the end. + +### 4.8 Effects headroom — unlocked by §4.5, **out of scope** (Phase 2) +Once borders/selection/hover are in a fragment shader, these effects become changes to a uniform, unaffordable on CPU per frame — **do NOT turn them on in Phase 1** (would break the iso-visual). Recorded as a product cap, to be scoped in a dedicated workstream: animated selection glow (distance falloff + `time`), anti-aliased SDF borders, animated dim (150 ms fade), search matches that "breathe". *(Animated effects imply a 60 fps rendering bounded by `CADisplayLink` for the duration of the transition — not a continuous loop.)* + +## 5. Verification + +- **Iso-visual (established screenshot method)**: side-by-side capture **before (CG) / after (Metal)** on the same scan — colors, cushion sheen, gutters, dim (highlight extension + search), selection spotlight, hover outline. Must be indistinguishable (sRGB tolerance, §6). +- **Orientation**: re-verify the historical bug — hovering the tiles **at the top** correctly highlights those at the top (the flip is now in the projection). +- **Perf (goal of the workstream)**: `sample`/Instruments (Metal System Trace) on `.build/ModuleCache` during a continuous drag → CPU rasterization (`CGContextFillRect`) **disappears** from the profile; GPU frame time < 1 ms; `NSEventThread` offloaded. +- **Interaction**: click (reveal), double-click (zoom/open/zoomOut), context menu, selection from the list → **unchanged** (CPU hit-test untouched). +- **Tests**: the tested logic (`TreemapLayout`, `squarify*`) is unchanged → existing tests green. Add a unit test on the **instance packing** (N tiles → N expected `TileInstance`, sub-pixel culling applied) — pure, no GPU. + +## 6. Risks & assumptions (🔬) + +- 🔬 **Color space**: CG draws in sRGB device RGB; the Metal drawable must match (`.bgra8Unorm_srgb` vs gamma conversion in the shader). Poorly calibrated → tiles lighter/darker. To lock down in capture before generalizing. +- 🔬 **`CAMetalLayer` + live-resize**: without `presentsWithTransaction = true` + **synchronous** presentation (`commit` → `waitUntilScheduled` → `drawable.present()` in the same transaction as the bounds change), the Metal layer **lags/tears** behind the window during the drag. This is THE gotcha; to be handled from the start. +- 🔬 **Inset border**: opaque `treemapBorder` gutter vs the current semi-transparent 0.6 px stroke. Validate in capture; possible fallback to an SDF border (fragment shader) if the difference displeases. +- **CPU placement as the new floor**: once GPU raster is free, the residual per-frame cost becomes `squarifySorted` (O(n) arith + rect alloc). If measured as bothersome: reuse the rect buffers, or even parallelize — **out of scope**, to be measured afterward. +- **Intel Macs** (macOS 15, minority): `storageModeShared` OK but less optimal; not blocking. + +## 7. Effort & dependencies + +**2–3 days.** Independent. The NSView seam of PR #17 is the prerequisite — **already in place**. No external dependency (Metal is system). The current CG code stays as a fallback, so no regression possible in case of a device issue. + +## 8. Scope — what this SPEC does / does NOT do + +**Does**: a **3D-native** renderer (3D geometry, camera, MVP, depth) rendered in **orthographic 2D projection**, iso-visual with the current tiles, on GPU. Tiles **and** overlay (selection/hover) in Metal. +**Does NOT** (today): +- No visible change: visualization, palette, layout, interaction **unchanged**. **Strict iso-visual.** +- No text in the tiles (stayed removed). +- **No animated effects**: the Metal overlay reproduces the current look identically; the shader headroom (§4.8) is unlocked but **off** — that's Phase 2. +- No height ≠ 0, no perspective camera, no orbit: the **3D stays unplugged** (top-down ortho camera, `size.height = 0`). See §9. + +## 9. 3D activation — deferred on the **product** side (+3–6 months), already **architected** + +The 3D is **not** a future overhaul workstream, it's a **configuration switch** of an already-3D engine. Nothing to rewrite — we "re-plug" what §4.2 laid down: + +- **Camera**: `projection` ortho → **perspective**; `view` top-down → **tilted + orbitable**. Pipeline, shaders, instance buffer: identical. +- **Height**: `size.height` goes from 0 to `f(data)` → the flat quads become **boxes** (the vertex shader goes from 2 to 12 triangles; same instance struct). +- **Depth buffer**: already active (§4.2) → correct box occlusion without changing anything. +- **Cushion → shading**: the top→bottom sheen becomes real lighting by face normal — same location in the fragment shader. + +Candidate dimensions for the height: `size(metric)`, `fileCount` ([FSNode.swift:28](../../Sources/SpaceMatters/Model/FSNode.swift#L28)), tree depth. ⚠️ **`FSNode` has no temporal field** (verified: identical to `main`) — an "age/freshness" height would imply adding `mtime` to the scan (dependency to be scoped separately, out of this SPEC). + +→ Product decision: **2D tiles now**, 3D activation in 3–6 months. This SPEC guarantees that this activation will be a **camera setting + a height attribute**, not a rewrite. diff --git a/docs/specs/SPEC-10-world-treemap.md b/docs/specs/SPEC-10-world-treemap.md new file mode 100644 index 0000000..30ef169 --- /dev/null +++ b/docs/specs/SPEC-10-world-treemap.md @@ -0,0 +1,110 @@ +# SPEC-10 — Persistent world treemap: continuous camera, hierarchical LOD, "map" navigation + +> **Findings**: resize profiling from 2026-07-12 (Instruments, Game Performance, release build, M4 Pro) — during a live-drag: **main thread saturated at ~80%**, **~16 ms CPU per presented frame** (120 Hz budget: 8.3 ms) → ~50 effective fps with hitches at 25-46 ms. Breakdown: **~22% app code** (continuous layout ~10%, `tiles` reconstruction/repack ~6%, present ~4% — including **IOSurface creation on every frame**, the changing `drawableSize` invalidating the drawable pool), **~78% AppKit/SwiftUI machinery** (chrome layout, constraints, AttributeGraph — two passes per frame). GPU: **0.3%** utilization, p50 0.13 ms — completely idle. +> **Paradigm flaw observed** (captures at two window sizes): the layout is a **function of the viewport**. Freezing the discrete decisions ([TreemapLayout.Cache](../../Sources/SpaceMatters/Views/TreemapLayout.swift#L57)) makes the resize *monotone*, but the cache is invalidated on every `version` bump (scan tick at 10 Hz, FSEvents refresh) and the decisions are re-decided **at the current window rect** — two invalidations at two sizes → two unrelated worlds, the big blocks change place. The "world" is re-rolled permanently. +> **Architecture decision (proposed)**: invert the paradigm, in the manner of an open-world game engine — **the layout is a pure function of the data, computed in world coordinates; the viewport is only a camera; the level of detail is a rendering decision; data changes are local animated touch-ups, never a global re-roll.** As a product bonus: the treemap becomes **navigable like Google Maps** (trackpad pan, continuous zoom toward the cursor, detail that reveals itself on zoom-in). +> **Prerequisite**: PR #24 (Metal renderer as the sole rendering path, [Camera.ortho](../../Sources/SpaceMatters/Views/TreemapMetalRenderer.swift#L39) already decoupling viewport and instances). +> **Status**: 📋 **PROPOSED** — to be planned. Replaces the "frozen reference" mechanic of SPEC-09/PR #17 with a world model; generalizes SPEC-05; prepares the 3D activation of SPEC-09 §9. + +## 1. Objective + +That **no** camera interaction (window resize, pan, zoom) recomputes or re-packs anything anymore: one camera frame = one matrix, budget **< 1 ms** CPU main thread. That the **position of a folder in the world be stable** — between two resizes, between two scan ticks, between two refreshes — and that any legitimate structural change (data that moves, aspect that drifts too much) be a **local animated transition**, not a global teleport. And open the product feature: **navigate the disk like a map** — two fingers to move, pinch to dive, level of detail that follows. + +## 2. Current state of the code (verified) + +What exists and what we build on: + +- **The camera already exists**: [Camera.ortho(viewport:)](../../Sources/SpaceMatters/Views/TreemapMetalRenderer.swift#L39) projects an arbitrary world-rect onto the drawable; the zoom animation ([startZoomAnimation](../../Sources/SpaceMatters/Views/TreemapView.swift#L679)) already animates a camera **over frozen instances** — the proof of concept of the model is in the code. +- **Partial freezing exists**: [TreemapLayout.Cache](../../Sources/SpaceMatters/Views/TreemapLayout.swift#L57) memoizes per-node the discrete decisions (row breaks, orientations) and only replays the continuous geometry. Flaws: global invalidation by `(metric, version, root)` ([ScanController.treemapLayout](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L540)), reference = window rect of the moment, and the continuous geometry + [relayout()](../../Sources/SpaceMatters/Views/TreemapView.swift#L399) + [packInstances](../../Sources/SpaceMatters/Views/TreemapView.swift#L442) return **on every frame** of resize. +- **The renderer copies the instances on every render()** (triple buffer) — no "camera only, unchanged buffer" path. +- **The CG overlay doesn't know how to follow the camera** (masked during animated zoom, hover disabled during the animation); the hit-test ([tileAt](../../Sources/SpaceMatters/Views/TreemapView.swift#L599)) is in view coordinates, valid only for camera at identity. +- **SPEC-05**: the files of the sole zoom root are tiles ([filesIn](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L562), `rootFileTiles` memoized); navigation is a **structural state** `zoomRoot` ([zoom(into:)](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L1150)) that re-layouts. +- Scale: typical host scan = **713,695 folders / 3.9 M files** (today's capture); possible depth > 20 levels. + +## 3. Retained design + +### 3.1 The world: parent-relative hierarchical coordinates +Each node stores its rect **relative to the parent, in [0,1]²** (Float32, 16 B), decided by the squarify on the children's weights — **never in pixels, never a function of the window**. A node's absolute world-rect is the **composition** of its lineage's relative rects, computed in `Double` at render time for the visible sub-trees only. Properties: + +- **Stability by construction**: a node's relative rect changes only if the weights of its siblings change — not on resize, not on zoom, not when a distant cousin moves. +- **Precision**: at 4 M files, a deep tile can measure 10⁻⁶ of the world; composing in Double then **camera re-basing** (camera-relative coordinates before Float conversion for the GPU — the game engines' *floating origin*) eliminates the Float32 precision breakage in deep zoom. +- **Storage**: extension of `Cache.Entry` (the per-node entries already exist) — no field added to `FSNode`, the model stays pure. Entries built **lazily** (only for LOD-expanded nodes) and LRU-evicted outside the viewport, like map tiles. + +The **world's aspect** lazily follows the window's aspect: during a drag the camera stretches (deformation tolerated, bounded by hysteresis ~±20%); at `viewDidEndLiveResize` or when the threshold is crossed, **animated global re-bake** (morph, §3.4). Decision ⚖️: this is the only event allowed to re-decide globally — and it is **rare and animated**. + +### 3.2 The camera: continuous map navigation +`Camera` is enriched with a `(world center, scale)` state with exact view↔world conversions (inverse for the hit-test). Gestures: + +- **Pan**: two-finger scroll / drag (hand mode). **Zoom**: pinch and wheel, **toward the cursor** (the point under the mouse stays under the mouse — the Google Maps invariant). **Double-click**: animated zoom-to-fit of the folder (replaces the re-layout of `zoom(into:)` with a camera movement — the current animation becomes *the* navigation). **⌘0 / breadcrumb / Home**: fit of an ancestor. +- **Bounds**: zoom-min = whole world (light rubber-band), zoom-max = when the smallest visible file reaches ~40 px per side. +- **`zoomRoot` becomes a derivative of the camera**: the deepest folder whose world-rect contains ~the viewport. Breadcrumb, list, a11y summary hook onto it as today — the list→map selection does a camera fit, the map→list navigation follows the derivative. The structural state disappears; the mental URL becomes "where is the camera". + +### 3.3 Hierarchical LOD by projected size +The static `maxDepth`/`minSide` disappear in favor of a per-node rule, evaluated on the **on-screen projected size** (px = world size × camera scale): + +- projected side < **T_collapse** (~8 px) → the folder is rendered **aggregated**: a tile of its `dominantExt` color (the field exists, [FSNode](../../Sources/SpaceMatters/Model/FSNode.swift#L46)); +- projected side > **T_expand** (~14 px) → its children are expanded (hysteresis T_expand > T_collapse against *popping* at the threshold edge); +- projected side > **T_files** (~400 px per side) → its **own files** appear as individual tiles — **generalization of SPEC-05**: no longer only the zoom root, any folder large enough on screen. File layouts computed on demand, LRU cache. + +The **subdivision is animated**: when a folder expands, its children are born from the parent rect and interpolate toward their rects (morph §3.4) — Google Maps' "tile split"; the inverse on collapse. The GPU instance set is managed in **contiguous ranges per expanded sub-tree**, rebuilt only when the LOD set changes; a camera-only frame **rewrites no buffer** (new `render(cameraOnly:)` path that reuses the last buffer — the current per-frame copy disappears). + +### 3.4 Morph: every re-bake is a transition +The vertex shader receives **two instance buffers** (before/after, paired by node) + a uniform `t`; it interpolates origin/size (~200 ms, current easing). Applies to: aspect re-bake (end of drag), **scan ticks and FSEvents refreshes** (coalesced at 10 Hz max — the world "breathes" instead of teleporting, the flaw of the captures disappears by construction), LOD expansion/collapse, future 2D↔3D transition. Orphan instances (deleted node) fade toward size 0; new instances are born from the parent rect. + +### 3.5 Stability under scan: local invalidation ("local moves") +The `version` invalidation ceases to be global: the scanner/FSEvents know the **dirty sub-trees** — only their entries (relative rects, decisions) are re-decided; siblings whose weights have drifted only below an **ε** (~2%) keep their decisions (continuous re-geometry only). This is the spirit of "stable treemaps via local moves" (Sondag et al., TVCG 2018) applied to our existing cache. During the initial scan, the world **builds progressively**: entries appear as data arrives (streaming), the LOD bounds what is computed — we never layout 713 k folders at once, only the visible + a margin. + +### 3.6 Presentation: drawable pooling, camera-aware overlay +- **Drawable**: `drawableSize` rounded up to the **256 px step**, framed via `contentsRect` → the IOSurface pool survives the drag (the per-frame allocations measured at the profile disappear). 🔬 to validate with `presentsWithTransaction`; fallback = reallocation only at `viewDidEndLiveResize`. +- **Overlay** (selection spotlight + hover): its 2-3 rects move to **world coordinates**, transformed by the camera on each present (trivial cost) → the overlay **follows** pan/zoom/morph, the hover stays active during the movements (the current inhibition goes away). The hit-test goes through the camera inverse (view → world → descent by containment in the relative rects). + +## 4. Implementation plan — milestones + +**M1 — Frozen world + passive camera (~2-3 days)**: parent-relative rects in `Cache.Entry`; world composition → instances; resize = camera only + animated re-bake at end of drag; `render(cameraOnly:)`; drawable pooling; overlay/hit-test via camera inverse; hover active during movements. *Output: resize 120 Hz, app share ~0 in the profile, no more block jumps on resize.* + +**M2 — Map navigation (~2-3 days)**: pan/zoom-toward-cursor (scroll, pinch, wheel), double-click = animated fit, bounds + rubber-band, `zoomRoot` derived (breadcrumb/list/a11y follow), ⌘0/Home = root fit. *Output: the "Google Maps" feature.* + +**M3 — Hierarchical LOD + files (~4-5 days)**: projected thresholds + hysteresis, lazy/LRU entries, instance ranges per sub-tree, subdivision-morph, files per folder beyond T_files (generalizes SPEC-05). *Output: zooming in reveals the detail, zooming out aggregates — at unlimited depth.* + +**M4 — Streaming & live scan (~3-4 days)**: invalidation by dirty sub-tree, ε-stability of the decisions, morphs coalesced on ticks/FSEvents, progressive construction during the scan. *Output: an ongoing scan is a world that fills in smoothly.* + +*(M5 = 3D: out of scope — see §8.)* + +## 5. Verification + +- **Stability (the bug of the captures)**: reference script — same scan, resize → version tick → resize sequence; the centroids of the 20 largest blocks must not move by > a few % of the world (excluding explicit morphs). Pure unit test on the relative rects: re-layout at different aspects ⇒ identical relative rects. +- **Perf**: Instruments re-profile of the same scenario (protocol of 2026-07-12: attach + warm-up ~10 s to absorb) — camera frame < 1 ms CPU app; zero `CAIOSurfaceCreate` during a drag; presents at the vsync cadence during pan/zoom. +- **Precision**: zoom at max on the smallest file of a 4 M scan — no geometry jitter (validates the camera re-basing). +- **LOD**: repeated threshold crossings (oscillating zoom) without popping or instance churn (hysteresis); LRU entries memory budget bounded and measured. +- **Interaction**: hover/click/menu/selection during pan, zoom **and** morph; breadcrumb consistent with the camera derivative; a11y (dominant folder summary); existing `TreemapLayout` tests green (the squarify itself does not change). + +## 6. Risks & assumptions (🔬) + +- 🔬 **Float precision in deep zoom** — mitigated by parent-relative + Double composition + camera re-basing; to be proven at milestone M1 (test of §5). +- 🔬 **`contentsRect` × `presentsWithTransaction`** on CAMetalLayer — to validate early; fallback documented (§3.6). +- 🔬 **Before/after morph pairing** under aggressive scan (nodes born/dying in bursts) — 10 Hz coalescing + birth-from-parent should suffice; otherwise degrade to cross-fade. +- 🔬 **Derived `zoomRoot` mental model**: the list and the map may transiently diverge during a pan — the breadcrumb UX "follows the camera" is to be tested (M2 prototype before freezing). +- ⚖️ **Wheel = zoom** (map convention) vs scroll = pan (document convention): decide at M2 (proposal: trackpad pan + pinch zoom, mouse wheel = zoom). +- **Memory**: full entries on a pathological tree — bounded by LOD-lazy + LRU, to measure M3. +- **Deformation during the drag** (world aspect ≠ window in the hysteresis band): assumed, bounded, corrected by the animated re-bake at end of drag. + +## 7. Effort & dependencies + +**~11-15 days** in 4 milestones deliverable independently (each leaves the app better than before). Depends on: PR #24 merged (Metal-only). No external dependency. The 78% AppKit/SwiftUI tax on *window* resize is **not** in this scope (separate chrome workstream) — but pan/zoom themselves do not touch the window: map navigation runs entirely within the GPU/camera budget. + +## 8. Scope — what this SPEC does / does NOT do + +**Does**: the world paradigm (layout = data), continuous pan/zoom camera, projected hierarchical LOD (folders **and** files), morphs on every change, streaming under scan, spatial stability guaranteed and tested. +**Does NOT**: 3D (heights, perspective, orbit) — but this model is its **clean prerequisite**: SPEC-09 §9 then activates via camera + height on an unchanged world, the LOD and the morph applying as-is to the boxes. No minimap (nice-to-have, to scope after M2). No SwiftUI chrome overhaul (the 78% of the window-resize profile — separate workstream). The tiles' look (palette, cushion, gutters) does not change. + +## 9. Post-implementation addendum (PR #25 — visual QA of 2026-07-12) + +Amendments enacted during the implementation, after four passes of visual QA on a real scan (4 M files): + +- **§3.6 drawable pooling: abandoned.** The 🔬 was confirmed — the `contentsRect` crop produces black bands during the drag. The documented fallback applies: exact `drawableSize` per frame; the resize having become camera-only, IOSurface reallocation is the only residual cost, assumed. +- **§3.4 subdivision morph: "appear-in-place", not grow-from-parent.** Geometric growth from the parent rect superimposes the children on each other and on their neighbors during the transition (large ghost squares on zoom). New tiles appear at their final place; only the tiles present in both builds slide, and their **colors cross-fade** (the luminosity renormalization fades instead of jumping). A rebuild mid-morph restarts from the displayed state (lerp at t), not from the previous target. +- **§3.4 live scan: teleport, not morph.** At 10 Hz of violent restructurings, 220 ms slides never land — the map disintegrates into scattered squares on the underlayer. An active scan rebuilds instantly (each frame is a coherent tiling); the morphs apply to the calm post-scan life (FSEvents, deletions, metric, aspect re-bake, LOD). +- **§3.5 ε anchored to decision shares + quality guard.** The drift measured against the last revalidation (sliding baseline) let the decisions drift without bound in small steps (10:1 slivers). The ε is measured against the shares **at the moment of the decision**, the aspect drift of the node's rect (±25%) also re-decides, and a self-repairing guard re-decides locally beyond a worst ratio of 5:1. +- **Parent underlayer**: each expanded folder (and subdivided files block) is painted under its children — the culled children (< 0.5 px) show the folder's color instead of punching a hole to the background; hover/click there land on the folder. +- **Normalized wheel**: precise deltas ÷12, factor bounded to [0.5, 2] per event (×1.15/notch, aligned on the pinch). +- Vanished tiles are removed without a fade (fading to zero would imply blending — opaque pipeline preserved). diff --git a/docs/specs/SPEC-11-incremental-refresh.md b/docs/specs/SPEC-11-incremental-refresh.md new file mode 100644 index 0000000..7ea2fd4 --- /dev/null +++ b/docs/specs/SPEC-11-incremental-refresh.md @@ -0,0 +1,76 @@ +# SPEC-11 — Per-directory incremental refresh: the living map + +> **Findings** (session 2026-07-12, live QA on a 315 GiB / 3.9 M files volume): a 4 GiB file created **at the root of the home** triggers, on Refresh, a parallel re-scan **of the entire home** — ~25 s (after PR #26, which bounded a single-threaded extensions walk that pushed this same refresh to several **minutes**). Structural cause: FSEvents is only exploited as a *dirty sub-tree marker* ([handleDiskChanges](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L1109) → `nearestNode`), and [refreshDirty](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L1139) → [invalidate(subtree:)](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L970) rebuilds the whole sub-tree. The banner's figure, for its part, is a **global statfs delta** ([volumeFreeBytes](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L1100)) — it also captures the noise from other processes. +> **What we don't exploit**: the precise semantics of FSEvents at directory granularity — an event without `MustScanSubDirs` means "**the direct content of THIS directory** has changed". A non-recursive `getattrlistbulk` pass over that single directory (milliseconds) is enough to compute the exact delta and propagate it. +> **Decision (settled)**: the **live mode is the default**. Changes apply automatically — deltas **per directory, propagated** for precise events; sub-tree re-scan **auto-triggered** (coalesced to the common ancestor) when FSEvents admits a loss of detail. There is **no dormant fallback**: both mechanisms are nominal, each on its own regime. The banner + Refresh button only remain as a **last resort** — an automatic catch-up that would be large enough to warrant the user's consent. The map becomes a *living* dashboard that breathes through morphs (SPEC-10). +> **Dependencies**: PR #26 (honest banner during refresh), SPEC-10 merged (local ε-revalidation + morphs — the treemap already absorbs incremental updates gracefully). +> **Status**: ✅ **IMPLEMENTED** (2026-07-12, branch `spec/11-incremental-refresh`, M1–M4). Measured at scale (955 k files / 55 GB): +512 MiB in a *new* folder → exact totals **0.01 s** after the write finishes; deletion faded in **0.73 s** — no click, no banner. Objective §1 (< 1 s) exceeded. + +## 1. Objective + +That a one-off disk change (file created/deleted/resized, folder added) be **visible in the map in < 1 s, with no intervention whatsoever** — measured against the current 25 s + one click on the 2026-07-12 scenario — with exact aggregates (sizes, counts). The downloaded file appears sliding in, the emptied trash melts the tiles away; the user no longer "refreshes", they watch. + +## 2. Current state of the code (verified) + +- [FSWatcher](../../Sources/SpaceMatters/Scanner/FSWatcher.swift#L5): FSEvents at **directory** granularity (no `FileEvents`), 1 s latency/coalescing, delivery on a private queue. **Does not forward event flags** (`MustScanSubDirs`, `Renamed`…) — first brick to lay. +- [handleDiskChanges](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L1109): path → nearest existing node → `dirtyPaths`; banner gated by the budget on the statfs delta. Since PR #26: silent during an in-flight refresh. +- [invalidate(subtree:)](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L970): full re-scan of the sub-tree (epoch + `structuralOpActive` + retained ancestors), File-types reconciliation **bounded to 100 k files** (PR #26). This is the future **fallback**, not the nominal path. +- `FSNode`: **atomic** aggregates (`aggLogical/aggPhysical/fileCount`, `directFilesSize/directFileCount`, `dominantExt`) — **no per-file inventory in memory** (low-RAM design). The listings exist only in the LRU `fileCache` of the visited folders. +- The **delta propagation up the ancestor chain already exists** (`wrappingAdd` — deletion and invalidate paths); likewise the sub-tree detachment (`applyDirectoryRemoval`). +- On the rendering side, SPEC-10 does the work: version bump → **local** ε-revalidation of the touched entries → morph. A one-off delta re-rolls nothing. + +## 3. Design + +### 3.1 FSWatcher: forward the flags +The handler receives `[(path, flags)]`. At directory granularity (no `FileEvents` — that's our anti-flood), the per-item flags (`ItemRenamed`, `ItemCreated`…) are **never delivered**; the only exploitable ones are `kFSEventStreamEventFlagMustScanSubDirs` (loss of detail → sub-tree catch-up) and `…RootChanged` — which requires `kFSEventStreamCreateFlagWatchRoot` at creation (to be added; root moved/deleted → banner proposing a Rescan). Renames are detected **structurally**: the diff of sub-folders on re-stat of the parent(s) sees a disappearance + an appearance. `IgnoreSelf` deliberately absent: the echo of our own deletions produces a convergent no-op re-stat, and catches up any discrepancy. 1 s latency kept (that's our natural coalescing). + +### 3.2 Applying a directory event `P` (nominal path) +All events converge toward a **serialized reconciliation queue** (a single consumer, on the MainActor between suspensions): at any instant at most one structural operation mutates the tree — same discipline as `remove`/`invalidate`, with which it shares `structuralOpActive` (op refused → re-queued, never forgotten). + +1. **Non-recursive re-stat** of `P` off the main thread: one pass of the existing primitive ([enumerateDirectory](../../Sources/SpaceMatters/Scanner/FSAttr.swift#L62)) → **absolute totals** (`directFilesSize/directFileCount/dominantExt` + set of direct sub-folders + file listing). Reused buffer, µs–ms per folder. +2. **Delta = absolute − node state, computed at apply time** on the MainActor — never a pre-computed delta (two re-stats in flight on the same folder converge in last-writer-wins instead of applying a stale delta) → `wrappingAdd` propagation up to the root. The treemap sees the bump and morphs. +3. **Diff of sub-folders** (name diff, at apply): appeared → mini-scan `DirectoryScanner` of the sole new sub-tree attached to `P` — respecting `skipPaths`/mount-points **like the initial scan** (otherwise Data firmlink double-counting); disappeared → subtraction of the aggregates + detachment (`applyDirectoryRemoval` plumbing, which already lifts zoom/selection/expansion toward the survivor). +4. **File-types**: **exact** per-extension diff via a **per-folder snapshot** (bounded LRU, ~4096 folders) fed by each re-stat — exact from the second delta on a folder; seeded by `fileCache` when it holds the complete listing (< 2000 items, its bound). Without a prior snapshot: we don't touch the table (marked drift, same class of trade-off as PR #26). A disappeared **leaf** folder whose snapshot is known is subtracted **exactly** (QA case: the watched download that erases itself — zero drift end to end). No more recursive walk anywhere on this path. +5. **Caches**: the listing obtained in 1 refreshes `fileCache` (the outline follows with no I/O); `sortCache` purged (the size ordering may have changed). The per-delta version bumps coalesce naturally per runloop turn (SwiftUI repaints only once per turn) — during a big cycle, the map updates progressively instead of jumping all at once. +6. **Safety invariants** unchanged: epoch re-checked after each suspension, retained ancestors, `structuralOpActive` exclusive. + +### 3.3 Automatic catch-up (not a dormant fallback — a second nominal regime) +When FSEvents **admits a loss of detail**, the detail cannot be reconstructed by deltas — the existing sub-tree re-scan ([invalidate(subtree:)](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L970), proven) takes over **automatically**, with no banner or click: +- `MustScanSubDirs` → auto re-scan of the signaled sub-tree; +- **event burst** → coalescing by **common-prefix clustering**, not to the global common ancestor (100 scattered folders have the root as common ancestor — we would re-scan 3.9 M files where 100 re-stats cost ~100 ms): the **dense** groups (≥ K≈16 dirty paths under the same near ancestor — build churn, checkout, rsync) coalesce each toward *its* ancestor in a single parallel pass; the scattered ones remain unit re-stats, capped (~256/cycle, the excess stays queued); +- **per-sub-tree cooldown** (~10 s) between two auto catch-ups of the same node — a build loop churning continuously marks dirty and waits for the next window, instead of re-scanning the same sub-tree every 2 s. + +The cases that the initial version of this spec handled as fallback and that **dissolve into the nominal path**: +- unmappable path (unknown folder, race with a rename) → re-stat of the nearest known **parent** (one-level recursion); +- **Exact mode** (settled): delta in **local attribution + drift marker** on the node (tooltip "hardlink dedup to re-verify") — no systematic re-scan; the full Rescan remains the exact re-synchronization point, as today. + +### 3.4 The banner: last resort only +A single case makes it appear: an automatic catch-up (§3.3) whose target sub-tree exceeds a **consent threshold** (`fileCount` > ~500 k, i.e. several seconds of re-scan) — there, we ask before burning the CPU, with PR #26's persistent spinner during execution. Below the threshold, everything is silent and living. `changedBytes` becomes the sum of the deltas actually applied (exact); the global statfs is demoted to a coherence guard-rail: `|statfs drift − applied physical deltas| > budget` → propose a Refresh — the sign that something escaped us (writes outside the observed sub-tree on the same volume remain the known false positive, budget 128 MB unchanged). + +## 4. Implementation plan — milestones + +**M1 (~1.5 days)** — Flags in FSWatcher; single-dir re-stat + propagated deltas, still triggered by the existing Refresh (the button becomes instant for the nominal cases — an observation step before removing the seatbelt). +**M2 (~1.5 days)** — Created/deleted sub-folders, renames (disappearance/appearance pair), unmappable paths via parent re-stat, epoch/structuralOp guard-rails under tests. +**M3 (~1 day)** — **Switch to live by default**: auto-application of deltas, auto-coalesced sub-tree catch-up (`MustScanSubDirs` + bursts), banner reduced to the consent threshold. +**M4 (~1 day)** — File-types via per-folder snapshots (LRU, seeded by `fileCache`); local attribution + drift marker in Exact mode; exact `changedBytes` + statfs guard-rail. + +## 5. Verification + +- **Fixtures** (NavigationTests pattern): create/delete/resize a file in a scanned folder → exact aggregates propagated up to the root **without** a sub-scan (spy-able scanner call counter); folder created with content → mini-scan of the sole sub-tree; folder deleted → exact subtraction. +- **Auto catch-up**: forged event with `MustScanSubDirs` → auto re-scan of the sub-tree, no banner; synthetic burst (> N dirs) → coalescing to the common ancestor + a single re-scan; giant burst (> consent threshold) → banner, and only the banner. +- **Live QA**: replay the 2026-07-12 scenario (`dd` 4 GiB at the root of the home) → appearance in the map **< 1 s** after the FSEvents latency, **no click**, morph in support; deletion → melt; `npm install` in a repo → a single coalesced, silent catch-up. ✅ *Measured headless at scale (`SM_QA_LIVE=1`, 955 k files): appearance 0.01 s, melt 0.73 s — see `liveReconcileAtScale`.* +- **Coherence**: after a salvo of deltas, `du -skx` byte-exact vs aggregates (the audits' verification method); statfs drift vs `changedBytes` under budget. + +## 6. Risks & assumptions (🔬) + +- 🔬 **Fine semantics of FSEvents flags** under aggressive coalescing (1 s latency) — to characterize early (M1) on forged and real events. Empirically validated on 2026-07-12: a stream on `/` does deliver the Data paths in **firmlink form** (`/Users/…`), which `nearestNode` already canonicalizes. +- **Renames of large folders**: detected structurally (disappearance + appearance at the parent(s)) → subtract + re-scan: correct but costly for a large renamed folder. The true re-attach would require the folder's inode persisted by `FSNode` (+8 bytes/node) to recognize the sub-tree — lever identified, deferred. +- 🔬 **APFS**: clones/sparse — the re-stat measures what `getattrlistbulk` reports, like the initial scan (coherent by construction); to verify on cloned files. +- **Exact mode**: local attribution + drift marker (settled §3.3) — hardlink dedup is only re-guaranteed at Rescan; to document in the marker's UI. +- 🔬 **Cascading auto catch-ups**: a `MustScanSubDirs` while an auto re-scan is already running — the serialized reconciliation queue + `structuralOpActive` + the per-sub-tree cooldown must absorb it without a re-scan storm (dedicated test). +- 🔬 **Two re-stats in flight on the same folder**: neutralized by construction (absolute totals, delta computed at apply, serialized queue) — dedicated test anyway. +- **dominantExt** local: recomputed on the sole touched folder — the color of an aggregated ancestor may drift marginally until Rescan (acceptable, cosmetic). + +## 7. Effort & scope + +**~4-6 days** in 4 deliverable milestones. **Does not change**: the initial scan, the full Rescan, the VM/K8s scans (out of scope — `isHostScan` only), the treemap (SPEC-10 absorbs the deltas by construction). **Changes**: FSWatcher (flags), ScanController (per-directory deltas + auto-coalesced catch-up), banner (reduced to the consent threshold in M3 — the default mode is **live, silent, exact**). diff --git a/docs/specs/SPEC-12-auto-update.md b/docs/specs/SPEC-12-auto-update.md new file mode 100644 index 0000000..5049ef0 --- /dev/null +++ b/docs/specs/SPEC-12-auto-update.md @@ -0,0 +1,147 @@ +# SPEC-12 — Auto-update: Sparkle 2 + +> **Context**: SPEC-07 delivered the v1 distribution (Developer ID signed DMG + notarized + stapled via GitHub Releases) and explicitly listed auto-update as out of scope (§4). This spec closes that workstream. +> **v1 scope**: consented automatic check + manual "Check for Updates…", in-place update from GitHub Releases, release notes displayed. Beta channel, deltas and Settings pane deferred (§3.8). +> **Product constraint (2026-07-12)**: no Apple Developer account for now, decision deferred as long as possible **without closing the door** — hence the two-track identity strategy of §3.7: self-signed stable today, switch to Developer ID by simply adding secrets, transparent transition for updates (EdDSA chain). +> **Status**: ✅ **implemented and validated live on 2026-07-12** (branch `feat/spec12-sparkle-updates`, not pushed) — milestones §4.1–4.7 done; **full update cycle proven on this machine**: v0.0.1 from `~/Applications` → "Check for Updates…" → detection of v9.9.9, markdown notes rendered, download, EdDSA validation, in-place replacement, **quarantine lifted** (0 xattr), **automatic relaunch** in 9.9.9. **FDA track A persistence proven** (identical cert-pinned DR, grant survives the update, §5). Consent prompt on 2nd launch confirmed (the observed absence = already-answered state, not a bug). All 🔬 assumptions lifted (§6). **Remaining before public release**: creation of the cert in the CI's ephemeral keychain (1st run), first real release. Relaunch note: does not trigger from `/private/tmp` (LaunchServices) — no effect in `~/Applications`/`/Applications`. + +## 1. Objective + +The user who installed SpaceMatters from the DMG is notified that a new version exists, reads the release notes, clicks "Install" — the app replaces itself in place, relaunches, **without re-passing Gatekeeper and without losing the Full Disk Access grant**. All of this without any network connection leaving before their explicit consent. + +Chosen framework: **[Sparkle 2](https://github.com/sparkle-project/Sparkle)** (2.9.4 at the time of writing). **MIT** license (+ BSD 2-clause for bsdiff/bspatch, MIT for ed25519 — verified on the 2.x branch): compatible with Apache 2.0, no obligation beyond preserving the copyright notices. + +## 2. Current state of the code (verified) + +- **Complete and functional release chain** (SPEC-07): [release.sh](../../Packaging/release.sh) locally, [release.yml](../../.github/workflows/release.yml) in CI (triggered on draft publication) — build → `bundle.sh` → sign hardened-runtime → DMG → notarize → staple → upload asset. Three tagged releases (v0.1.0 → v0.3.0). +- **Release notes already produced**: [release-drafter.yml](../../.github/workflows/release-drafter.yml) maintains a markdown draft, categorized by PR labels — the raw material for update notes already exists, it just needs to be routed to the appcast. +- **Versioning compatible with Sparkle as-is**: `CFBundleShortVersionString` = semver tag ([Packaging/bundle.sh:49](../../Packaging/bundle.sh#L49)), `CFBundleVersion` = **commit count** ([Packaging/bundle.sh:32](../../Packaging/bundle.sh#L32), `fetch-depth: 0` in CI for that, [release.yml:45](../../.github/workflows/release.yml#L45)) — integer, strictly increasing on `main`: directly usable as `sparkle:version`. +- **Handcrafted bundle, single static binary**: `bundle.sh` generates the Info.plist via heredoc ([Packaging/bundle.sh:39-64](../../Packaging/bundle.sh#L39-L64)), no `Contents/Frameworks`, no rpath. Current signature via `codesign --deep` ([Packaging/bundle.sh:75](../../Packaging/bundle.sh#L75), [Packaging/release.sh](../../Packaging/release.sh#L36)) — will need to become explicit (inner→outer) with an embedded framework. +- **No sandbox, no entitlements** (verified: no `.entitlements`, no `com.apple.security.*`): Sparkle's XPC services are useless here, integration simplified. +- **No network code in the app**: zero `URLSession` in `Sources/`. Sparkle will be the app's **first outbound connection** — the house ethos is explicit ([NativeCleaner.swift:53](../../Sources/SpaceMatters/Scanner/NativeCleaner.swift#L53): "a cleaner must not update taps or phone home"), so consent first, README transparency next. +- **UI anchor points**: GUI entry point [SpaceMattersApp.swift:40-56](../../Sources/SpaceMatters/App/SpaceMattersApp.swift#L40-L56) (caution: the `@main` `Entry` enum also routes headless CLI subcommands — the updater must only live in the GUI path); a single `.commands` block ([SpaceMattersApp.swift:49-55](../../Sources/SpaceMatters/App/SpaceMattersApp.swift#L49-L55)); no Settings scene (ad-hoc settings via `@AppStorage`). +- **FDA keyed on the signature**: the Developer ID designated requirement is stable → the TCC grant survives a bundle replacement by Sparkle (same identity, same bundle id). This is precisely the scenario that SPEC-07's stable signature makes possible. + +## 3. Design axes & tradeoffs + +### 3.1 Sparkle vs "home-made check" GitHub API + +A home-made check (query `releases/latest`, offer the download) would avoid the dependency — but everything of value is in the install: atomic replacement of the bundle, signature validation, **quarantine lifting**, clean relaunch. Reimplementing that means rewriting Sparkle's most sensitive code without its ten years of hardening. Sparkle is the de facto standard outside the App Store (iTerm2, Transmit…), its license is trivial, and it is consumed as an SPM dependency (binary XCFramework). **Decision: Sparkle 2.** + +### 3.2 Update deliverable: a dedicated zip, the DMG stays for the human + +The DMG (styled background, drag-to-Applications) remains the channel for the **first** download. For the appcast, we additionally publish a **zip** created by `ditto -c -k --sequesterRsrc --keepParent` (symlink preservation, required by the Sparkle docs so as not to break the signature). Sparkle can consume a DMG, but the zip is faster to extract, and it is the input format for `generate_appcast` deltas if we enable them later. + +### 3.3 Appcast hosting + +| Option | Pro | Con | +|---|---|---| +| **A. Release asset, URL `releases/latest/download/appcast.xml`** ✅ | Zero infra, same pipeline as the DMG, stable URL (302 redirect followed by Sparkle) | Pulling a faulty release = re-uploading the asset; `latest` ignores prereleases (to revisit for a beta channel) | +| B. `gh-pages` branch + GitHub Pages | Feed independent of releases, patchable without re-release | A branch and a Pages deployment to maintain for one file | +| C. Commit on `main`, served by raw.githubusercontent | — | CI pushing to `main`: no | + +**Decision: A.** `SUFeedURL = https://github.com/Evariops/SpaceMatters/releases/latest/download/appcast.xml`. `generate_appcast` preserves the entries of an existing appcast: CI downloads the previous release's appcast, adds the new entry, re-uploads the accumulation — the old absolute URLs (per tag) remain valid. + +### 3.4 Chain of trust, quarantine, notarization + +- **EdDSA**: `generate_keys` (once, locally) → public key in the plist (`SUPublicEDKey`), private key exported (`generate_keys -x`) to a GitHub secret `SPARKLE_ED_PRIVATE_KEY` (read by `generate_appcast --ed-key-file -` on stdin) **and saved outside GitHub** (keychain + vault): since the public key is baked into every installed app, losing it makes existing clients unable to update. +- Sparkle verifies **EdDSA + the Apple signature** (the update must be signed by the same team as the installed app): even a compromised GitHub account cannot push an accepted update without the EdDSA private key. +- **Quarantine**: the zip downloaded by Sparkle receives `com.apple.quarantine` like any download; Sparkle itself validates both signatures then **removes the attribute at install time** — the updated app relaunches without re-passing Gatekeeper or app translocation. This is the central mechanism the home-made check would not have. +- **Notarizing the zip anyway** *(track B only — without an Apple account this point drops, and Sparkle doesn't need it)*: the Sparkle flow doesn't require it (quarantine lifted), but the same zip is downloadable by hand from the Release page. So we notarize **the app before zipping**: `ditto` → `notarytool submit` of the zip → `stapler staple` on the `.app` → re-`ditto` of the final zip → the DMG is then built from the stapled app (the existing DMG flow doesn't change, its submission becomes near-instant, content already ticketed). + +### 3.5 Release notes: the release-drafter draft, rendered by Sparkle + +Sparkle 2.9+ renders **markdown** natively (``). No post-processing to write: `generate_appcast` consumes a **`.md` file adjacent to the archive** (same base name: `SpaceMatters-X.Y.Z.md`) and, with `--embed-release-notes`, embeds it as-is in `` — **verified by running the tool** (§6.1). CI writes this file from `gh release view --json body`. Rendering constraint (source read, §6.2): it's a native `NSAttributedString` text rendering, not WKWebView — lists, links and emphasis pass; tables and raw HTML do not. Release-drafter produces only lists with links → compatible. Fallback if the rendering disappoints visually: `` to the GitHub release page. + +### 3.6 UX: consent first + +- **Instantiation**: `SPUStandardUpdaterController(startingUpdater: true, …)` owned by `SpaceMattersApp` (GUI path only — never in `Entry`'s CLI subcommands). +- **Menu**: "Check for Updates…" via `CommandGroup(after: .appInfo)` in the existing `.commands` block, enabled/disabled by `updater.publisher(for: \.canCheckForUpdates)` (small observable `UpdaterModel`). +- **Automatic check**: we keep the standard Sparkle behavior — **consent prompt on the second launch**, no check before agreement. `SUEnableAutomaticChecks` deliberately absent from the plist (forcing it would short-circuit the prompt). Aligned with the no-phone-home ethos; the README documents what the check does (appcast request, no telemetry). +- No Settings scene in v1: the Sparkle prompt + the menu suffice; a toggle in a future Settings scene (`automaticallyChecksForUpdates`) will come with other settings. + +### 3.7 Signing identity: self-signed first, Developer ID when we want it + +Product decision: defer the Apple Developer account (99 $/year) as long as possible, without closing the door. Two tracks, **same architecture** — the update chain of trust is EdDSA in both cases, the Apple identity is just a parameter: + +**Track A — now, free: stable self-signed certificate.** +- Create a self-signed "SpaceMatters" code-signing certificate (Keychain ▸ Certificate Assistant, the one [Packaging/bundle.sh:68-71](../../Packaging/bundle.sh#L68-L71) already documents) — **long validity (3650 days)**, not the default 365: at expiry, changing certificate = new designated requirement = FDA re-requested. +- Export it as `.p12` → repo secrets `MACOS_CERT_P12` / `MACOS_CERT_PASSWORD` (the **same names** as for Developer ID: track B will literally consist of replacing the secret's content). +- Signature **without hardened runtime** (proven §6.1: without a Team ID, library validation kills the app at launch); no notarization (impossible without an account, and useless: Sparkle lifts the update quarantine itself, §6.2). +- What it guarantees: stable designated requirement (the cert is embedded in the signature) → **FDA persists across updates**; identity accepted by `generate_appcast` (ad-hoc passes, proven §6.1) and by Sparkle client-side validation. +- What it costs: hostile first install on macOS 15 (no more right-click ▸ Open: Settings ▸ Privacy ▸ "Open anyway"), to document honestly in the README. Once installed, no more Gatekeeper passage. + +**Track B — later, 99 $/year: Developer ID + notarization.** +- Replace the secrets' `.p12` with the Developer ID certificate and add `APPLE_ID`/`APPLE_TEAM_ID`/`APPLE_APP_SPECIFIC_PASSWORD` — the workflow is already conditional (`HAS_SIGNING`/`HAS_NOTARY`), notarization activates on its own. +- Re-enable `--options runtime` (conditioned in the scripts: hardened runtime **if and only if** the identity is "Developer ID Application: *"). + +**The A→B transition is safe for the installed base** (source read + confirmed on 2026-07-12): `validateUpdateForHost` ([SUUpdateValidator.m:306]) decides on `passedDSACheck || passedCodeSigning` — a valid EdDSA suffices, an update whose code-signing identity differs from the installed app is accepted as long as its own signature is valid (the "same identity" check is only a fallback when EdDSA is missing). Our EdDSA key does not change → the self-signed base accepts a Developer ID update. One-time cost of the switch: the designated requirement changes → **FDA re-requested once** per user; to mention in the notes of the transition release. + +To adapt in [release.yml](../../.github/workflows/release.yml): the identity extraction ([release.yml:67-68](../../.github/workflows/release.yml#L67-L68)) only matches `Developer ID Application:` **and** uses `find-identity -v`, which masks an untrusted self-signed cert (observed on 2026-07-12: the SpaceMatters identity appears as `CSSMERR_TP_NOT_TRUSTED` without `-v`, invisible with it, whereas `codesign` signs perfectly with it). → drop the `-v`, match any code-signing identity, and condition `--options runtime` + notarization on the `Developer ID Application:` pattern. + +### 3.8 Out of scope for v1 + +- **Binary deltas**: `generate_appcast` produces them for free if we give it the N previous zips — to enable when the app grows (a few MB today, marginal gain). +- **Beta channel** (`sparkle:channel`) — will require revisiting option A of §3.3 (`latest` ignores prereleases). +- **Homebrew Cask** (`auto_updates true` once Sparkle is in place). + +## 4. Implementation plan + +1. **[Package.swift](../../Package.swift)**: dependency `.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0")`, produces `Sparkle` on the executable target, rpath `@executable_path/../Frameworks` via `linkerSettings` (the target already uses `unsafeFlags`, no new constraint). Dev and tests require **no other setting**: SwiftPM copies the framework next to the build binary and lays down a `@loader_path` rpath (verified §6.1). +2. **[Packaging/bundle.sh](../../Packaging/bundle.sh)**: between the binary copy and the signature — create `Contents/Frameworks/`, copy `Sparkle.framework` into it from `.build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/` (observed path), **remove `Versions/B/XPCServices/`** (424 KB, useless outside sandbox — §6.2) as well as `Headers/`, `PrivateHeaders/` and `Modules/` (224 KB, useless at runtime); optional: `lipo -thin arm64` on the framework's three Mach-O files (the app is already distributed arm64-only — native `swift build`, arm64 CI runner), gain ≈ 1 MB installed / 0.4 MB downloaded; add `SUFeedURL` and `SUPublicEDKey` to the plist heredoc (**mandatory before building a release: without `SUPublicEDKey` in the archived app, `generate_appcast` emits no signature** — §6.1); replace the `codesign --deep` with an explicit inner→outer signature (`Autoupdate`, `Updater.app`, the framework, then the app) — same thing in [Packaging/release.sh](../../Packaging/release.sh#L36). ⚠️ **`--options runtime` only if the identity is Developer ID** (track B): without a Team ID — ad-hoc dev like the track A self-signed certificate — library validation kills the app at launch (`different Team IDs`, proven §6.1). +3. **Swift code**: `UpdaterModel` (wrapping `SPUStandardUpdaterController` + published `canCheckForUpdates`), instantiated in `SpaceMattersApp`, menu item in `.commands`. +4. **Keys**: ✅ **done on 2026-07-12** — EdDSA pair generated (`generate_keys`): private in the session keychain (item "Private key for signing Sparkle updates") **and** repo secret `SPARKLE_ED_PRIVATE_KEY`; public to bake into `bundle.sh`'s heredoc: + ```xml + SUPublicEDKey giAqx0Y+CIUuSvv4DDNfJnC/wF16Y71qi03XF3LZx30= + ``` + ✅ Vault backup done (2026-07-12). The private key exists in three copies: session keychain, repo secret, vault. +5. **Self-signed certificate (track A, §3.7)**: ✅ **created on 2026-07-12** (openssl: CN=SpaceMatters, RSA 2048, critical code-signing EKU, **valid until 2036-07-09**), imported into the session keychain, signature tested (`Authority=SpaceMatters`, designated requirement pinned `certificate leaf = H"6ec027a9795823a6b14b4c84d209f2ebf16fe27f"`). `.p12` + password in `~/Documents/SpaceMatters-signing-backup/` — **to move to the vault then delete from disk** (lost cert = FDA re-requested for all users). ✅ Repo secrets `MACOS_CERT_P12` + `MACOS_CERT_PASSWORD` set (2026-07-12) — the track A inventory is complete with `SPARKLE_ED_PRIVATE_KEY`. ✅ Identity extraction broadened (without `-v`) and runtime/notarization conditioned on the identity type in `release.yml`. + > **Implementation (2026-07-12)**: milestones 1–7 completed on `feat/spec12-sparkle-updates` — scripts moved into `Packaging/` (+ shared CI/local `make-appcast.sh`), 102 green tests, release bundle signed `SpaceMatters` verified strict + launched, CI appcast wired. Keychain pitfall noted along the way: a `.p12` imported via CLI lacks the Apple partition list → `codesign` prompts on every call; remedy: `security set-key-partition-list -S apple-tool:,apple: -s ~/Library/Keychains/login.keychain-db` (once). +6. **[release.yml](../../.github/workflows/release.yml)**: after signing the app — zip `ditto -c -k --sequesterRsrc --keepParent`, notarize the zip, staple the app, re-zip, then existing DMG flow unchanged; `generate_appcast` step: download the previous release's appcast into a folder with only the new zip + `SpaceMatters-X.Y.Z.md` (body of `gh release view --json body`), run with `echo "$SPARKLE_ED_PRIVATE_KEY" | generate_appcast --ed-key-file - --download-url-prefix "https://github.com/Evariops/SpaceMatters/releases/download/vX.Y.Z/" --embed-release-notes` — the accumulation and preservation of old URLs are proven (§6.1); keep `--maximum-versions` at default (3); upload the zip + `appcast.xml` as release assets. Mirror in `release.sh` for local releases. +7. **README**: transparency paragraph (what the update check does, how to turn it off) + track A first-install walkthrough (Settings ▸ "Open anyway"), honest and illustrated. + +## 5. Verification + +- **Full cycle locally**: serve a forged appcast on `http://localhost:8000` (`python3 -m http.server`, dev `SUFeedURL` in a test bundle), install an aged build → detection, markdown notes rendered, install, relaunch in higher version. +- **Quarantine**: after a real update, `xattr -lr /Applications/SpaceMatters.app` shows no `com.apple.quarantine`; the app relaunches without a Gatekeeper dialog. +- **FDA**: ✅ **validated live on 2026-07-12**. Two builds (v0.0.1 and v9.9.9) signed with the stable "SpaceMatters" cert → **identical designated requirement** (`identifier "com.spacematters.app" and certificate leaf = H"6ec027a9…"`), deterministic proof that TCC treats them as the same client. End-to-end: FDA granted to v0.0.1 from `~/Applications`, Sparkle update → v9.9.9, **the grant persisted without a new authorization**. This was THE unproven link of track A; now it is proven. +- **Signature**: `codesign --verify --strict --verbose=2` on the updated app. Track B only: `spctl -a -vv` (fails by construction on track A, non-notarized) and hardened runtime on all Mach-O files (the safeguard is notarization). +- **First install track A**: DMG downloaded on a clean session → observe and document the exact macOS 15 "Open anyway" walkthrough for the README. +- **Appcast**: `curl -L …/releases/latest/download/appcast.xml` → valid XML, accumulated entries, `sparkle:version` = commit count increasing between two releases. +- **Real end-to-end test**: machine with vN-1 installed from the public DMG → publish vN → the update arrives, installs, release notes conform to the release-drafter draft. + +## 6. Acquired certainties & residual risks + +All 🔬 assumptions from the first draft were lifted on **2026-07-12**, by three means: real experimentation on this machine (Sparkle 2.9.4 added to the project, built, tested; `generate_appcast`/`sign_update` tools run on simulated releases; framework embedded, signed and launched), reading the Sparkle source cloned at tag 2.9.4, and queries against the real GitHub repo. `Package.swift` was restored after the experiment — none of this is committed. + +### 6.1 Proven by experiment + +- ✅ **XCFramework + SwiftPM: zero friction.** Dependency added to `Package.swift`: `swift build` OK, **`swift test`: 102 tests, 15 suites, all green, with no configuration whatsoever**. SwiftPM copies `Sparkle.framework` next to the build binary and lays down a `@loader_path` rpath — the raw binary (`--volumes`) also runs. The "rpath to set for tests" assumption was unfounded. Framework: 3.0 MB of which 424 KB of XPC services; tools in `.build/artifacts/sparkle/Sparkle/bin/`. +- ✅ **Appcast accumulation in CI.** Full simulation of two successive releases (archives fabricated, real `generate_appcast`) in the exact CI scenario — previous appcast + only the new archive: the old entry is **preserved identically** (URL prefixed `v0.1.0` and `sparkle:edSignature` intact despite `--download-url-prefix …/v0.2.0/`), the new one is added with its own URL. `--maximum-versions` (default 3) bounds the accumulation. +- ✅ **Markdown notes embedded without post-processing.** `.md` file adjacent to the archive + `--embed-release-notes` → `` in CDATA, UTF-8 content as-is (observed in the generated appcast). +- ✅ **Explicit inner→outer signature.** Framework actually embedded in a SpaceMatters bundle (XPC removed), signed `Autoupdate` → `Updater.app` → framework → app: `codesign --verify --strict --verbose=2` passes, designated requirement satisfied. +- ✅ **Dev pitfall identified and remedy proven.** Ad-hoc + `--options runtime`: `codesign` succeeds **but dyld kills the app at launch** — `mapping process and mapped file (non-platform) have different Team IDs` (hardened runtime library validation). Re-signed ad-hoc **without** runtime: the app loads the embedded framework and runs. Hence the firm instruction in §4.2 for `bundle.sh`. In CI, Developer ID gives the same Team ID to the app and to the re-signed framework → the constraint is satisfied by construction. +- ✅ **`generate_appcast` safeguards.** The tool **refuses** an archive whose app is not validly Apple-signed ("failed Apple Code Signing checks", observed on an unsigned app), and emits a `sparkle:edSignature` only if the archived app declares a `SUPublicEDKey` matching the private key provided (observed: without the key in the plist, appcast generated without signature). CI therefore cannot accidentally publish an unsigned update or one signed with the wrong key — but this imposes the order in §4: key in the plist **before** the first Sparkle release. +- ✅ **CI key format.** `--ed-key-file -` accepts the seed format (base64 of 32 bytes) on stdin — tested with `sign_update` and `generate_appcast` (signature emitted and consistent between the two tools). +- ✅ **Stable `latest/download` URL.** `releases/latest/download/SpaceMatters-0.3.0.dmg` on the real repo: 302 followed → 200 on the GitHub CDN. Sparkle (NSURLSession) follows redirects. + +### 6.2 Proven by the Sparkle 2.9.4 source + +- ✅ **Quarantine lifting**: first step of the installation — `SUPlainInstaller.m:50` calls `releaseItemFromQuarantineAtRootURL`; implementation `SUFileManager.m:120`: **recursive** removal of the `com.apple.quarantine` xattr (non-fatal failure, logged). No dependency on the sandbox or the hardened runtime. The `xattr` test in §5 remains as end-to-end confirmation, not as an assumption. +- ✅ **Double validation of updates**: `SUUpdateValidator.m:162` — for an app bundle, EdDSA **and** the Apple signature are verified; the "code signing only" fallback only exists if EdDSA fails and then requires the **same Developer ID Team ID as the installed app** (`codeSignatureIsValidAtDownloadURL:andMatchesDeveloperIDTeamFromOldBundleURL:`, `SUUpdateValidator.m:84`). +- ✅ **Markdown**: `SUAppcastItem.m:533` accepts `sparkle:format` ∈ {plain-text, markdown, html} (default html); `SUUpdateAlert.m:189` routes markdown to `SUTextViewReleaseNotesView` (native `NSAttributedString` rendering, macOS 12+ API — we target 15). Lists, links, emphasis, code: yes; GFM tables and raw HTML: no. Compatible with release-drafter (lists with links). +- ✅ **XPC services opt-in**: `SPUXPCServiceInfo.m` — used only if the host app declares `SUEnableInstallerLauncherService`/`SUEnableDownloaderService`/… in its plist. Absent in our case → removal of `XPCServices/` (424 KB) with no functional effect whatsoever. +- ✅ **Consent before any network**: `SPUUpdater.m:415` — the authorization prompt waits for the **second launch** (`SUPromptUserOnFirstLaunchKey` to force it on the first) and no automatic check leaves before agreement. Conforms to the no-phone-home ethos. + +### 6.3 Residual risks (real, assumed) + +- **Track A — one remaining link at the first CI run**: `codesign` with the self-signed cert imported into the CI's ephemeral keychain (the workflow's `.p12` import is battle-tested for Developer ID; a self-signed cert may require a trust setting — to be observed). FDA persistence, for its part, is **proven** live (§5). +- **Track B (when the day comes)**: the `notarytool` pass on the app with Sparkle embedded will only be proven at the first CI run with the Apple secrets. High confidence (hardened runtime everywhere, same Team ID); and the switch costs a one-time FDA re-grant per user (§3.7). +- **On-screen appearance of the notes**: the supported markdown subset is known (source read), but the visual rendering is judged in live driving (§5) — `releaseNotesLink` fallback ready. +- **Loss of the EdDSA key** (operational, permanent): public key baked into every installed app → the §4.4 vault backup is not optional. +- **Weight** (measured on the real release app, `ditto` zips): installed app 4.5 MB → 6.9 MB (**+2.4 MB**, universal framework without XPC/headers); download 1.4 MB → 2.3 MB (**+0.9 MB**). With `lipo -thin arm64` (consistent: the app is arm64-only): installed **+1.4 MB**, downloaded **+0.5 MB**. Accepted — it's the price of the hardened installer. +- External prerequisite unchanged since SPEC-07: Developer ID secrets + notarization configured in the repo so the CI flow actually signs. + +## 7. Effort & dependencies + +**1–2 days** (integration + CI + real tests on two releases). Depends on SPEC-07 (✅ delivered) and the CI signing secrets (external prerequisite). Independent of the rest. diff --git a/specs/README.md b/specs/README.md deleted file mode 100644 index b6126da..0000000 --- a/specs/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Spécifications par chantier — MacDirStats - -Chaque `SPEC-*.md` est un cahier des charges autonome pour un chantier différé du [plan d'action](../PLAN-ACTION.md), rédigé pour être repris en session dédiée. Format commun : - -1. **Objectif & findings couverts** — le pourquoi, avec renvois aux audits. -2. **État actuel du code (vérifié)** — ce qui existe, cité `fichier:ligne`. -3. **Axes de conception & tradeoffs** — options réelles, avec recommandation justifiée. -4. **Plan d'implémentation** — fichiers, étapes. -5. **Vérification** — tests + pilotage live (la méthode par captures d'écran + clics par coordonnées est établie et fonctionne). -6. **Risques & hypothèses (🔬)** — ce qui reste à prouver. -7. **Effort & dépendances**. - -Principe directeur (repris du plan) : **intégrité > performance > robustesse > simplicité > maintenabilité**, long terme d'abord. - -| Spec | Chantier | Findings | Effort | Dépend de | -|---|---|---|---|---| -| [SPEC-01](SPEC-01-keyboard-navigation.md) | Navigation clavier & liste native | J3.1, J4.2, J3.7 | 1–2 j | — | -| [SPEC-02](SPEC-02-invalidation-rescan.md) | Invalidation & re-scan de sous-arbre | B1(cure), A6, A7, J4.4, D1 | 1–2 j | — | -| [SPEC-03](SPEC-03-exact-vs-attribution.md) | Comptage exact vs attribution + réconciliation | A3, A4, A10, J9 | 2–3 j | — | -| [SPEC-04](SPEC-04-fsevents-live.md) | FSEvents — tableau de bord vivant | S3, J5.2, J4.5, D1 | 1–2 j | SPEC-02 | -| [SPEC-05](SPEC-05-file-level-treemap.md) | Raffinement fichier **au zoom** (overview par dossier) | S5 | 2–3 j | — | -| [SPEC-06](SPEC-06-pluggable-backends.md) | Backends pluggables | S6 | ~1 j/backend | — | -| [SPEC-07](SPEC-07-distribution.md) | Distribution **DMG signé/notarisé via GitHub** (v1) | J1.1–J1.5, D4 | 1–2 j | — | -| [SPEC-08](SPEC-08-accessibility.md) | Accessibilité & i18n complètes | J10.1–J10.4, J9.5 | 1–2 j | SPEC-01 (partiel) | -| [SPEC-09](SPEC-09-gpu-treemap-rendering.md) | Rendu GPU **3D-natif** du treemap (Metal, projection 2D ortho) | perf-resize (PR #17) | 2–3 j | PR #17 (couture NSView, faite) | - -**Ordre recommandé** : SPEC-02 (débloque SPEC-04 et ferme A6/A7 proprement) → SPEC-01 → SPEC-03 → SPEC-08 → SPEC-04 → SPEC-05/06/07 selon priorités produit. diff --git a/specs/SPEC-01-keyboard-navigation.md b/specs/SPEC-01-keyboard-navigation.md deleted file mode 100644 index 2f4feea..0000000 --- a/specs/SPEC-01-keyboard-navigation.md +++ /dev/null @@ -1,72 +0,0 @@ -# SPEC-01 — Navigation clavier & liste native - -> **Findings** : J3.1 (app 100 % souris), J4.2 (pas de ⌘⌫ ni multi-sélection), partiellement J3.7 (tri/colonnes). Renvoie à D-E du plan. -> **Statut** : ✅ **IMPLÉMENTÉ** (Axe B — `NSTableView` en `NSViewRepresentable`). Voir [DirectoryTable.swift](../Sources/MacDirStats/Views/DirectoryTable.swift). - -## 0. Résultat d'implémentation - -- `DirectoryTable: NSViewRepresentable` (NSScrollView + `MacDirTableView`), lignes = `NSHostingView(OutlineRowView)` → **zéro régression visuelle** (rendu SwiftUI réutilisé tel quel). -- Souris **déterministe** : `MacDirTableView.hitTest` renvoie la table pour tout point in-bounds (cellules purement visuelles) ; la sélection passe par `NSTableView.mouseDown`/`row(at:)`, pas par le forwarding de la chaîne de responders. Chevron plier/déplier par géométrie dans `mouseDown`. -- Clavier : ↑/↓ natifs, ←/→ plier/parent & déplier/enfant, ⏎ zoom/ouvrir, **⌘⌫ corbeille de la sélection (gaté `!isScanning`)**, espace Quick Look, type-select natif. -- **Multi-sélection** (`allowsMultipleSelection`) : `ScanController.selectedRowIDs` (set) + `setListSelection(_:primary:)` — le *primary* pilote le treemap, le set pilote ⌘⌫. Menu contextuel par ligne (Quick Look/Open/Reveal/Copy/Trash/Delete) via `menu(for:)`. -- Synchro treemap↔liste : `selectedRowID` reste la source unique ; `updateNSView` idempotent, `scrollRowToVisible` sur `revealTarget`. -- **Vérifié** : build vert ; 12 tests (dont `listSelectionTracksPrimaryAndSet`, `deletingAncestorClearsStaleMultiSelection`) ; **live** — table native rend l'outline avec fidélité totale (chevrons/icônes/barres/tailles/tri), sélection dessinée, **chaque ligne exposée à l'AX** avec label (« Folder alpha, 1000 KB »), aucun crash. Le pilotage par événements synthétisés (clic/flèches) était bloqué par un panneau *Secure Event Input* d'une autre app ; logique couverte par les tests ViewModel. -- **Reste optionnel (hors scope 100 %)** : J3.7 en-têtes de colonnes triables — explicitement « facultatif » au §5.7. Menu contextuel multi-cible (aujourd'hui ⌘⌫ couvre la corbeille de masse). - -## 1. Objectif - -Rendre la liste de répertoires pilotable au clavier pour l'utilisateur cible (dev senior) : flèches ↑/↓ pour parcourir, ←/→ pour plier/déplier, ⏎ pour zoomer/ouvrir, ⌘⌫ pour envoyer à la corbeille, barre espace pour QuickLook, multi-sélection (⇧/⌘-clic) pour le nettoyage de masse, et type-select (frappe d'un préfixe). - -## 2. État actuel du code (vérifié) - -- [DirectoryListView.swift](../Sources/MacDirStats/Views/DirectoryListView.swift) : `List { ForEach(rows) { OutlineRowView… } }`, `listStyle(.plain)`, lignes custom. Sélection via `.onTapGesture { select() }`, zoom via `.simultaneousGesture(TapGesture(count: 2))`, chevron via un `.onTapGesture` dédié, `.contextMenu`. La sélection est `controller.selectedRowID` (dérive `selection`). -- `visibleRows()` ([ScanController.swift:398](../Sources/MacDirStats/ViewModel/ScanController.swift#L398)) produit un **tableau plat** (dirs + fichiers mélangés, biggest-first) — déjà le bon modèle pour une table. -- Le zoom/sélection/expansion sont centralisés dans `ScanController` (bien). - -## 3. Leçon acquise (vérifiée en live) - -`List(selection:)` + `.tag(row.id)` a été essayé et **piloté à l'écran** : -- la `List` ne prend **pas** le focus clavier (flèches inertes) ; -- la sélection native ne s'engage **pas** au simple clic tant que les lignes portent leurs propres `onTapGesture`/`contentShape`/double-clic ; -- retirer le `onTapGesture` **casse** le clic simple sans débloquer les flèches. - -→ La navigation clavier **n'est pas un bolt-on** sur la `List` SwiftUI actuelle. - -## 4. Axes de conception & tradeoffs - -- **Axe A — Tout-SwiftUI `List(selection:)`** : dépouiller les lignes de tous leurs gestes, laisser la `List` posséder la sélection, ré-ajouter chevron/double-clic/hover autrement. *Tradeoff* : combat les quirks de focus SwiftUI (observés), rendu de la sélection native à re-styliser, comportement flèches non garanti sur `List` à contenu custom. **Risque élevé, résultat incertain.** -- **Axe B — `NSViewRepresentable` autour d'un `NSTableView` (recommandé)** : contrôle **déterministe** du clavier (arrows, ⌘⌫, type-select, multi-select), du focus (first responder), de la sélection et du défilement. C'est ainsi que les apps Mac denses (Finder-like) procèdent. *Tradeoff* : plus de code (≈250–350 lignes), un pont SwiftUI↔AppKit à maintenir. *Bénéfice* : robuste, testable, extensible (colonnes triables J3.7, multi-sélection J4.2 gratuites). -- **Axe C — `NSOutlineView`** : gère l'arborescence nativement (disclosure), mais on a déjà un modèle *plat* (`visibleRows()`) et l'expansion vit dans le contrôleur → `NSTableView` colle mieux, moins de friction. - -**Recommandation : Axe B.** Un `NSTableView` en `NSViewRepresentable`, alimenté par `visibleRows()`, cellules rendues via `NSHostingView(OutlineRowView)` pour **réutiliser le design SwiftUI existant** (zéro régression visuelle). Le clavier/focus/multi-sélection deviennent natifs et fiables. - -## 5. Plan d'implémentation - -1. **`DirectoryTable: NSViewRepresentable`** produisant `NSScrollView` + `NSTableView` (une colonne, `rowHeight` fixe, `usesAlternatingRowBackgroundColors = false`, style plain). -2. **Data source / delegate** (`Coordinator`) : lignes = `controller.visibleRows()` ; `numberOfRows`, `viewFor:` → `NSHostingView(rootView: OutlineRowView(row:…))` réutilisé (pool via `makeView(withIdentifier:)`). -3. **Sélection** : `allowsMultipleSelection = true`. `tableViewSelectionDidChange` → pousser vers `controller.selectedRowID`/`selection` ; réciproquement, `updateNSView` re-sélectionne quand `controller.selectedRowID` change (breadcrumb/treemap → liste). -4. **Clavier** (sous-classe `NSTableView.keyDown` ou `NSResponder`) : - - ↑/↓ : natif. - - ←/→ : plier/déplier la ligne dossier sélectionnée (`controller.toggleExpanded`). - - ⏎ : `controller.zoom(into:)` (dossier) / `openItem` (fichier). - - ⌘⌫ : corbeille de la (ou des) ligne(s) sélectionnée(s), **gaté par `!isScanning`** (J4.4), via le flux async `remove(...)` existant. - - espace : QuickLook des URLs sélectionnées. - - type-select : natif via `tableView(_:typeSelectStringFor:)`. -5. **Multi-sélection → actions de masse** : `controller.remove(rows:)` itérant le flux async ; menu contextuel `contextMenu(forSelectionType:)` équivalent. -6. Remplacer `List{…}` par `DirectoryTable(controller:)` dans `DirectoryListView`. Conserver `revealTarget`/scroll-to (via `scrollRowToVisible`). -7. Facultatif (J3.7) : en-têtes de colonnes triables (nom / taille / nb fichiers / date) → `sortDescriptors` → nouveau tri dans le contrôleur. - -## 6. Vérification - -- **Live (méthode établie)** : `--open `, capture d'écran, `key code 125/126` (↓/↑) → la sélection se déplace (vérifiable au surlignage) ; ⌘⌫ sur une ligne → corbeille + total décrémenté ; ⇧-clic → multi-sélection ; espace → panneau QuickLook. -- **Tests ViewModel** : `controller.selectRow`, multi-remove, gating pendant scan (étendre `NavigationTests`). - -## 7. Risques & hypothèses - -- 🔬 **Perf `NSHostingView`-par-ligne** sur très grande liste : mitiger par réutilisation de vues + `OutlineRowView` léger ; à mesurer sur un dossier de 100 k lignes visibles (rare, la liste est virtualisée à O(visible)). -- 🔬 Synchronisation bidirectionnelle sélection liste ↔ treemap sans boucle d'événements : garder `selectedRowID` comme source unique, `updateNSView` idempotent. -- Anneau de focus / apparence de sélection à accorder au thème (dessiner la sélection dans `OutlineRowView` via `isSelected`, désactiver la sélection dessinée par AppKit). - -## 8. Effort & dépendances - -**1–2 jours.** Aucune dépendance. Débloque J3.1, J4.2, et prépare J3.7 (colonnes) et une partie de SPEC-08 (focus/rotor accessibles nativement). diff --git a/specs/SPEC-02-invalidation-rescan.md b/specs/SPEC-02-invalidation-rescan.md deleted file mode 100644 index 2f8fac8..0000000 --- a/specs/SPEC-02-invalidation-rescan.md +++ /dev/null @@ -1,63 +0,0 @@ -# SPEC-02 — Invalidation & re-scan de sous-arbre - -> **Findings** : B1 (cure de fond — le garde-fou est déjà en place), **A6** (table des types périmée après suppression), A7 (compteur dossiers), J4.4 (delete pendant scan — gaté), D1 (TOCTOU). Renvoie à D-B/S2 du plan. -> **Statut** : ✅ **IMPLÉMENTÉ** — A6 résolu (chemin suppression, exact) + brique `invalidate(subtree:)` livrée pour SPEC-04. - -## 0. Résultat d'implémentation - -- **A6 résolu (exact) — suppression** : `remove(directory:)` walk le sous-arbre **avant** suppression (`DirectoryScanner.subtreeExtensions(path:)`, miroir exact du tally du scan : même `ExtKey`, mêmes tailles alloc/logique) et retranche cette contribution de la table `extStats` du scanner (`subtractExtensions`, entrées à zéro supprimées). `remove(file:)` retranche l'extension du fichier. Le panneau File-types se met à jour. Test : `deletingSubtreeUpdatesFileTypeTable` (les `.mp4` supprimés disparaissent, `.txt` intact). -- **Brique `invalidate(subtree:)`** (réutilisée par SPEC-04) : re-scan **en place** du sous-arbre (seed sur le nœud → parents corrects), réconciliation **exacte** des agrégats ancêtres (delta ancien→neuf), du **dirCount** (A7), et **ré-résolution nav par chemin** (`node(at:)`) car les nœuds descendants deviennent des objets neufs (pas de dangling `unowned`). Test : `invalidateReflectsExternalChanges` (ajout fichier+dossier externe reflété, zoom re-lié par chemin). -- **Approche = hybride recommandé (§4/§7)** : soustraction exacte des agrégats conservée (évite de re-scanner un parent potentiellement énorme) ; seul `extStats` est corrigé par delta. -- **Limite documentée (extStats dans `invalidate` uniquement)** : pour une extension **partagée entre sous-arbres ET modifiée** dans le sous-arbre invalidé, le delta n'est pas exact (la sous-contribution d'origine n'est pas stockée par nœud — parti low-RAM assumé §3) ; erreur bornée au delta du changement, auto-corrigée au prochain rescan complet. **Le chemin suppression (le finding A6) reste exact** (walk avant changement). Tailles/comptes/dirCount toujours exacts. -- **B1/A7/J4.4** : déjà en place, inchangés et toujours valides. **D1** : borné par le gating `!isScanning` ; détection live → SPEC-04. - -## 1. Objectif - -Remplacer la **chirurgie manuelle** des agrégats dans `remove()` par une **invalidation + re-scan ciblé** du répertoire parent. Bénéfices : supprime la classe entière de comptabilité fragile (A6 impossible à corriger autrement — voir §3), élimine tout risque résiduel d'incohérence, et fournit la **brique commune** réutilisée par FSEvents (SPEC-04). - -## 2. État actuel du code (vérifié) - -- `remove(directory:)`/`applyDirectoryRemoval` ([ScanController.swift:507+](../Sources/MacDirStats/ViewModel/ScanController.swift#L507)) : soustrait les agrégats de chaque ancêtre (atomics), remonte `zoomRoot`/`selection`/`expanded` hors du sous-arbre (garde-fou B1), décrémente `dirCount` (A7), détache le nœud. -- **Ce qui n'est PAS ajusté** : `extStats` du scanner (A6). La ventilation par extension du sous-arbre supprimé n'est **pas stockée** dans l'arbre (les nœuds ne gardent que `dominantExt` + agrégats). Donc soustraire les contributions par extension du sous-arbre supprimé est **infaisable** sans re-énumérer. -- Le scanner sait déjà scanner un sous-arbre : `DirectoryScanner(root:, seeds:[Seed(path:node:)])` — une suppression = une nouvelle seed sur `node.parent`. - -## 3. Le nœud du problème (A6) - -`extStats: [ExtKey: ExtStat]` est **global au scan**, construit par accumulation pendant le scan. Après suppression d'un sous-arbre, il contient encore les octets/■comptes des fichiers supprimés. Trois façons d'en sortir : - -- **Stocker par nœud la table d'extensions** : coût mémoire non borné (à l'opposé du parti « 1 nœud/répertoire, low-RAM »). ❌ -- **Re-scanner le sous-arbre supprimé *avant* suppression pour connaître ses contributions et les soustraire** : possible mais fragile (double travail, race avec le scan). ⚠️ -- **Invalidation + re-scan du parent (recommandé)** : après une suppression réussie, re-scanner le répertoire parent reconstruit ses enfants **et** ré-accumule `extStats` pour ce sous-arbre. Reste à réconcilier avec la table globale. - -## 4. Axes de conception & tradeoffs - -- **Axe A — Consolider la mutation en place** : ajouter la soustraction `extStats`. *Infaisable proprement* (§3). ❌ -- **Axe B — Re-scan du parent, table d'extensions recalculée globalement** : sur suppression, (1) détacher le nœud (comme aujourd'hui, garde-fous B1 conservés), (2) relancer un `DirectoryScanner` seedé sur `parent.path` dans un **sous-arbre neuf**, (3) swap atomique de `parent._children`, (4) **reconstruire `extStats` en re-walkant tout l'arbre restant** (les fichiers ne sont pas en mémoire → re-énumération disque des dossiers, coûteux) *ou* maintenir `extStats` comme somme de tables par-seed recalculées. -- **Axe C — Re-scan du parent + `extStats` maintenu par sous-arbre** : le scanner tient `extStats` **par répertoire de premier niveau** (ou par seed), de sorte qu'invalider un sous-arbre = soustraire sa sous-table + ré-accumuler. Plus d'état, mais soustraction O(1) exacte. - -**Recommandation : Axe B, variante pragmatique.** Le re-scan du parent est la vraie cure de B1/A6/A7. Pour `extStats`, **marquer le panneau "File types" comme approximatif** juste après la suppression *et* le recalculer par re-énumération **paresseuse** du parent re-scanné (le re-scan du parent réaccumule déjà les extensions du sous-arbre ; il suffit de soustraire l'ancienne contribution du parent connue avant le re-scan). Décision-clé documentée ci-dessous. - -**Décision `extStats`** : avant le re-scan, **snapshotter la table d'extensions restreinte au sous-arbre du parent** (obtenable par une passe de re-scan « à blanc » du parent, ou en soustrayant post-re-scan). *La plus simple et exacte* : le re-scan du parent produit sa **nouvelle** sous-table d'extensions ; on tient une `extStats` **par nœud-parent-direct-de-seed** afin de faire `global = Σ sous-tables`. 🔬 à valider : le surcoût mémoire d'une sous-table par enfant-de-seed (borné au nb d'extensions distinctes, petit). - -## 5. Plan d'implémentation - -1. `DirectoryScanner` : exposer un mode **re-scan de sous-arbre** — `rescan(node:path:)` qui repart d'une seed unique, construit un `FSNode` racine temporaire, et retourne `(children, directFiles, extSubtable, dirCount)`. -2. `ScanController.remove(...)` : sur succès disque → au lieu de la chirurgie d'agrégats, appeler `invalidate(subtree: node.parent)`. -3. `invalidate(subtree:)` : lance le re-scan (détaché, non bloquant), applique sur MainActor : swap `_children`, recalcul des agrégats des ancêtres (delta = nouveau − ancien sous-total), mise à jour `extStats` (Σ sous-tables), `dirCount`, ré-résolution de `selection`/`zoomRoot` **par chemin** (pas par identité de nœud, car les nœuds sont neufs — réutiliser `path(for:)` + une résolution inverse). -4. Conserver les garde-fous B1 (ils deviennent redondants mais inoffensifs) le temps de la migration, puis les retirer. -5. Gating pendant scan (J4.4) : déjà en place ; l'invalidation ne s'exécute que `phase != .scanning`. - -## 6. Vérification - -- **Tests** (étendre `NavigationTests`) : après suppression d'un sous-arbre contenant des `.mp4`, **`extStats` ne contient plus les `.mp4` supprimés** (verrouille A6) ; `dirCount`, agrégats et absence de nœud pendant restent corrects ; `selection`/`zoomRoot` ré-résolus par chemin. -- **Live** : supprimer `sub2` de la fixture → le panneau « File types » perd les `.bin` de `sub2/deep`, total/dossiers cohérents (capture avant/après). - -## 7. Risques & hypothèses - -- 🔬 Ré-résolution `selection`/`zoomRoot` par chemin après swap de nœuds : nécessite un index chemin→nœud transitoire. -- 🔬 Coût du re-scan d'un très gros parent (ex. supprimer un dossier dans `/` re-scanne toute la racine) : borner en re-scannant **le nœud supprimé lui-même n'existe plus**, donc on re-scanne son parent — potentiellement énorme. Mitigation : ne re-scanner que si le parent a peu d'enfants restants, sinon garder la soustraction d'agrégats (exacte) et n'invalider QUE `extStats` du sous-arbre (hybride). -- Le choix hybride (agrégats par soustraction + extStats par sous-table) est peut-être le meilleur compromis simplicité/coût — à trancher au moment de l'implémentation. - -## 8. Effort & dépendances - -**1–2 jours.** Aucune dépendance amont. **Débloque SPEC-04 (FSEvents)** qui réutilise `invalidate(subtree:)`. diff --git a/specs/SPEC-03-exact-vs-attribution.md b/specs/SPEC-03-exact-vs-attribution.md deleted file mode 100644 index c4ff0d3..0000000 --- a/specs/SPEC-03-exact-vs-attribution.md +++ /dev/null @@ -1,71 +0,0 @@ -# SPEC-03 — Comptage exact vs attribution + réconciliation des chiffres - -> **Findings** : A3 (hardlinks comptés par lien), A4 (clones APFS non déduits), A10 (base 1024 étiquetée KB), J9 (« pourquoi ça ne matche pas Finder ? »), J9.5 (locale mixte). Renvoie à D-G/S4 du plan. A1 (montages) est **déjà corrigé**. -> **Statut** : ✅ **IMPLÉMENTÉ** — A3 exact (validé contre `du`), A10/J9.5, J9 réconciliation. A4 en repli honnête (conforme §3.b/§6). - -## 0. Résultat d'implémentation - -- **A3 hardlinks (exact) — validé contre `du`** : `CountingMode { attribution | exact }` (toggle toolbar, hôte). En mode exact, l'énumérateur bulk demande `ATTR_CMN_FILEID` + `ATTR_FILE_LINKCOUNT` (lus en ordre de bits exact ; **le mode attribution par défaut packe un buffer identique** — reads gardés par le masque `returned`), et le scanner dédup les inodes multi-liens (`Set`, seulement `linkCount > 1` → mémoire négligeable). Test golden `exactModeDedupsHardlinks` : exact ↔ `du -skx`, attribution ↔ `du -sklx`. Bascule = re-scan (dédup au scan). VM = attribution. -- **A10 + J9.5 (format)** : `Format.bytes` → base 1024 avec **libellés IEC honnêtes** (KiB/MiB/…), séparateur décimal **localisé**. Test `formatBytes` mis à jour. -- **J9 réconciliation** : `Reconciliation` (modèle) + `ReconciliationButton`/popover (scans de volume entier). Décompose « utilisé (API) » = scan + corbeille (`~/.Trash` + `.Trashes`) + purgeable (`importantUsage − available`, avec nb de snapshots `tmutil`) + non-attribué ; signale `scanExceedsUsed` (signature de l'attribution sur hardlinks/clones) et les chemins illisibles. Test `reconciliationArithmetic`. -- **A4 clones APFS — repli honnête (conforme au plan)** : le §3.b/§6 marquait `ATTR_CMNEXT_PRIVATESIZE` 🔬 « à prototyper / repli honnête si non fiable ». Le parsing d'attributs étendus (`forkattr`) est plus risqué que le buffer standard ; **non adopté**. À la place, note UI explicite (tooltip du toggle + panneau réconciliation) : « les clones APFS sont comptés pleins dans les deux modes et peuvent gonfler le total au-delà de `df` ». -- **A1 (montages)** : déjà corrigé. - -## 1. Objectif - -Formaliser deux **modes de comptage** explicites et régler la première objection de tout utilisateur (« ça ne matche pas Finder/df ») : -- **Attribution** (défaut actuel) : « qui est responsable de l'espace » — hardlinks par lien, clones pleins. -- **Exact disque** : dédup hardlinks + taille privée des clones + borné au volume (A1 déjà fait) → **matche `df`**. -Plus un **panneau de réconciliation** qui décompose l'écart avec l'espace « utilisé » du volume. - -## 2. État actuel du code (vérifié) - -- `physical` = Σ `fileAllocSize` ([FSAttr.swift](../Sources/MacDirStats/Scanner/FSAttr.swift), `ATTR_FILE_ALLOCSIZE`). Hardlinks : chaque lien compté (validé = `du -skl`). Clones : comptés pleins. -- A1 (mount status) : **corrigé et vérifié** — le scan reste sur le volume (`du -skx`). -- `Format.bytes` ([Formatting.swift:5](../Sources/MacDirStats/Util/Formatting.swift#L5)) : base 1024, libellés « KB/MB » (non localisé) ; `Format.count` localisé (J9.5, locale mixte). -- Le volume expose déjà capacités via `Volume` / `URLResourceValues`. - -## 3. Axes de conception & tradeoffs - -### 3.a Hardlinks (A3) -- Demander `ATTR_CMN_FILEID` + `ATTR_CMN_DEVID` + `ATTR_FILE_LINKCOUNT` dans le bulk. -- Pour les seules entrées `nlink > 1` : dédup via `Set` clé `(dev, ino)` (ou `[dev: Set]`). Ne compter les blocs qu'à la **première** occurrence. Mémoire bornée au nb de fichiers multi-liens (marginal). -- *Tradeoff* : +12 octets/entrée dans le buffer bulk, un `Set` partagé sous lock (ou par-worker fusionné). En mode « Exact » seulement. - -### 3.b Clones APFS (A4) -- `ATTR_CMNEXT_PRIVATESIZE` (taille non partagée par fichier) via `FSOPT_ATTR_CMN_EXTENDED` + `forkattr`. 🔬 disponibilité/fiabilité selon versions, coût buffer plus gros — **à mesurer avant d'adopter**. -- Repli honnête si non fiable : note UI « les clones APFS peuvent gonfler ce total ». - -### 3.c Réconciliation (J9) -Panneau en fin de scan de volume : -``` -Volume utilisé (API) = scan + corbeille + snapshots locaux + purgeable + illisible + delta -``` -- scan = total mesuré ; corbeille = taille de `~/.Trash` (et `.Trashes` du volume) ; snapshots = `tmutil listlocalsnapshots` ; purgeable = `volumeAvailableCapacityForImportantUsage − volumeAvailableCapacity` ; illisible = `errorCount` (chemins skipped) ; delta = reste inexpliqué. - -### 3.d Base 1024 / locale (A10, J9.5) -- Libellés honnêtes **KiB/MiB** *ou* toggle base 10 / 1024 à côté du toggle On disk/Logical. -- Unifier la locale : `Format.bytes` via `MeasurementFormatter`/`ByteCountFormatter` localisé, cohérent avec `Format.count`. - -## 4. Plan d'implémentation - -1. Enum `CountingMode { .attribution, .exact }` dans `ScanController`, exposé par un toggle (à côté de On disk/Logical). -2. `DirectoryScanner` : en mode `.exact`, demander les attributs hardlink + (optionnel) private-size ; dédup `(dev,ino)` ; utiliser `privateSize` au lieu de `allocSize` pour les clones si disponible. -3. Panneau **Réconciliation** : nouveau composant sous le treemap (ou onglet), alimenté par `Volume` capacités + `tmutil` (via `ProcessRunner`) + taille corbeille + `errorCount`. -4. `Format.bytes` : libellés KiB/MiB (ou toggle) + localisation. - -## 5. Vérification - -- **Golden tests** : fixture avec hardlinks → mode **Exact** == `du -sk` (dédupliqué), mode **Attribution** == `du -skl`. Fixture avec clone `cp -c` → mode Exact ≈ taille privée. -- **Réconciliation** : sur un vrai volume, `scan + corbeille + snapshots + purgeable + illisible + delta ≈ used(API)` avec delta petit ; comparer à `df`. -- **Live** : basculer Exact/Attribution → le total change du bon montant sur une fixture à hardlinks. - -## 6. Risques & hypothèses - -- 🔬 `ATTR_CMNEXT_PRIVATESIZE` : disponibilité, coût buffer, sémantique exacte sur clones partiels — **à prototyper**. -- 🔬 Ordre de packing des attributs étendus (`forkattr`) dans le buffer bulk — même prudence que pour A1 (`dirattr`). -- La réconciliation dépend de `tmutil` (droit d'accès aux snapshots) et de la précision de `purgeable` (approximation Apple). - -## 7. Effort & dépendances - -**2–3 jours** (A3 ~½ j, A4 incertain ~½–1 j, réconciliation ~1 j, formatage ~¼ j). Indépendant. Décision **produit** (deux modes) autant que technique. diff --git a/specs/SPEC-04-fsevents-live.md b/specs/SPEC-04-fsevents-live.md deleted file mode 100644 index d3ef712..0000000 --- a/specs/SPEC-04-fsevents-live.md +++ /dev/null @@ -1,51 +0,0 @@ -# SPEC-04 — FSEvents : du snapshot au tableau de bord vivant - -> **Findings** : S3 (différenciateur produit), J5.2 (données périmées sans indicateur), J4.5 (« Put Back » invisible), D1 (TOCTOU des suppressions). **Dépend de SPEC-02** (réutilise `invalidate(subtree:)`). -> **Statut** : ✅ **IMPLÉMENTÉ & LIVE-VÉRIFIÉ** (bannière + timestamp capturés dans l'app). - -## 0. Résultat d'implémentation - -- **`FSWatcher`** ([FSWatcher.swift](../Sources/MacDirStats/Scanner/FSWatcher.swift)) : `FSEventStreamCreate` sur les seed paths, granularité **répertoire** (pas `FileEvents`), débounce `latency` (1 s), livraison sur une **dispatch queue** privée (`kFSEventStreamCreateFlagUseCFTypes | NoDefer`). -- **Cycle de vie** : démarre à la fin du scan (hôte), s'arrête sur `goHome`/nouveau scan/`deinit`. -- **Suivi des sales par chemin** (pas par identité — `invalidate` recrée les nœuds) : `handleDiskChanges` → `nearestNode(toPath:)` **symlink-tolérant** (canonicalisation : FSEvents rapporte `/private/var/…`, bug trouvé et corrigé), `dirtyPaths` + `diskChanged`. -- **UI** : bannière « The disk changed — [Refresh] » (`DiskChangedBanner`), **pastille par ligne** sur les dossiers sales, **« scanned N ago »** dans la barre de stats (J5.2, `TimelineView` 30 s). **Live-vérifié** (bannière + « just now » capturés). -- **Refresh = `invalidate(subtree:)`** (SPEC-02) sur les sous-arbres sales, ancêtres subsumant les descendants (un seul re-scan). **Portée** : seeds hôtes uniquement. -- **D1** : surfacé par la **bannière persistante** (l'utilisateur est prévenu que le disque a changé avant d'agir) plutôt qu'un modal par-suppression — plus simple, moins intrusif ; la suppression reste gatée `!isScanning`. -- **Test** : `fsEventsMarkDirtyAndRefreshCatchesUp` (end-to-end : écriture externe → `diskChanged` → `refreshDirty` → totaux rattrapés). - -## 1. Objectif - -Pendant qu'un résultat est affiché, observer le disque et **refléter les changements** : badge « le disque a changé », re-scan incrémental des sous-arbres touchés, âge du scan visible. C'est le différenciateur qui manque à tous les WinDirStat-like, et l'architecture (seeds, arbre live, re-scan par nœud de SPEC-02) y est bien prédisposée. - -## 2. État actuel (vérifié) - -- Aucun `FSEventStream` (grep). L'arbre est un instantané figé ; `path(for:)` reconstruit des chemins depuis un état potentiellement vieux (D1). -- SPEC-02 fournira `invalidate(subtree:)` — la brique de re-scan ciblé. - -## 3. Axes & tradeoffs - -- **Granularité** : `kFSEventStreamCreateFlagFileEvents` (par fichier, précis, plus d'events) vs par répertoire (coalescé, moins cher). *Recommandé* : par répertoire, latence ~1 s, suffisant pour marquer un sous-arbre sale. -- **Réaction** : re-scan automatique immédiat vs **badge + re-scan sur demande**. *Recommandé* : badge « changé » + re-scan du sous-arbre sale au clic (respecte l'intention, évite le travail intempestif), avec option « auto ». -- **Portée** : les seeds uniquement (host scans ; pas les scans VM/K8s). - -## 4. Plan d'implémentation - -1. `FSWatcher` : `FSEventStreamCreate` sur `seedPaths`, callback → set de chemins sales (débounce ~1 s), sur un run loop dédié. -2. Mapper chaque chemin sale → nœud le plus proche (index chemin→nœud, cf. SPEC-02) → marquer « dirty ». -3. UI : badge/pastille sur les nœuds sales + bandeau « le disque a changé — [Actualiser] » ; horodatage « scanné il y a N min » dans la barre d'état (J5.2). -4. Actualiser = `invalidate(subtree:)` (SPEC-02) sur les nœuds sales. -5. Avant toute suppression, si la cible est dans un sous-arbre sale → alerte D1 « le disque a changé, relancez ». - -## 5. Vérification - -- **Live** : scanner la fixture, créer/supprimer un fichier dedans en shell → badge apparaît ; Actualiser → l'arbre reflète le changement. -- **Test** : `FSWatcher` émet bien un event de coalescence pour un chemin touché (test d'intégration bref). - -## 6. Risques & hypothèses - -- 🔬 Volume d'events sur de gros arbres actifs (caches) : débounce + coalescence indispensables. -- Cohérence avec l'index chemin→nœud de SPEC-02 (dépendance forte). - -## 7. Effort & dépendances - -**1–2 jours**, après SPEC-02. diff --git a/specs/SPEC-05-file-level-treemap.md b/specs/SPEC-05-file-level-treemap.md deleted file mode 100644 index 3a52b9d..0000000 --- a/specs/SPEC-05-file-level-treemap.md +++ /dev/null @@ -1,64 +0,0 @@ -# SPEC-05 — Raffinement fichier au zoom (vue globale par dossier) - -> **Findings** : S5 (README l'évoque), complète A8 (couleur de tuile). Optionnel. -> **Contrainte produit (imposée)** : la **vue globale reste strictement par dossier** — lisibilité prioritaire. Le détail fichier est une propriété du **niveau de zoom courant**, jamais de la carte entière. -> **Statut** : ✅ **IMPLÉMENTÉ & LIVE-VÉRIFIÉ** (Axe B2 — tuiles-fichiers au zoomRoot, sous-dossiers agrégés). - -## 0. Résultat d'implémentation - -- **Axe B2** : `TreemapLayout.compute(..., rootFiles:)` — seuls les fichiers directs du **zoomRoot** (depth 0) sont matérialisés en tuiles individuelles ; les sous-dossiers restent agrégés (récursion avec `files: nil`). L'overview reste strictement par dossier. -- **`TreemapTile.file: FileTileInfo?`** (nom, taille au métrique courant, extension) porte la tuile-fichier ; couleur par **extension du fichier** (`treemapTypeColor`, cohérent avec la légende), label = nom du fichier, hover/menu/double-clic (Open/Reveal/Copy) dédiés. **Live-vérifié** : `demo05` montre video.mp4/archive.zip/photo.png/document.pdf/notes.txt en tuiles colorées distinctes, `subfolder` agrégé. -- **Résidu** : si la liste de fichiers est plafonnée (`maxFilesPerFolder`), le reliquat reste un bloc agrégé « other files » (proportions exactes). -- **Mémoire** : les fichiers viennent du `fileCache` **borné** existant (≤ 2000/dossier, par répertoire visité), rempli au zoom-in via `filesIn(zoomRoot)`. Non matérialisé **pendant** le scan (évite la ré-énumération à 10 Hz) — raffinement une fois le scan stabilisé. -- **Test** : `zoomRootRefinesIntoFileTiles` (zoomRoot → N tuiles-fichiers + sous-dossier agrégé ; overview → 1 bloc agrégé, 0 tuile-fichier). - -## 1. Objectif - -Quand on **entre dans un dossier** (clic/double-clic/zoom), la région de ce dossier se **raffine jusqu'aux fichiers** ; à l'inverse, la vue d'ensemble n'affiche **que des dossiers**. On ne disperse jamais de tuiles-fichiers sur toute la carte : la lisibilité globale prime. - -## 2. État actuel (vérifié) - -- Modèle « 1 nœud/répertoire » ([FSNode.swift](../Sources/MacDirStats/Model/FSNode.swift)) : les fichiers ne sont pas des objets ; agrégés dans `directFiles*`. -- Le treemap rend le sous-arbre de `zoomRoot` ([TreemapView.swift](../Sources/MacDirStats/Views/TreemapView.swift)) ; le zoom (double-clic tuile, breadcrumb, ⌘↑) existe et fonctionne. -- Aujourd'hui, dans un dossier zoomé, les sous-dossiers sont des tuiles mais les **fichiers directs sont agrégés** en une tuile-feuille (couleur = dominant direct, cf. A8). Énumération on-demand : `filesIn` ([ScanController.swift:344](../Sources/MacDirStats/ViewModel/ScanController.swift#L344)), non persistée. - -## 3. Principe de conception - -- **Niveau overview** (`zoomRoot` = racine du scan, ou tout niveau « de loin ») : tuiles **par dossier** uniquement — comportement actuel **préservé**. -- **Entrée dans un dossier** (`zoomRoot` = ce dossier) : la carte affiche les **fichiers directs** de ce dossier comme tuiles individuelles, entremêlés avec ses sous-dossiers (toujours agrégés). -- **Sortie de zoom** : retour à l'agrégat par dossier ; la mémoire fichier du dossier quitté est **libérée**. - -→ Le raffinement suit le **zoom**, pas un seuil global. C'est ce qui garantit une overview lisible. - -## 4. Axes & tradeoffs - -- **Axe A — Seuil de taille global** (afficher les fichiers partout où ils dépassent le seuil de tuile) : **rejeté** — disperserait des fichiers dans la vue globale, contraire à la contrainte produit. ❌ -- **Axe B — Raffinement piloté par le zoom (recommandé)** : matérialiser les tuiles-fichiers **seulement pour le `zoomRoot` courant**. Overview intacte, mémoire bornée au dossier ouvert. - - **B1** : fichiers du `zoomRoot` **et** de ses sous-dossiers de 1ᵉʳ niveau (raffinement d'un cran — plus riche, un peu plus dense). - - **B2** : fichiers directs du `zoomRoot` uniquement ; les sous-dossiers restent des tuiles agrégées jusqu'à ce qu'on y entre (plus conservateur, le plus lisible). **Défaut recommandé.** -- **Stockage** : blob colonnaire compact par répertoire raffiné `[(nameOffset: UInt32, logical: Int64, physical: Int64, extIndex)]` + arène de noms (~24 o/fichier), **rempli au zoom-in, vidé au zoom-out**. - -**Recommandé : Axe B / B2**, activable par un toggle « détail fichiers » (défaut on), avec possibilité de passer à B1. - -## 5. Plan d'implémentation - -1. `FileBlock` compact (colonnaire) construit à la volée pour le `zoomRoot` quand on y entre (réutilise l'énumération de `filesIn`, mais persistée compactement). -2. `TreemapLayout.compute` : si le nœud rendu **est le `zoomRoot`** (B2) et possède un `FileBlock`, subdiviser sa région en tuiles-fichiers (squarify des tailles fichiers) au lieu d'une tuile-feuille unique. Au-delà de ce niveau → agrégat par dossier inchangé. -3. Cycle de vie mémoire : remplir dans `zoom(into:)`, libérer dans `zoomOut`/`resetZoom`/`navigate` quand on quitte le dossier (borne stricte : au plus le contenu fichier d'un dossier à la fois, +1 niveau si B1). -4. Hit-test / hover / sélection au niveau fichier **uniquement dans la zone raffinée** ; ailleurs, comportement nœud actuel. -5. Couleur par fichier via `ExtKey`/palette (déjà cohérent avec la légende). Profiter du passage pour A8 (dominant pondéré du sous-arbre sur les tuiles-dossier agrégées). - -## 6. Vérification - -- **Live (méthode établie)** : overview → tuiles **dossier** (capture, lisible) ; zoomer dans `sub1` → fichiers de `sub1` en tuiles individuelles (capture) ; ⌘↑ → retour à l'agrégat dossier. Vérifier qu'à l'overview **aucune** tuile-fichier n'apparaît. -- **Test** : `FileBlock` round-trip (noms/tailles/ext) ; budget ≤ 24 o/fichier ; libération à la sortie de zoom. - -## 7. Risques & hypothèses - -- 🔬 Transition visuelle zoom-in/out (apparition/disparition des tuiles-fichier) : animer ou basculer proprement au relayout (`recompute`). -- Choix B1 vs B2 à calibrer sur des dossiers réels (densité vs richesse). -- Interaction avec le hit-test qui raisonne aujourd'hui en nœuds : introduire un type de tuile « fichier » distinct. - -## 8. Effort & dépendances - -**2–3 jours.** Indépendant (synergie A8). Le pilotage par zoom réutilise l'infrastructure `zoomRoot` existante. diff --git a/specs/SPEC-06-pluggable-backends.md b/specs/SPEC-06-pluggable-backends.md deleted file mode 100644 index 7b0d5cc..0000000 --- a/specs/SPEC-06-pluggable-backends.md +++ /dev/null @@ -1,53 +0,0 @@ -# SPEC-06 — Backends de scan pluggables - -> **Findings** : S6. H1 (Docker) est **déjà fait**. Étend le contrat `ScanBackend`. -> **Statut** : ✅ **IMPLÉMENTÉ** — contrat enrichi + backend SSH générique + carte splash. - -## 0. Résultat d'implémentation - -- **Contrat enrichi** ([ScanBackend.swift](../Sources/MacDirStats/Scanner/ScanBackend.swift)) : `var source: ScanSource { get }` (`host | vm | remote | archive`, avec `isReadOnly`/`label`) + `func diagnostics() -> String`, defaults fournis. L'UI gate déjà les actions destructrices sur `!isHostScan` (⇒ remote/vm lecture seule). -- **Commande `find` partagée** : `RemoteFind.command(rootPath:sudo:)` + `printf` centralisé — VMProbe **et** SSH le réutilisent (plus de dérive de format). -- **Backend SSH générique** : `SSHTarget` (user/host/port/path/identity/sudo) → `command()` construit `ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new … `. `ScanController.scanRemote` / `AppModel.scanRemote` lancent un `CommandScanner` (`source: .remote`) — **réutilise à 100 % le parser de flux streamé existant**, quasi gratuit. -- **UI splash** : section « Remote » → `RemoteCard` → `RemoteScanSheet` (formulaire host/user/path/port/identity/sudo, note lecture seule + auth par clé). -- **Tests** : `sshTargetBuildsFindCommand`, `hostOnlyTargetOmitsUserAndOptionals`, et **`commandScannerParsesFindStream`** (le parser SSH exercé localement via `printf` au format NUL exact — arbre/tailles/dirCount/extensions validés). -- **🔬 Non testable ici** : un scan SSH réel exige Remote Login **et** GNU `find`/`-printf` côté distant (le `find` de macOS ne l'a pas) — exactement le cas que le canal d'erreur D-D diagnostique (J6.3). - -## 1. Objectif - -Faire de `ScanBackend` un vrai point d'extension pour ajouter des sources de scan (SSH générique, archives, Time Machine, autre Mac) avec un minimum d'effort par backend, en s'appuyant sur l'infrastructure existante (arbre live, canal d'erreur, `ProcessRunner`). - -## 2. État actuel (vérifié) - -- `ScanBackend` ([ScanBackend.swift](../Sources/MacDirStats/Scanner/ScanBackend.swift)) : `start/cancel/isFinished/directoryCount/scanErrorCount/failure/snapshotExtensions`. **`failure` déjà ajouté** (canal d'erreur D-D). -- Deux implémentations : `DirectoryScanner` (syscalls locaux) et `CommandScanner` (`find` streamé SSH, framing NUL déjà corrigé). Le `CommandScanner` est déjà **90 % d'un backend SSH générique**. -- `ProcessRunner` (timeout + annulation) disponible. - -## 3. Axes & tradeoffs - -- Enrichir le contrat de : `var source: ScanSource { get }` (`host | vm(machine) | remote(host) | archive(url)`), `func diagnostics() -> String`. Permet à l'UI d'adapter les actions (lecture seule pour remote/archive, cf. J6.5). -- Nouveaux backends, par ordre de facilité : - - **SSH générique** : `CommandScanner` avec une commande `ssh user@host 'find … -printf …\0'` — quasi gratuit. - - **Time Machine** : `tmutil` + parcours d'un snapshot monté. - - **Archives** (`tar`/`zip`) : lister les entrées + tailles → alimenter l'arbre (pas de suppression). - - **Autre Mac** : SSH générique + auth. - -## 4. Plan d'implémentation - -1. Ajouter `source`/`diagnostics` au protocole (défaut fournis). -2. Généraliser `CommandScanner` : accepter une commande arbitraire (exe+args) + un `rootPath` — déjà presque le cas ; extraire un `RemoteFindConfig`. -3. `SSHScanBackend` : construit la commande `find` distante (réutilise `VMProbe.scanCommand` généralisé) ; gestion clé/hôte. -4. UI splash : carte « Serveur distant… » (host + chemin) ; actions gatées `source.isReadOnly`. - -## 5. Vérification - -- **Test** : parsing du flux `find` distant (déjà couvert par les tests `CommandScanner`). -- **Live** : SSH vers `localhost` sur un dossier → arbre peuplé, lecture seule (pas de corbeille). - -## 6. Risques & hypothèses - -- 🔬 Disponibilité de GNU `find`/`-printf` côté distant (busybox) — déjà diagnostiqué par le canal d'erreur (J6.3). -- Auth SSH (clé/agent) hors périmètre v1 : documenter les prérequis. - -## 7. Effort & dépendances - -**~1 jour par backend.** Le contrat enrichi : ~½ jour. Indépendant. diff --git a/specs/SPEC-07-distribution.md b/specs/SPEC-07-distribution.md deleted file mode 100644 index c6b8687..0000000 --- a/specs/SPEC-07-distribution.md +++ /dev/null @@ -1,59 +0,0 @@ -# SPEC-07 — Distribution : DMG signé + notarisé via GitHub - -> **Findings** : J1.1 (pas de binaire de release), J1.2 (icône générique), J1.4 (usage descriptions TCC absentes), J1.5 (version/About), D4 (signature/notarisation). I3 (`bundle.sh` `|| true`) **déjà corrigé**. -> **Périmètre v1 (imposé)** : distribuer **uniquement un `.dmg` via GitHub Releases**. Pas de Mac App Store. Cask Homebrew / auto-update repoussés hors v1. -> **Statut** : ✅ **IMPLÉMENTÉ** (tout le codable) — signature/notarisation exécutables une fois le compte Developer ID fourni (prérequis externe). - -## 0. Résultat d'implémentation - -- **Icône** (J1.2) : [`Packaging/make-icon.sh`](../Packaging/make-icon.sh) rend `chart.pie.fill` (accent sur fond panneau) → `Packaging/AppIcon.icns` (généré, commité) ; `bundle.sh` la copie + `CFBundleIconFile`. Vérifié visuellement. -- **Usage descriptions TCC** (J1.4) : `NSDesktop/Documents/Downloads/RemovableVolumes/NetworkVolumes UsageDescription` ajoutées au plist. `plutil -lint` OK. -- **Version depuis git** (J1.5) : `CFBundleShortVersionString` = `git describe --tags` (repli `0.1.0`), `CFBundleVersion` = nb de commits. Vérifié (`0.1.0` / build `26`). -- **`release.sh`** : build → `bundle.sh` (Developer ID) → **hardened-runtime sign + verify** → DMG (`hdiutil`, app + alias `/Applications`) → sign DMG → `notarytool submit --wait` → `stapler staple` → `spctl` check → **`gh release create`**. Prérequis externes documentés en tête (cert Developer ID + profil `notarytool` + `gh auth`). -- **README** : section « Download » pointant `releases/latest`. -- **`.gitignore`** : `*.dmg` (artefact) exclu ; `MacDirStats.app/` déjà exclu. -- **🔬 Non exécutable ici** : la signature Developer ID + notarisation exigent un compte Apple Developer (prérequis externe assumé par la spec §6). Le repli ad-hoc de `bundle.sh` reste pour le dev local (vérifié : bundle signé ad-hoc lance sans crash). - -## 1. Objectif - -Un collègue télécharge le `.dmg` depuis la page **GitHub Releases**, le monte, glisse l'app dans Applications, et l'ouvre **sans blocage Gatekeeper** — donc DMG **signé Developer ID + notarisé + stapled**. - -## 2. État actuel (vérifié) - -- `bundle.sh` : signature **ad-hoc** (cdhash instable → FDA à re-granter), **pas** de notarisation, **aucun** `.icns`/`CFBundleIconFile`, `CFBundleShortVersionString` figé « 1.0 », bundle id `com.macdirstats.app`. Échec total de `codesign` **désormais non masqué** (I3 fait). -- Aucune clé `NS*UsageDescription` → prompts TCC nus (J1.4). -- Détection FDA via ouverture de `TCC.db` ([FullDiskAccess.swift](../Sources/MacDirStats/Util/FullDiskAccess.swift)) — bien documentée ; sensible au cdhash → **la signature stable (Developer ID) fait persister le grant FDA**, gros gain vs ad-hoc. - -## 3. Plan d'implémentation (v1) - -1. **Icône** (J1.2) : `AppIcon.icns` (le camembert `chart.pie.fill` du splash convient) + `CFBundleIconFile` dans `bundle.sh`. -2. **Usage descriptions TCC** (J1.4) : ajouter au plist généré `NSDesktopFolderUsageDescription`, `NSDocumentsFolderUsageDescription`, `NSDownloadsFolderUsageDescription`, `NSRemovableVolumesUsageDescription`, `NSNetworkVolumesUsageDescription` — phrases explicites. -3. **Version depuis git** (J1.5) : `CFBundleShortVersionString`/`CFBundleVersion` dérivés de `git describe --tags` au build ; petit panneau About custom. -4. **Signature Developer ID + notarisation** : - - `codesign --force --options runtime --sign "Developer ID Application: …" MacDirStats.app` - - construire le DMG (`hdiutil create` ou `create-dmg`) avec l'app + alias `/Applications` - - `codesign` le DMG, `xcrun notarytool submit MacDirStats.dmg --keychain-profile … --wait`, puis `xcrun stapler staple MacDirStats.dmg`. -5. **Script `release.sh`** : build release → bundle → sign → dmg → notarize → staple → `gh release create vX.Y.Z MacDirStats.dmg --notes …`. -6. **README** : section « Download » pointant la dernière Release GitHub. - -## 4. Hors périmètre v1 (à garder en tête) - -- Cask Homebrew (`brew install --cask macdirstats`) — trivial une fois les Releases en place. -- Auto-update (Sparkle appcast **ou** simple « check GitHub releases »). -- Mac App Store (sandbox incompatible avec le scan disque global — non pertinent). - -## 5. Vérification - -- `spctl -a -vv -t open --context context:primary-signature MacDirStats.dmg` → « accepted, source=Notarized Developer ID ». -- `codesign -dv --verbose=4 MacDirStats.app` → identité Developer ID, hardened runtime. -- **Test réel** : télécharger le DMG depuis la Release GitHub sur une **autre** session/machine → ouverture sans « développeur non identifié ». -- Vérifier que le grant FDA **persiste** après un rebuild signé (cdhash stable). - -## 6. Risques & hypothèses - -- Prérequis externe : compte Apple Developer (Developer ID Application) + `notarytool` keychain profile. -- 🔬 Hardened runtime vs debuggabilité locale : garder le fallback ad-hoc de `bundle.sh` pour les builds de dev ; le chemin signé/notarisé est réservé à `release.sh`. - -## 7. Effort & dépendances - -**1–2 jours** (hors obtention du compte Developer). Indépendant. diff --git a/specs/SPEC-08-accessibility.md b/specs/SPEC-08-accessibility.md deleted file mode 100644 index b439634..0000000 --- a/specs/SPEC-08-accessibility.md +++ /dev/null @@ -1,51 +0,0 @@ -# SPEC-08 — Accessibilité & internationalisation complètes - -> **Findings** : J10.1 (VoiceOver), J10.2 (daltonisme), J10.3 (tailles de texte figées), J10.4 + J9.5 (i18n, locale mixte). -> **Statut** : ✅ VoiceOver complété · ✅ daltonisme/F6 · ✅ J9.5 · 🔬 J10.3 & traduction complète = chantiers dédiés documentés. - -## 0. Résultat d'implémentation - -- **VoiceOver (J10.1 complété)** : labels/valeurs ajoutés sur — pickers *Size metric* / *Counting mode*, bascule de thème, bouton *Storage reconciliation*, lignes *File types* (nom + taille + nb, trait bouton/sélection), segments du *breadcrumb* (nom + taille + « current/zoom »), **cartes de volume** (label+valeur « N% full »), **jauge K8s** (`N percent, OK/High/Critical`). Le **treemap** (Canvas opaque) expose désormais un résumé parlé : dossier zoomé + ses plus gros enfants en %. S'ajoute au 1er passage (stats, lignes de liste, dézoom). -- **Daltonisme (J10.2)** : le **pourcentage en texte** est un canal redondant sûr sur les jauges de volume (live-vérifié « 81% · … »), et un **niveau textuel** `OK/High/Critical` (`Theme.usageLevel`) dans l'accessibilité. **F6 corrigé** : seuils **unifiés 70/90** partout (`Theme.usageColor`) — volumes et K8s étaient 70/90 vs 70/85. -- **i18n — J9.5 corrigé** (SPEC-03) : `Format.bytes` est **localisé** (séparateur décimal), cohérent avec `Format.count` — plus de locale mixte (live-vérifié : « 85,7 GiB »). - -## 0.b Chantiers dédiés restants (honnêtes) - -- **🔬 J10.3 (échelle de texte / Dynamic Type)** : l'app utilise des tailles `.system(size:)` **fixes** par choix de densité ; les rendre réactives à « Larger Text » est un **rétrofit design global** (≈200 sites) qui doit être calibré pour ne pas casser la mise en page dense — chantier dédié, pas un bolt-on (cohérent avec le parti « robustesse du design »). Non tenté à chaud. -- **🔬 J10.4 (traduction complète)** : le **bug** de locale mixte (J9.5) est réglé ; la **traduction** dans d'autres langues (String Catalog `.xcstrings` peuplé) est un effort de localisation à part entière, hors v1. - -## 1. Objectif - -Rendre l'app utilisable au lecteur d'écran, robuste au daltonisme et aux préférences de taille de texte, et prête pour la localisation. - -## 2. État actuel (vérifié) - -- **J10.1 (premier passage livré)** : `accessibilityLabel/Value` ajoutés sur la barre de stats, les lignes de la liste (« Folder sub1, 979 KB » — **lu en live via l'API AX**), le treemap (zoom root + taille) et le bouton dézoom. Le reste du contenu (treemap = `Canvas`, tuiles individuelles) reste opaque. -- Couleur : info « type de fichier » portée par la **seule teinte** (16 teintes, collisions) ; jauges vert/orange/rouge sans forme/texte de secours (J10.2). -- Typos figées 9–13 pt, pas de réaction à la taille de texte système (J10.3). -- Tout en anglais hardcodé ; `Format.count` localisé mais `Format.bytes` non (J9.5, locale mixte). - -## 3. Plan d'implémentation - -1. **VoiceOver (compléter J10.1)** : - - Boutons toolbar (Home/Rescan/theme) : `accessibilityLabel`. - - Treemap : exposer les tuiles comme `accessibilityChildren` (ou un rotor) avec label « nom, taille, % du parent » — permet de parcourir la carte au clavier/VoiceOver. - - Panneau File types, breadcrumb, jauges K8s : labels + valeurs. - - (Synergie SPEC-01 : un `NSTableView` donne la sélection/focus/rotor accessibles nativement.) -2. **Daltonisme (J10.2)** : ne pas reposer sur la seule teinte — ajouter le **% en texte** sur les jauges, un motif/onde léger ou une étiquette de type sur les tuiles au survol, et vérifier les contrastes. Unifier les seuils de couleur (70/90 % VolumeCard vs 70/85 % K8s, cf. F6). -3. **Taille de texte (J10.3)** : facteur d'échelle global dans `Theme` piloté par la préférence système (ou un réglage), appliqué aux tailles de police. -4. **i18n (J10.4, J9.5)** : String Catalog (`.xcstrings`) pour les chaînes ; `Format.bytes` via un formateur **localisé** (cohérent avec `Format.count`) ; décider KiB/MiB vs base 10 (cf. SPEC-03). - -## 4. Vérification - -- **Live (méthode établie)** : lire l'arbre AX de l'app (`osascript` System Events) → chaque contrôle expose un label/valeur pertinent ; naviguer le treemap au rotor. -- Test manuel VoiceOver ; simulateur daltonien (Sim Daltonism) ; « Larger Text ». - -## 5. Risques & hypothèses - -- 🔬 Exposer les tuiles du `Canvas` treemap à AX sans dégrader le rendu (overlay d'éléments accessibles invisibles vs `accessibilityRepresentation`). -- i18n : le mélange de locales actuel (J9.5) est le premier bug à corriger avant toute traduction. - -## 6. Effort & dépendances - -**1–2 jours.** Le volet VoiceOver bénéficie de SPEC-01 (liste native). Indépendant sinon. diff --git a/specs/SPEC-09-gpu-treemap-rendering.md b/specs/SPEC-09-gpu-treemap-rendering.md deleted file mode 100644 index c61724b..0000000 --- a/specs/SPEC-09-gpu-treemap-rendering.md +++ /dev/null @@ -1,140 +0,0 @@ -# SPEC-09 — Rendu GPU **3D-natif** du treemap (Metal, projection 2D orthographique) - -> **Findings** : chantier **perf-resize** (continuation de la PR #17 « solution C »). Profilage : après avoir supprimé la taxe SwiftUI (`NSHostingView.layout` 5373→101, `dispatchActions` 3395→1), le coût a basculé sur la **rastérisation CPU** des tuiles — `CGContextFillRect` ≈ 7482 échantillons pendant un drag, sur le `NSEventThread`. Sur un arbre pathologique (`.build/ModuleCache`, des milliers de tuiles minuscules) le remplissage par tuile reste le plancher. -> **Décision d'architecture (actée)** : le moteur de rendu est **3D-natif dès le départ** — vraie géométrie 3D, caméra, matrices MVP, depth buffer. L'affichage courant est sa **projection orthographique top-down** : tuiles plates (hauteur = 0) vues d'aplomb → **iso-visuel strict** avec la visu 2D actuelle. Une projection ortho de tuiles plates **préserve exactement les proportions** (pas de fuyantes), donc le 2D d'aujourd'hui est mathématiquement identique. **Passer en 3D** (plus tard, §9) = changer la **caméra** (perspective + orbite) et donner une **hauteur** aux tuiles — **aucune réécriture**, le pipeline est déjà 3D. -> **Contrainte produit** : **on garde les tuiles 2D actuelles.** Le 3D reste **différé (+3–6 mois)** côté produit. -> **Statut** : 📋 **PROPOSÉ** — à planifier. La couture (NSView séparant layout/interaction de `draw()`) est déjà en place (PR #17), ce qui rend le renderer Metal un **remplacement local** du seul dessin des tuiles. - -## 1. Objectif - -Rendre le treemap sur le **GPU** au lieu du CPU, via un **pipeline 3D natif** dont le rendu courant est une **projection orthographique 2D**, pour que le resize (et à terme le zoom, puis la 3D) soit fluide même sur des dizaines de milliers de tuiles — **sans toucher au visuel ni à l'interaction aujourd'hui**. Le raisonnement, prouvé sur ce projet : - -- L'ancien `Canvas` + `.drawingGroup()` était fluide **parce qu'il passait par le GPU** (Metal offscreen) ; son coût réel était la **taxe de reconcile SwiftUI** autour, pas le dessin. -- La solution C (PR #17) a supprimé cette taxe (AppKit pilote le resize) mais a **rapatrié le dessin sur le CPU** (`CALayer.draw` → Core Graphics). -- **Metal = les deux gains à la fois** : dessin GPU **et** pas de taxe SwiftUI. - -Un treemap est un ensemble de quads colorés axis-aligned : le cas d'école du **GPU instancing** (un seul draw call pour N tuiles). Le remplissage séquentiel de milliers de rects sur un thread devient un upload de N instances + **un** appel de dessin, rastérisé en parallèle. - -## 2. État actuel du code (vérifié) - -Couture (posée par la PR #17) — le dessin est **déjà isolé** du reste : - -- [TreemapView.swift:13](../Sources/SpaceMatters/Views/TreemapView.swift#L13) `struct TreemapView` (wrapper SwiftUI : observation + overlay hover + a11y) → [:70](../Sources/SpaceMatters/Views/TreemapView.swift#L70) `TreemapRepresentable: NSViewRepresentable` → [:110](../Sources/SpaceMatters/Views/TreemapView.swift#L110) `final class TreemapNSView: NSView, CALayerDelegate`. -- Deux `CALayer` : [:133](../Sources/SpaceMatters/Views/TreemapView.swift#L133) `tileLayer` (tuiles, redessiné au relayout) + [:134](../Sources/SpaceMatters/Views/TreemapView.swift#L134) `overlayLayer` (hover + sélection, redessiné seul). -- **Le goulot** : [`drawTiles`](../Sources/SpaceMatters/Views/TreemapView.swift#L387) — boucle par tuile avec [`ctx.setFillColor` + `ctx.fill(r)`](../Sources/SpaceMatters/Views/TreemapView.swift#L398-L399), [`drawLinearGradient` par tuile](../Sources/SpaceMatters/Views/TreemapView.swift#L405) (cushion), remplissage de dim, `stroke` de bordure. **Tout sur CPU.** -- Contournement actuel du coût pixel : [`viewWillStartLiveResize` passe `contentsScale` à 1](../Sources/SpaceMatters/Views/TreemapView.swift#L208) (¼ des pixels pendant le drag, re-net à la fin). Pansement — Metal le rend inutile (§4.7). -- Orientation : rects top-left, contexte CG bottom-left → [`flip(_:_:)` par tuile](../Sources/SpaceMatters/Views/TreemapView.swift#L383). Bug historique (survol inversé verticalement) corrigé côté hit-test [:490](../Sources/SpaceMatters/Views/TreemapView.swift#L490). - -Ce qui **ne bouge pas** (et donc n'est pas réécrit) : - -- **Layout** : [`TreemapLayout.compute`](../Sources/SpaceMatters/Views/TreemapLayout.swift#L77) + [`Cache`](../Sources/SpaceMatters/Views/TreemapLayout.swift#L49) size-independent + [`squarifySorted`](../Sources/SpaceMatters/Views/TreemapLayout.swift#L192) ; seule la **placement** re-tourne par frame, la structure triée est mémoïsée. Reste CPU (bon marché après cache). Piloté par [`ScanController.treemapLayout`](../Sources/SpaceMatters/ViewModel/ScanController.swift#L453), invalidé par [`version`](../Sources/SpaceMatters/ViewModel/ScanController.swift#L30). -- **Couleurs** : [`computeColors`](../Sources/SpaceMatters/Views/TreemapView.swift#L302) + LUT `(hueIndex, bucket luminance)` → aujourd'hui `CGColor` ([`cgColor(for:weight:)`](../Sources/SpaceMatters/Views/TreemapView.swift#L326)), via [`Theme.treemapTypeColor(hueIndex:weight:)`](../Sources/SpaceMatters/App/Theme.swift#L61). **Seul changement** : sortir des **RGBA packés** (`SIMD4`) au lieu de `CGColor`. -- **Interaction** : [`tileAt`](../Sources/SpaceMatters/Views/TreemapView.swift#L490), hover ([:507](../Sources/SpaceMatters/Views/TreemapView.swift#L507)), `mouseUp` ([:524](../Sources/SpaceMatters/Views/TreemapView.swift#L524)), `menu(for:)` ([:568](../Sources/SpaceMatters/Views/TreemapView.swift#L568)) — **inchangés** (hit-test CPU sur `tiles`). -- **Overlay** actuel : [`drawOverlay`](../Sources/SpaceMatters/Views/TreemapView.swift#L432) (1 spotlight + 1 liseré + 1 outline) en CoreGraphics — **migre en 2ᵉ passe Metal** (§4.5) ; le code CG reste comme fallback. - -Aucun usage Metal aujourd'hui (grep : seul un `.drawingGroup()` résiduel dans `KubernetesResultView`, hors sujet). - -## 3. Conception retenue - -Le renderer est un **pipeline Metal à quads instancés, 3D-natif, projeté en ortho 2D**. Les décisions, chacune actée : - -1. **GPU instancing** — un quad unité (généré depuis `[[vertex_id]]`, sans VBO) dessiné **N fois** via `drawPrimitives(instanceCount: N)` ; chaque instance lit son `{origin, size, color}` dans un **buffer d'instances** indexé par `[[instance_id]]`. Un seul draw call pour toutes les tuiles. *(Modèle OpenGL équivalent : `glDrawArraysInstanced` + attribut par-instance ; MSL au lieu de GLSL, pipeline state pré-compilé au lieu de l'état global mutable.)* - -2. **3D-natif, projection ortho** — vertices 3D, **matrice MVP** pilotée par une caméra, depth buffer actif. Aujourd'hui : `height = 0`, caméra orthographique top-down → sortie pixel-identique au 2D. Ce choix a **le même coût par frame** qu'un pipeline 2D figé, mais rend la 3D (§9) gratuite (caméra + hauteur) au lieu d'une réécriture. La matrice MVP remplace le `flip(_:_:)` par-tuile (flip encodé une fois dans la caméra). - -3. **Rendu à la demande** — `CAMetalLayer` piloté manuellement (draw appelé depuis `setFrameSize`/`apply`), GPU au repos quand rien ne change. Pas de `MTKView` (évite un sous-view + delegate ; on garde notre `NSView` unique et son interaction). - -4. **Buffer shared + anneau** — `MTLStorageModeShared` (mémoire unifiée Apple Silicon : CPU écrit, GPU lit, **zéro copie/blit**). Anneau de 2–3 buffers + `DispatchSemaphore` pour que le CPU n'écrase pas un buffer encore lu par le GPU (utile pendant la rafale de frames d'un live-resize). - -5. **Bordures par gouttière inset** — effacer la couche en `treemapBorder`, puis dessiner chaque fill **inséré de 0,6 px** → la couleur de bord transparaît comme des gouttières. Zéro géométrie de bordure, look « grille » net (le trick classique des treemaps). - -6. **Overlay = 2ᵉ passe Metal** — sélection + hover rendus par une poignée d'instances, bordure/dim en **fragment shader** (SDF). C'est *le* lieu des futurs effets de highlight (§4.8) ; le mettre en Metal dès maintenant évite d'écrire un overlay CPU qu'on arracherait ensuite. **Phase 1 : reproduit le look actuel à l'identique.** - -## 4. Plan d'implémentation - -Principe : **remplacer uniquement les tripes du dessin**. Layout, couleurs (source), hit-test et interaction restent tels quels. - -### 4.1 Nouveau fichier `Views/TreemapMetalRenderer.swift` -Encapsule tout le Metal, testable/remplaçable, sans polluer `TreemapNSView` : -- `device: MTLDevice`, `commandQueue`, `pipelineState: MTLRenderPipelineState`, `depthState: MTLDepthStencilState`, anneau de `instanceBuffers: [MTLBuffer]`, `inflightSemaphore`, une `Camera` (§4.2). -- Struct d'instance **3D-native** (compacte, alignée) : - ``` - struct TileInstance { // 48 o : 3× SIMD4 - var origin: SIMD4 // x, y, z(=0) en espace monde ; w = padding - var size: SIMD4 // largeur, profondeur, hauteur(=0 aujourd'hui), w = padding - var color: SIMD4 // RGBA linéaire ; le dim est déjà folded dedans - } - ``` - Aujourd'hui `origin.z = 0` et `size.height = 0` → chaque instance est un **quad plat** posé sur le plan sol ; demain `size.height = f(size|count|depth)` → une **boîte** extrudée, **même struct**. -- `func render(instances:, drawable:, camera: Camera, borderColor:)`. - -### 4.2 Caméra & convention monde (le cœur du 3D-natif) -- **Monde** : plan **sol XZ** (comme une carte / une ville), axe **Y = hauteur** (« code city » : les tuiles gisent au sol, les boîtes montent en Y). Le layout squarify (rects top-left en pixels) est mappé sur le plan sol par une transformation fixe (échelle + flip top-left → monde). -- **Caméra** = `view` (position/orientation) × `projection`, encapsulée dans `Camera { viewProjection() -> float4x4 }`. **Aujourd'hui** : caméra **orthographique**, au-dessus, regardant droit vers le bas (−Y), *up* aligné pour reproduire la disposition des rects → ortho top-down = les rects 2D au pixel près (garantie iso-visuel). -- **Depth buffer** activé dès maintenant (`MTLDepthStencilState`, `.depth32Float`) : inerte tant que les tuiles sont plates, **prêt** pour le recouvrement des boîtes en 3D. Coût négligeable. - -### 4.3 Shaders `Views/Treemap.metal` -- **Vertex** : sommet de quad (`vertex_id` → 2 triangles ; demain 12 triangles de boîte) placé par `instance.origin/size`, transformé en clip space par la **MVP caméra** (uniforme). Passe `uv∈[0,1]` et la taille pixel au fragment. -- **Fragment** : `fill = instance.color` ; **cushion** = reproduction des 3 stops actuels ([TreemapView.swift:160-165](../Sources/SpaceMatters/Views/TreemapView.swift#L160-L165)) — blanc α .16 en haut → 0 à 0.45 → noir α .20 en bas, composité sur le fill, **skippé** si tuile < 6 px (comme aujourd'hui) ; **bordure** par gouttière inset (fond pré-effacé en `treemapBorder`, fill inséré de 0,6 px). Sortie sRGB (§6). - -### 4.4 Buffer d'instances — discipline d'alloc -- Réutiliser un `MTLBuffer` par slot d'anneau, **agrandi seulement** quand `tiles.count` dépasse la capacité (comme `sizeScratch`). Jamais d'alloc par frame. -- Remplissage : boucle unique sur `tiles`, culling sub-pixel **côté CPU avant écriture** (on n'envoie pas une tuile ≤ 0,5 px — déjà filtré [:395](../Sources/SpaceMatters/Views/TreemapView.swift#L395)). Écriture directe dans le buffer shared. -- Couleurs : `computeColors` produit des `SIMD4` (mêmes valeurs que `treemapTypeColor`, converties une fois) au lieu de `CGColor`. Le **dim** (highlight/search) est **folded** dans la couleur au remplissage → un simple recalcul du buffer sur changement de highlight (peu fréquent), pas de seconde passe. - -### 4.5 Intégration dans `TreemapNSView` -- Remplacer `tileLayer` (CALayer CPU) par une `CAMetalLayer` (`tileMetalLayer`), `device` assigné, `pixelFormat` calibré (§6), `framebufferOnly = true`, `presentsWithTransaction = true` (§6 gotcha resize). -- `relayout()`/`apply()` inchangés dans leur logique ; à la fin, au lieu de `tileLayer.setNeedsDisplay()` → `renderer.render(...)` synchrone. -- `setFrameSize` : mettre à jour `tileMetalLayer.drawableSize = bounds.size * scale`, puis render. **Supprimer** le hack `contentsScale = 1` du live-resize (§4.7). -- **Overlay** (sélection + hover) : 2ᵉ passe Metal dans le même drawable (spotlight `evenOdd` → test dedans/dehors + liseré SDF ; outline hover → SDF). **Phase 1 : look actuel à l'identique.** Le `drawOverlay` CG reste comme fallback (§4.6). - -### 4.6 Repli (défensif) -Si `MTLCreateSystemDefaultDevice()` renvoie `nil` (jamais sur macOS 15, mais by-the-book) → conserver le chemin CoreGraphics actuel (`drawTiles`/`drawOverlay`) en fallback. Le code de dessin CG **n'est pas supprimé**, il devient le plan B → **pas de régression possible**. - -### 4.7 Bonus attendu : retrait du pansement live-resize -Metal rastérise le plein retina quasi gratuitement → plus besoin du `contentsScale = 1` pendant le drag ([:208-217](../Sources/SpaceMatters/Views/TreemapView.swift#L208-L217)). Le treemap reste **net pendant** le resize, pas seulement à la fin. - -### 4.8 Headroom effets — débloqué par §4.5, **hors périmètre** (Phase 2) -Une fois bordures/sélection/hover en fragment shader, ces effets deviennent des changements d'un uniforme, impayables sur CPU par frame — **à ne PAS allumer en Phase 1** (romprait l'iso-visuel). Consignés comme cap produit, à cadrer dans un chantier dédié : glow de sélection animé (falloff de distance + `time`), bordures SDF anti-aliasées, dim animé (fondu 150 ms), matches de recherche qui « respirent ». *(Les effets animés impliquent un rendu 60 fps borné par `CADisplayLink` le temps de la transition — pas une boucle continue.)* - -## 5. Vérification - -- **Iso-visuel (méthode captures établie)** : capture côté-à-côté **avant (CG) / après (Metal)** sur le même scan — couleurs, sheen cushion, gouttières, dim (highlight extension + search), spotlight de sélection, outline de hover. Doivent être indiscernables (tolérance sRGB, §6). -- **Orientation** : re-vérifier le bug historique — survoler les tuiles **du haut** highlight bien celles du haut (le flip est maintenant dans la projection). -- **Perf (objectif du chantier)** : `sample`/Instruments (Metal System Trace) sur `.build/ModuleCache` pendant un drag continu → la rastérisation CPU (`CGContextFillRect`) **disparaît** du profil ; temps de frame GPU < 1 ms ; `NSEventThread` déchargé. -- **Interaction** : clic (reveal), double-clic (zoom/open/zoomOut), menu contextuel, sélection depuis la liste → **inchangés** (hit-test CPU non touché). -- **Tests** : la logique testée (`TreemapLayout`, `squarify*`) est inchangée → tests existants verts. Ajouter un test unitaire sur le **packing d'instances** (N tuiles → N `TileInstance` attendus, culling sub-pixel appliqué) — pur, sans GPU. - -## 6. Risques & hypothèses (🔬) - -- 🔬 **Espace colorimétrique** : CG dessine en sRGB device RGB ; le drawable Metal doit matcher (`.bgra8Unorm_srgb` vs conversion gamma dans le shader). Mal calibré → tuiles plus claires/sombres. À figer en capture avant de généraliser. -- 🔬 **`CAMetalLayer` + live-resize** : sans `presentsWithTransaction = true` + présentation **synchrone** (`commit` → `waitUntilScheduled` → `drawable.present()` dans la même transaction que le changement de bounds), la couche Metal **retarde/tearing** derrière la fenêtre pendant le drag. C'est LE gotcha ; à traiter dès le départ. -- 🔬 **Bordure inset** : gouttière opaque `treemapBorder` vs stroke semi-transparent 0,6 px de l'actuel. Valider en capture ; repli possible sur une bordure SDF (fragment shader) si l'écart déplaît. -- **Placement CPU comme nouveau plancher** : une fois la raster GPU gratuite, le coût résiduel par frame devient `squarifySorted` (O(n) arith + alloc des rects). Si mesuré gênant : réutiliser les buffers de rects, voire paralléliser — **hors périmètre**, à mesurer après coup. -- **Intel Macs** (macOS 15, minoritaires) : `storageModeShared` OK mais moins optimal ; pas bloquant. - -## 7. Effort & dépendances - -**2–3 jours.** Indépendant. La couture NSView de la PR #17 est le prérequis — **déjà en place**. Aucune dépendance externe (Metal est système). Le code CG actuel reste comme fallback, donc pas de régression possible en cas de souci device. - -## 8. Périmètre — ce que cette SPEC fait / ne fait PAS - -**Fait** : un renderer **3D-natif** (géométrie 3D, caméra, MVP, depth) rendu en **projection orthographique 2D**, iso-visuel avec les tuiles actuelles, sur GPU. Tuiles **et** overlay (sélection/hover) en Metal. -**Ne fait PAS** (aujourd'hui) : -- Aucun changement visible : visu, palette, layout, interaction **inchangés**. **Iso-visuel strict.** -- Pas de texte dans les tuiles (resté retiré). -- **Pas d'effets animés** : l'overlay Metal reproduit le look actuel à l'identique ; le headroom shader (§4.8) est débloqué mais **éteint** — c'est Phase 2. -- Pas de hauteur ≠ 0, pas de caméra perspective, pas d'orbite : la **3D reste débranchée** (caméra ortho top-down, `size.height = 0`). Voir §9. - -## 9. Activation 3D — différée côté **produit** (+3–6 mois), déjà **architecturée** - -Le 3D n'est **pas** un futur chantier de refonte, c'est un **basculement de configuration** d'un moteur déjà 3D. Rien à réécrire — on « rebranche » ce que §4.2 a posé : - -- **Caméra** : `projection` ortho → **perspective** ; `view` top-down → **inclinée + orbitable**. Pipeline, shaders, buffer d'instances : identiques. -- **Hauteur** : `size.height` passe de 0 à `f(donnée)` → les quads plats deviennent des **boîtes** (le vertex shader passe de 2 à 12 triangles ; même struct d'instance). -- **Depth buffer** : déjà actif (§4.2) → recouvrement des boîtes correct sans rien changer. -- **Cushion → shading** : le sheen top→bottom devient un vrai éclairage par normale de face — même emplacement dans le fragment shader. - -Dimensions candidates pour la hauteur : `size(metric)`, `fileCount` ([FSNode.swift:28](../Sources/SpaceMatters/Model/FSNode.swift#L28)), profondeur d'arbre. ⚠️ **`FSNode` n'a aucun champ temporel** (vérifié : identique à `main`) — une hauteur « âge/fraîcheur » impliquerait d'ajouter `mtime` au scan (dépendance à cadrer séparément, hors de cette SPEC). - -→ Décision produit : **tuiles 2D maintenant**, activation 3D dans 3–6 mois. Cette SPEC garantit que cette activation sera un **réglage de caméra + un attribut de hauteur**, pas une réécriture. diff --git a/specs/SPEC-10-world-treemap.md b/specs/SPEC-10-world-treemap.md deleted file mode 100644 index 8dd3478..0000000 --- a/specs/SPEC-10-world-treemap.md +++ /dev/null @@ -1,110 +0,0 @@ -# SPEC-10 — Treemap-monde persistant : caméra continue, LOD hiérarchique, navigation « carte » - -> **Findings** : profilage resize du 2026-07-12 (Instruments, Game Performance, build release, M4 Pro) — pendant un live-drag : **main thread saturé à ~80 %**, **~16 ms CPU par frame présentée** (budget 120 Hz : 8,3 ms) → ~50 fps effectifs avec hitches à 25-46 ms. Répartition : **~22 % code app** (layout continu ~10 %, reconstruction `tiles`/repack ~6 %, présent ~4 % — dont création d'**IOSurface à chaque frame**, le `drawableSize` changeant invalide le pool de drawables), **~78 % machinerie AppKit/SwiftUI** (layout du chrome, contraintes, AttributeGraph — deux passes par frame). GPU : **0,3 %** d'utilisation, p50 0,13 ms — totalement désœuvré. -> **Défaut de paradigme constaté** (captures à deux tailles de fenêtre) : le layout est une **fonction du viewport**. Le gel des décisions discrètes ([TreemapLayout.Cache](../Sources/SpaceMatters/Views/TreemapLayout.swift#L57)) rend le resize *monotone*, mais le cache est invalidé à chaque bump de `version` (tick de scan à 10 Hz, refresh FSEvents) et les décisions sont re-décidées **au rect courant de la fenêtre** — deux invalidations à deux tailles → deux mondes sans rapport, les gros blocs changent de place. Le « monde » est re-roulé en permanence. -> **Décision d'architecture (proposée)** : inverser le paradigme, à la manière d'un moteur de jeu open-world — **le layout est une fonction pure des données, calculée en coordonnées monde ; le viewport n'est qu'une caméra ; le niveau de détail est une décision de rendu ; les changements de données sont des retouches locales animées, jamais un re-roll global.** En prime produit : le treemap devient **navigable façon Google Maps** (pan au trackpad, zoom continu vers le curseur, le détail qui se révèle en zoomant). -> **Prérequis** : PR #24 (renderer Metal seul chemin de rendu, [Camera.ortho](../Sources/SpaceMatters/Views/TreemapMetalRenderer.swift#L39) découplant déjà viewport et instances). -> **Statut** : 📋 **PROPOSÉ** — à planifier. Remplace la mécanique de « référence gelée » de SPEC-09/PR #17 par un modèle monde ; généralise SPEC-05 ; prépare l'activation 3D de SPEC-09 §9. - -## 1. Objectif - -Que plus **aucune** interaction caméra (resize de fenêtre, pan, zoom) ne recalcule ni ne re-packe quoi que ce soit : une frame caméra = une matrice, budget **< 1 ms** CPU main thread. Que la **position d'un dossier dans le monde soit stable** — entre deux resizes, entre deux ticks de scan, entre deux refreshes — et que tout changement structurel légitime (données qui bougent, aspect qui dérive trop) soit une **transition animée locale**, pas une téléportation globale. Et ouvrir la feature produit : **naviguer le disque comme une carte** — deux doigts pour se déplacer, pincer pour plonger, le niveau de détail qui suit. - -## 2. État actuel du code (vérifié) - -Ce qui existe et sur quoi on s'appuie : - -- **La caméra existe déjà** : [Camera.ortho(viewport:)](../Sources/SpaceMatters/Views/TreemapMetalRenderer.swift#L39) projette un rect-monde arbitraire sur le drawable ; l'animation de zoom ([startZoomAnimation](../Sources/SpaceMatters/Views/TreemapView.swift#L679)) anime déjà une caméra **sur des instances figées** — la preuve de concept du modèle est dans le code. -- **Le gel partiel existe** : [TreemapLayout.Cache](../Sources/SpaceMatters/Views/TreemapLayout.swift#L57) mémoïse par nœud les décisions discrètes (breaks de rangées, orientations) et ne rejoue que la géométrie continue. Défauts : invalidation globale par `(metric, version, root)` ([ScanController.treemapLayout](../Sources/SpaceMatters/ViewModel/ScanController.swift#L540)), référence = rect fenêtre du moment, et la géométrie continue + [relayout()](../Sources/SpaceMatters/Views/TreemapView.swift#L399) + [packInstances](../Sources/SpaceMatters/Views/TreemapView.swift#L442) retournent **à chaque frame** de resize. -- **Le renderer copie les instances à chaque render()** (triple buffer) — pas de chemin « caméra seule, buffer inchangé ». -- **L'overlay CG ne sait pas suivre la caméra** (masqué pendant le zoom animé, hover désactivé pendant l'animation) ; le hit-test ([tileAt](../Sources/SpaceMatters/Views/TreemapView.swift#L599)) est en coordonnées vue, valide seulement caméra à l'identité. -- **SPEC-05** : les fichiers du seul zoom root sont des tuiles ([filesIn](../Sources/SpaceMatters/ViewModel/ScanController.swift#L562), `rootFileTiles` mémoïsé) ; la navigation est un **état structurel** `zoomRoot` ([zoom(into:)](../Sources/SpaceMatters/ViewModel/ScanController.swift#L1150)) qui re-layoute. -- Échelle : scan hôte type = **713 695 dossiers / 3,9 M fichiers** (capture du jour) ; profondeur possible > 20 niveaux. - -## 3. Conception retenue - -### 3.1 Le monde : coordonnées hiérarchiques parent-relatives -Chaque nœud stocke son rect **relatif au parent, dans [0,1]²** (Float32, 16 o), décidé par le squarify sur les poids des enfants — **jamais en pixels, jamais fonction de la fenêtre**. Le rect-monde absolu d'un nœud est la **composition** des rects relatifs de sa lignée, calculée en `Double` au moment du rendu pour les seuls sous-arbres visibles. Propriétés : - -- **Stabilité par construction** : le rect relatif d'un nœud ne change que si les poids de sa fratrie changent — pas au resize, pas au zoom, pas quand un cousin lointain bouge. -- **Précision** : à 4 M de fichiers, une tuile profonde peut mesurer 10⁻⁶ du monde ; composer en Double puis **re-baser caméra** (coordonnées caméra-relatives avant conversion Float pour le GPU — le *floating origin* des moteurs de jeu) élimine la casse de précision Float32 en zoom profond. -- **Rangement** : extension de `Cache.Entry` (les entrées par nœud existent déjà) — pas de champ ajouté à `FSNode`, le modèle reste pur. Entrées construites **paresseusement** (seulement pour les nœuds LOD-développés) et évincées LRU hors viewport, comme des tuiles de carte. - -L'**aspect du monde** suit paresseusement l'aspect de la fenêtre : pendant un drag la caméra étire (déformation tolérée, bornée par hystérésis ~±20 %) ; au `viewDidEndLiveResize` ou au franchissement du seuil, **re-bake global animé** (morph, §3.4). Décision ⚖️ : c'est le seul événement autorisé à re-décider globalement — et il est **rare et animé**. - -### 3.2 La caméra : navigation carte continue -`Camera` s'enrichit d'un état `(centre monde, échelle)` avec conversions vue↔monde exactes (inverse pour le hit-test). Gestes : - -- **Pan** : scroll deux doigts / drag (mode main). **Zoom** : pincement et molette, **vers le curseur** (le point sous la souris reste sous la souris — l'invariant Google Maps). **Double-clic** : zoom-to-fit animé du dossier (remplace le re-layout de `zoom(into:)` par un mouvement de caméra — l'animation actuelle devient *la* navigation). **⌘0 / breadcrumb / Home** : fit d'un ancêtre. -- **Bornes** : zoom-min = monde entier (léger rubber-band), zoom-max = quand le plus petit fichier visible atteint ~40 px de côté. -- **`zoomRoot` devient un dérivé de la caméra** : le dossier le plus profond dont le rect-monde contient ~le viewport. Breadcrumb, liste, résumé a11y s'y accrochent comme aujourd'hui — la sélection liste→carte fait un fit caméra, la navigation carte→liste suit le dérivé. L'état structurel disparaît ; l'URL mentale devient « où est la caméra ». - -### 3.3 LOD hiérarchique par taille projetée -Le `maxDepth`/`minSide` statiques disparaissent au profit d'une règle par nœud, évaluée sur la **taille projetée à l'écran** (px = taille monde × échelle caméra) : - -- côté projeté < **T_collapse** (~8 px) → le dossier est rendu **agrégé** : une tuile de sa couleur `dominantExt` (le champ existe, [FSNode](../Sources/SpaceMatters/Model/FSNode.swift#L46)) ; -- côté projeté > **T_expand** (~14 px) → ses enfants sont développés (hystérésis T_expand > T_collapse contre le *popping* au bord du seuil) ; -- côté projeté > **T_files** (~400 px de côté) → ses **fichiers propres** apparaissent en tuiles individuelles — **généralisation de SPEC-05** : plus seulement le zoom root, tout dossier assez gros à l'écran. Layouts de fichiers calculés à la demande, cache LRU. - -La **subdivision est animée** : quand un dossier se développe, ses enfants naissent du rect parent et interpolent vers leurs rects (morph §3.4) — le « tile split » de Google Maps ; l'inverse au repli. Le set d'instances GPU est géré en **ranges contigus par sous-arbre développé**, reconstruits seulement quand le set LOD change ; une frame caméra-seule **ne réécrit aucun buffer** (nouveau chemin `render(cameraOnly:)` qui réutilise le dernier buffer — la copie actuelle par frame disparaît). - -### 3.4 Morph : tout re-bake est une transition -Le vertex shader reçoit **deux buffers d'instances** (avant/après, appariés par nœud) + un uniforme `t` ; il interpole origine/taille (~200 ms, easing actuel). S'applique à : re-bake d'aspect (fin de drag), **ticks de scan et refreshes FSEvents** (coalescés à 10 Hz max — le monde « respire » au lieu de téléporter, le défaut des captures disparaît par construction), développement/repli LOD, futur passage 2D↔3D. Instances orphelines (nœud supprimé) fondent vers taille 0 ; nouvelles instances naissent du rect parent. - -### 3.5 Stabilité sous scan : invalidation locale (« local moves ») -L'invalidation `version` cesse d'être globale : le scanner/FSEvents connaissent les **sous-arbres sales** — seules leurs entrées (rects relatifs, décisions) sont re-décidées ; les fratries dont les poids n'ont dérivé que sous un **ε** (~2 %) gardent leurs décisions (re-géométrie continue seule). C'est l'esprit « stable treemaps via local moves » (Sondag et al., TVCG 2018) appliqué à notre cache existant. Pendant le scan initial, le monde **se construit progressivement** : les entrées apparaissent au fil des données (streaming), le LOD borne ce qui est calculé — on ne layoute jamais 713 k dossiers d'un coup, seulement le visible + une marge. - -### 3.6 Présentation : pooling du drawable, overlay caméra-aware -- **Drawable** : `drawableSize` arrondi au **palier de 256 px** supérieur, cadré via `contentsRect` → le pool d'IOSurfaces survit au drag (les allocations par frame mesurées au profil disparaissent). 🔬 à valider avec `presentsWithTransaction` ; repli = réallocation au seul `viewDidEndLiveResize`. -- **Overlay** (spotlight sélection + hover) : ses 2-3 rects passent en **coordonnées monde**, transformés par la caméra à chaque present (coût trivial) → l'overlay **suit** pan/zoom/morph, le hover reste actif pendant les mouvements (l'inhibition actuelle saute). Le hit-test passe par l'inverse caméra (vue → monde → descente par containment dans les rects relatifs). - -## 4. Plan d'implémentation — jalons - -**M1 — Monde figé + caméra passive (~2-3 j)** : rects parent-relatifs dans `Cache.Entry` ; composition monde→instances ; resize = caméra seule + re-bake animé en fin de drag ; `render(cameraOnly:)` ; pooling drawable ; overlay/hit-test via inverse caméra ; hover actif pendant les mouvements. *Sortie : resize 120 Hz, part app ~0 dans le profil, plus aucun saut de blocs au resize.* - -**M2 — Navigation carte (~2-3 j)** : pan/zoom-au-curseur (scroll, pincement, molette), double-clic = fit animé, bornes + rubber-band, `zoomRoot` dérivé (breadcrumb/liste/a11y suivent), ⌘0/Home = fit racine. *Sortie : la feature « Google Maps ».* - -**M3 — LOD hiérarchique + fichiers (~4-5 j)** : seuils projetés + hystérésis, entrées lazy/LRU, ranges d'instances par sous-arbre, subdivision-morph, fichiers par dossier au-delà de T_files (généralise SPEC-05). *Sortie : zoomer révèle le détail, dézoomer agrège — à profondeur illimitée.* - -**M4 — Streaming & scan vivant (~3-4 j)** : invalidation par sous-arbre sale, ε-stabilité des décisions, morphs coalescés sur ticks/FSEvents, construction progressive pendant le scan. *Sortie : un scan en cours est un monde qui se remplit en douceur.* - -*(M5 = 3D : hors périmètre — voir §8.)* - -## 5. Vérification - -- **Stabilité (le bug des captures)** : script de référence — même scan, séquence resize → tick de version → resize ; les centroïdes des 20 plus gros blocs ne doivent pas bouger de > quelques % du monde (hors morphs explicites). Test unitaire pur sur les rects relatifs : re-layout à aspects différents ⇒ rects relatifs identiques. -- **Perf** : re-profil Instruments du même scénario (protocole du 2026-07-12 : attach + warm-up ~10 s à absorber) — frame caméra < 1 ms CPU app ; zéro `CAIOSurfaceCreate` pendant un drag ; presents à la cadence vsync pendant pan/zoom. -- **Précision** : zoom au max sur le plus petit fichier d'un scan 4 M — pas de jitter de géométrie (valide le re-basage caméra). -- **LOD** : franchissements de seuils répétés (zoom oscillant) sans popping ni churn d'instances (hystérésis) ; budget mémoire entrées LRU borné et mesuré. -- **Interaction** : hover/clic/menu/sélection pendant pan, zoom **et** morph ; breadcrumb cohérent avec le dérivé caméra ; a11y (résumé du dossier dominant) ; tests `TreemapLayout` existants verts (le squarify lui-même ne change pas). - -## 6. Risques & hypothèses (🔬) - -- 🔬 **Précision Float en zoom profond** — mitigée par parent-relatif + composition Double + re-basage caméra ; à prouver au jalon M1 (test du §5). -- 🔬 **`contentsRect` × `presentsWithTransaction`** sur CAMetalLayer — à valider tôt ; repli documenté (§3.6). -- 🔬 **Appariement avant/après du morph** sous scan agressif (nœuds qui naissent/meurent en rafale) — coalescence 10 Hz + naissance-du-parent doivent suffire ; sinon dégrader en cross-fade. -- 🔬 **Modèle mental `zoomRoot` dérivé** : la liste et la carte peuvent diverger transitoirement pendant un pan — l'UX du breadcrumb « suit la caméra » est à éprouver (prototype M2 avant de figer). -- ⚖️ **Molette = zoom** (convention carte) vs scroll = pan (convention document) : trancher au M2 (proposition : trackpad pan + pincement zoom, molette souris = zoom). -- **Mémoire** : entrées complètes sur un arbre pathologique — bornée par LOD-lazy + LRU, à mesurer M3. -- **Déformation pendant le drag** (aspect monde ≠ fenêtre dans la bande d'hystérésis) : assumée, bornée, corrigée par le re-bake animé de fin de drag. - -## 7. Effort & dépendances - -**~11-15 j** en 4 jalons livrables indépendamment (chacun laisse l'app meilleure qu'avant). Dépend de : PR #24 mergée (Metal-only). Aucune dépendance externe. Les 78 % de taxe AppKit/SwiftUI au resize de *fenêtre* ne sont **pas** dans ce périmètre (chantier chrome séparé) — mais pan/zoom, eux, ne touchent pas la fenêtre : la navigation carte tourne intégralement dans le budget GPU/caméra. - -## 8. Périmètre — ce que cette SPEC fait / ne fait PAS - -**Fait** : le paradigme monde (layout = données), caméra continue pan/zoom, LOD hiérarchique projeté (dossiers **et** fichiers), morphs sur tout changement, streaming sous scan, stabilité spatiale garantie et testée. -**Ne fait PAS** : la 3D (hauteurs, perspective, orbite) — mais ce modèle en est le **prérequis propre** : SPEC-09 §9 s'active ensuite par caméra + hauteur sur un monde inchangé, le LOD et le morph s'appliquant tels quels aux boîtes. Pas de minimap (nice-to-have, à cadrer après M2). Pas de refonte du chrome SwiftUI (les 78 % du profil resize-fenêtre — chantier séparé). Le look des tuiles (palette, cushion, gouttières) ne change pas. - -## 9. Addendum post-implémentation (PR #25 — QA visuelle du 2026-07-12) - -Amendements actés pendant l'implémentation, après quatre passes de QA visuelle sur un scan réel (4 M fichiers) : - -- **§3.6 pooling du drawable : abandonné.** Le 🔬 s'est confirmé — le crop `contentsRect` produit des bandes noires pendant le drag. Le repli documenté s'applique : `drawableSize` exact par frame ; le resize étant devenu caméra-only, la réallocation d'IOSurface est le seul coût résiduel, assumé. -- **§3.4 morph de subdivision : « appear-in-place », pas grow-from-parent.** La croissance géométrique depuis le rect parent superpose les enfants entre eux et sur leurs voisins pendant la transition (gros carrés fantômes au zoom). Les tuiles nouvelles apparaissent à leur place finale ; seules les tuiles présentes dans les deux builds glissent, et leurs **couleurs cross-fadent** (la renormalisation de luminosité fond au lieu de sauter). Un rebuild en plein morph repart de l'état affiché (lerp à t), pas de la cible précédente. -- **§3.4 scan vivant : téléportation, pas morph.** À 10 Hz de restructurations violentes, des glissades de 220 ms n'atterrissent jamais — la carte se délite en carrés épars sur la sous-couche. Un scan actif rebuild instantanément (chaque frame est un pavage cohérent) ; les morphs s'appliquent à la vie calme post-scan (FSEvents, suppressions, métrique, re-bake d'aspect, LOD). -- **§3.5 ε ancré aux parts de décision + garde de qualité.** La dérive mesurée contre la dernière revalidation (baseline glissante) laissait les décisions dériver sans borne par petits pas (lamelles 10:1). L'ε se mesure contre les parts **au moment de la décision**, la dérive d'aspect du rect du nœud (±25 %) re-décide aussi, et une garde auto-réparante re-décide localement au-delà d'un pire ratio de 5:1. -- **Sous-couche parent** : chaque dossier développé (et bloc fichiers subdivisé) est peint sous ses enfants — les enfants cullés (< 0,5 px) montrent la couleur du dossier au lieu de percer un trou vers le fond ; hover/clic y tombent sur le dossier. -- **Molette normalisée** : deltas précis ÷12, facteur borné à [0.5, 2] par événement (×1.15/cran, aligné sur le pincement). -- Les tuiles disparues sont retirées sans fondu (le fondu à zéro impliquerait du blending — pipeline opaque conservé). diff --git a/specs/SPEC-11-incremental-refresh.md b/specs/SPEC-11-incremental-refresh.md deleted file mode 100644 index 32f8a72..0000000 --- a/specs/SPEC-11-incremental-refresh.md +++ /dev/null @@ -1,76 +0,0 @@ -# SPEC-11 — Refresh incrémental par répertoire : la carte vivante - -> **Findings** (session 2026-07-12, QA live sur volume 315 GiB / 3,9 M fichiers) : un fichier de 4 GiB créé **à la racine du home** déclenche, au Refresh, un re-scan parallèle **du home entier** — ~25 s (après PR #26, qui a borné un walk d'extensions mono-thread qui portait ce même refresh à plusieurs **minutes**). Cause structurelle : FSEvents n'est exploité que comme *marqueur de sous-arbre sale* ([handleDiskChanges](../Sources/SpaceMatters/ViewModel/ScanController.swift#L1109) → `nearestNode`), et [refreshDirty](../Sources/SpaceMatters/ViewModel/ScanController.swift#L1139) → [invalidate(subtree:)](../Sources/SpaceMatters/ViewModel/ScanController.swift#L970) reconstruit tout le sous-arbre. Le chiffre du bandeau, lui, est un **delta statfs global** ([volumeFreeBytes](../Sources/SpaceMatters/ViewModel/ScanController.swift#L1100)) — il capte aussi le bruit des autres process. -> **Ce qu'on n'exploite pas** : la sémantique précise de FSEvents en granularité répertoire — un événement sans `MustScanSubDirs` signifie « **le contenu direct de CE répertoire** a changé ». Une passe `getattrlistbulk` non récursive sur ce seul répertoire (millisecondes) suffit à calculer le delta exact et à le propager. -> **Décision (actée)** : le **live est le mode par défaut**. Les changements s'appliquent automatiquement — deltas **par répertoire, propagés** pour les événements précis ; re-scan de sous-arbre **auto-déclenché** (coalescé à l'ancêtre commun) quand FSEvents avoue une perte de détail. Il n'y a **pas de fallback dormant** : les deux mécanismes sont nominaux, chacun sur son régime. Le bandeau + bouton Refresh ne subsistent qu'en **dernier recours** — un rattrapage automatique qui serait assez gros pour mériter le consentement de l'utilisateur. La carte devient un tableau de bord *vivant* qui respire par morphs (SPEC-10). -> **Dépendances** : PR #26 (bandeau honnête pendant refresh), SPEC-10 mergée (ε-revalidation locale + morphs — le treemap absorbe déjà des updates incrémentaux avec grâce). -> **Statut** : ✅ **IMPLÉMENTÉ** (2026-07-12, branche `spec/11-incremental-refresh`, M1–M4). Mesuré à l'échelle (955 k fichiers / 55 Go) : +512 MiB dans un dossier *nouveau* → totaux exacts **0,01 s** après la fin de l'écriture ; suppression fondue en **0,73 s** — sans clic, sans bandeau. Objectif §1 (< 1 s) dépassé. - -## 1. Objectif - -Qu'un changement ponctuel du disque (fichier créé/supprimé/retaillé, dossier ajouté) soit **visible dans la carte en < 1 s, sans aucune intervention** — mesuré contre les 25 s + un clic actuels sur le scénario du 2026-07-12 — avec des agrégats exacts (tailles, comptes). Le fichier téléchargé apparaît en glissant, la corbeille vidée fait fondre les tuiles ; l'utilisateur ne « rafraîchit » plus, il regarde. - -## 2. État actuel du code (vérifié) - -- [FSWatcher](../Sources/SpaceMatters/Scanner/FSWatcher.swift#L5) : FSEvents en granularité **répertoire** (pas de `FileEvents`), latence/coalescence 1 s, livraison sur queue privée. **Ne transmet pas les flags d'événements** (`MustScanSubDirs`, `Renamed`…) — première brique à poser. -- [handleDiskChanges](../Sources/SpaceMatters/ViewModel/ScanController.swift#L1109) : chemin → nœud existant le plus proche → `dirtyPaths` ; bandeau gate-é par le budget sur le delta statfs. Depuis PR #26 : muet pendant un refresh en vol. -- [invalidate(subtree:)](../Sources/SpaceMatters/ViewModel/ScanController.swift#L970) : re-scan complet du sous-arbre (epoch + `structuralOpActive` + ancêtres retenus), réconciliation File-types **bornée à 100 k fichiers** (PR #26). C'est le futur **fallback**, pas le chemin nominal. -- `FSNode` : agrégats **atomiques** (`aggLogical/aggPhysical/fileCount`, `directFilesSize/directFileCount`, `dominantExt`) — **pas d'inventaire par fichier en mémoire** (design low-RAM). Les listings existent seulement dans le `fileCache` LRU des dossiers consultés. -- La **propagation de deltas par la chaîne d'ancêtres existe déjà** (`wrappingAdd` — chemins suppression et invalidate) ; idem le détachement de sous-arbre (`applyDirectoryRemoval`). -- Côté rendu, SPEC-10 fait le travail : bump de version → ε-revalidation **locale** des entrées touchées → morph. Un delta ponctuel ne re-roule rien. - -## 3. Conception - -### 3.1 FSWatcher : transmettre les flags -Le handler reçoit `[(path, flags)]`. En granularité répertoire (pas de `FileEvents` — c'est notre anti-flood), les flags par item (`ItemRenamed`, `ItemCreated`…) **ne sont jamais livrés** ; les seuls exploitables sont `kFSEventStreamEventFlagMustScanSubDirs` (perte de détail → rattrapage sous-arbre) et `…RootChanged` — qui exige `kFSEventStreamCreateFlagWatchRoot` à la création (à ajouter ; racine déplacée/supprimée → bandeau proposant un Rescan). Les renames se détectent **structurellement** : le diff des sous-dossiers au re-stat du/des parent(s) voit une disparition + une apparition. `IgnoreSelf` volontairement absent : l'écho de nos propres suppressions produit un re-stat convergent no-op, et rattrape tout écart. Latence 1 s conservée (c'est notre coalescence naturelle). - -### 3.2 Application d'un événement répertoire `P` (chemin nominal) -Tous les événements convergent vers une **file de réconciliation sérialisée** (un seul consommateur, sur le MainActor entre les suspensions) : à tout instant au plus une opération structurelle mutile l'arbre — même discipline que `remove`/`invalidate`, avec lesquels elle partage `structuralOpActive` (op refusée → re-queue, jamais oubliée). - -1. **Re-stat non récursif** de `P` hors main thread : une passe du primitif existant ([enumerateDirectory](../Sources/SpaceMatters/Scanner/FSAttr.swift#L62)) → **totaux absolus** (`directFilesSize/directFileCount/dominantExt` + set des sous-dossiers directs + listing fichiers). Buffer réutilisé, µs–ms par dossier. -2. **Delta = absolu − état du nœud, calculé au moment de l'apply** sur le MainActor — jamais un delta pré-calculé (deux re-stats en vol sur le même dossier convergent en last-writer-wins au lieu d'appliquer un delta périmé) → propagation `wrappingAdd` jusqu'à la racine. Le treemap voit le bump et morphe. -3. **Diff des sous-dossiers** (diff de noms, à l'apply) : apparu → mini-scan `DirectoryScanner` du seul nouveau sous-arbre rattaché à `P` — en respectant `skipPaths`/mount-points **comme le scan initial** (sinon double-comptage firmlink Data) ; disparu → soustraction des agrégats + détachement (plomberie `applyDirectoryRemoval`, qui relève déjà zoom/sélection/expansion vers le survivant). -4. **File-types** : diff **exact** par extension via un **snapshot par-dossier** (LRU borné, ~4096 dossiers) alimenté par chaque re-stat — exact dès le second delta sur un dossier ; amorcé par `fileCache` quand il détient le listing complet (< 2000 items, sa borne). Sans ancien snapshot : on ne touche pas la table (dérive marquée, même classe de compromis que PR #26). Un dossier **feuille** disparu dont le snapshot est connu se soustrait **exactement** (cas QA : le téléchargement surveillé qui s'efface — zéro dérive de bout en bout). Plus de walk récursif nulle part sur ce chemin. -5. **Caches** : le listing obtenu en 1 rafraîchit `fileCache` (l'outline suit sans I/O) ; `sortCache` purgé (l'ordre par taille a pu changer). Les bumps de version par delta se coalescent naturellement par tour de runloop (SwiftUI ne repeint qu'une fois par tour) — pendant un gros cycle, la carte se met à jour progressivement au lieu de sauter d'un coup. -6. **Invariants de sûreté** inchangés : epoch re-vérifié après chaque suspension, ancêtres retenus, `structuralOpActive` exclusif. - -### 3.3 Rattrapage automatique (pas un fallback dormant — un second régime nominal) -Quand FSEvents **avoue une perte de détail**, le détail ne peut pas être reconstruit par deltas — le re-scan de sous-arbre existant ([invalidate(subtree:)](../Sources/SpaceMatters/ViewModel/ScanController.swift#L970), éprouvé) prend le relais **automatiquement**, sans bandeau ni clic : -- `MustScanSubDirs` → re-scan auto du sous-arbre signalé ; -- **rafale d'événements** → coalescence par **clustering de préfixe commun**, pas à l'ancêtre commun global (100 dossiers épars ont pour ancêtre commun la racine — on re-scannerait 3,9 M fichiers là où 100 re-stats coûtent ~100 ms) : les groupes **denses** (≥ K≈16 chemins sales sous un même ancêtre proche — churn de build, checkout, rsync) coalescent chacun vers *son* ancêtre en une passe parallèle ; les épars restent des re-stats unitaires, plafonnés (~256/cycle, l'excédent reste en file) ; -- **cooldown par sous-arbre** (~10 s) entre deux rattrapages auto du même nœud — une boucle de build qui churne en continu marque sale et attend la fenêtre suivante, au lieu de re-scanner le même sous-arbre toutes les 2 s. - -Les cas que la version initiale de cette spec traitait en fallback et qui **se dissolvent dans le chemin nominal** : -- chemin non mappable (dossier inconnu, course avec un rename) → re-stat du **parent** connu le plus proche (récursion d'un niveau) ; -- **mode Exact** (acté) : delta en **attribution locale + marqueur de dérive** sur le nœud (tooltip « dédup hardlinks à re-vérifier ») — pas de re-scan systématique ; le Rescan complet reste le point de re-synchronisation exacte, comme aujourd'hui. - -### 3.4 Le bandeau : dernier recours uniquement -Un seul cas le fait apparaître : un rattrapage automatique (§3.3) dont le sous-arbre cible dépasse un **seuil de consentement** (`fileCount` > ~500 k, soit plusieurs secondes de re-scan) — là, on demande avant de brûler le CPU, avec le spinner persistant de PR #26 pendant l'exécution. En dessous du seuil, tout est silencieux et vivant. `changedBytes` devient la somme des deltas réellement appliqués (exact) ; le statfs global est rétrogradé en garde-fou de cohérence : `|drift statfs − deltas physiques appliqués| > budget` → proposer un Refresh — le signe que quelque chose nous a échappé (écritures hors du sous-arbre observé sur le même volume restent le faux positif connu, budget 128 MB inchangé). - -## 4. Plan d'implémentation — jalons - -**M1 (~1,5 j)** — Flags dans FSWatcher ; re-stat single-dir + deltas propagés, encore déclenché par le Refresh existant (le bouton devient instantané pour les cas nominaux — étape d'observation avant d'ôter la ceinture). -**M2 (~1,5 j)** — Sous-dossiers créés/supprimés, renames (paire disparition/apparition), chemins non mappables via re-stat du parent, garde-fous epoch/structuralOp sous tests. -**M3 (~1 j)** — **Bascule live par défaut** : auto-application des deltas, rattrapage sous-arbre auto-coalescé (`MustScanSubDirs` + rafales), bandeau réduit au seuil de consentement. -**M4 (~1 j)** — File-types via snapshots par-dossier (LRU, amorcé par `fileCache`) ; attribution locale + marqueur de dérive en mode Exact ; `changedBytes` exact + garde-fou statfs. - -## 5. Vérification - -- **Fixtures** (pattern NavigationTests) : créer/supprimer/retailler un fichier dans un dossier scanné → agrégats exacts propagés jusqu'à la racine **sans** sub-scan (compteur d'appels scanner espionnable) ; dossier créé avec contenu → mini-scan du seul sous-arbre ; dossier supprimé → soustraction exacte. -- **Rattrapage auto** : événement forgé avec `MustScanSubDirs` → re-scan auto du sous-arbre, sans bandeau ; rafale synthétique (> N dirs) → coalescence à l'ancêtre commun + un seul re-scan ; rafale géante (> seuil de consentement) → bandeau, et lui seul. -- **Live QA** : rejouer le scénario du 2026-07-12 (`dd` 4 GiB à la racine du home) → apparition dans la carte **< 1 s** après la latence FSEvents, **sans clic**, morph à l'appui ; suppression → fonte ; `npm install` dans un repo → un seul rattrapage coalescé, silencieux. ✅ *Mesuré headless à l'échelle (`SM_QA_LIVE=1`, 955 k fichiers) : apparition 0,01 s, fonte 0,73 s — voir `liveReconcileAtScale`.* -- **Cohérence** : après une salve de deltas, `du -skx` byte-exact vs agrégats (la méthode de vérif des audits) ; drift statfs vs `changedBytes` sous le budget. - -## 6. Risques & hypothèses (🔬) - -- 🔬 **Sémantique fine des flags FSEvents** sous coalescence agressive (latence 1 s) — à caractériser tôt (M1) sur événements forgés et réels. Validé empiriquement le 2026-07-12 : un stream sur `/` livre bien les chemins Data en **forme firmlink** (`/Users/…`), que `nearestNode` canonicalise déjà. -- **Renames de gros dossiers** : détectés structurellement (disparition + apparition au(x) parent(s)) → soustraire + re-scanner : correct mais coûteux pour un gros dossier renommé. Le vrai re-attach demanderait l'inode du dossier persisté par `FSNode` (+8 o/nœud) pour reconnaître le sous-arbre — levier identifié, différé. -- 🔬 **APFS** : clones/sparse — le re-stat mesure ce que `getattrlistbulk` rapporte, comme le scan initial (cohérent par construction) ; à vérifier sur fichiers clonés. -- **Exact mode** : attribution locale + marqueur de dérive (acté §3.3) — la dédup hardlinks n'est re-garantie qu'au Rescan ; à documenter dans l'UI du marqueur. -- 🔬 **Rattrapages auto en cascade** : un `MustScanSubDirs` pendant qu'un re-scan auto tourne déjà — la file de réconciliation sérialisée + `structuralOpActive` + le cooldown par sous-arbre doivent absorber sans tempête de re-scans (test dédié). -- 🔬 **Deux re-stats en vol sur le même dossier** : neutralisé par construction (totaux absolus, delta calculé à l'apply, file sérialisée) — test dédié quand même. -- **dominantExt** local : recalculé sur le seul dossier touché — la couleur d'un ancêtre agrégé peut dériver marginalement jusqu'au Rescan (acceptable, cosmétique). - -## 7. Effort & périmètre - -**~4-6 j** en 4 jalons livrables. **Ne change pas** : le scan initial, le Rescan complet, les scans VM/K8s (hors périmètre — `isHostScan` seulement), le treemap (SPEC-10 absorbe les deltas par construction). **Change** : FSWatcher (flags), ScanController (deltas par répertoire + rattrapage auto-coalescé), bandeau (réduit au seuil de consentement en M3 — le mode par défaut est **live, silencieux, exact**). diff --git a/specs/SPEC-12-auto-update.md b/specs/SPEC-12-auto-update.md deleted file mode 100644 index 662bf6f..0000000 --- a/specs/SPEC-12-auto-update.md +++ /dev/null @@ -1,147 +0,0 @@ -# SPEC-12 — Mises à jour automatiques : Sparkle 2 - -> **Contexte** : SPEC-07 a livré la distribution v1 (DMG signé Developer ID + notarisé + stapled via GitHub Releases) et listait explicitement l'auto-update comme hors périmètre (§4). Cette spec ferme ce chantier. -> **Périmètre v1** : check automatique consenti + « Check for Updates… » manuel, update in-place depuis GitHub Releases, release notes affichées. Canal beta, deltas et pane Settings repoussés (§3.8). -> **Contrainte produit (2026-07-12)** : pas de compte Apple Developer pour l'instant, décision repoussée au maximum **sans fermer la porte** — d'où la stratégie d'identité en deux pistes du §3.7 : auto-signée stable aujourd'hui, bascule Developer ID par simple ajout de secrets, transition transparente pour les updates (chaîne EdDSA). -> **Statut** : ✅ **implémenté et validé en live le 2026-07-12** (branche `feat/spec12-sparkle-updates`, non poussée) — jalons §4.1–4.7 faits ; **cycle d'update complet prouvé sur cette machine** : v0.0.1 depuis `~/Applications` → « Check for Updates… » → détection v9.9.9, notes markdown rendues, download, validation EdDSA, remplacement in-place, **quarantaine levée** (0 xattr), **relaunch automatique** en 9.9.9. **Persistance FDA piste A prouvée** (DR identique cert-pinnée, grant survit à l'update, §5). Prompt de consentement au 2ᵉ lancement confirmé (l'absence observée = état déjà répondu, pas un bug). Toutes les hypothèses 🔬 levées (§6). **Reste avant release publique** : création du cert dans le keychain éphémère du CI (1er run), première vraie release. Note relaunch : ne se déclenche pas depuis `/private/tmp` (LaunchServices) — sans effet en `~/Applications`/`/Applications`. - -## 1. Objectif - -L'utilisateur qui a installé SpaceMatters depuis le DMG est prévenu qu'une nouvelle version existe, lit les notes de version, clique « Install » — l'app se remplace en place, relance, **sans re-passage Gatekeeper et sans perdre le grant Full Disk Access**. Le tout sans qu'aucune connexion réseau ne parte avant son consentement explicite. - -Framework retenu : **[Sparkle 2](https://github.com/sparkle-project/Sparkle)** (2.9.4 au moment de la rédaction). Licence **MIT** (+ BSD 2-clause pour bsdiff/bspatch, MIT pour ed25519 — vérifié sur la branche 2.x) : compatible Apache 2.0, aucune obligation au-delà de la conservation des mentions de copyright. - -## 2. État actuel du code (vérifié) - -- **Chaîne de release complète et fonctionnelle** (SPEC-07) : [release.sh](../Packaging/release.sh) en local, [release.yml](../.github/workflows/release.yml) en CI (déclenché à la publication du draft) — build → `bundle.sh` → sign hardened-runtime → DMG → notarize → staple → upload asset. Trois releases taguées (v0.1.0 → v0.3.0). -- **Release notes déjà produites** : [release-drafter.yml](../.github/workflows/release-drafter.yml) maintient un draft en markdown, catégorisé par labels de PR — la matière première des notes d'update existe déjà, il faut juste la faire transiter vers l'appcast. -- **Versionnage compatible Sparkle tel quel** : `CFBundleShortVersionString` = tag semver ([Packaging/bundle.sh:49](../Packaging/bundle.sh#L49)), `CFBundleVersion` = **nombre de commits** ([Packaging/bundle.sh:32](../Packaging/bundle.sh#L32), `fetch-depth: 0` en CI pour ça, [release.yml:45](../.github/workflows/release.yml#L45)) — entier, strictement croissant sur `main` : directement utilisable comme `sparkle:version`. -- **Bundle artisanal, binaire statique unique** : `bundle.sh` génère l'Info.plist par heredoc ([Packaging/bundle.sh:39-64](../Packaging/bundle.sh#L39-L64)), aucun `Contents/Frameworks`, aucun rpath. Signature actuelle en `codesign --deep` ([Packaging/bundle.sh:75](../Packaging/bundle.sh#L75), [Packaging/release.sh](../Packaging/release.sh#L36)) — devra devenir explicite (inner→outer) avec un framework embarqué. -- **Pas de sandbox, pas d'entitlements** (vérifié : aucun `.entitlements`, aucun `com.apple.security.*`) : les XPC services de Sparkle sont inutiles ici, intégration simplifiée. -- **Aucun code réseau dans l'app** : zéro `URLSession` dans `Sources/`. Sparkle sera la **première connexion sortante** de l'app — l'ethos maison est explicite ([NativeCleaner.swift:53](../Sources/SpaceMatters/Scanner/NativeCleaner.swift#L53) : « a cleaner must not update taps or phone home »), donc consentement d'abord, transparence README ensuite. -- **Points d'ancrage UI** : entry point GUI [SpaceMattersApp.swift:40-56](../Sources/SpaceMatters/App/SpaceMattersApp.swift#L40-L56) (attention : l'enum `Entry` `@main` route aussi des sous-commandes CLI headless — l'updater ne doit vivre que dans le chemin GUI) ; un seul bloc `.commands` ([SpaceMattersApp.swift:49-55](../Sources/SpaceMatters/App/SpaceMattersApp.swift#L49-L55)) ; pas de scène Settings (réglages ad-hoc via `@AppStorage`). -- **FDA keyé sur la signature** : la designated requirement Developer ID est stable → le grant TCC survit à un remplacement du bundle par Sparkle (même identité, même bundle id). C'est précisément le scénario que la signature stable de SPEC-07 rend possible. - -## 3. Axes de conception & tradeoffs - -### 3.1 Sparkle vs « check maison » GitHub API - -Un check maison (interroger `releases/latest`, proposer le téléchargement) éviterait la dépendance — mais tout ce qui a de la valeur est dans l'install : remplacement atomique du bundle, validation de signature, **levée de quarantaine**, relance propre. Réimplémenter ça, c'est réécrire le code le plus sensible de Sparkle sans ses dix ans de durcissement. Sparkle est le standard de facto hors App Store (iTerm2, Transmit…), sa licence est triviale, et il se consomme en dépendance SPM (XCFramework binaire). **Décision : Sparkle 2.** - -### 3.2 Livrable d'update : un zip dédié, le DMG reste pour l'humain - -Le DMG (fond stylisé, drag-to-Applications) reste le canal du **premier** téléchargement. Pour l'appcast, on publie en plus un **zip** créé par `ditto -c -k --sequesterRsrc --keepParent` (préservation des symlinks, exigée par la doc Sparkle pour ne pas casser la signature). Sparkle sait consommer un DMG, mais le zip est plus rapide à extraire, et c'est le format d'entrée des deltas `generate_appcast` si on les active plus tard. - -### 3.3 Hébergement de l'appcast - -| Option | Pour | Contre | -|---|---|---| -| **A. Asset de release, URL `releases/latest/download/appcast.xml`** ✅ | Zéro infra, même pipeline que le DMG, URL stable (redirection 302 suivie par Sparkle) | Retirer une release fautive = ré-uploader l'asset ; `latest` ignore les prereleases (à revisiter pour un canal beta) | -| B. Branche `gh-pages` + GitHub Pages | Feed indépendant des releases, patchable sans re-release | Une branche et un déploiement Pages à maintenir pour un fichier | -| C. Commit sur `main`, servi par raw.githubusercontent | — | CI qui pushe sur `main` : non | - -**Décision : A.** `SUFeedURL = https://github.com/Evariops/SpaceMatters/releases/latest/download/appcast.xml`. `generate_appcast` préserve les entrées d'un appcast existant : le CI télécharge l'appcast de la release précédente, ajoute la nouvelle entrée, ré-uploade le cumul — les anciennes URLs absolues (par tag) restent valides. - -### 3.4 Chaîne de confiance, quarantaine, notarisation - -- **EdDSA** : `generate_keys` (une fois, en local) → clé publique dans le plist (`SUPublicEDKey`), clé privée exportée (`generate_keys -x`) vers un secret GitHub `SPARKLE_ED_PRIVATE_KEY` (lu par `generate_appcast --ed-key-file -` sur stdin) **et sauvegardée hors GitHub** (trousseau + coffre) : la clé publique étant gravée dans toutes les apps installées, sa perte rend les clients existants incapables d'updater. -- Sparkle vérifie **EdDSA + la signature Apple** (l'update doit être signée par la même équipe que l'app en place) : même un compte GitHub compromis ne peut pas pousser une update acceptée sans la clé privée EdDSA. -- **Quarantaine** : le zip téléchargé par Sparkle reçoit `com.apple.quarantine` comme tout téléchargement ; Sparkle valide lui-même les deux signatures puis **retire l'attribut à l'installation** — l'app updatée relance sans re-passage Gatekeeper ni app translocation. C'est le mécanisme central que le check maison n'aurait pas. -- **Notarisation du zip quand même** *(piste B uniquement — sans compte Apple ce point tombe, et Sparkle n'en a pas besoin)* : le flux Sparkle ne l'exige pas (quarantaine levée), mais le même zip est téléchargeable à la main depuis la page Release. On notarise donc **l'app avant zippage** : `ditto` → `notarytool submit` du zip → `stapler staple` sur le `.app` → re-`ditto` du zip final → le DMG est ensuite construit à partir de l'app stapled (le flux DMG existant ne change pas, sa soumission devient quasi instantanée, contenu déjà ticketé). - -### 3.5 Release notes : le draft release-drafter, rendu par Sparkle - -Sparkle 2.9+ rend le **markdown** nativement (``). Aucun post-traitement à écrire : `generate_appcast` consomme un fichier **`.md` adjacent à l'archive** (même nom de base : `SpaceMatters-X.Y.Z.md`) et, avec `--embed-release-notes`, l'embarque tel quel en `` — **vérifié en exécutant l'outil** (§6.1). Le CI écrit ce fichier depuis `gh release view --json body`. Contrainte de rendu (source lue, §6.2) : c'est un rendu texte natif `NSAttributedString`, pas WKWebView — listes, liens et emphase passent ; tableaux et HTML brut non. Release-drafter ne produit que des listes à liens → compatible. Repli si le rendu déçoit visuellement : `` vers la page GitHub de la release. - -### 3.6 UX : consentement d'abord - -- **Instanciation** : `SPUStandardUpdaterController(startingUpdater: true, …)` détenu par `SpaceMattersApp` (chemin GUI uniquement — jamais dans les sous-commandes CLI de `Entry`). -- **Menu** : « Check for Updates… » via `CommandGroup(after: .appInfo)` dans le bloc `.commands` existant, activé/désactivé par `updater.publisher(for: \.canCheckForUpdates)` (petit `UpdaterModel` observable). -- **Check automatique** : on garde le comportement standard Sparkle — **prompt de consentement au second lancement**, aucun check avant accord. `SUEnableAutomaticChecks` volontairement absent du plist (le forcer court-circuiterait le prompt). Aligné avec l'ethos no-phone-home ; le README documente ce que fait le check (requête de l'appcast, pas de télémétrie). -- Pas de scène Settings en v1 : le prompt Sparkle + le menu suffisent ; un toggle dans une future scène Settings (`automaticallyChecksForUpdates`) viendra avec d'autres réglages. - -### 3.7 Identité de signature : auto-signée d'abord, Developer ID quand on voudra - -Décision produit : repousser le compte Apple Developer (99 $/an) au maximum, sans se fermer la porte. Deux pistes, **même architecture** — la chaîne de confiance des updates est EdDSA dans les deux cas, l'identité Apple n'est qu'un paramètre : - -**Piste A — maintenant, gratuite : certificat auto-signé stable.** -- Créer un certificat de code signing auto-signé « SpaceMatters » (Trousseau ▸ Assistant de certification, celui que [Packaging/bundle.sh:68-71](../Packaging/bundle.sh#L68-L71) documente déjà) — **validité longue (3650 jours)**, pas les 365 par défaut : à l'expiration, changer de certificat = nouvelle designated requirement = FDA re-demandé. -- L'exporter en `.p12` → secrets repo `MACOS_CERT_P12` / `MACOS_CERT_PASSWORD` (les **mêmes noms** que pour Developer ID : la piste B consistera littéralement à remplacer le contenu du secret). -- Signature **sans hardened runtime** (prouvé §6.1 : sans Team ID, la library validation tue l'app au lancement) ; pas de notarisation (impossible sans compte, et inutile : Sparkle lève la quarantaine des updates lui-même, §6.2). -- Ce que ça garantit : designated requirement stable (le cert est embarqué dans la signature) → **FDA persiste à travers les updates** ; identité acceptée par `generate_appcast` (l'ad-hoc passe, prouvé §6.1) et par la validation Sparkle côté client. -- Ce que ça coûte : premier install hostile sur macOS 15 (plus de clic-droit ▸ Ouvrir : Réglages ▸ Confidentialité ▸ « Ouvrir quand même »), à documenter honnêtement dans le README. Une fois installée, plus aucun passage Gatekeeper. - -**Piste B — plus tard, 99 $/an : Developer ID + notarisation.** -- Remplacer le `.p12` des secrets par le certificat Developer ID et ajouter `APPLE_ID`/`APPLE_TEAM_ID`/`APPLE_APP_SPECIFIC_PASSWORD` — le workflow est déjà conditionnel (`HAS_SIGNING`/`HAS_NOTARY`), la notarisation s'active seule. -- Réactiver `--options runtime` (conditionné dans les scripts : hardened runtime **si et seulement si** l'identité est « Developer ID Application: * »). - -**La transition A→B est sûre pour le parc installé** (source lue + confirmée le 2026-07-12) : `validateUpdateForHost` ([SUUpdateValidator.m:306]) tranche sur `passedDSACheck || passedCodeSigning` — l'EdDSA valide suffit, une update dont l'identité de code signing diffère de l'app en place est acceptée tant que sa propre signature est valide (le contrôle « même identité » n'est qu'un repli quand l'EdDSA manque). Notre clé EdDSA ne change pas → le parc auto-signé accepte une update Developer ID. Coût unique de la bascule : la designated requirement change → **FDA re-demandé une fois** par utilisateur ; à mentionner dans les notes de la release de transition. - -À adapter dans [release.yml](../.github/workflows/release.yml) : l'extraction d'identité ([release.yml:67-68](../.github/workflows/release.yml#L67-L68)) ne matche que `Developer ID Application:` **et** utilise `find-identity -v`, qui masque un cert auto-signé non trusté (constaté le 2026-07-12 : l'identité SpaceMatters apparaît `CSSMERR_TP_NOT_TRUSTED` sans `-v`, invisible avec, alors que `codesign` signe parfaitement avec). → dropper le `-v`, matcher toute identité de code signing, et conditionner `--options runtime` + notarisation au motif `Developer ID Application:`. - -### 3.8 Hors périmètre v1 - -- **Deltas binaires** : `generate_appcast` les produit gratuitement si on lui fournit les N zips précédents — à activer quand l'app grossira (aujourd'hui quelques Mo, gain marginal). -- **Canal beta** (`sparkle:channel`) — exigera de revisiter l'option A de §3.3 (`latest` ignore les prereleases). -- **Cask Homebrew** (`auto_updates true` une fois Sparkle en place). - -## 4. Plan d'implémentation - -1. **[Package.swift](../Package.swift)** : dépendance `.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0")`, produit `Sparkle` sur la cible exécutable, rpath `@executable_path/../Frameworks` via `linkerSettings` (le target utilise déjà `unsafeFlags`, pas de contrainte nouvelle). Dev et tests ne demandent **aucun autre réglage** : SwiftPM copie le framework à côté du binaire de build et pose un rpath `@loader_path` (vérifié §6.1). -2. **[Packaging/bundle.sh](../Packaging/bundle.sh)** : entre la copie du binaire et la signature — créer `Contents/Frameworks/`, y copier `Sparkle.framework` depuis `.build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/` (chemin constaté), **supprimer `Versions/B/XPCServices/`** (424 Ko, inutiles hors sandbox — §6.2) ainsi que `Headers/`, `PrivateHeaders/` et `Modules/` (224 Ko, inutiles à l'exécution) ; optionnel : `lipo -thin arm64` sur les trois Mach-O du framework (l'app est déjà distribuée arm64-only — `swift build` natif, runner CI arm64), gain ≈ 1 Mo installé / 0,4 Mo téléchargé ; ajouter `SUFeedURL` et `SUPublicEDKey` au heredoc du plist (**obligatoire avant de builder une release : sans `SUPublicEDKey` dans l'app archivée, `generate_appcast` n'émet pas de signature** — §6.1) ; remplacer le `codesign --deep` par une signature explicite inner→outer (`Autoupdate`, `Updater.app`, le framework, puis l'app) — même chose dans [Packaging/release.sh](../Packaging/release.sh#L36). ⚠️ **`--options runtime` seulement si l'identité est Developer ID** (piste B) : sans Team ID — ad-hoc dev comme certificat auto-signé de la piste A — la library validation tue l'app au lancement (`different Team IDs`, prouvé §6.1). -3. **Code Swift** : `UpdaterModel` (wrapping `SPUStandardUpdaterController` + `canCheckForUpdates` publié), instancié dans `SpaceMattersApp`, item de menu dans `.commands`. -4. **Clés** : ✅ **fait le 2026-07-12** — paire EdDSA générée (`generate_keys`) : privée dans le trousseau de session (item « Private key for signing Sparkle updates ») **et** secret repo `SPARKLE_ED_PRIVATE_KEY` ; publique à graver dans le heredoc de `bundle.sh` : - ```xml - SUPublicEDKey giAqx0Y+CIUuSvv4DDNfJnC/wF16Y71qi03XF3LZx30= - ``` - ✅ Sauvegarde coffre faite (2026-07-12). La clé privée existe en trois exemplaires : trousseau de session, secret repo, coffre. -5. **Certificat auto-signé (piste A, §3.7)** : ✅ **créé le 2026-07-12** (openssl : CN=SpaceMatters, RSA 2048, EKU code-signing critique, **valide jusqu'au 2036-07-09**), importé dans le trousseau de session, signature testée (`Authority=SpaceMatters`, designated requirement épinglée `certificate leaf = H"6ec027a9795823a6b14b4c84d209f2ebf16fe27f"`). `.p12` + mot de passe dans `~/Documents/SpaceMatters-signing-backup/` — **à mettre au coffre puis supprimer du disque** (cert perdu = FDA re-demandé chez tous les utilisateurs). ✅ Secrets repo `MACOS_CERT_P12` + `MACOS_CERT_PASSWORD` posés (2026-07-12) — l'inventaire piste A est complet avec `SPARKLE_ED_PRIVATE_KEY`. ✅ Extraction d'identité élargie (sans `-v`) et runtime/notarisation conditionnés au type d'identité dans `release.yml`. - > **Implémentation (2026-07-12)** : jalons 1–7 réalisés sur `feat/spec12-sparkle-updates` — scripts déménagés dans `Packaging/` (+ `make-appcast.sh` partagé CI/local), 102 tests verts, bundle release signé `SpaceMatters` vérifié strict + lancé, appcast CI câblé. Piège trousseau relevé au passage : un `.p12` importé en CLI n'a pas la partition list Apple → `codesign` prompte à chaque appel ; remède : `security set-key-partition-list -S apple-tool:,apple: -s ~/Library/Keychains/login.keychain-db` (une fois). -6. **[release.yml](../.github/workflows/release.yml)** : après la signature de l'app — zip `ditto -c -k --sequesterRsrc --keepParent`, notarisation du zip, staple de l'app, re-zip, puis flux DMG existant inchangé ; étape `generate_appcast` : télécharger l'appcast de la release précédente dans un dossier avec le seul nouveau zip + `SpaceMatters-X.Y.Z.md` (corps de `gh release view --json body`), lancer avec `echo "$SPARKLE_ED_PRIVATE_KEY" | generate_appcast --ed-key-file - --download-url-prefix "https://github.com/Evariops/SpaceMatters/releases/download/vX.Y.Z/" --embed-release-notes` — le cumul et la préservation des anciennes URLs sont prouvés (§6.1) ; garde `--maximum-versions` par défaut (3) ; upload du zip + `appcast.xml` en assets de release. Miroir dans `release.sh` pour les releases locales. -7. **README** : paragraphe transparence (ce que fait le check d'update, comment le couper) + parcours premier install piste A (Réglages ▸ « Ouvrir quand même »), honnête et illustré. - -## 5. Vérification - -- **Cycle complet en local** : servir un appcast forgé sur `http://localhost:8000` (`python3 -m http.server`, `SUFeedURL` de dev dans un bundle de test), installer un build vieilli → détection, notes markdown rendues, install, relance en version supérieure. -- **Quarantaine** : après une vraie update, `xattr -lr /Applications/SpaceMatters.app` ne montre aucun `com.apple.quarantine` ; l'app relance sans dialogue Gatekeeper. -- **FDA** : ✅ **validé en live le 2026-07-12**. Deux builds (v0.0.1 et v9.9.9) signés avec le cert stable « SpaceMatters » → **designated requirement identique** (`identifier "com.spacematters.app" and certificate leaf = H"6ec027a9…"`), preuve déterministe que TCC les traite comme le même client. Bout en bout : FDA accordé à la v0.0.1 depuis `~/Applications`, update Sparkle → v9.9.9, **le grant a persisté sans nouvelle autorisation**. C'était LE maillon non prouvé de la piste A ; il l'est. -- **Signature** : `codesign --verify --strict --verbose=2` sur l'app updatée. Piste B uniquement : `spctl -a -vv` (échoue par construction en piste A, non notarisée) et hardened runtime sur tous les Mach-O (le garde-fou est la notarisation). -- **Premier install piste A** : DMG téléchargé sur une session vierge → constater et documenter le parcours exact « Ouvrir quand même » de macOS 15 pour le README. -- **Appcast** : `curl -L …/releases/latest/download/appcast.xml` → XML valide, entrées cumulées, `sparkle:version` = commit count croissant entre deux releases. -- **Test réel de bout en bout** : machine avec vN-1 installée depuis le DMG public → publier vN → l'update arrive, s'installe, release notes conformes au draft release-drafter. - -## 6. Certitudes acquises & risques résiduels - -Toutes les hypothèses 🔬 de la première rédaction ont été levées le **2026-07-12**, par trois moyens : expérimentation réelle sur cette machine (Sparkle 2.9.4 ajouté au projet, buildé, testé ; outils `generate_appcast`/`sign_update` exécutés sur des releases simulées ; framework embarqué, signé et lancé), lecture de la source Sparkle clonée au tag 2.9.4, et requêtes sur le repo GitHub réel. `Package.swift` a été restauré après l'expérience — rien de tout ceci n'est commité. - -### 6.1 Prouvé par l'expérience - -- ✅ **XCFramework + SwiftPM : zéro friction.** Dépendance ajoutée à `Package.swift` : `swift build` OK, **`swift test` : 102 tests, 15 suites, tous verts, sans aucune configuration**. SwiftPM copie `Sparkle.framework` à côté du binaire de build et pose un rpath `@loader_path` — le binaire brut (`--volumes`) tourne aussi. L'hypothèse « rpath à régler pour les tests » était infondée. Framework : 3,0 Mo dont 424 Ko de XPC services ; outils dans `.build/artifacts/sparkle/Sparkle/bin/`. -- ✅ **Cumul d'appcast en CI.** Simulation complète de deux releases successives (archives fabriquées, `generate_appcast` réel) dans le scénario CI exact — appcast précédent + seule la nouvelle archive : l'entrée ancienne est **préservée à l'identique** (URL préfixée `v0.1.0` et `sparkle:edSignature` intactes malgré `--download-url-prefix …/v0.2.0/`), la nouvelle s'ajoute avec sa propre URL. `--maximum-versions` (défaut 3) borne le cumul. -- ✅ **Notes markdown embarquées sans post-traitement.** Fichier `.md` adjacent à l'archive + `--embed-release-notes` → `` en CDATA, contenu UTF-8 tel quel (constaté dans l'appcast généré). -- ✅ **Signature explicite inner→outer.** Framework réellement embarqué dans un bundle SpaceMatters (XPC supprimés), signé `Autoupdate` → `Updater.app` → framework → app : `codesign --verify --strict --verbose=2` passe, designated requirement satisfaite. -- ✅ **Piège dev identifié et remède prouvé.** Ad-hoc + `--options runtime` : `codesign` réussit **mais dyld tue l'app au lancement** — `mapping process and mapped file (non-platform) have different Team IDs` (library validation du hardened runtime). Re-signé ad-hoc **sans** runtime : l'app charge le framework embarqué et tourne. D'où la consigne ferme du §4.2 pour `bundle.sh`. En CI, Developer ID donne le même Team ID à l'app et au framework re-signé → la contrainte est satisfaite par construction. -- ✅ **Garde-fous de `generate_appcast`.** L'outil **refuse** une archive dont l'app n'est pas signée Apple valide (« failed Apple Code Signing checks », constaté sur une app non signée), et n'émet de `sparkle:edSignature` que si l'app archivée déclare `SUPublicEDKey` correspondant à la clé privée fournie (constaté : sans la clé dans le plist, appcast généré sans signature). Le CI ne peut donc pas publier par accident une update non signée ou signée avec la mauvaise clé — mais ça impose l'ordre du §4 : clé dans le plist **avant** la première release Sparkle. -- ✅ **Format de clé CI.** `--ed-key-file -` accepte le format seed (base64 de 32 octets) sur stdin — testé avec `sign_update` et `generate_appcast` (signature émise et cohérente entre les deux outils). -- ✅ **URL stable `latest/download`.** `releases/latest/download/SpaceMatters-0.3.0.dmg` sur le repo réel : 302 suivi → 200 sur le CDN GitHub. Sparkle (NSURLSession) suit les redirections. - -### 6.2 Prouvé par la source Sparkle 2.9.4 - -- ✅ **Levée de quarantaine** : première étape de l'installation — `SUPlainInstaller.m:50` appelle `releaseItemFromQuarantineAtRootURL` ; implémentation `SUFileManager.m:120` : suppression **récursive** de l'xattr `com.apple.quarantine` (échec non fatal, loggé). Aucune dépendance au sandbox ni au hardened runtime. Le test `xattr` de §5 reste comme confirmation de bout en bout, pas comme hypothèse. -- ✅ **Double validation des updates** : `SUUpdateValidator.m:162` — pour un bundle d'app, EdDSA **et** signature Apple sont vérifiées ; le repli « code signing seul » n'existe que si l'EdDSA échoue et exige alors le **même Team ID Developer ID que l'app en place** (`codeSignatureIsValidAtDownloadURL:andMatchesDeveloperIDTeamFromOldBundleURL:`, `SUUpdateValidator.m:84`). -- ✅ **Markdown** : `SUAppcastItem.m:533` accepte `sparkle:format` ∈ {plain-text, markdown, html} (défaut html) ; `SUUpdateAlert.m:189` route markdown vers `SUTextViewReleaseNotesView` (rendu natif `NSAttributedString`, API macOS 12+ — on cible 15). Listes, liens, emphase, code : oui ; tableaux GFM et HTML brut : non. Compatible release-drafter (listes à liens). -- ✅ **XPC services opt-in** : `SPUXPCServiceInfo.m` — utilisés uniquement si l'app hôte déclare `SUEnableInstallerLauncherService`/`SUEnableDownloaderService`/… dans son plist. Absents chez nous → suppression de `XPCServices/` (424 Ko) sans aucun effet fonctionnel. -- ✅ **Consentement avant tout réseau** : `SPUUpdater.m:415` — le prompt d'autorisation attend le **second lancement** (`SUPromptUserOnFirstLaunchKey` pour forcer au premier) et aucun check automatique ne part avant accord. Conforme à l'ethos no-phone-home. - -### 6.3 Risques résiduels (réels, assumés) - -- **Piste A — un maillon restant au premier run CI** : `codesign` avec le cert auto-signé importé dans le keychain éphémère du CI (l'import `.p12` du workflow est rodé pour Developer ID ; un cert auto-signé peut exiger un réglage de confiance — à constater). La persistance FDA, elle, est **prouvée** en live (§5). -- **Piste B (le jour venu)** : le passage `notarytool` sur l'app avec Sparkle embarqué ne se prouvera qu'au premier run CI avec les secrets Apple. Confiance haute (hardened runtime partout, même Team ID) ; et la bascule coûte un re-grant FDA unique par utilisateur (§3.7). -- **Apparence des notes à l'écran** : le sous-ensemble markdown supporté est connu (source lue), mais le rendu visuel se juge en pilotage live (§5) — repli `releaseNotesLink` prêt. -- **Perte de la clé EdDSA** (opérationnel, permanent) : clé publique gravée dans toutes les apps installées → la sauvegarde coffre du §4.4 n'est pas optionnelle. -- **Poids** (mesuré sur l'app release réelle, zips `ditto`) : app installée 4,5 Mo → 6,9 Mo (**+2,4 Mo**, framework universel sans XPC/headers) ; téléchargement 1,4 Mo → 2,3 Mo (**+0,9 Mo**). Avec `lipo -thin arm64` (cohérent : l'app est arm64-only) : installé **+1,4 Mo**, téléchargé **+0,5 Mo**. Accepté — c'est le prix de l'installer durci. -- Prérequis externe inchangé depuis SPEC-07 : secrets Developer ID + notarisation configurés dans le repo pour que le flux CI signe réellement. - -## 7. Effort & dépendances - -**1–2 jours** (intégration + CI + tests réels sur deux releases). Dépend de SPEC-07 (✅ livré) et des secrets de signature CI (prérequis externe). Indépendant du reste. From 6d2772927fb04b652966db33aeff8e7a7f0bfde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Sun, 12 Jul 2026 21:41:17 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(spec):=20corrige=20les=203=20points=20?= =?UTF-8?q?r=C3=A9siduels=20(liens/glyphe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README : supprime le lien mort ../../PLAN-ACTION.md (fichier retiré au commit 'cleanup'), garde « action plan » en texte - SPEC-02 : retire le glyphe parasite ■ (bytes/■counts → bytes/counts) - SPEC-12 : [SUUpdateValidator.m:306] (crochets sans cible) → code inline, cohérent avec les refs :162/:84 Tous les liens relatifs résolvent désormais depuis docs/specs/ (72/72). --- docs/specs/README.md | 2 +- docs/specs/SPEC-02-invalidation-rescan.md | 2 +- docs/specs/SPEC-12-auto-update.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/specs/README.md b/docs/specs/README.md index a7a73c9..fd421f4 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -1,6 +1,6 @@ # Specifications by workstream — MacDirStats -Each `SPEC-*.md` is a self-contained requirements document for a deferred workstream from the [action plan](../../PLAN-ACTION.md), written to be picked up in a dedicated session. Common format: +Each `SPEC-*.md` is a self-contained requirements document for a deferred workstream from the action plan, written to be picked up in a dedicated session. Common format: 1. **Objective & findings covered** — the why, with references to the audits. 2. **Current state of the code (verified)** — what exists, cited `file:line`. diff --git a/docs/specs/SPEC-02-invalidation-rescan.md b/docs/specs/SPEC-02-invalidation-rescan.md index 2544959..5de60ca 100644 --- a/docs/specs/SPEC-02-invalidation-rescan.md +++ b/docs/specs/SPEC-02-invalidation-rescan.md @@ -23,7 +23,7 @@ Replace the **manual surgery** of the aggregates in `remove()` with a **targeted ## 3. The crux of the problem (A6) -`extStats: [ExtKey: ExtStat]` is **global to the scan**, built by accumulation during the scan. After deletion of a sub-tree, it still contains the bytes/■counts of the deleted files. Three ways out: +`extStats: [ExtKey: ExtStat]` is **global to the scan**, built by accumulation during the scan. After deletion of a sub-tree, it still contains the bytes/counts of the deleted files. Three ways out: - **Store the extensions table per node**: unbounded memory cost (the opposite of the "1 node/directory, low-RAM" stance). ❌ - **Re-scan the deleted sub-tree *before* deletion to know its contributions and subtract them**: possible but fragile (double work, race with the scan). ⚠️ diff --git a/docs/specs/SPEC-12-auto-update.md b/docs/specs/SPEC-12-auto-update.md index 5049ef0..cd754d3 100644 --- a/docs/specs/SPEC-12-auto-update.md +++ b/docs/specs/SPEC-12-auto-update.md @@ -75,7 +75,7 @@ Product decision: defer the Apple Developer account (99 $/year) as long as possi - Replace the secrets' `.p12` with the Developer ID certificate and add `APPLE_ID`/`APPLE_TEAM_ID`/`APPLE_APP_SPECIFIC_PASSWORD` — the workflow is already conditional (`HAS_SIGNING`/`HAS_NOTARY`), notarization activates on its own. - Re-enable `--options runtime` (conditioned in the scripts: hardened runtime **if and only if** the identity is "Developer ID Application: *"). -**The A→B transition is safe for the installed base** (source read + confirmed on 2026-07-12): `validateUpdateForHost` ([SUUpdateValidator.m:306]) decides on `passedDSACheck || passedCodeSigning` — a valid EdDSA suffices, an update whose code-signing identity differs from the installed app is accepted as long as its own signature is valid (the "same identity" check is only a fallback when EdDSA is missing). Our EdDSA key does not change → the self-signed base accepts a Developer ID update. One-time cost of the switch: the designated requirement changes → **FDA re-requested once** per user; to mention in the notes of the transition release. +**The A→B transition is safe for the installed base** (source read + confirmed on 2026-07-12): `validateUpdateForHost` (`SUUpdateValidator.m:306`) decides on `passedDSACheck || passedCodeSigning` — a valid EdDSA suffices, an update whose code-signing identity differs from the installed app is accepted as long as its own signature is valid (the "same identity" check is only a fallback when EdDSA is missing). Our EdDSA key does not change → the self-signed base accepts a Developer ID update. One-time cost of the switch: the designated requirement changes → **FDA re-requested once** per user; to mention in the notes of the transition release. To adapt in [release.yml](../../.github/workflows/release.yml): the identity extraction ([release.yml:67-68](../../.github/workflows/release.yml#L67-L68)) only matches `Developer ID Application:` **and** uses `find-identity -v`, which masks an untrusted self-signed cert (observed on 2026-07-12: the SpaceMatters identity appears as `CSSMERR_TP_NOT_TRUSTED` without `-v`, invisible with it, whereas `codesign` signs perfectly with it). → drop the `-v`, match any code-signing identity, and condition `--options runtime` + notarization on the `Developer ID Application:` pattern.