From 06195e425f0c8b122c6a8ac0c1cbc6026ad840e2 Mon Sep 17 00:00:00 2001 From: Melu Date: Mon, 12 Oct 2026 05:07:14 -0300 Subject: [PATCH 01/16] =?UTF-8?q?docs:=20propose=20shell=20surfaces=20?= =?UTF-8?q?=E2=80=94=20taskbar=20+=20Start=20menu=20styling=20(ADR-0024=20?= =?UTF-8?q?+=20plan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/decisions/0024-shell-surfaces.md | 82 +++++++ .../2026-10-12-taskbar-startmenu-surfaces.md | 220 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 docs/decisions/0024-shell-surfaces.md create mode 100644 docs/plans/2026-10-12-taskbar-startmenu-surfaces.md diff --git a/docs/decisions/0024-shell-surfaces.md b/docs/decisions/0024-shell-surfaces.md new file mode 100644 index 00000000..edaf2a52 --- /dev/null +++ b/docs/decisions/0024-shell-surfaces.md @@ -0,0 +1,82 @@ +# ADR-0024 — Shell surfaces: taskbar pins and Start menu shortcuts are styleable + +- **Status**: **Proposed** (fork draft, 2026-10-12) — NOT owner-disposed. Written per the + 2026-07-21 community-proposal panel record: fork contributions state touched spec + sections up front; this ADR IS that statement for the scan/participation/UI delta. +- **Amends**: **ADR-0015 D7** ("The simulated taskbar is generic-but-photoreal scenery… + never impersonates the user's real taskbar, never gains interactivity"), **spec 06 §4** + ("The taskbar is scenery: zero interactivity… never impersonates the user's own + taskbar"), **spec 06 §4/§3** where the decorative `TASKBAR_PINNED` fixture is normative, + and the review acceptance line (2026-07-09-icon-frontend-panel.md Q7: "taskbar stays + original-Windows scenery, styling never applies to it"). +- **Does NOT touch**: ADR-0021 (arrow overlay — already global), ADR-0022 (consent ladder — + this feature walks it unchanged), spec 07 (resident stays desktop-only until its own + consent-gated follow-up). + +## Context + +1. **The user ask is real and public**: upstream issue #8 (open, enhancement) — restyled + desktop icons still show original icons on the taskbar. Windows resolves a taskbar + button's icon from the PINNED shortcut + (`%APPDATA%\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\*.lnk`) or the + running exe's resources — never from the desktop `.lnk` DeskMakeover wrote. Start menu + entries are a third independent copy of the same shortcut with the same problem. +2. **Scenery was a scope decision, not a platform limit**: D7 bounded the simulated + taskbar so the mirror stays honest OS chrome. The platform limit it avoided — + impersonation — is about *fake Windows chrome*, not about *our own tiles on it*. The + write lever (`.lnk` `IconLocation`) is the exact lever the desktop path already owns, + with the exact same snapshot/ledger/CAS/restore envelope. +3. **The machinery is already kind-agnostic**: a pinned/start `.lnk` is an ordinary + `ItemKind::Shortcut` — `apply/shortcut.rs` → `shell_link::set_icon_location`, + `RestoreAnchor::FileBytes` byte-literal replay, per-item txn CAS. `ScopeRoots` already + classifies `ProgramData` ancestry as `PrivilegedScope::ProgramData`, so the all-users + Start menu rides the existing elevated batch (one disclosed UAC). No new writer, no new + privilege, no new recovery path. +4. **No supported lever exists for exe resources**: rewriting `app.exe` icons breaks + Authenticode and leaves the reversibility envelope (a restored `.lnk` is byte-replay; a + rewritten exe is not). Running-window buttons that Windows draws from the exe may keep + the original while the app runs — an honest, documented limitation, verified on-box + (`[WINDOWS-VERIFY]`, plan §9), not a broken promise. + +## Decision + +1. **Three surfaces, one scan**: `IconItemDto` gains `surface: "desktop" | "taskbar" | + "startMenu"` (+ `parent` for Start-menu folder grouping), bridge schema 10 → 11. + Roots resolve through `known_folders.rs` known-folder + labeled suffix (pin folder, + `FOLDERID_Programs`, `FOLDERID_CommonPrograms`); Start menu walks recursively (depth + cap, reparse skip). Desktop behavior is untouched. +2. **Participation, not look**: a persisted `surfaces: { taskbar, startMenu }` axis rides + the module state beside `kindPolicy` (default **OFF — opt-in**), whitelisted by the + normalizers on BOTH bridge sides, and never in `ConfigDto` (presets/history stay clean, + same rule as spec 06 §6 kindPolicy). Cascade: `styleable:false > per-icon override > + kindPolicy > surface gate`. +3. **D7 amended, scenery preserved**: the strip's TRAY/clock/Start-flag chrome stays + decorative and non-interactive; what changes is that the pinned row renders the USER'S + REAL pins as ordinary tiles (hover try-on, owned-verb menu, exception badges — the §3 + contract), and a canvas-toolbar surface chip opens a Start-panel mock listing real + Start-menu items. The mirror never impersonates a Windows surface the user does not + have: it shows OUR tiles over the scenery backdrop, clearly our chrome. +4. **Bake/apply/restore/refresh unchanged**: same chunked bake, same per-item CAS txns, + same `restart_shell()` + `purge_icon_caches()` refresh (proven taskbar repaint path), + `icons.restore()` covers all surfaces by ledger walk. ConsentSheet discloses the + per-surface counts + "shortcut icons only, never the apps' files" (i18n en + zh-hans + lockstep). +5. **Never**: exe resource rewriting · UWP package-asset edits (spec 06 §6 stands) · + per-surface looks (v2 parking lot) · resident auto-format on new surfaces without its + own consent gate · any new privileged surface beyond the already-classified + `ProgramData` Start menu. + +## Consequences + +- Spec 06 §4 + ADR-0015 D7 text updated with the code that enforces this ADR (same-commit + rule); spec 06 §3 tile contract applies verbatim to the new surfaces, §6 taxonomy + unchanged (pinned/start entries classify by target like any `.lnk`). +- The pinned row of the mirror is now partly user-data: a user with zero pins sees the + decorative fixture (honest), a user with pins sees them styled live. +- Issue #8 can be answered with a shipped mechanism instead of a partial accident. +- Upstream merge risk is structural, not technical: per the 2026-07-21 record, expect + deferral behind write-surface reliability; the fork carries the feature and the PRs + split spec-scoped (contracts+scan · store+UI · docs) when the owner is ready. +- New failure surface is bounded: pin/start entries are ordinary `.lnk`s in + ordinary-privilege (user) or already-existing-privileged (ProgramData) scopes; a + locked pin fails its own per-item txn, never the batch (existing contract, G12). diff --git a/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md b/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md new file mode 100644 index 00000000..434deee2 --- /dev/null +++ b/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md @@ -0,0 +1,220 @@ +--- +plan: taskbar-startmenu-surfaces +created: 2026-10-12 +owner-approved: none — fork draft (ADR-0024 status: Proposed, NOT owner-disposed) +status: proposed +touches: dm-domain, dm-contracts, dm-windows, dm-operations, src-tauri, src +--- + +# Style taskbar pins and Start menu shortcuts (shell surfaces) + +## Problem + +DeskMakeover styles ONLY the desktop. `WindowsScanner::scan_blocking` +(`dm-windows/src/shell/scan.rs`) reads the two desktop roots + Recycle Bin + +CLSID virtuals and nothing else. The community ask (upstream issue #8, open +enhancement): icons restyled on the desktop still show the ORIGINAL icon on the +taskbar — Windows resolves a taskbar button's icon from the PINNED shortcut +(`%APPDATA%\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\*.lnk`) +or from the running exe's resources, never from the desktop `.lnk` DeskMakeover +wrote. Start menu entries are a third independent copy of the same shortcut with +the same problem. There is no supported override API for running-window icons +(see §Non-goals); the levers that EXIST are the shortcut `IconLocation` writes we +already own. + +Dev-box inventory (2026-10-12, the verification corpus): 3 taskbar pins (Brave, +File Explorer, Windows PowerShell) · 20 user Start menu `.lnk` across 6 +subfolders · 72 all-users Start menu `.lnk` · 5 desktop items. + +The write machinery does NOT need a new applier: a pinned/start `.lnk` is an +ordinary `ItemKind::Shortcut` and rides `apply/shortcut.rs` → +`shell_link::set_icon_location`, `RestoreAnchor::FileBytes`, the CAS ledger and +the existing elevated batch (a `ProgramData` ancestry already classifies +`PrivilegedScope::ProgramData`). The delta is SCAN + CONTRACT + PARTICIPATION + +MIRROR, plus the ADR-0024 amendment reversing the "taskbar is scenery" clauses. + +## Design + +### Surface model — one scan, three labeled surfaces + +`IconItemDto` gains `surface: SurfaceDto` (`"desktop" | "taskbar" | +"startMenu"`, `#[serde(default)] = desktop` for downgrade tolerance) and +`parent: String` (Start menu subfolder relative path, `""` at root; `""` for +desktop/taskbar). Bridge schema **10 → 11**; regenerate bindings. + +Identity stays `ItemId::from_source_path("filesystem", path)` — a pin and a +desktop shortcut of the same app are TWO items with independent fingerprints, +ledger rows and masters. The content-addressed store dedupes the pixels: same +look ⇒ same `.ico` hash ⇒ one asset, N ledger rows. Display-name duplication is +expected (both `Brave`), and is exactly why positions stop being name-matched +across surfaces (§Mirror). + +Scan roots, all resolved through `known_folders.rs` (`SHGetKnownFolderPath` + +labeled suffix, same rule as `desktop_roots_labeled` — never a full hardcoded +path): + +| Surface | Root | Walk | +|---|---|---| +| desktop | existing | flat (unchanged) | +| taskbar | `FOLDERID_RoamingAppData` + `\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar` | flat | +| startMenu | `FOLDERID_Programs` (user) + `FOLDERID_CommonPrograms` (all-users) | RECURSIVE, depth cap 8, skip reparse points | + +The pin folder itself is Hidden+System; the EXISTING per-entry checks +(`is_ignored_entry`, `has_system_attribute`) run on entries INSIDE a root and +stay as-is — no bypass, entries are ordinary `.lnk`s. `desktop.ini` skipping +already applies. Common-Start-menu items classify `PrivilegedScope::ProgramData` +by ancestry → they ride `apply_privileged_batch` (kind `Shortcut`/`AppxShortcut` +covers every Start menu `.lnk`) → ONE UAC for the batch, disclosed by the +existing consent sheet. No new elevation code. + +All surfaces ALWAYS scan (mirroring kindPolicy semantics): a disabled surface +renders its tiles as-original and ships no master (RESTORE-FIRST); enabling a +surface never requires a rescan. + +### Participation axis — `surfaces`, orthogonal to `kindPolicy` + +```ts +surfaces: { desktop: true /* always-on, not user-toggleable */, + taskbar: boolean, // default FALSE (opt-in) + startMenu: boolean } // default FALSE (opt-in) +``` + +Persisted through the SAME channel as `kindPolicy` (`icons.setLook` → +`IconPersistedDto` → module state), and — same rule as spec 06 §6 — **never in +`ConfigDto`** (surfaces are participation, not look; presets/history must stay +clean). Normalizers whitelist the key on BOTH bridge sides (G11: a closed +whitelist — `src/lib/kind-policy.ts` + the Rust normalizer in +`dm-operations/settings_store.rs`), or the key is silently dropped. + +Cascade for a tile's effective config (outermost first): + +``` +styleable:false > per-icon override > kindPolicy bucket > surface gate +``` + +The surface gate is the outermost PARTICIPATION gate: a disabled surface's +items never enter `bakeSet`. Bake set (frontend `stores/icons.ts apply()`): + +``` +items.filter(i => surfacesOn(i.surface) + && kindParticipates(bucket(i)) + && !effectiveTileConfig(i).showOriginal) +``` + +…with `restoreIds` for kept-original overrides unchanged. + +### Mirror — real pins in the strip, a Start panel view + +- **Taskbar strip**: the decorative `TASKBAR_PINNED` fixture entries + (`taskbar-icons.tsx`, 7 hardcoded glyphs) are REPLACED by the real scanned + pins (styled previews through the normal Tile renderer — app chrome, NOT the + OS-mirror palette; tray/clock/Start-flag scenery stays untouched). Pins with + `styleable:false` render full-fidelity original (honest, never dimmed) with + no context menu — existing §3.5 rule. +- **Start menu**: a new surface chip in the canvas toolbar (`桌面 | 任务栏 | + 开始菜单` → `Escritorio | Barra de tareas | Menú Inicio`) swaps the canvas to + a Start-panel mock that lists the REAL Start menu items (grouped by `parent`) + as ordinary tiles. The switch is OUR chrome (canvas toolbar), so the + decorative Start flag itself stays non-interactive — D7's "no interactivity" + is preserved for scenery; only tiles gain behavior. +- **Tile behavior is uniform across surfaces**: hover try-on, press-to-peek, + owned-verb context menu (保留原样 / 跟随全局 / 单独配色), exception badges, + undo/redo steps. Position slots: `live_slots` name-matching in + `icon_host/scan.rs` applies **only to `surface == desktop`** (a desktop `Brave` + and a pinned `Brave` would otherwise share one slot — G2); taskbar/startMenu + tiles lay out by strip order / `parent` grouping in the component. + +### Apply, refresh, restore — unchanged machinery + +- Chunked bake (`applyBakedBegin/Chunk/Commit`), session caps, per-item txns, + CAS anchors at scan fingerprint, journal + ledger + recovery: **zero changes**. +- Refresh after a mutating commit keeps `restart_shell()` + + `purge_icon_caches()` (the proven path; `SHChangeNotify` alone does not repaint + the taskbar) — the existing test + `every_desktop_mutating_op_restarts_the_shell_but_an_idempotent_reapply_does_not` + keeps its intent. ConsentSheet copy gains the per-surface counts (i18n + en + zh-hans in lockstep): "also styles N taskbar pins and M Start menu + shortcuts — your shortcuts' icons only, never the apps' files". +- `icons.restore()` walks every ledger row regardless of surface — one-click + full restore covers the new surfaces with no code. + +### Non-goals (v1) + +- **Exe resource rewriting — never.** Editing `app.exe` icons breaks + Authenticode signatures and is out of the reversibility envelope (a restored + `.lnk` is byte-replay; a rewritten exe is not). Running-window buttons whose + Windows renders from the exe keep the original icon while the app runs — + documented honestly `[WINDOWS-VERIFY]` (the issue-#8 field report shows pins + DO repaint for many apps after shell restart). +- **UWP package assets** stay immutable (shortcut-level styling only — + existing spec 06 §6 rule). +- **Resident/background auto-format** (spec 07) keeps desktop-only; surfaces + join it in a follow-up behind the same consent ladder. +- **Per-surface looks** (different style per surface) — v2 parking lot. +- Windows' own taskbar chrome (tray icons, running indicators) — scenery forever. + +## Ports / DTOs (dm-domain, dm-contracts) + +```rust +// dm-contracts/src/icons.rs +#[derive(Default)] #[serde(rename_all = "camelCase")] +pub enum SurfaceDto { #[default] Desktop, Taskbar, StartMenu } // serde literals tested +// IconItemDto += surface: SurfaceDto (default), parent: String (default "") +// BRIDGE_SCHEMA_VERSION 10 → 11 (src/bridge/types.ts), bindings regenerated +``` + +No new ports: scan is a `WindowsScanner` internal; apply/restore/scope reuse +every existing port. + +## Task list + +1. **Docs**: ADR-0024 (amends ADR-0015 D7 · spec 06 §4 · spec 01 canvas clause) + + this plan — the only commit that lands before code. +2. **dm-contracts**: `SurfaceDto` + `IconItemDto.{surface,parent}` + serde + defaults + literal tests; schema 11. `bun run gen:bindings` + + `cargo test -p deskmakeover-desktop --test bindings` green. +3. **dm-windows**: `known_folders.rs` roots (`taskbar_pin_root`, + `start_menu_roots_labeled`) + `scan.rs` surface-labeled walk (flat pin, + recursive start menu, depth cap, per-root label); `classify_entry` unchanged; + unit tests on fs fixtures (pin folder, nested start folders, desktop.ini in + roots, reparse skip). +4. **src-tauri `icon_host`**: surface-aware assembly — `live_slots` + + `synthetic_layout` desktop-only; other surfaces emit `surface`/`parent` + (x/y = 0, frontend-owned layout); persisted `surfaces` state through + `getPersisted`/`setLook` with the Rust-side normalizer whitelist. +5. **dm-operations**: normalizer whitelist for `surfaces` (settings_store); + confirm `scope.classify` ancestry hit for CommonPrograms paths (test: + `C:\ProgramData\Microsoft\Windows\Start Menu\Programs\…` → `ProgramData`); + NO other logic changes. +6. **Frontend store**: `surfaces` state + setters + normalizers (G11, both + sides), bakeSet gate, undo steps for surface flips (one step each), draft + snapshot/resume (surfaces ride the same draft as kindPolicy). +7. **Frontend UI**: participation rows (「参与美化的类型」 grows a SURFACES + group with live counts), canvas surface chips, strip real-pin tiles, Start + panel mock view, i18n en + zh-hans lockstep. +8. **Gates**: `bun test` · `bun run lint` · `tsc -b` · `cargo test --workspace` + · bindings drift · banned-colors allowlist (expect NO change: styled tiles + ride the shared Tile component, strip files keep only scenery literals — + confirm, adjust the allowlist only if the test scans rendered subtree). +9. **[WINDOWS-VERIFY] live pass** (owner-supervised, per + `docs/verification/`): scan shows 3+20(+72) · preview mirrors pins · apply → + pins repaint after shell restart (running vs cold apps noted honestly) · + Start menu entries restyle (user + all-users UAC batch) · hand-edit a pin → + conflict, untouched · restore → byte-identical originals (FileBytes) · + uninstall residue check. +10. **Upstream**: per the 2026-07-21 community-proposal panel record — spec-scoped + PR splits (contracts+scan · store+UI · docs), each PR states touched spec + sections + gates run + the 5 owner invariants; adversarial review on + ledger/CAS/parity. Expect deferral (write-surface reliability is the owner's + dominant ship risk) — the fork carries the feature until then. + +## Follow-ups (documented, out of v1) + +- Resident auto-format covers the new surfaces (spec 07 §8.2 consent ladder). +- Per-surface style variants (v2 parking lot). +- `keepNewIconsStyled`-equivalent for pins created AFTER apply (the resident + reconciler's watcher root gains the pin folder). +- Running-app exe-icon honesty: a "this app shows its .exe icon while running" + badge once [WINDOWS-VERIFY] measures which app classes repaint. +- Common Start Menu per-item UAC-free path if the helper grows per-surface + batching (today: one batched UAC, already disclosed). From 1487e76ad10e520b5cbd03c154bfe4c81cea697c Mon Sep 17 00:00:00 2001 From: Melu Date: Mon, 12 Oct 2026 06:26:45 -0300 Subject: [PATCH 02/16] feat(icons): style taskbar pins + Start menu shortcuts (ADR-0024, fork draft) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan + wire three shell surfaces behind a second, OPT-IN participation axis (surfaces: {taskbar, startMenu}, all-OFF default — consent is never inherited; kindPolicy semantics inverted on purpose). The desktop stays always-on and kindPolicy-governed. - dm-contracts: SurfaceDto + IconItemDto.{surface,parent} (serde defaults); bridge schema 10->11; bindings regenerated. - dm-windows: taskbar pin root + both Start menu roots; surface-labeled walk (flat pin, recursive Start menu with parent grouping, depth cap 8, reparse skip); fs-fixture tests. - dm-operations: recipe surfaces parsed in style_resolve + gated in effective_config (one participation authority); scope classify test for Common Start menu -> ProgramData; settings_store round-trip preservation test (G11). - src-tauri: surface-aware icon_host assembly; live_slots stays desktop-only; non-desktop roots are best-effort (only the user desktop is mandatory/loud). - Store: surfaces rides the recipe draft (4th knob; G11 whitelist on both bridge sides), surface gate inside effectiveTileConfig so disabling a surface auto-restores it on the next apply (RESTORE-FIRST), setSurface with one undo step per flip, history/preset/snapshot paths carry it. - UI: SURFACES participation group with live counts, canvas surface chips (Desktop | Taskbar | Start menu), real pins rendered in the taskbar strip via the shared tile renderer, Start panel mock grouped by parent, per-surface consent copy; i18n en + zh-hans in lockstep. - Resident/reconciler stays hard desktop-only (spec 07 scan filter unchanged). Gates: bun test 670/0 · bun run lint · tsc -b · cargo test --workspace · bindings drift · banned-colors. Live [WINDOWS-VERIFY] pass pending (plan task 9). ADR-0024 status: Proposed — NOT owner-disposed. --- crates/dm-contracts/src/icons.rs | 48 +++ crates/dm-contracts/src/lib.rs | 2 +- crates/dm-domain/src/item.rs | 26 ++ crates/dm-domain/src/lib.rs | 2 +- crates/dm-operations/src/icons/scope.rs | 20 + .../dm-operations/src/icons/style_resolve.rs | 142 +++++-- crates/dm-operations/src/icons/tests.rs | 6 +- .../dm-operations/src/icons/version_switch.rs | 12 +- crates/dm-operations/src/settings_store.rs | 22 ++ crates/dm-resident/src/driver/tests.rs | 4 +- crates/dm-resident/src/reconciler/mod.rs | 16 +- crates/dm-resident/src/reconciler/tests.rs | 4 +- crates/dm-windows/src/shell/known_folders.rs | 35 +- crates/dm-windows/src/shell/scan.rs | 374 ++++++++++++++---- scripts/oracle/desktop-session.ts | 2 + src-tauri/src/devhost_icons.rs | 6 +- src-tauri/src/icon_host/dto.rs | 17 +- src-tauri/src/icon_host/scan.rs | 17 +- src/bridge/generated.ts | 15 + src/bridge/mock-desktop.ts | 2 + src/bridge/tauri.ts | 11 +- src/bridge/types.ts | 34 +- src/components/canvas/icons-mirror.tsx | 89 ++++- src/components/canvas/icons-tile.tsx | 39 +- src/components/canvas/start-panel.tsx | 247 ++++++++++++ src/components/canvas/surface-chips.tsx | 40 ++ src/components/canvas/taskbar-strip.tsx | 39 +- src/components/common/ceremony.tsx | 18 + src/components/panels/icons-panel.tsx | 5 + src/components/panels/icons-participation.tsx | 76 +++- src/lib/i18n/en.ts | 11 + src/lib/i18n/zh-hans.ts | 11 + src/lib/icon-look.ts | 19 +- src/lib/icons-assemble.ts | 11 +- src/lib/kind-policy.ts | 46 ++- src/stores/icons.ts | 73 +++- tests/arrow-restore-integration.test.ts | 1 + tests/icon-look.test.ts | 20 + tests/icons-store.test.ts | 59 ++- 39 files changed, 1452 insertions(+), 169 deletions(-) create mode 100644 src/components/canvas/start-panel.tsx create mode 100644 src/components/canvas/surface-chips.tsx diff --git a/crates/dm-contracts/src/icons.rs b/crates/dm-contracts/src/icons.rs index dc3f0033..9298c1fb 100644 --- a/crates/dm-contracts/src/icons.rs +++ b/crates/dm-contracts/src/icons.rs @@ -48,6 +48,19 @@ pub enum IconKindDto { Unsupported, } +/// WHICH shell surface an item was observed on (ADR-0024, fork draft): the desktop grid, a +/// taskbar pin (`…\User Pinned\TaskBar\*.lnk`), or a Start menu shortcut. Serialized camelCase +/// to match the TS union `'desktop' | 'taskbar' | 'startMenu'`. `#[serde(default)]` reads an +/// absent field as `Desktop`, so a downgraded/older host degrades to the classic surface. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SurfaceDto { + #[default] + Desktop, + Taskbar, + StartMenu, +} + /// One desktop item as the scanner observed it — raw platform truth ONLY (no override, no style). /// The frontend overlays its own draft per-icon overrides + renders the styling locally from /// `sourceUrls` (256px, `[0]` primary; the Recycle Bin ships TWO — empty + full). Positions are @@ -66,6 +79,13 @@ pub struct IconItemDto { /// error detail) is `\t`-joined with it: `"Icons_Reason_ExtractFailed\t"`. The host has /// no locale, so it must never emit display text here (an English user saw Chinese otherwise). pub status_reason: Option, + /// Which shell surface this item was observed on (ADR-0024). Absent ⇒ `desktop`. + #[serde(default)] + pub surface: SurfaceDto, + /// Start menu subfolder RELATIVE to the programs root (`""` at the root; always `""` for + /// desktop/taskbar items) — the mirror groups Start menu tiles by it. Absent ⇒ `""`. + #[serde(default)] + pub parent: String, pub x: i32, pub y: i32, /// The item's 256px source URL(s) the compositor renders from; `[0]` is primary. The Recycle @@ -218,6 +238,29 @@ mod tests { assert_eq!(serde_json::from_str::("\"hidden\"").unwrap(), ArrowOverlayDto::Hidden); } + #[test] + fn surface_serializes_as_the_ts_literals() { + for (variant, literal) in [ + (SurfaceDto::Desktop, "\"desktop\""), + (SurfaceDto::Taskbar, "\"taskbar\""), + (SurfaceDto::StartMenu, "\"startMenu\""), + ] { + assert_eq!(serde_json::to_string(&variant).unwrap(), literal); + assert_eq!(serde_json::from_str::(literal).unwrap(), variant); + } + } + + #[test] + fn a_pre_surface_item_json_reads_as_desktop_with_an_empty_parent() { + // A schema-10 payload (no `surface`/`parent` keys) must deserialize to the classic + // surface — the fields are additive wire data, never a breaking shape change + // (ADR-0024; downgrade tolerance for an older host's cached/echoed DTOs). + let legacy = r#"{"id":"abc","label":"App","kind":"Shortcut","isShortcut":true,"styleable":true,"statusReason":null,"x":14,"y":62,"sourceUrls":["dmicon://abc/0"]}"#; + let item: IconItemDto = serde_json::from_str(legacy).unwrap(); + assert_eq!(item.surface, SurfaceDto::Desktop); + assert_eq!(item.parent, ""); + } + #[test] fn item_uses_camel_case_keys_and_no_override_fields() { let item = IconItemDto { @@ -227,6 +270,8 @@ mod tests { is_shortcut: true, styleable: true, status_reason: None, + surface: SurfaceDto::Desktop, + parent: String::new(), x: 14, y: 62, source_urls: vec!["dmicon://abc/0".into()], @@ -260,6 +305,9 @@ mod tests { is_shortcut: false, styleable: true, status_reason: None, + // Exercise the NON-default surface: Start menu items carry their relative parent. + surface: SurfaceDto::StartMenu, + parent: "WinRAR".into(), x: 0, y: 0, // Recycle Bin ships two sources (empty + full). diff --git a/crates/dm-contracts/src/lib.rs b/crates/dm-contracts/src/lib.rs index dc6da99c..5750891a 100644 --- a/crates/dm-contracts/src/lib.rs +++ b/crates/dm-contracts/src/lib.rs @@ -18,7 +18,7 @@ mod wallpaper; pub use common::{SystemInfoDto, ToastDto}; pub use icons::{ ArrowOverlayDto, GridMetricsDto, IconChunkItemDto, IconItemDto, IconKindDto, IconOpResultDto, - IconPersistedDto, IconScanDto, LookVersionDto, + IconPersistedDto, IconScanDto, LookVersionDto, SurfaceDto, }; pub use presets::{ PresetEntryDto, PresetMetaDto, PresetPackageReadDto, PresetReadEntryDto, PresetSaveDto, diff --git a/crates/dm-domain/src/item.rs b/crates/dm-domain/src/item.rs index b23f48ac..63a6892e 100644 --- a/crates/dm-domain/src/item.rs +++ b/crates/dm-domain/src/item.rs @@ -131,6 +131,23 @@ impl ItemTarget { } } +/// WHICH shell surface a scanned item was observed on (ADR-0024, fork draft): the desktop grid, +/// a taskbar pin (`…\User Pinned\TaskBar\*.lnk`), or a Start menu shortcut. Every constructor +/// states its surface explicitly — the default is never implicit, so a new scan root cannot +/// silently masquerade as desktop truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ItemSurface { + Desktop, + Taskbar, + StartMenu, +} + +impl Default for ItemSurface { + fn default() -> Self { + ItemSurface::Desktop + } +} + /// A scanned desktop item, mirroring `Core/DesktopItem.cs`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DesktopItem { @@ -138,6 +155,11 @@ pub struct DesktopItem { pub name: String, pub path: String, pub kind: ItemKind, + /// Which shell surface this item was observed on (ADR-0024). + pub surface: ItemSurface, + /// Start menu subfolder RELATIVE to the programs root (`""` at the root; always `""` for + /// desktop/taskbar items) — the mirror groups Start menu tiles by it. + pub parent: String, pub icon: Option, pub state: ItemState, pub requires_explicit_consent: bool, @@ -210,6 +232,8 @@ mod tests { name: "App".into(), path: r"C:\Desktop\App.lnk".into(), kind: ItemKind::Shortcut, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, @@ -310,6 +334,8 @@ mod tests { name: "n".into(), path: "p".into(), kind, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state, requires_explicit_consent: false, diff --git a/crates/dm-domain/src/lib.rs b/crates/dm-domain/src/lib.rs index 9b2d9374..bc3a5883 100644 --- a/crates/dm-domain/src/lib.rs +++ b/crates/dm-domain/src/lib.rs @@ -23,7 +23,7 @@ pub use elevated::{ }; pub use error::{PortError, PortResult}; pub use fingerprint::Fingerprint; -pub use item::{DesktopItem, IconRef, IconSourceKind, ItemId, ItemKind, ItemState, ItemTarget}; +pub use item::{DesktopItem, IconRef, IconSourceKind, ItemId, ItemKind, ItemState, ItemSurface, ItemTarget}; pub use source::{ FileId, IconLocationState, PackageState, SourceFingerprint, SourceIdentity, TargetState, }; diff --git a/crates/dm-operations/src/icons/scope.rs b/crates/dm-operations/src/icons/scope.rs index bb0743c2..2d91351f 100644 --- a/crates/dm-operations/src/icons/scope.rs +++ b/crates/dm-operations/src/icons/scope.rs @@ -212,6 +212,26 @@ mod tests { assert_eq!(privileged_scope(r"C:\Users\Dev\ProgramData notes\x.lnk", &public, &pd), None); } + #[test] + fn an_all_users_start_menu_shortcut_classifies_as_programdata() { + // ADR-0024: the all-users Start menu lives under ProgramData, so its `.lnk` items + // classify privileged and ride the EXISTING elevated batch — no new elevation path. + let roots = ScopeRoots::resolved( + vec![r"C:\Users\Public\Desktop".to_string()], + vec![r"C:\ProgramData".to_string()], + ) + .unwrap(); + assert_eq!( + roots.classify(r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\WinRAR\WinRAR.lnk"), + Some(PrivilegedScope::ProgramData) + ); + // The USER Start menu stays the user's own data — never privileged. + assert_eq!( + roots.classify(r"C:\Users\Dev\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Brave.lnk"), + None + ); + } + #[test] fn raw_predicate_with_empty_roots_matches_nothing() { // The RAW predicate is a pure ancestry test: no roots → no ancestry → `None`. On its own diff --git a/crates/dm-operations/src/icons/style_resolve.rs b/crates/dm-operations/src/icons/style_resolve.rs index adc3a044..ef8e0c09 100644 --- a/crates/dm-operations/src/icons/style_resolve.rs +++ b/crates/dm-operations/src/icons/style_resolve.rs @@ -19,7 +19,7 @@ use dm_icon_core::config::{ Band, Config as CoreConfig, Distinction, FilterStyle, IconShape, MarkStyle, MonoStyle, PlateFallback, Subject, }; -use dm_domain::ItemKind; +use dm_domain::{ItemKind, ItemSurface}; use serde::Deserialize; use crate::error::{OperationError, Result}; @@ -146,6 +146,30 @@ fn yes() -> bool { true } +/// Per-SURFACE participation (ADR-0024). The OPPOSITE default of [`KindPolicyJson`], on purpose: +/// every leg derives FALSE, so a recipe with a MISSING `surfaces` (and a missing leg inside an +/// explicit one) means OPTED OUT — the taskbar and Start menu are NEW write surfaces whose +/// consent must never be inherited from an existing recipe; the user flips them on explicitly. +/// The desktop is not a leg here: it is never surface-gated (see `StyleRecipe::participates`). +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SurfacesJson { + /// Taskbar pins (`…\User Pinned\TaskBar\*.lnk`). + pub taskbar: bool, + /// Start menu shortcuts (user + all-users roots), camelCase `startMenu` on the wire. + pub start_menu: bool, +} + +impl SurfacesJson { + fn allows(&self, surface: ItemSurface) -> bool { + match surface { + ItemSurface::Desktop => true, + ItemSurface::Taskbar => self.taskbar, + ItemSurface::StartMenu => self.start_menu, + } + } +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TypeOverridesJson { @@ -157,7 +181,9 @@ pub struct TypeOverridesJson { pub file: Option, } -/// The parsed appearance recipe: the three global knobs of an `IconStyle`. +/// The parsed appearance recipe: the look knobs (`config`, `typeOverrides`) plus the two +/// PARTICIPATION knobs — `kind_policy` (which KINDS) and `surfaces` (which SHELL SURFACES, +/// ADR-0024). Unknown keys stay ignored (serde denies nothing: the frontend owns the recipe). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StyleRecipe { @@ -166,6 +192,8 @@ pub struct StyleRecipe { pub kind_policy: KindPolicyJson, #[serde(default)] pub type_overrides: TypeOverridesJson, + #[serde(default)] + pub surfaces: SurfacesJson, } impl StyleRecipe { @@ -183,7 +211,13 @@ impl StyleRecipe { } } - fn participates(&self, kind: ItemKind) -> bool { + fn participates(&self, kind: ItemKind, surface: ItemSurface) -> bool { + // The SURFACE gate is outermost (ADR-0024): a recipe that never opted the taskbar/Start + // menu in styles NOTHING there, whatever its kindPolicy says. The desktop itself is + // never surface-gated — it is the app's core promise; `kindPolicy` governs it alone. + if !self.surfaces.allows(surface) { + return false; + } match bucket_of(kind) { None => true, Some(KindBucket::App) => self.kind_policy.app, @@ -204,10 +238,15 @@ impl StyleRecipe { } /// The config an item of `kind` renders with under this recipe, or `None` when the item does - /// not participate (kind-policy opt-out → keep original). Mirrors `effectiveTileConfig` for - /// items with no per-icon override (the background item's definition). - pub fn effective_config(&self, kind: ItemKind, is_shortcut: bool) -> Result> { - if !self.participates(kind) { + /// not participate (kind-policy opt-out OR surface opt-out → keep original). Mirrors + /// `effectiveTileConfig` for items with no per-icon override (the background item's definition). + pub fn effective_config( + &self, + kind: ItemKind, + is_shortcut: bool, + surface: ItemSurface, + ) -> Result> { + if !self.participates(kind, surface) { return Ok(None); } let mut cfg = self.config.clone(); @@ -400,7 +439,7 @@ mod tests { fn parses_and_resolves_the_base_config_for_a_participating_kind() { let s = style(json!({ "config": base_config(), "kindPolicy": {}, "typeOverrides": {} })); let recipe = StyleRecipe::parse(&s).unwrap(); - let cfg = recipe.effective_config(ItemKind::Shortcut, true).unwrap().unwrap(); + let cfg = recipe.effective_config(ItemKind::Shortcut, true, ItemSurface::Desktop).unwrap().unwrap(); assert_eq!(cfg.shape, IconShape::Circle); assert_eq!(cfg.subject, Subject::Original); assert_eq!(cfg.tint, 0xFF6F5E); @@ -417,13 +456,13 @@ mod tests { "typeOverrides": {} })); let recipe = StyleRecipe::parse(&s).unwrap(); - assert!(recipe.effective_config(ItemKind::Folder, false).unwrap().is_none()); - assert!(recipe.effective_config(ItemKind::Shortcut, true).unwrap().is_some()); + assert!(recipe.effective_config(ItemKind::Folder, false, ItemSurface::Desktop).unwrap().is_none()); + assert!(recipe.effective_config(ItemKind::Shortcut, true, ItemSurface::Desktop).unwrap().is_some()); // System virtual items ride the App switch after the merge (owner 2026-07-16) — // the legacy System:false above has no effect. - assert!(recipe.effective_config(ItemKind::RecycleBin, false).unwrap().is_some()); + assert!(recipe.effective_config(ItemKind::RecycleBin, false, ItemSurface::Desktop).unwrap().is_some()); // Bucketless kinds pass the policy (governed by styleable upstream). - assert!(recipe.effective_config(ItemKind::Unsupported, false).unwrap().is_some()); + assert!(recipe.effective_config(ItemKind::Unsupported, false, ItemSurface::Desktop).unwrap().is_some()); } #[test] @@ -437,11 +476,11 @@ mod tests { } })); let recipe = StyleRecipe::parse(&s).unwrap(); - let folder = recipe.effective_config(ItemKind::Folder, false).unwrap().unwrap(); + let folder = recipe.effective_config(ItemKind::Folder, false, ItemSurface::Desktop).unwrap().unwrap(); assert_eq!(folder.shape, IconShape::Folder); assert_eq!(folder.tint, 0x112233); assert_eq!(folder.subject, Subject::Original, "unpatched keys keep base"); - let file = recipe.effective_config(ItemKind::RegularFile, false).unwrap().unwrap(); + let file = recipe.effective_config(ItemKind::RegularFile, false, ItemSurface::Desktop).unwrap().unwrap(); assert_eq!(file.shape, IconShape::Circle, "a follower takes base"); } @@ -451,9 +490,9 @@ mod tests { cfg["shortcutShape"] = json!("Diamond"); let s = style(json!({ "config": cfg, "kindPolicy": {}, "typeOverrides": {} })); let recipe = StyleRecipe::parse(&s).unwrap(); - let shortcut = recipe.effective_config(ItemKind::Shortcut, true).unwrap().unwrap(); + let shortcut = recipe.effective_config(ItemKind::Shortcut, true, ItemSurface::Desktop).unwrap().unwrap(); assert_eq!(shortcut.shape, IconShape::Diamond, "shortcuts adopt the uniform shape"); - let folder = recipe.effective_config(ItemKind::Folder, false).unwrap().unwrap(); + let folder = recipe.effective_config(ItemKind::Folder, false, ItemSurface::Desktop).unwrap().unwrap(); assert_eq!(folder.shape, IconShape::Circle, "non-shortcuts keep the ladder shape"); } @@ -475,17 +514,17 @@ mod tests { })); let recipe = StyleRecipe::parse(&s).unwrap(); // A folder shortcut keeps the Folder type's shape, not the uniform. - let folder_lnk = recipe.effective_config(ItemKind::Folder, true).unwrap().unwrap(); + let folder_lnk = recipe.effective_config(ItemKind::Folder, true, ItemSurface::Desktop).unwrap().unwrap(); assert_eq!(folder_lnk.shape, IconShape::Folder, "type shape wins over the uniform"); // The App bucket obeys the same precedence (owner: one rule everywhere). - let app_lnk = recipe.effective_config(ItemKind::Shortcut, true).unwrap().unwrap(); + let app_lnk = recipe.effective_config(ItemKind::Shortcut, true, ItemSurface::Desktop).unwrap().unwrap(); assert_eq!(app_lnk.shape, IconShape::Tile, "App type shape wins too"); // A patch WITHOUT a shape still yields to the uniform. - let file_lnk = recipe.effective_config(ItemKind::RegularFile, true).unwrap().unwrap(); + let file_lnk = recipe.effective_config(ItemKind::RegularFile, true, ItemSurface::Desktop).unwrap().unwrap(); assert_eq!(file_lnk.shape, IconShape::Diamond, "shape-less patch yields to the uniform"); assert_eq!(file_lnk.tint, 0x3FB6A8, "the rest of the patch still applies"); // Non-shortcut folders never take the uniform. - let folder = recipe.effective_config(ItemKind::Folder, false).unwrap().unwrap(); + let folder = recipe.effective_config(ItemKind::Folder, false, ItemSurface::Desktop).unwrap().unwrap(); assert_eq!(folder.shape, IconShape::Folder); } @@ -495,7 +534,7 @@ mod tests { cfg["subject"] = json!("Technicolor"); let s = style(json!({ "config": cfg, "kindPolicy": {}, "typeOverrides": {} })); let recipe = StyleRecipe::parse(&s).unwrap(); - assert!(recipe.effective_config(ItemKind::Shortcut, true).is_err()); + assert!(recipe.effective_config(ItemKind::Shortcut, true, ItemSurface::Desktop).is_err()); } #[test] @@ -511,7 +550,66 @@ mod tests { "config": base_config(), "typeOverrides": {} })) .unwrap(); - assert!(recipe.effective_config(ItemKind::Folder, false).unwrap().is_some()); + assert!(recipe.effective_config(ItemKind::Folder, false, ItemSurface::Desktop).unwrap().is_some()); + } + + #[test] + fn surfaces_default_to_opted_out_but_the_desktop_is_never_gated() { + // ADR-0024: the NEW surfaces are opt-in CONSENT — a recipe with no `surfaces` key styles + // the taskbar/Start menu NOWHERE. Deliberately the OPPOSITE of kindPolicy's + // participate-by-default: an existing recipe must never inherit consent to write new + // surfaces. The desktop itself is never surface-gated — it is the app's core promise. + let recipe: StyleRecipe = + serde_json::from_value(json!({ "config": base_config(), "typeOverrides": {} })).unwrap(); + assert!(!recipe.surfaces.taskbar && !recipe.surfaces.start_menu, "both legs default off"); + assert!(recipe + .effective_config(ItemKind::Shortcut, true, ItemSurface::Desktop) + .unwrap() + .is_some()); + assert!(recipe + .effective_config(ItemKind::Shortcut, true, ItemSurface::Taskbar) + .unwrap() + .is_none()); + assert!(recipe + .effective_config(ItemKind::Shortcut, true, ItemSurface::StartMenu) + .unwrap() + .is_none()); + } + + #[test] + fn an_enabled_surface_leg_grants_only_that_surface_and_a_missing_leg_stays_off() { + let s = style(json!({ + "config": base_config(), + "kindPolicy": {}, + "typeOverrides": {}, + "surfaces": { "taskbar": true, "startMenu": true } + })); + let recipe = StyleRecipe::parse(&s).unwrap(); + assert!(recipe + .effective_config(ItemKind::Shortcut, true, ItemSurface::Taskbar) + .unwrap() + .is_some()); + assert!(recipe + .effective_config(ItemKind::Shortcut, true, ItemSurface::StartMenu) + .unwrap() + .is_some()); + // A leg MISSING from an explicit `surfaces` object still defaults to off (per-field + // default — `#[serde(rename_all = "camelCase", default)]` on the container). + let s2 = style(json!({ + "config": base_config(), + "kindPolicy": {}, + "typeOverrides": {}, + "surfaces": { "taskbar": true } + })); + let recipe2 = StyleRecipe::parse(&s2).unwrap(); + assert!(recipe2 + .effective_config(ItemKind::Shortcut, true, ItemSurface::StartMenu) + .unwrap() + .is_none()); + assert!(recipe2 + .effective_config(ItemKind::Shortcut, true, ItemSurface::Taskbar) + .unwrap() + .is_some()); } #[test] diff --git a/crates/dm-operations/src/icons/tests.rs b/crates/dm-operations/src/icons/tests.rs index dc9b4ec4..414f273e 100644 --- a/crates/dm-operations/src/icons/tests.rs +++ b/crates/dm-operations/src/icons/tests.rs @@ -4,7 +4,7 @@ //! files and the apply drives the real transaction engine. use dm_contracts::IconStyle; -use dm_domain::{DesktopItem, ItemId, ItemKind, ItemState}; +use dm_domain::{DesktopItem, ItemId, ItemKind, ItemState, ItemSurface}; use serde_json::json; use super::*; @@ -33,6 +33,8 @@ fn item(id: &str, kind: ItemKind) -> DesktopItem { name: id.into(), path: format!("C:/Desktop/{id}"), kind, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, @@ -1337,6 +1339,8 @@ fn pub_item(id: &str) -> DesktopItem { name: id.into(), path: format!("C:/Users/Public/Desktop/{id}"), kind: ItemKind::Shortcut, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, diff --git a/crates/dm-operations/src/icons/version_switch.rs b/crates/dm-operations/src/icons/version_switch.rs index 863affa5..226f5069 100644 --- a/crates/dm-operations/src/icons/version_switch.rs +++ b/crates/dm-operations/src/icons/version_switch.rs @@ -155,9 +155,9 @@ fn bake_and_apply( if !item.can_style() { continue; } - let cfg = match recipe.effective_config(item.kind, item.kind.is_shortcut())? { + let cfg = match recipe.effective_config(item.kind, item.kind.is_shortcut(), item.surface)? { Some(c) => c, - None => continue, // kind-policy opt-out → keep original + None => continue, // kind-policy / surface opt-out → keep original }; // CAS anchor = the fingerprint read NOW; the driver skips an item hand-edited since. let fingerprint = match ports.reader.read_fingerprint(&item.target()) { @@ -308,8 +308,8 @@ mod tests { use super::*; use dm_contracts::IconStyle; use dm_domain::{ - ApplyAssets, AssetRef, DecodedImage, Fingerprint, ItemId, ItemKind, ItemState, ItemTarget, - PortError, PortResult, RestoreAnchor, + ApplyAssets, AssetRef, DecodedImage, Fingerprint, ItemId, ItemKind, ItemState, ItemSurface, + ItemTarget, PortError, PortResult, RestoreAnchor, }; use std::cell::RefCell; use std::collections::HashMap; @@ -406,6 +406,8 @@ mod tests { name: id.into(), path: format!("C:/Users/Dev/Desktop/{id}.lnk"), kind: ItemKind::Shortcut, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, @@ -503,6 +505,8 @@ mod tests { name: "pub".into(), path: "C:/Users/Public/Desktop/Tool.lnk".into(), kind: ItemKind::Shortcut, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, diff --git a/crates/dm-operations/src/settings_store.rs b/crates/dm-operations/src/settings_store.rs index 8f84674a..5f31baca 100644 --- a/crates/dm-operations/src/settings_store.rs +++ b/crates/dm-operations/src/settings_store.rs @@ -381,6 +381,28 @@ mod tests { assert!(store.get_saved_style().unwrap().is_none()); } + #[test] + fn a_recipe_carried_surfaces_key_survives_the_round_trip_verbatim() { + // ADR-0024 G11: `surfaces` rides the SAME channel as kindPolicy — the store must + // whitelist it (tolerate + persist verbatim), never drop it on save/load, or enabling a + // surface would silently revert to opted-out after a restart. The envelope validator + // tolerates the extra forward-compat key; this pins that the VALUE survives storage too. + let store = SettingsStore::open_in_memory().unwrap(); + let with_surfaces = IconStyle::from_value(serde_json::json!({ + "config": { "seed": 1 }, + "kindPolicy": {}, + "typeOverrides": {}, + "surfaces": { "taskbar": true, "startMenu": false } + })) + .unwrap(); + store.set_saved_style(Some(&with_surfaces)).unwrap(); + let back = store.get_saved_style().unwrap().expect("saved style"); + assert_eq!( + back.as_value().get("surfaces"), + Some(&serde_json::json!({ "taskbar": true, "startMenu": false })) + ); + } + #[test] fn saved_style_is_independent_of_the_settings_row_and_persists() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/dm-resident/src/driver/tests.rs b/crates/dm-resident/src/driver/tests.rs index 06b50832..f1acd79a 100644 --- a/crates/dm-resident/src/driver/tests.rs +++ b/crates/dm-resident/src/driver/tests.rs @@ -12,7 +12,7 @@ use dm_contracts::IconStyle; use dm_domain::{ ActivityMonitor, ApplyAssets, AssetRef, AssetStore, DecodedImage, DesktopItem, DesktopScanner, Fingerprint, IconApplier, IconSourceExtractor, ItemId, ItemKind, ItemState, ItemStateReader, - ItemTarget, PortError, PortResult, RestoreAnchor, + ItemSurface, ItemTarget, PortError, PortResult, RestoreAnchor, }; use dm_operations::icons::scope::ScopeRoots; use dm_operations::{MemLedgerStore, TxnIdAllocator, VecJournal}; @@ -105,6 +105,8 @@ fn user_item(id: &str) -> DesktopItem { name: id.to_string(), path: format!("C:/Users/Dev/Desktop/{id}.lnk"), kind: ItemKind::Shortcut, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, diff --git a/crates/dm-resident/src/reconciler/mod.rs b/crates/dm-resident/src/reconciler/mod.rs index 0a1365ef..fd3d5754 100644 --- a/crates/dm-resident/src/reconciler/mod.rs +++ b/crates/dm-resident/src/reconciler/mod.rs @@ -182,7 +182,17 @@ impl Reconciler { return Ok(out); } let recipe = StyleRecipe::parse(style)?; - let items = ports.scanner.scan().map_err(op_err)?; + // The resident stays DESKTOP-ONLY (spec 07 §8.2 + ADR-0024 follow-up): the shared scanner + // now also labels taskbar pins and Start menu shortcuts, but background auto-format never + // touches them in v1 — a non-desktop item participates only through a FOREGROUND Apply + // whose recipe opted that surface in (its `surfaces` leg). + let items: Vec<_> = ports + .scanner + .scan() + .map_err(op_err)? + .into_iter() + .filter(|i| i.surface == dm_domain::ItemSurface::Desktop) + .collect(); self.settle.retain_paths(items.iter().map(|i| i.path.as_str())); let mut candidates: Vec = Vec::new(); @@ -196,7 +206,7 @@ impl Reconciler { continue; } // Kind-policy opt-out / non-participating bucket → keep original (spec 07 §6). - match recipe.effective_config(item.kind, item.kind.is_shortcut()) { + match recipe.effective_config(item.kind, item.kind.is_shortcut(), item.surface) { Ok(Some(_)) => {} Ok(None) => continue, Err(e) => { @@ -328,7 +338,7 @@ impl Reconciler { out.conflicts.push(item.id.clone()); continue; } - let cfg = match recipe.effective_config(item.kind, item.kind.is_shortcut()) { + let cfg = match recipe.effective_config(item.kind, item.kind.is_shortcut(), item.surface) { Ok(Some(c)) => c, Ok(None) => continue, Err(e) => { diff --git a/crates/dm-resident/src/reconciler/tests.rs b/crates/dm-resident/src/reconciler/tests.rs index 606a4e2f..bdce5575 100644 --- a/crates/dm-resident/src/reconciler/tests.rs +++ b/crates/dm-resident/src/reconciler/tests.rs @@ -10,7 +10,7 @@ use dm_contracts::IconStyle; use dm_domain::{ ActivityMonitor, ApplyAssets, AssetRef, AssetStore, DecodedImage, DesktopItem, DesktopScanner, Fingerprint, IconApplier, IconSourceExtractor, ItemId, ItemKind, ItemState, ItemStateReader, - ItemTarget, PortError, PortResult, RestoreAnchor, + ItemSurface, ItemTarget, PortError, PortResult, RestoreAnchor, }; use dm_operations::{MemLedgerStore, TxnIdAllocator, VecJournal}; use serde_json::json; @@ -219,6 +219,8 @@ fn item(id: &str, kind: ItemKind, path: &str) -> DesktopItem { name: id.to_string(), path: path.to_string(), kind, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, diff --git a/crates/dm-windows/src/shell/known_folders.rs b/crates/dm-windows/src/shell/known_folders.rs index 7820ec0d..03112adb 100644 --- a/crates/dm-windows/src/shell/known_folders.rs +++ b/crates/dm-windows/src/shell/known_folders.rs @@ -7,8 +7,8 @@ use dm_domain::{PortError, PortResult}; use windows::core::GUID; use windows::Win32::System::Com::CoTaskMemFree; use windows::Win32::UI::Shell::{ - SHGetKnownFolderPath, FOLDERID_Desktop, FOLDERID_ProgramData, FOLDERID_PublicDesktop, - KF_FLAG_DEFAULT, + SHGetKnownFolderPath, FOLDERID_CommonPrograms, FOLDERID_Desktop, FOLDERID_Programs, + FOLDERID_ProgramData, FOLDERID_PublicDesktop, FOLDERID_RoamingAppData, KF_FLAG_DEFAULT, }; /// The desktop roots LABELED by identity: `(user desktop, public desktop)`. The USER desktop is @@ -68,6 +68,37 @@ pub fn privileged_roots() -> Option<(PathBuf, PathBuf)> { Some((public, programdata)) } +/// The taskbar PIN root for the taskbar surface (ADR-0024): the folder Explorer stores the user's +/// pinned taskbar shortcuts in. Resolved from `FOLDERID_RoamingAppData` + the documented suffix — +/// never a fully hardcoded path (spec 07 §3). Best-effort by contract: `None` when the folder does +/// not resolve, so a locked-down edition narrows the SURFACES covered instead of failing the scan. +/// +/// [WINDOWS-VERIFY] runtime. +pub fn taskbar_pin_root() -> Option { + let base = known_folder(&FOLDERID_RoamingAppData).ok().flatten()?; + let root = base.join(r"Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar"); + root.is_dir().then_some(root) +} + +/// The Start menu program roots for the start-menu surface (ADR-0024): `(user, all-users)`, both +/// plain `SHGetKnownFolderPath` known folders (no suffix). Order is stable — user first, so an +/// all-users failure can never displace it (the R-vanish labeling lesson, per root). Both legs are +/// best-effort: a missing root narrows the surface instead of failing the scan; the all-users leg +/// classifies `PrivilegedScope::ProgramData` downstream and rides the existing elevated batch. +/// +/// [WINDOWS-VERIFY] runtime. +pub fn start_menu_roots_labeled() -> Vec { + let mut roots = Vec::new(); + for id in [&FOLDERID_Programs, &FOLDERID_CommonPrograms] { + match known_folder(id) { + Ok(Some(path)) if path.is_dir() => roots.push(path), + Ok(_) => {} + Err(_) => continue, + } + } + roots +} + fn known_folder(id: &GUID) -> PortResult> { // SAFETY: `SHGetKnownFolderPath` allocates a `PWSTR` that we own and free with // `CoTaskMemFree`; `to_string` reads the NUL-terminated buffer before it is freed. diff --git a/crates/dm-windows/src/shell/scan.rs b/crates/dm-windows/src/shell/scan.rs index 92162975..cc8e2f3a 100644 --- a/crates/dm-windows/src/shell/scan.rs +++ b/crates/dm-windows/src/shell/scan.rs @@ -6,7 +6,8 @@ use std::path::Path; use std::sync::Arc; use dm_domain::{ - DesktopItem, DesktopScanner, IconRef, IconSourceKind, ItemId, ItemKind, ItemState, PortResult, + DesktopItem, DesktopScanner, IconRef, IconSourceKind, ItemId, ItemKind, ItemState, ItemSurface, + PortResult, }; use crate::classify::{classify_entry, display_name, is_ignored_entry}; @@ -31,6 +32,18 @@ impl DesktopScanner for WindowsScanner { } } +/// One scan root and how it walks (ADR-0024). `surface` labels EVERY item found under the root — +/// a root states its surface explicitly, so a new scan root can never silently masquerade as +/// desktop truth. `recurse` descends into subfolders (the Start menu's app folders); `mandatory` +/// makes an unreadable root fail the whole scan loud (only the user's own desktop is mandatory — +/// 2026-07-19 vanish audit). +struct RootSpec { + surface: ItemSurface, + path: std::path::PathBuf, + recurse: bool, + mandatory: bool, +} + /// The scan body, executed on the STA thread. [WINDOWS-VERIFY] runtime. fn scan_blocking() -> PortResult> { let mut items = Vec::new(); @@ -38,78 +51,27 @@ fn scan_blocking() -> PortResult> { // NAME, never by vector position — a failed user-desktop lookup errors instead of letting // the Public root masquerade as a successful empty scan. let (user_root, public_root) = known_folders::desktop_roots_labeled()?; - let roots: Vec<(std::path::PathBuf, bool)> = std::iter::once((user_root, true)) - .chain(public_root.into_iter().map(|p| (p, false))) - .collect(); - for (root, is_user) in &roots { - let read_dir = match std::fs::read_dir(root) { - Ok(rd) => rd, - // The USER desktop failing to enumerate must be LOUD (2026-07-19 vanish audit): - // silently treating it as empty binds the apply to a bogus "empty desktop" snapshot - // and the UI cannot tell a bare desktop from a broken scan (OneDrive Files-On-Demand - // hiccup, transient access denial). The PUBLIC root staying best-effort is deliberate - // — it is often absent/ACL-restricted on managed machines. - Err(e) if *is_user => { - return Err(dm_domain::PortError::Io(format!( - "cannot enumerate the desktop folder {}: {e}", - root.display() - ))) - } - Err(e) => { - log::warn!("desktop scan skipped unreadable root {}: {e}", root.display()); - continue; - } - }; - for entry in read_dir.flatten() { - let path = entry.path(); - let file_name = match path.file_name().and_then(|n| n.to_str()) { - Some(n) => n.to_string(), - None => continue, - }; - if is_ignored_entry(&file_name) { - continue; - } - // System-attributed items are invisible on the real desktop (oracle: Test-VisibleDesktopItem). - if has_system_attribute(&path) { - continue; - } - // A reparse point (symlink/junction) is not a safe styling target: writing desktop.ini - // or a .lnk through it would follow the link elsewhere. Skip it defensively. - if has_reparse_point(&path) { - continue; - } - let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); - let kind = classify_entry(&file_name, is_dir); - // Use the path only if it round-trips as UTF-8. to_string_lossy would substitute U+FFFD - // for invalid units and silently corrupt the item id and every downstream path read, so - // skip a non-representable entry instead. - let path_str = match path.to_str() { - Some(s) => s.to_string(), - None => continue, - }; - // Wrapper reunification: a `.lnk` this app created around a loose file (the original - // sits beside it, Hidden+System, and IS the link target) re-presents as the ORIGINAL - // RegularFile item — same id, same kind — so the ledger row, the CAS surface, and the - // restore affordance all keep addressing one item across re-scans. Without this, every - // wrapped file mutates into a foreign Shortcut item after its first apply (the oracle - // shared this gap). [WINDOWS-VERIFY] runtime. - if kind == ItemKind::Shortcut { - if let Some(item) = reunify_wrapper(&path_str) { - items.push(item); - continue; - } - } - items.push(DesktopItem { - id: ItemId::from_source_path("filesystem", &path_str), - name: display_name(&file_name, kind), - icon: read_icon_source(kind, &path_str), - path: path_str, - kind, - state: ItemState::Ready, - requires_explicit_consent: false, - status_message: None, - }); - } + let mut roots: Vec = vec![RootSpec { + surface: ItemSurface::Desktop, + path: user_root, + recurse: false, + mandatory: true, + }]; + if let Some(public) = public_root { + roots.push(RootSpec { surface: ItemSurface::Desktop, path: public, recurse: false, mandatory: false }); + } + // Taskbar pins + Start menu roots (ADR-0024): best-effort exactly like the Public desktop — a + // locked-down edition that fails one lookup narrows the SURFACES covered instead of failing + // the whole scan. The pin root is flat; Start menu roots descend into app folders. + // [WINDOWS-VERIFY] runtime. + if let Some(pin) = known_folders::taskbar_pin_root() { + roots.push(RootSpec { surface: ItemSurface::Taskbar, path: pin, recurse: false, mandatory: false }); + } + for menu in known_folders::start_menu_roots_labeled() { + roots.push(RootSpec { surface: ItemSurface::StartMenu, path: menu, recurse: true, mandatory: false }); + } + for spec in &roots { + walk_root(spec, &mut items)?; } // The Recycle Bin is a VIRTUAL shell folder the filesystem enumeration can never see — inject // it explicitly so the mirror matches the real desktop 1:1 (oracle: @@ -121,6 +83,8 @@ fn scan_blocking() -> PortResult> { name, path: RECYCLE_BIN_PARSING.to_string(), kind: ItemKind::RecycleBin, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, @@ -144,6 +108,8 @@ fn scan_blocking() -> PortResult> { name, path: parsing, kind: ItemKind::System, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, @@ -157,6 +123,121 @@ fn scan_blocking() -> PortResult> { Ok(items) } +/// Enumerate one root into `items`, honoring its surface + walk mode. The top-level `read_dir` +/// failure follows `mandatory` (loud error vs. best-effort skip); subfolder failures during a +/// recursive descent are ALWAYS best-effort (one locked app folder must not fail the scan). +fn walk_root(spec: &RootSpec, items: &mut Vec) -> PortResult<()> { + let read_dir = match std::fs::read_dir(&spec.path) { + Ok(rd) => rd, + // The USER desktop failing to enumerate must be LOUD (2026-07-19 vanish audit): + // silently treating it as empty binds the apply to a bogus "empty desktop" snapshot + // and the UI cannot tell a bare desktop from a broken scan (OneDrive Files-On-Demand + // hiccup, transient access denial). The PUBLIC root staying best-effort is deliberate + // — it is often absent/ACL-restricted on managed machines. + Err(e) if spec.mandatory => { + return Err(dm_domain::PortError::Io(format!( + "cannot enumerate the desktop folder {}: {e}", + spec.path.display() + ))) + } + Err(e) => { + log::warn!("scan skipped unreadable root {}: {e}", spec.path.display()); + return Ok(()); + } + }; + walk_dir(spec, read_dir, "", 0, items); + Ok(()) +} + +/// The recursive walk behind [`walk_root`]. Each entry carries its `parent` — the subfolder path +/// RELATIVE to the root (`""` at the root) — so the mirror can group Start menu tiles; desktop +/// and taskbar roots are flat and always produce `""`. Use the path only if it round-trips as +/// UTF-8: `to_string_lossy` would substitute U+FFFD for invalid units and silently corrupt the +/// item id and every downstream path read, so a non-representable entry is skipped instead. +/// [WINDOWS-VERIFY] runtime. +fn walk_dir( + spec: &RootSpec, + read_dir: std::fs::ReadDir, + parent: &str, + depth: u32, + items: &mut Vec, +) { + /// Depth cap for recursive surfaces: deep enough for every real Start menu tree, shallow + /// enough that a misattributed junction can never make the walk unbounded. + const START_MENU_MAX_DEPTH: u32 = 8; + for entry in read_dir.flatten() { + let path = entry.path(); + let file_name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + if is_ignored_entry(&file_name) { + continue; + } + // System-attributed items are invisible on the real desktop (oracle: Test-VisibleDesktopItem). + if has_system_attribute(&path) { + continue; + } + // A reparse point (symlink/junction) is not a safe styling target: writing desktop.ini + // or a .lnk through it would follow the link elsewhere. Skip it defensively. + if has_reparse_point(&path) { + continue; + } + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir && spec.recurse { + // A recursive surface's folders are CONTAINERS (the Start menu's app folders): descend + // into them instead of styling them. The folder itself never becomes an item — the + // start-menu surface styles its SHORTCUTS; the spec-06 taxonomy is unchanged. + if depth >= START_MENU_MAX_DEPTH { + continue; + } + let child = if parent.is_empty() { file_name } else { format!("{parent}\\{file_name}") }; + match std::fs::read_dir(&path) { + Ok(rd) => walk_dir(spec, rd, &child, depth + 1, items), + Err(e) => log::warn!("scan skipped unreadable folder {}: {e}", path.display()), + } + continue; + } + if is_dir && spec.surface != ItemSurface::Desktop { + // Taskbar pins are a flat `.lnk` set by definition — a directory there is not a pin, + // and the desktop Folder taxonomy does not apply to it. + continue; + } + let kind = classify_entry(&file_name, is_dir); + let path_str = match path.to_str() { + Some(s) => s.to_string(), + None => continue, + }; + // Wrapper reunification: a `.lnk` this app created around a loose file (the original + // sits beside it, Hidden+System, and IS the link target) re-presents as the ORIGINAL + // RegularFile item — same id, same kind — so the ledger row, the CAS surface, and the + // restore affordance all keep addressing one item across re-scans. Without this, every + // wrapped file mutates into a foreign Shortcut item after its first apply (the oracle + // shared this gap). The reunified item inherits the ROOT's surface/parent — the label + // always comes from the root that found it (ADR-0024). [WINDOWS-VERIFY] runtime. + if kind == ItemKind::Shortcut { + if let Some(mut item) = reunify_wrapper(&path_str) { + item.surface = spec.surface; + item.parent = parent.to_string(); + items.push(item); + continue; + } + } + items.push(DesktopItem { + id: ItemId::from_source_path("filesystem", &path_str), + name: display_name(&file_name, kind), + icon: read_icon_source(kind, &path_str), + path: path_str, + kind, + surface: spec.surface, + parent: parent.to_string(), + state: ItemState::Ready, + requires_explicit_consent: false, + status_message: None, + }); + } +} + /// The shell parsing name for the Recycle Bin (the canonical CLSID form; the oracle's /// `shell:RecycleBinFolder` resolves to the same folder). const RECYCLE_BIN_PARSING: &str = "::{645FF040-5081-101B-9F08-00AA002F954E}"; @@ -257,6 +338,10 @@ fn reunify_wrapper(lnk_path: &str) -> Option { icon: None, path: original.to_string(), kind: ItemKind::RegularFile, + // Placeholder: the walk that FOUND the wrapper overwrites surface/parent with its root's + // labels (ADR-0024) — wrappers are desktop-born, so desktop is the honest default. + surface: ItemSurface::Desktop, + parent: String::new(), state: ItemState::Ready, requires_explicit_consent: false, status_message: None, @@ -311,3 +396,146 @@ fn has_reparse_point(path: &Path) -> bool { .map(|m| m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) .unwrap_or(false) } + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(surface: ItemSurface, root: &Path, recurse: bool, mandatory: bool) -> RootSpec { + RootSpec { surface, path: root.to_path_buf(), recurse, mandatory } + } + + /// Creates a plain byte file at `rel` under `root` (mkdir -p). The walk treats `.lnk` names + /// as ordinary shortcut candidates: reunify's ownership gate rejects these foreign bytes + /// (no COM marker) exactly like a user's own shortcut. + fn touch(root: &Path, rel: &str) { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"fixture").unwrap(); + } + + fn walk(root: &Path, surface: ItemSurface, recurse: bool) -> Vec { + let mut items = Vec::new(); + walk_root(&spec(surface, root, recurse, false), &mut items).unwrap(); + items + } + + #[test] + fn a_flat_pin_root_yields_taskbar_items_with_an_empty_parent() { + let dir = tempfile::tempdir().unwrap(); + touch(dir.path(), "Brave.lnk"); + touch(dir.path(), "File Explorer.lnk"); + let items = walk(dir.path(), ItemSurface::Taskbar, false); + assert_eq!(items.len(), 2); + for item in &items { + assert_eq!(item.surface, ItemSurface::Taskbar); + assert_eq!(item.parent, ""); + assert_eq!(item.kind, ItemKind::Shortcut); + } + // Display names drop the .lnk (the strip / Start panel label). + let mut names: Vec = items.iter().map(|i| i.name.clone()).collect(); + names.sort(); + assert_eq!(names, vec!["Brave".to_string(), "File Explorer".to_string()]); + } + + #[test] + fn a_flat_desktop_root_keeps_folders_as_items_and_never_descends() { + let dir = tempfile::tempdir().unwrap(); + touch(dir.path(), "mine.lnk"); + touch(dir.path(), "Nested/child.lnk"); + let items = walk(dir.path(), ItemSurface::Desktop, false); + // Desktop taxonomy unchanged: the folder is ONE Folder item; its child is not scanned. + assert!(items.iter().any(|i| i.name == "Nested" && i.kind == ItemKind::Folder)); + assert!(!items.iter().any(|i| i.name == "child")); + assert!(items.iter().all(|i| i.surface == ItemSurface::Desktop && i.parent.is_empty())); + } + + #[test] + fn a_recursive_start_menu_root_groups_items_by_relative_parent() { + let dir = tempfile::tempdir().unwrap(); + touch(dir.path(), "Top.lnk"); + touch(dir.path(), "WinRAR/WinRAR.lnk"); + touch(dir.path(), "WinRAR/Tools/Archiver.lnk"); + let items = walk(dir.path(), ItemSurface::StartMenu, true); + let find = |label: &str| items.iter().find(|i| i.name == label).unwrap_or_else(|| panic!("no {label}")); + assert_eq!(find("Top").parent, ""); + assert_eq!(find("WinRAR").parent, "WinRAR"); + assert_eq!(find("Archiver").parent, r"WinRAR\Tools"); + // Start menu folders are CONTAINERS — descended into, never styled as items. + assert!(items.iter().all(|i| i.kind == ItemKind::Shortcut)); + assert!(items.iter().all(|i| i.surface == ItemSurface::StartMenu)); + } + + #[test] + fn desktop_ini_stays_skipped_on_every_surface_root() { + let dir = tempfile::tempdir().unwrap(); + touch(dir.path(), "desktop.ini"); + touch(dir.path(), "app.lnk"); + for (surface, recurse) in [(ItemSurface::Desktop, false), (ItemSurface::StartMenu, true)] { + let items = walk(dir.path(), surface, recurse); + assert_eq!(items.len(), 1, "{surface:?} must skip its own desktop.ini"); + assert_eq!(items[0].name, "app"); + } + } + + #[test] + fn an_unreadable_root_fails_loud_when_mandatory_and_narrows_when_best_effort() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("never-created"); + let mut items = Vec::new(); + // Best-effort roots (taskbar / start menu / public desktop) narrow the surfaces covered. + walk_root(&spec(ItemSurface::Taskbar, &missing, false, false), &mut items).unwrap(); + assert!(items.is_empty()); + // The user's own desktop stays LOUD (the 2026-07-19 vanish audit). + assert!(walk_root(&spec(ItemSurface::Desktop, &missing, false, true), &mut items).is_err()); + } + + #[test] + fn a_recursive_walk_hits_the_depth_cap_instead_of_running_away() { + let dir = tempfile::tempdir().unwrap(); + touch(dir.path(), "0/a.lnk"); + let mut deep = String::from("0"); + for level in 1..12 { + deep = format!("{deep}/d{level}"); + touch(dir.path(), &format!("{deep}/f.lnk")); + } + let items = walk(dir.path(), ItemSurface::StartMenu, true); + for item in &items { + let comps = if item.parent.is_empty() { 0 } else { item.parent.split('\\').count() }; + assert!(comps <= 8, "parent {:?} exceeded the depth cap", item.parent); + } + // The level-11 file (parent of 12 components) is beyond the cap: never scanned. + assert!(!items.iter().any(|i| i.parent.ends_with("d11"))); + // In-range entries DID scan (7 f.lnk within cap + the root-level a.lnk). + assert_eq!(items.iter().filter(|i| i.name == "f").count(), 7); + } + + #[test] + fn a_reparse_point_directory_is_never_descended() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real"); + std::fs::create_dir(&target).unwrap(); + touch(&target, "inside.lnk"); + let junction = dir.path().join("link"); + // Junctions need no elevation on Windows; skip (never fail) if the box refuses one. + let created = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction) + .arg(&target) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !created { + eprintln!("skipping: mklink /J unavailable on this box"); + return; + } + assert!(has_reparse_point(&junction)); + let items = walk(dir.path(), ItemSurface::StartMenu, true); + // The real directory still yields its file; the JUNCTION path never does. + assert!(items.iter().any(|i| i.name == "inside")); + assert!( + !items.iter().any(|i| Path::new(&i.path).starts_with(&junction)), + "a junction's foreign contents must never become style targets" + ); + } +} diff --git a/scripts/oracle/desktop-session.ts b/scripts/oracle/desktop-session.ts index 35e7c8fa..b56a49d5 100644 --- a/scripts/oracle/desktop-session.ts +++ b/scripts/oracle/desktop-session.ts @@ -160,6 +160,8 @@ function asItem(s: OracleSource): IconItemDto { isShortcut: s.isShortcut, styleable: s.kind !== 'Unsupported', statusReason: null, + surface: 'desktop', + parent: '', x: 0, y: 0, sourceUrls: [s.sourceUrl], diff --git a/src-tauri/src/devhost_icons.rs b/src-tauri/src/devhost_icons.rs index b094759a..a28157b5 100644 --- a/src-tauri/src/devhost_icons.rs +++ b/src-tauri/src/devhost_icons.rs @@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex}; use dm_domain::{ ApplyAssets, DecodedImage, DesktopGeometry, DesktopGeometryReader, DesktopIconGrid, DesktopIconSlot, DesktopItem, DesktopScanner, ExplorerRefresher, Fingerprint, IconApplier, IconSourceExtractor, - ItemId, ItemKind, ItemState, ItemStateReader, ItemTarget, OverlayControl, OverlayOutcome, - OverlayStyle, PortError, PortResult, RestoreAnchor, + ItemId, ItemKind, ItemState, ItemStateReader, ItemSurface, ItemTarget, OverlayControl, + OverlayOutcome, OverlayStyle, PortError, PortResult, RestoreAnchor, }; use image::ImageEncoder; @@ -60,6 +60,8 @@ fn dev_item(def: &DevIcon) -> DesktopItem { name: def.name.into(), path: dev_path(def.id), kind: def.kind, + surface: ItemSurface::Desktop, + parent: String::new(), icon: None, state: ItemState::Ready, requires_explicit_consent: false, diff --git a/src-tauri/src/icon_host/dto.rs b/src-tauri/src/icon_host/dto.rs index acd131df..233249c3 100644 --- a/src-tauri/src/icon_host/dto.rs +++ b/src-tauri/src/icon_host/dto.rs @@ -1,7 +1,9 @@ //! DTO mapping: recipe (de)serialization, look-history + kind mapping, and the persisted-state DTO. -use dm_contracts::{ArrowOverlayDto, IconKindDto, IconPersistedDto, IconStyle, LookVersionDto}; -use dm_domain::ItemKind; +use dm_contracts::{ + ArrowOverlayDto, IconKindDto, IconPersistedDto, IconStyle, LookVersionDto, SurfaceDto, +}; +use dm_domain::{ItemKind, ItemSurface}; use dm_operations::{IconStoreState, LookVersion}; use super::IconHost; @@ -39,6 +41,17 @@ pub(super) fn map_kind(k: ItemKind) -> IconKindDto { } } +/// Maps the domain surface onto the wire literal (ADR-0024): three labeled variants the TS union +/// mirrors. `parent` stays a separate flat field the scanner already set — this fns only decides +/// WHICH surface an item claims. +pub(super) fn map_surface(surface: ItemSurface) -> SurfaceDto { + match surface { + ItemSurface::Desktop => SurfaceDto::Desktop, + ItemSurface::Taskbar => SurfaceDto::Taskbar, + ItemSurface::StartMenu => SurfaceDto::StartMenu, + } +} + /// Synthetic desktop-grid positions for the dev host (a fixed column-major layout). On Windows the /// real observed `IFolderView2` positions replace these ([WINDOWS-VERIFY]). pub(super) fn synthetic_layout(i: usize) -> (i32, i32) { diff --git a/src-tauri/src/icon_host/scan.rs b/src-tauri/src/icon_host/scan.rs index 63ad2124..57c4197a 100644 --- a/src-tauri/src/icon_host/scan.rs +++ b/src-tauri/src/icon_host/scan.rs @@ -4,9 +4,10 @@ use std::collections::HashMap; use std::sync::atomic::Ordering; use dm_contracts::{GridMetricsDto, IconItemDto, IconPersistedDto, IconScanDto}; +use dm_domain::ItemSurface; use dm_operations::{LedgerStore, ScannedItem}; -use super::dto::{map_kind, synthetic_layout}; +use super::dto::{map_kind, map_surface, synthetic_layout}; use super::source_cache::{cache_source_into, SCAN_SOURCE_BUDGET}; use super::IconHost; @@ -211,7 +212,17 @@ impl IconHost { } else { extract_err }; - let (x, y) = live_slots.get(&item.name).copied().unwrap_or_else(|| synthetic_layout(i)); + // Desktop-only slot matching (ADR-0024 G2): a desktop `Brave` and a pinned `Brave` + // share a NAME, so a non-desktop item must never claim a desktop slot (nor steal the + // other's observed position). Non-desktop surfaces lay out in their own component + // (strip row / Start panel); their wire x/y is the honest observed-zero, never a + // predicted slot for somebody else's grid. + let (x, y) = match item.surface { + ItemSurface::Desktop => { + live_slots.get(&item.name).copied().unwrap_or_else(|| synthetic_layout(i)) + } + _ => (0, 0), + }; dtos.push(IconItemDto { id: item.id.as_str().into(), label: item.name.clone(), @@ -219,6 +230,8 @@ impl IconHost { is_shortcut: item.kind.is_shortcut(), styleable: item.can_style() && degraded_reason.is_none(), status_reason: degraded_reason.clone().or_else(|| item.status_message.clone()), + surface: map_surface(item.surface), + parent: item.parent.clone(), x, y, source_urls: urls, diff --git a/src/bridge/generated.ts b/src/bridge/generated.ts index 6f51d6a1..4b0e24a0 100644 --- a/src/bridge/generated.ts +++ b/src/bridge/generated.ts @@ -145,6 +145,13 @@ export type IconItemDto = { * no locale, so it must never emit display text here (an English user saw Chinese otherwise). */ statusReason: string | null, + /** Which shell surface this item was observed on (ADR-0024). Absent ⇒ `desktop`. */ + surface?: SurfaceDto, + /** + * Start menu subfolder RELATIVE to the programs root (`""` at the root; always `""` for + * desktop/taskbar items) — the mirror groups Start menu tiles by it. Absent ⇒ `""`. + */ + parent?: string, x: number, y: number, /** @@ -388,6 +395,14 @@ export type SettingsPatch = { wallpaperCoachShown?: boolean | null, }; +/** + * WHICH shell surface an item was observed on (ADR-0024, fork draft): the desktop grid, a + * taskbar pin (`…\User Pinned\TaskBar\*.lnk`), or a Start menu shortcut. Serialized camelCase + * to match the TS union `'desktop' | 'taskbar' | 'startMenu'`. `#[serde(default)]` reads an + * absent field as `Desktop`, so a downgraded/older host degrades to the classic surface. + */ +export type SurfaceDto = "desktop" | "taskbar" | "startMenu"; + /** * Environment snapshot for the diagnostics report (mirrors the TS `SystemInfoDto`). Returned by * `diagnostics.getInfo` — the audit #7 fix that replaces the browser `(mock)` stub (which shipped diff --git a/src/bridge/mock-desktop.ts b/src/bridge/mock-desktop.ts index c2cfd392..fd6cbc34 100644 --- a/src/bridge/mock-desktop.ts +++ b/src/bridge/mock-desktop.ts @@ -264,6 +264,8 @@ function toItem(entry: PackEntry, index: number, rows: number): IconItemDto { isShortcut: entry.isShortcut ?? (entry.kind === 'Shortcut' || entry.kind === 'UrlShortcut' || entry.kind === 'AppxShortcut'), styleable, statusReason: styleable ? null : 'MOCK-HOST-REASON', + surface: 'desktop', + parent: '', x: MOCK_GRID.inset + col * MOCK_GRID.cellWidth, y: MOCK_GRID.inset + row * MOCK_GRID.cellHeight, sourceUrls: entry.sourceUrls, diff --git a/src/bridge/tauri.ts b/src/bridge/tauri.ts index 340c5b04..2ab12752 100644 --- a/src/bridge/tauri.ts +++ b/src/bridge/tauri.ts @@ -117,7 +117,16 @@ export async function tauriCall(method: string, params: unknown): Promise ({ ...it, overrideMode: null, overrideTint: null })), + items: scan.items.map((it) => ({ + ...it, + // Schema 11 normalizing boundary: the wire carries the surface pair OPTIONAL + // (`#[serde(default)]`), the frontend shape is REQUIRED — a missing pair means the + // classic desktop surface (ADR-0024 downgraded-host tolerance). + surface: it.surface ?? 'desktop', + parent: it.parent ?? '', + overrideMode: null, + overrideTint: null, + })), } } case 'icons.getPersisted': diff --git a/src/bridge/types.ts b/src/bridge/types.ts index 2e11c202..2f15ed66 100644 --- a/src/bridge/types.ts +++ b/src/bridge/types.ts @@ -24,7 +24,10 @@ // PresetPackageReadDto / PresetEntryDto / PresetSaveDto (dm-contracts); library thumbnails ride // the scoped dmpreset:// protocol. Rust owns structure/security; payload SEMANTICS stay with the // ONE TS validator (lib/icon-look), so readPackage is PURE and save is the only library writer. -export const BRIDGE_SCHEMA_VERSION = 10 +// Schema 11 (ADR-0024, fork draft): shell-surface labels on IconItemDto (surface/parent) — the +// scan now reports taskbar pins + Start menu shortcuts alongside the desktop, and the wire +// distinguishes them with a labeled union instead of path inference. +export const BRIDGE_SCHEMA_VERSION = 11 // Preset package DTOs (schema 9) are consumed straight from the generated // contract — they carry no frontend-only fields, so a hand mirror would be @@ -211,6 +214,18 @@ export type IconKindBucket = 'App' | 'Folder' | 'File' * on the module state. Cascade: styleable:false > per-icon override > kindPolicy. */ export type KindPolicy = Record +/** Per-surface "participate in beautify?" (ADR-0024, fork draft): the SECOND + * participation axis, ORTHOGONAL to kindPolicy — kindPolicy asks WHICH KINDS, + * this asks WHICH SHELL SURFACES. The desktop itself is not a leg (it is + * always on — the app's core promise, governed by kindPolicy alone); the two + * NEW write surfaces each default false: consent to write the taskbar / + * Start menu is opt-in, never inherited by an existing recipe. Not part of + * ConfigDto (same rule as kindPolicy) — rides the recipe's 4th key. */ +export interface Surfaces { + taskbar: boolean + startMenu: boolean +} + /** The per-type style envelope (ADR-0017 D3): ONLY these axes may differ by * type — shape (the findability axis), saliency (colorMode limited to the * desaturation family — never Original islands), its tint/band companions, @@ -247,6 +262,11 @@ export interface TypeOverrideEntry { * a look switch swaps the whole ladder coherently. */ export type TypeOverrides = Partial> +/** WHICH shell surface an item was observed on (ADR-0024, fork draft): the desktop grid, a + * taskbar pin (`…\User Pinned\TaskBar\*.lnk`), or a Start menu shortcut. Mirrors the generated + * `SurfaceDto` union; the hand mirror keeps the frontend-owned `IconItemDto` self-contained. */ +export type Surface = 'desktop' | 'taskbar' | 'startMenu' + /** One desktop item (icons contract v2, spec 06 §2): the web renders styling * locally from `sourceUrls` (256px, [0] primary; RecycleBin ships TWO — * empty + full). Positions are OBSERVED desktop truth, never predicted. */ @@ -258,6 +278,11 @@ export interface IconItemDto { styleable: boolean /** Host-localized human reason when styleable is false (e.g. UWP). */ statusReason: string | null + /** Which shell surface this item was observed on (ADR-0024). */ + surface: Surface + /** Start menu subfolder RELATIVE to the programs root (`""` at the root; always `""` for + * desktop/taskbar items) — the mirror groups Start menu tiles by it. */ + parent: string x: number y: number sourceUrls: string[] @@ -279,9 +304,11 @@ export interface HistoryEntryDto { label: string isCurrent: boolean /** v2: entries carry their FULL recipe so 回到此版 re-bakes locally + restores the same - * participation policy — config + typeOverrides + kindPolicy, the three ② knobs (spec 07 §8.2). */ + * participation policy — config + typeOverrides + kindPolicy + surfaces (the recipe's + * participation knobs, spec 07 §8.2 + ADR-0024). */ config: ConfigDto kindPolicy: KindPolicy + surfaces: Surfaces typeOverrides: TypeOverrides } @@ -309,6 +336,9 @@ export interface IconsStateDto { /** Per-bucket participation policy (schema v3). Persisted via setLook; the * host + future auto-format read the same map. Default: every bucket true. */ kindPolicy: KindPolicy + /** Per-surface participation (ADR-0024): which SHELL SURFACES the look writes. + * Default false per leg — opt-in consent. The desktop is always on (not a leg). */ + surfaces: Surfaces /** Per-type style overrides (ADR-0017) — the look's type ladder. */ typeOverrides: TypeOverrides /** [M6-WIRE] Native shortcut-arrow state (ADR-0021 machine-wide overlay). diff --git a/src/components/canvas/icons-mirror.tsx b/src/components/canvas/icons-mirror.tsx index b8eeafda..8bb34eda 100644 --- a/src/components/canvas/icons-mirror.tsx +++ b/src/components/canvas/icons-mirror.tsx @@ -4,13 +4,15 @@ import { useCanvasView } from '@/lib/canvas-view' import { useDevicePixelRatio } from '@/lib/use-dpr' import { displaySize, useIcons } from '@/stores/icons' import { format, useHostReason, useT } from '@/lib/i18n' -import type { IconItemDto } from '@/bridge/types' +import type { IconItemDto, Surface } from '@/bridge/types' import { BUCKET_NAME_KEY, kindBucket } from '@/lib/kind-policy' import { cn } from '@/lib/utils' import { TaskbarStrip } from './taskbar-strip' +import { StartPanel, TaskbarPinsView } from './start-panel' +import { SurfaceChips } from './surface-chips' import { CanvasProgress, CanvasToolbar } from './canvas-toolbar' import { MenuRow } from './canvas-menu' -import { IconTile } from './icons-tile' +import { IconTile, TileGlyph } from './icons-tile' // The desktop mirror (spec 06): an equal-scale replica of the real screen — // real wallpaper, compositor-rendered tiles at OBSERVED positions, decorative @@ -48,6 +50,18 @@ export function IconsMirror() { const observerRef = React.useRef(null) const [viewport, setViewport] = React.useState({ w: 0, h: 0 }) const [menu, setMenu] = React.useState(null) + // ADR-0024: which surface the canvas SHOWS — the desktop mirror (default), the + // taskbar pins enlarged, or the Start panel mock. View-only chrome (the chips), + // never persisted. + const [surfaceView, setSurfaceView] = React.useState('desktop') + + // Surface partitions (ADR-0024): the desktop grid renders ONLY desktop items — + // pins feed the strip / taskbar view, Start menu items the panel. Non-desktop + // items carry no observed position (x/y = 0), so stacking them in the grid + // would pile every pin at the top-left corner. + const desktopItems = React.useMemo(() => items.filter((i) => i.surface === 'desktop'), [items]) + const taskbarPinItems = React.useMemo(() => items.filter((i) => i.surface === 'taskbar'), [items]) + const startItems = React.useMemo(() => items.filter((i) => i.surface === 'startMenu'), [items]) const attachViewport = React.useCallback((el: HTMLDivElement | null) => { observerRef.current?.disconnect() @@ -107,6 +121,31 @@ export function IconsMirror() { // `dpr` is read inside displaySize; subscribing here makes a monitor-DPI change // (which fires no resize) re-render tiles at the new density (wv2 audit §4). const renderSize = displaySize(state, view.scale, dpr) + // The one global original flag every surface's tiles share (compare hold, the + // System-Default lens); per-item gates fold in inside the renderer. + const showOriginalAll = comparing || hoveringBare || (bareLook && !hoverConfig) + const unstyleableTipFor = (item: IconItemDto) => + item.styleable + ? null + : item.statusReason && item.statusReason !== 'MOCK-HOST-REASON' + ? hostReason(item.statusReason) + : t('Icons_Unstyleable') + // Real pins for the strip (ADR-0024): pre-rendered glyphs through the SAME + // tile renderer; empty list → the strip keeps its decorative fixture pack. + const stripPins = taskbarPinItems.map((item) => ({ + key: item.id, + glyph: ( + + ), + })) // Fit toggle (owner 2026-07-09): one button flips between 满宽 (full width, // centered) and 满高·靠左 (full height, pinned to the LEFT — where most users @@ -154,27 +193,62 @@ export function IconsMirror() { )} - {/* Icon tiles — compositor canvases, positions are OBSERVED truth */} + {/* Icon tiles — compositor canvases, positions are OBSERVED truth. + ADR-0024: only DESKTOP items lay out here — pins/Start items carry + no observed position and render in their own views below. */}
- {items.map((item) => ( + {desktopItems.map((item) => ( setMenu({ kind: 'tile', item, x, y })} /> ))}
- + {/* ADR-0024 surface views: a non-desktop chip dims the desktop and + layers that surface's mock over it (D7: the switch is OUR chrome — + the scenery itself stays inert). */} + {surfaceView !== 'desktop' &&
} + {surfaceView === 'taskbar' && ( + setMenu({ kind: 'tile', item, x, y })} + /> + )} + {surfaceView === 'startMenu' && ( + setMenu({ kind: 'tile', item, x, y })} + /> + )} + +
{/* Scanning shimmer */} @@ -223,6 +297,7 @@ export function IconsMirror() { refreshTip={t('Canvas_Refresh_Tip')} onRefresh={() => void rescan()} /> + {menu?.kind === 'tile' && ( { const ref = React.useRef(null) const kindPolicy = useIcons((s) => s.state?.kindPolicy) ?? DEFAULT_KIND_POLICY + const surfaces = useIcons((s) => s.state?.surfaces) ?? DEFAULT_SURFACES const typeOverrides = useIcons((s) => s.hoverTypeOverrides ?? s.state?.typeOverrides) - const eff = effectiveTileConfig(item, config, kindPolicy, typeOverrides) + const eff = effectiveTileConfig(item, config, kindPolicy, typeOverrides, surfaces) const original = showOriginal || eff.showOriginal const styleKey = tileStyleKey(eff.config, item.isShortcut, original, renderSize) const sourceUrl = item.sourceUrls[0] ?? '' @@ -207,3 +208,37 @@ export const IconTile = React.memo(function IconTile({ ) }) + +/** A styled tile BITMAP with NO desktop chrome (label, absolute position, menu + * and badges stay the caller's job) — the taskbar-strip pins + Start panel rows + * render through the SAME compositor path as the desktop grid (one renderer, + * spec 06 §6; ADR-0024: tile PREVIEW behavior is uniform across surfaces). + * Per-item gates (kindPolicy, surface, keep) still apply inside useTileBitmap. */ +export function TileGlyph({ + item, + config, + showOriginal, + renderSize, + renderTick, + waveStamp, + size, +}: { + item: IconItemDto + config: ConfigDto + showOriginal: boolean + renderSize: number + renderTick: number + waveStamp: number + /** Display size in CSS px; the canvas keeps its higher-density backing store. */ + size?: number +}) { + const canvasRef = useTileBitmap(item, config, showOriginal, renderSize, renderTick, waveStamp) + return ( + + ) +} diff --git a/src/components/canvas/start-panel.tsx b/src/components/canvas/start-panel.tsx new file mode 100644 index 00000000..46a2bbfd --- /dev/null +++ b/src/components/canvas/start-panel.tsx @@ -0,0 +1,247 @@ +import * as React from 'react' +import type { ConfigDto, GridDto, IconItemDto } from '@/bridge/types' +import { useT } from '@/lib/i18n' +import { cn } from '@/lib/utils' +import { TileGlyph } from './icons-tile' + +// The Start-panel mock view (ADR-0024, spec 06 §4 amended — fork draft): OUR +// chrome inside the mirror canvas, a Win11-flavoured acrylic panel listing the +// REAL scanned Start menu shortcuts grouped by their parent folder. Tiles render +// through the SAME renderer as the desktop grid (hover try-on, press-to-peek, +// owned-verb menu, exception badges — tile behavior is uniform across surfaces). +// D7 preserved: the switch to this view is the canvas toolbar's chip; the +// scenery underneath never gains interactivity. + +/** One compact Start tile: the styled glyph + label, peek + the owned menu. */ +function StartTile({ + item, + grid, + config, + showOriginal, + renderSize, + renderTick, + waveStamp, + unstyleableTip, + peekTip, + onMenu, +}: { + item: IconItemDto + grid: GridDto + config: ConfigDto + showOriginal: boolean + renderSize: number + renderTick: number + waveStamp: number + unstyleableTip: string | null + peekTip: string + onMenu: (item: IconItemDto, x: number, y: number) => void +}) { + const [peeking, setPeeking] = React.useState(false) + const lineHeight = Math.ceil(grid.labelFontPx * 1.35) + const menuAt = (e: React.MouseEvent, x: number, y: number) => { + const host = (e.currentTarget.closest('.cursor-grab') as HTMLElement).getBoundingClientRect() + onMenu(item, x - host.left, y - host.top) + } + return ( +
{ + if (e.button === 0 && item.styleable) setPeeking(true) + }} + onPointerUp={() => setPeeking(false)} + onPointerLeave={() => setPeeking(false)} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + if (!item.styleable) return // no fake affordances on un-editable items (spec 06 §3.5) + menuAt(e, e.clientX, e.clientY) + }} + > + {item.styleable && ( + + )} + {item.overrideMode !== null && ( + + )} + + + {item.label} + +
+ ) +} + +/** The panel itself: grouped sections (root first, then alphabetical parent). */ +export function StartPanel({ + items, + grid, + config, + showOriginal, + renderSize, + renderTick, + waveStamp, + unstyleableTip, + peekTip, + onMenu, +}: { + items: IconItemDto[] + grid: GridDto + config: ConfigDto + showOriginal: boolean + renderSize: number + renderTick: number + waveStamp: number + unstyleableTip: (item: IconItemDto) => string | null + peekTip: string + onMenu: (item: IconItemDto, x: number, y: number) => void +}) { + const t = useT() + const groups = React.useMemo(() => { + const map = new Map() + for (const i of items) { + const list = map.get(i.parent) ?? [] + list.push(i) + map.set(i.parent, list) + } + // Root group first, then alphabetical — folders read top-to-bottom. + return [...map.entries()].sort(([a], [b]) => (a === '' ? -1 : b === '' ? 1 : a.localeCompare(b))) + }, [items]) + + return ( +
+ {groups.length === 0 ? ( +

{t('Start_Empty')}

+ ) : ( + groups.map(([parent, list]) => ( +
+

+ {parent === '' ? t('Start_Pinned') : parent.replace(/\\/g, ' \\ ')} +

+
+ {list.map((item) => ( + + ))} +
+
+ )) + )} +
+ ) +} + +/** The taskbar-surface view (ADR-0024): the real pins enlarged into a centered + * row on the wallpaper — same renderer, same verbs as every other tile. */ +export function TaskbarPinsView({ + items, + grid, + config, + showOriginal, + renderSize, + renderTick, + waveStamp, + unstyleableTip, + peekTip, + onMenu, +}: { + items: IconItemDto[] + grid: GridDto + config: ConfigDto + showOriginal: boolean + renderSize: number + renderTick: number + waveStamp: number + unstyleableTip: (item: IconItemDto) => string | null + peekTip: string + onMenu: (item: IconItemDto, x: number, y: number) => void +}) { + const t = useT() + return ( +
+ {items.length === 0 ? ( +

+ {t('Taskbar_NoPins')} +

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/components/canvas/surface-chips.tsx b/src/components/canvas/surface-chips.tsx new file mode 100644 index 00000000..23b4f088 --- /dev/null +++ b/src/components/canvas/surface-chips.tsx @@ -0,0 +1,40 @@ +import type { Surface } from '@/bridge/types' +import { useT } from '@/lib/i18n' +import { cn } from '@/lib/utils' + +// The canvas surface switcher (ADR-0024, fork draft): OUR chrome in a small +// glass pill above the toolbar — 桌面 | 任务栏 | 开始菜单 (Escritorio | Barra de +// tareas | Menú Inicio). Picking a non-desktop chip swaps the canvas to that +// surface's mock view (the strip pins enlarged / the Start panel); the scenery +// itself stays inert — only tiles gain behavior (spec 06 §4 amended). + +const GLASS = 'bg-glass text-glass-ink ring-1 ring-glass-ring backdrop-blur-md' + +export function SurfaceChips({ view, onChange }: { view: Surface; onChange: (view: Surface) => void }) { + const t = useT() + const chips: { key: Surface; label: string }[] = [ + { key: 'desktop', label: t('Surface_Desktop') }, + { key: 'taskbar', label: t('Surface_Taskbar') }, + { key: 'startMenu', label: t('Surface_StartMenu') }, + ] + return ( +
+
+ {chips.map((c) => ( + + ))} +
+
+ ) +} diff --git a/src/components/canvas/taskbar-strip.tsx b/src/components/canvas/taskbar-strip.tsx index e26e1590..85b43646 100644 --- a/src/components/canvas/taskbar-strip.tsx +++ b/src/components/canvas/taskbar-strip.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { BatteryFull, ChevronUp, Volume2, Wifi } from 'lucide-react' -import { TASKBAR_PINNED } from './taskbar-icons' +import { FlagIcon, SearchIcon, TASKBAR_PINNED } from './taskbar-icons' // Decorative Win11-style taskbar strip that completes the desktop-mirror illusion // (spec 06 §4). This is SIMULATED OS chrome, never the user's real taskbar: neutral @@ -48,11 +48,21 @@ function useClock() { return now } -export function TaskbarStrip({ height }: { height: number }) { +export function TaskbarStrip({ + height, + pins, +}: { + height: number + /** REAL scanned taskbar pins (ADR-0024), pre-rendered by the caller through + * the normal Tile renderer. Empty/absent → the decorative fixture pack keeps + * the mirror illusion (browser mock, desktop-only scan). */ + pins?: { key: string; glyph: React.ReactNode }[] +}) { const now = useClock() const hh = String(now.getHours()).padStart(2, '0') const mm = String(now.getMinutes()).padStart(2, '0') const date = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}` + const real = (pins?.length ?? 0) > 0 return (
{/* Pinned row — centered on the whole bar (Win11 center alignment). - REAL extracted icons from the dev fixture pack (owner order - 2026-07-09); the drawn vectors in taskbar-icons.tsx are only the - fixture-less fallback. */} + ADR-0024: the REAL scanned pins render through the normal Tile renderer + (styled previews); Start + Search remain SCENERY (the OS's own buttons, + never style targets — spec 06 §4), and with no real pins the fixture + pack completes the mock exactly as before. */}
- {TASKBAR_PINNED.map((g) => ( - - - - ))} + + } /> + + + } /> + + {real + ? pins!.map((p) => {p.glyph}) + : TASKBAR_PINNED.filter((g) => g.key !== 'start' && g.key !== 'search').map((g) => ( + + + + ))}
{/* Tray cluster — right edge */} diff --git a/src/components/common/ceremony.tsx b/src/components/common/ceremony.tsx index 6f759349..e6dcc52c 100644 --- a/src/components/common/ceremony.tsx +++ b/src/components/common/ceremony.tsx @@ -108,12 +108,18 @@ export function ConfirmSheet({ export function ConsentSheet({ open, count, + taskbarCount = 0, + startMenuCount = 0, multiUser = false, onAgree, onCancel, }: { open: boolean count: number + /** ADR-0024: styleable items on the ENABLED extra surfaces (the caller gates + * them by the surface legs). Both zero keeps the classic desktop-only copy. */ + taskbarCount?: number + startMenuCount?: number multiUser?: boolean onAgree: () => void onCancel: () => void @@ -121,6 +127,18 @@ export function ConsentSheet({ const t = useT() const rows: { glyph: string; text: string }[] = [ { glyph: '✓', text: t('ConsentWhatFormat').replace('{0}', String(count)) }, + // ADR-0024 informed consent: when a surface leg is ON, name its exact scope + // and the non-goal (the shortcuts' icons only — never the apps' files). + ...(taskbarCount + startMenuCount > 0 + ? [ + { + glyph: '📌', + text: t('ConsentAlsoSurfaces') + .replace('{0}', String(taskbarCount)) + .replace('{1}', String(startMenuCount)), + }, + ] + : []), { glyph: '🛡', text: t('ConsentNot') }, // Informed consent (owner 2026-07-17): applying restarts the shell so the desktop re-resolves // folder/.url custom icons (the only reliable refresh) — that closes any open folder windows. diff --git a/src/components/panels/icons-panel.tsx b/src/components/panels/icons-panel.tsx index 37bdbeaa..1e04557f 100644 --- a/src/components/panels/icons-panel.tsx +++ b/src/components/panels/icons-panel.tsx @@ -51,6 +51,7 @@ export function IconsPanel() { const t = useT() const { state, phase, statusText, ctaText } = useIconsHero() const bareLook = useIcons((s) => s.bareLook) + const items = useIcons((s) => s.items) // A host op is in flight when the modal is up (`working`) OR a slow arrow-restore is running // (`overlayRestoring`, which holds no `working` flag) — both must inert the version-jump crossings // (codex R4-Minor: a control that looks clickable but silently no-ops is worse than a disabled one). @@ -859,6 +860,10 @@ export function IconsPanel() { i.surface === 'taskbar' && i.styleable).length : 0} + startMenuCount={state.surfaces.startMenu ? items.filter((i) => i.surface === 'startMenu' && i.styleable).length : 0} multiUser={state.activeUserProfiles > 1} onAgree={() => { // v2 records that the machine-wide arrow disclosure was shown; keep diff --git a/src/components/panels/icons-participation.tsx b/src/components/panels/icons-participation.tsx index 522db68b..32157c16 100644 --- a/src/components/panels/icons-participation.tsx +++ b/src/components/panels/icons-participation.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { AnimatePresence, motion, useReducedMotion } from 'motion/react' -import { Check, ChevronDown, RotateCcw } from 'lucide-react' +import { Check, ChevronDown, LayoutGrid, Monitor, PanelBottom, RotateCcw } from 'lucide-react' import { cn } from '@/lib/utils' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { PropertyRow, SwatchButton, SwatchPicker, swatchButtonClass } from '@/components/common/inspector' @@ -11,7 +11,7 @@ import { ALL_SHAPES, TYPE_PLATE_SWATCHES, paleOf } from '@/components/panels/ico import { useIcons } from '@/stores/icons' import { BUCKET_NAME_KEY, KIND_BUCKETS, kindBucket } from '@/lib/kind-policy' import { typeIsCustom } from '@/lib/type-config' -import type { ConfigDto, IconKindBucket, TypeOverrides, TypePatch } from '@/bridge/types' +import type { ConfigDto, IconKindBucket, Surfaces, TypeOverrides, TypePatch } from '@/bridge/types' import { format, useT } from '@/lib/i18n' // Participation surfaces (chief-UI/UX + owner 2026-07-09), both fed by one @@ -23,6 +23,12 @@ import { format, useT } from '@/lib/i18n' // same PropertyRow grammar as the styling axes, but with a checkbox corner so // they read as MULTI-select, never pick-one. Always visible — even a count-0 // bucket toggles, so a user can pre-opt-out a kind the desktop has none of yet. +// ADR-0024: the same row grammar grows a SURFACES group (live counts; the +// desktop row is the disabled always-on anchor) — the second participation +// axis, orthogonal to kindPolicy. + +/** The toggleable surface legs (the desktop is never a leg — always on). */ +const SURFACE_LEGS = ['taskbar', 'startMenu'] as const satisfies readonly (keyof Surfaces)[] /** The per-icon keep ledger — soft inset chip, pin dot, expandable cancel list. */ export function KeptBar() { @@ -110,6 +116,7 @@ export function KindTypeSection({ tryOn }: { tryOn: TypeTryOn }) { const t = useT() const items = useIcons((s) => s.items) const policy = useIcons((s) => s.state?.kindPolicy) + const surfaces = useIcons((s) => s.state?.surfaces) const config = useIcons((s) => s.state?.config) const rawMonoSwatches = useIcons((s) => s.state?.monoSwatches) // Owner 2026-07-09: pure black/white mono dots are redundant with the 黑白 @@ -129,7 +136,7 @@ export function KindTypeSection({ tryOn }: { tryOn: TypeTryOn }) { // keeps rendering its real persisted state. const bareLook = useIcons((s) => s.bareLook) const typeOverrides = bareLook ? undefined : rawTypeOverrides - const { setKindPolicy, setTypeOverride, setEditingBucket, resetTypeOverrides } = useIcons.getState() + const { setKindPolicy, setSurface, setTypeOverride, setEditingBucket, resetTypeOverrides } = useIcons.getState() const [openBucket, setOpenBucket] = React.useState(null) const reduced = useReducedMotion() @@ -143,13 +150,24 @@ export function KindTypeSection({ tryOn }: { tryOn: TypeTryOn }) { return c }, [items]) + // ADR-0024: live per-surface counts — what the scan actually found on each + // write surface (the honest number behind each checkbox). + const surfaceCounts = React.useMemo(() => { + const c = { taskbar: 0, startMenu: 0 } + for (const i of items) { + if (i.surface === 'taskbar') c.taskbar++ + else if (i.surface === 'startMenu') c.startMenu++ + } + return c + }, [items]) + // Scope feedback follows the open row; never leak a stale scope on unmount. React.useEffect(() => { setEditingBucket(openBucket) return () => setEditingBucket(null) }, [openBucket, setEditingBucket]) - if (!policy || !config) return null + if (!policy || !surfaces || !config) return null type PatchChange = Partial> @@ -461,6 +479,56 @@ export function KindTypeSection({ tryOn }: { tryOn: TypeTryOn }) {
) })} + {/* ADR-0024 SURFACES group — the second participation axis in the SAME + row grammar, with live scan counts. The desktop row is a disabled + always-on anchor so "why can't I turn the desktop off?" answers + itself; each leg flips ONE surface (one undo step) and disabling a + surface auto-restores it on the next apply (RESTORE-FIRST). */} +
+

{t('Axis_Surface')}

+
+ + + + + + + {t('Surface_Desktop')} + {t('Surface_Always')} +
+ {SURFACE_LEGS.map((leg) => { + const on = surfaces[leg] + const name = t(leg === 'taskbar' ? 'Surface_Taskbar' : 'Surface_StartMenu') + const Icon = leg === 'taskbar' ? PanelBottom : LayoutGrid + return ( +
+ + + + {name} + + {surfaceCounts[leg]} +
+ ) + })} +
) diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index bf3b6693..646678c7 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -189,6 +189,16 @@ export const en = { "Plate_NeedShape": "Pick a shape to set a plate", "Shortcut_UniformShape": "Shortcut shape", "Shortcut_ShapeGhost": "Shortcut uniform shape overrides type shapes", + // ADR-0024 surfaces: the second participation axis (fork draft). + "Axis_Surface": "Beautified surfaces", + "Surface_Desktop": "Desktop", + "Surface_DesktopTip": "The desktop is always styled — this one never turns off", + "Surface_Taskbar": "Taskbar", + "Surface_StartMenu": "Start menu", + "Surface_Always": "Always on", + "Start_Pinned": "Pinned", + "Start_Empty": "No Start menu shortcuts found", + "Taskbar_NoPins": "No pinned shortcuts on the taskbar", "Color_Mono": "Mono", "Color_Orig": "Original", "ComboLabel": "Style", @@ -203,6 +213,7 @@ export const en = { "Compare_Short": "Compare", "ComingSoon": "Coming soon", // PENDING-RESX (v3 filter roadmap slot) "ConsentAgree": "OK, beautify", + "ConsentAlsoSurfaces": "It also styles {0} taskbar pins and {1} Start menu shortcuts — your shortcuts' icons only, never the apps' files", // PENDING-RESX (ADR-0024) "ConsentArrow": "When you apply a look, we hide the small shortcut arrow Windows adds and let DeskMakeover draw a cleaner one instead. This takes effect for the whole computer, so it also touches shortcuts outside the desktop and on other accounts on this PC; you can restore it any time in Settings with one click.", // PENDING-RESX (arrow-restore panel 2026-07-11) "ConsentCancel": "Not yet", "ConsentNot": "What does not happen: no files are deleted, moved, or modified", diff --git a/src/lib/i18n/zh-hans.ts b/src/lib/i18n/zh-hans.ts index 72cfba26..824ce5a0 100644 --- a/src/lib/i18n/zh-hans.ts +++ b/src/lib/i18n/zh-hans.ts @@ -191,6 +191,16 @@ export const zhHans: Record = { "Plate_NeedShape": "选一个形状后可换底色", // PENDING-RESX (ADR-0018) "Shortcut_UniformShape": "快捷方式形状", // PENDING-RESX (ADR-0017, 无=不统一) "Shortcut_ShapeGhost": "快捷方式已统一形状,覆盖各类型形状", // PENDING-RESX (ADR-0017) + // ADR-0024 美化范围:第二参与轴(fork 草案)。 + "Axis_Surface": "美化范围", + "Surface_Desktop": "桌面", + "Surface_DesktopTip": "桌面始终参与美化,不可关闭", + "Surface_Taskbar": "任务栏", + "Surface_StartMenu": "开始菜单", + "Surface_Always": "始终开启", + "Start_Pinned": "已固定", + "Start_Empty": "未找到开始菜单快捷方式", + "Taskbar_NoPins": "未找到任务栏固定快捷方式", "Color_Mono": "单色", "Color_Orig": "原彩", "ComboLabel": "风格", @@ -205,6 +215,7 @@ export const zhHans: Record = { "Compare_Short": "对比", "ComingSoon": "即将推出", // PENDING-RESX (v3 filter roadmap slot) "ConsentAgree": "好,开始美化", + "ConsentAlsoSurfaces": "同时还会美化 {0} 个任务栏固定图标和 {1} 个开始菜单快捷方式——只改快捷方式的图标,绝不动应用本身的文件", // PENDING-RESX (ADR-0024) "ConsentArrow": "应用美化时,我们会隐藏 Windows 自带的快捷方式小箭头,改由 DeskMakeover 统一绘制,让图标更清爽。这项改动对整台电脑生效,也会影响桌面以外、以及本机其他账户的快捷方式;随时可以在设置里一键恢复。", // PENDING-RESX (arrow-restore panel 2026-07-11) "ConsentCancel": "再想想", "ConsentNot": "不会发生:不删除、不移动、不修改你的任何文件", diff --git a/src/lib/icon-look.ts b/src/lib/icon-look.ts index 021abcd3..e963621e 100644 --- a/src/lib/icon-look.ts +++ b/src/lib/icon-look.ts @@ -3,10 +3,12 @@ import type { IconKindBucket, IconShape, KindPolicy, + Surfaces, TypeOverrides, TypePatch, } from '@/bridge/types' import type { IconStyleRecipe } from '@/lib/icons-assemble' +import { DEFAULT_SURFACES, normalizeSurfaces } from '@/lib/kind-policy' import { TYPE_PATCH_KEYS } from '@/lib/type-config' import { ICON_LOOK_VERSION, migrateIconLook } from '@/lib/preset-migrations' @@ -19,11 +21,13 @@ import { ICON_LOOK_VERSION, migrateIconLook } from '@/lib/preset-migrations' /** What a preset package entry carries (spec 09 §2): kindPolicy only rides when * the exporter opted in (owner decision #4 — participation is a per-machine - * choice, not aesthetics). */ + * choice, not aesthetics). `surfaces` (ADR-0024) follows the SAME rule: absent + * on style-only payloads, present when the host recipe carried it. */ export interface IconLookPayload { config: ConfigDto typeOverrides: TypeOverrides kindPolicy?: KindPolicy + surfaces?: Surfaces } // ---- Enum whitelists (validator truth; compile-checked BOTH ways) ---------- @@ -71,9 +75,10 @@ export function serializeIconLook(payload: IconLookPayload): string { return JSON.stringify({ v: ICON_LOOK_VERSION, config: payload.config, - // JSON.stringify drops undefined — an omitted kindPolicy writes no key. + // JSON.stringify drops undefined — an omitted kindPolicy/surfaces writes no key. kindPolicy: payload.kindPolicy, typeOverrides: payload.typeOverrides, + surfaces: payload.surfaces, }) } @@ -106,6 +111,9 @@ export function parseIconLook(json: string | null): IconStyleRecipe | null { config: payload.config, kindPolicy: (payload.kindPolicy ?? {}) as KindPolicy, typeOverrides: payload.typeOverrides, + // Absent surfaces reads as OPTED OUT (ADR-0024) — a legacy recipe or a style-only + // preset never inherits consent to write the taskbar/Start menu. + surfaces: payload.surfaces ?? { ...DEFAULT_SURFACES }, } } @@ -122,7 +130,12 @@ export function normalizeIconLook(raw: unknown): IconLookPayload | null { if (typeOverrides === null) return null const kindPolicy = normalizeKindPolicy(obj.kindPolicy) if (kindPolicy === null) return null - return kindPolicy === undefined ? { config, typeOverrides } : { config, typeOverrides, kindPolicy } + const surfaces = normalizeSurfaces(obj.surfaces) + if (surfaces === null) return null + const out: IconLookPayload = { config, typeOverrides } + if (kindPolicy !== undefined) out.kindPolicy = kindPolicy + if (surfaces !== undefined) out.surfaces = surfaces + return out } function normalizeConfig(raw: unknown): ConfigDto | null { diff --git a/src/lib/icons-assemble.ts b/src/lib/icons-assemble.ts index 5aa9dbdd..89493bb0 100644 --- a/src/lib/icons-assemble.ts +++ b/src/lib/icons-assemble.ts @@ -15,17 +15,21 @@ import type { KindPolicy, LookVersionDto, PresetDto, + Surfaces, TypeOverrides, } from '@/bridge/types' -import { DEFAULT_KIND_POLICY } from '@/lib/kind-policy' +import { DEFAULT_KIND_POLICY, DEFAULT_SURFACES } from '@/lib/kind-policy' import { parseIconLook } from '@/lib/icon-look' import { typeOverridesEqual } from '@/lib/type-config' -/** The three global knobs a saved appearance recipe carries (store ②③, spec 07 §8.2). */ +/** The knobs a saved appearance recipe carries (store ②③, spec 07 §8.2): the + * look (`config` + `typeOverrides`) and the TWO participation axes — kindPolicy + * (which kinds) and surfaces (which shell surfaces, ADR-0024). */ export interface IconStyleRecipe { config: ConfigDto kindPolicy: KindPolicy typeOverrides: TypeOverrides + surfaces: Surfaces } // ---- Preset collection v3 (OWNER-CURATED, hand-tuned exports 2026-07-16) ---- @@ -168,6 +172,7 @@ export function defaultRecipe(kindPolicy: KindPolicy): IconStyleRecipe { config: { ...BASE_CONFIGS[DEFAULT_PRESET_ID] }, kindPolicy: { ...kindPolicy }, typeOverrides: structuredClone(PRESET_TYPE_OVERRIDES[DEFAULT_PRESET_ID] ?? {}), + surfaces: { ...DEFAULT_SURFACES }, } } @@ -194,6 +199,7 @@ export function parseHistory(history: LookVersionDto[], savedStyleJson: string | isCurrent: savedStyleJson !== null && v.styleJson === savedStyleJson, config: recipe?.config ?? { ...BASE_CONFIGS[DEFAULT_PRESET_ID] }, kindPolicy: recipe?.kindPolicy ?? { ...DEFAULT_KIND_POLICY }, + surfaces: recipe?.surfaces ?? { ...DEFAULT_SURFACES }, typeOverrides: recipe?.typeOverrides ?? {}, } }) @@ -280,6 +286,7 @@ export function assembleIconsState(args: { grid: iconGrid(draft.config.size, gridMetrics), wallpaperUrl, kindPolicy: { ...draft.kindPolicy }, + surfaces: { ...draft.surfaces }, typeOverrides: structuredClone(draft.typeOverrides), arrowOverlay: persisted.arrowOverlay, activeUserProfiles: persisted.activeUserProfiles, diff --git a/src/lib/kind-policy.ts b/src/lib/kind-policy.ts index 16d50a8d..24f156ae 100644 --- a/src/lib/kind-policy.ts +++ b/src/lib/kind-policy.ts @@ -1,4 +1,4 @@ -import type { IconKind, IconKindBucket, KindPolicy } from '@/bridge/types' +import type { IconKind, IconKindBucket, KindPolicy, Surface, Surfaces } from '@/bridge/types' import type { StringKey } from '@/lib/i18n' // The participation-policy bucketing (chief-UI/UX + owner 2026-07-09): three @@ -57,3 +57,47 @@ export function kindParticipates(kind: IconKind, policy: KindPolicy): boolean { const bucket = kindBucket(kind) return bucket === null ? true : policy[bucket] } + +// ---- Surface participation (ADR-0024) — the second participation axis -------- + +/** Every NEW write surface starts OPTED OUT (ADR-0024): consent to write the + * taskbar / Start menu is granted explicitly, never inherited — deliberately + * the OPPOSITE default of kindPolicy's participate-by-default. */ +export const DEFAULT_SURFACES: Surfaces = { taskbar: false, startMenu: false } + +/** The `surfaces` legs — a CLOSED whitelist (G11): only these keys exist, any + * other key in a `surfaces` object is dropped on read (additive tolerance, + * mirroring unknown kind buckets). */ +const SURFACE_KEYS = ['taskbar', 'startMenu'] as const satisfies readonly (keyof Surfaces)[] + +/** Whether an item on `surface` participates under `surfaces`. The desktop is + * NEVER gated here — it is the app's core promise; kindPolicy governs it alone. */ +export function surfaceParticipates(surface: Surface, surfaces: Surfaces): boolean { + switch (surface) { + case 'desktop': + return true + case 'taskbar': + return surfaces.taskbar + case 'startMenu': + return surfaces.startMenu + } +} + +/** G11 normalizer for a recipe's `surfaces` leg: undefined → undefined (absent + * — a style-only preset stays clean); null/non-object → null (REJECT the + * recipe, never render garbage); unknown keys dropped; a KNOWN key with a + * non-boolean value rejects the whole leg. Missing known legs default off + * (opt-in consent must never be inferred). */ +export function normalizeSurfaces(raw: unknown): Surfaces | undefined | null { + if (raw === undefined) return undefined + if (raw === null || typeof raw !== 'object') return null + const src = raw as Record + const out: Surfaces = { ...DEFAULT_SURFACES } + for (const key of SURFACE_KEYS) { + const v = src[key] + if (v === undefined) continue + if (typeof v !== 'boolean') return null + out[key] = v + } + return out +} diff --git a/src/stores/icons.ts b/src/stores/icons.ts index d40f6592..f3a0f041 100644 --- a/src/stores/icons.ts +++ b/src/stores/icons.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { call } from '@/bridge/client' -import type { ConfigDto, GridMetricsDto, IconItemDto, IconOpResultDto, IconPersistedDto, IconScanDto, IconsStateDto, IconKindBucket, KindPolicy, TypeOverrideEntry, TypeOverrides } from '@/bridge/types' -import { DEFAULT_KIND_POLICY, kindBucket, kindParticipates } from '@/lib/kind-policy' +import type { ConfigDto, GridMetricsDto, IconItemDto, IconOpResultDto, IconPersistedDto, IconScanDto, IconsStateDto, IconKindBucket, KindPolicy, Surfaces, TypeOverrideEntry, TypeOverrides } from '@/bridge/types' +import { DEFAULT_KIND_POLICY, DEFAULT_SURFACES, kindBucket, kindParticipates, surfaceParticipates } from '@/lib/kind-policy' import { appAccentSeed, resolveTypeConfig, typeAssertsShape, typeHasFixedPlate } from '@/lib/type-config' import { activePresetIdOf as activePresetIdOfRecipe, assembleIconsState, defaultRecipe, parseHistory, parseRecipe, SYSTEM_DEFAULT_CONFIG, type IconStyleRecipe } from '@/lib/icons-assemble' import { serializeIconLook } from '@/lib/icon-look' @@ -55,6 +55,7 @@ interface LookSnapshot { config: ConfigDto overrides: Map kindPolicy: KindPolicy + surfaces: Surfaces typeOverrides: TypeOverrides /** The System-Default reset intent rides history too (undo un-selects it). */ bareLook: boolean @@ -128,6 +129,10 @@ interface IconsState { setOverride: (id: string, mode: 'keep' | 'tint' | 'follow', tint?: string) => void clearOverrides: () => void setKindPolicy: (bucket: IconKindBucket, styled: boolean) => void + /** Per-surface participation (ADR-0024): toggles ONE surface leg (taskbar / + * startMenu — the desktop is always on). One undo step; like kindPolicy it + * never lifts the System-Default lens. */ + setSurface: (leg: keyof Surfaces, on: boolean) => void /** Per-type style source (ADR-0017): null/global clears the bucket's patch. */ setTypeOverride: (bucket: IconKindBucket, entry: TypeOverrideEntry | null) => void /** One-click wipe of EVERY type's style override (owner 2026-07-10) — @@ -221,8 +226,8 @@ export function displaySize(state: IconsStateDto | null, scale: number, dpr = wi return Math.min(256, Math.max(24, Math.ceil(raw / 4) * 4)) } -/** The config a tile actually renders with. Cascade (spec 06 §6): styleable:false - * > per-icon override > kindPolicy default. A per-icon override always wins over +/** The config a tile actually renders with. Cascade (spec 06 §6 + ADR-0024): styleable:false + * > surface gate > per-icon override > kindPolicy default. A per-icon override always wins over * the type policy — an icon the user styled individually stays styled even if its * whole bucket is opted out. */ export function effectiveTileConfig( @@ -230,8 +235,14 @@ export function effectiveTileConfig( config: ConfigDto, policy: KindPolicy, typeOverrides?: TypeOverrides, + surfaces: Surfaces = DEFAULT_SURFACES, ): { config: ConfigDto; showOriginal: boolean } { if (!item.styleable || item.overrideMode === 'keep') return { config, showOriginal: true } + // Surface gate (ADR-0024), immediately below styleable/keep: a disabled surface renders + // as-original AND rides restoreIds, so turning a surface OFF auto-restores it on the next + // apply (RESTORE-FIRST). A per-icon tint never rescues a surface the user turned off — the + // participation gate is enforced HERE once, so render, bakeSet and restore stay coherent. + if (!surfaceParticipates(item.surface, surfaces)) return { config, showOriginal: true } // ADR-0017 composition invariant: style = resolve(bucket) + shortcut layer. // The type ladder resolves FIRST. Shape precedence (owner 2026-07-16): a // bucket's own custom shape BEATS the opt-in uniform shortcut shape — a @@ -359,6 +370,7 @@ export const useIcons = create((set, get) => { s.items.filter((i) => i.overrideMode !== null).map((i) => [i.id, { mode: i.overrideMode as 'keep' | 'tint', tint: i.overrideTint }]), ), kindPolicy: { ...s.state!.kindPolicy }, + surfaces: { ...s.state!.surfaces }, typeOverrides: structuredClone(s.state!.typeOverrides), bareLook: s.bareLook, } @@ -368,7 +380,7 @@ export const useIcons = create((set, get) => { const s = get() if (!s.state) return set({ - state: { ...s.state, config: { ...snap.config }, kindPolicy: { ...snap.kindPolicy }, typeOverrides: structuredClone(snap.typeOverrides) }, + state: { ...s.state, config: { ...snap.config }, kindPolicy: { ...snap.kindPolicy }, surfaces: { ...snap.surfaces }, typeOverrides: structuredClone(snap.typeOverrides) }, items: s.items.map((i) => { const o = snap.overrides.get(i.id) return { ...i, overrideMode: o?.mode ?? null, overrideTint: o?.tint ?? null } @@ -500,10 +512,10 @@ export const useIcons = create((set, get) => { if (sourcesLoading === 0) recomputeHueSpread() // all sources were cached } - /** The store's live draft (config/kindPolicy/typeOverrides), or null before the first scan. */ + /** The store's live draft (config/kindPolicy/surfaces/typeOverrides), or null before the first scan. */ const currentDraft = (): IconStyleRecipe | null => { const s = get().state - return s ? { config: s.config, kindPolicy: s.kindPolicy, typeOverrides: s.typeOverrides } : null + return s ? { config: s.config, kindPolicy: s.kindPolicy, typeOverrides: s.typeOverrides, surfaces: s.surfaces } : null } /** True while ANY host round-trip is in flight — apply / restore (`working`), arrow-restore @@ -866,6 +878,21 @@ export const useIcons = create((set, get) => { schedulePersist() }, + // Surface participation (ADR-0024): the SECOND axis, orthogonal to kindPolicy — + // governs which SHELL SURFACES a global Apply writes (taskbar / Start menu; the + // desktop is always on). Same lens + persistence rules as kindPolicy: one undo + // step per flip, never lifts bareLook. Disabling a surface auto-restores it on + // the next apply — the surface gate in effectiveTileConfig routes its tiles to + // restoreIds (RESTORE-FIRST). + setSurface: (leg, on) => { + if (busy()) return + const s = get() + if (!s.state || s.state.surfaces[leg] === on) return + pushUndo() + set({ state: markDirty({ ...s.state, surfaces: { ...s.state.surfaces, [leg]: on } }) }) + schedulePersist() + }, + // Lens rule (spec 06 §3.13): writing a CUSTOM patch is a value-asserting edit // and lifts the System-Default lens; clearing back to 跟随全局 is a // toward-default action and keeps it. @@ -942,10 +969,11 @@ export const useIcons = create((set, get) => { const config = s.state.config const policy = s.state.kindPolicy const typeOverrides = s.state.typeOverrides + const surfaces = s.state.surfaces // Bake only tiles that are actually beautified — styleable, not per-icon - // kept, and their bucket participates (kindPolicy). showOriginal tiles are - // RESTORE-FIRST, no master. - const bakeSet = s.items.filter((i) => !effectiveTileConfig(i, config, policy, typeOverrides).showOriginal) + // kept, their surface is ON (ADR-0024) and their bucket participates + // (kindPolicy). showOriginal tiles are RESTORE-FIRST, no master. + const bakeSet = s.items.filter((i) => !effectiveTileConfig(i, config, policy, typeOverrides, surfaces).showOriginal) const jobs = bakeSet.flatMap((item) => item.sourceUrls.map((url, sourceIndex) => ({ item, url, sourceIndex })), ) @@ -955,7 +983,7 @@ export const useIcons = create((set, get) => { // unreadable → styleable:false) must still be revertable — Rust CAS-gates each revert and // safely no-ops on an id with no ledger row, so a never-styled degraded item is harmless. const restoreIds = s.items - .filter((i) => effectiveTileConfig(i, config, policy, typeOverrides).showOriginal) + .filter((i) => effectiveTileConfig(i, config, policy, typeOverrides, surfaces).showOriginal) .map((i) => i.id) set({ state: { ...s.state, working: true }, applyProgress: { done: 0, total: jobs.length } }) try { @@ -976,7 +1004,7 @@ export const useIcons = create((set, get) => { const chunk = jobs.slice(at, at + APPLY_CHUNK) const rendered: { id: string; sourceIndex: number; masterPng: string }[] = [] for (const j of chunk) { - const eff = effectiveTileConfig(j.item, config, policy, typeOverrides) + const eff = effectiveTileConfig(j.item, config, policy, typeOverrides, surfaces) const png = await compositor.bakeMasterPng(bakeSourceId(j.item.id, j.sourceIndex), eff.config, j.item.isShortcut, frozenOpts.get(j.item.id)) if (!png) throw new Error(`master missing: ${j.item.id}#${j.sourceIndex}`) rendered.push({ id: j.item.id, sourceIndex: j.sourceIndex, masterPng: png }) @@ -988,12 +1016,13 @@ export const useIcons = create((set, get) => { } // The full recipe rides as an opaque JSON string (Rust persists it as ②③); a tint override // is baked into its master. `restoreIds` carries the 「保留原样」 items so Rust reverts any - // that are currently styled (spec 06 §2). The styleJson is the three global knobs (spec 07 - // §8.2), serialized by the ONE versioned serializer (lib/icon-look, spec 09 §1). - const styleJson = serializeIconLook({ config, kindPolicy: policy, typeOverrides }) + // that are currently styled (spec 06 §2). The styleJson is the recipe's participation + + // look knobs (spec 07 §8.2 + ADR-0024), serialized by the ONE versioned serializer + // (lib/icon-look, spec 09 §1). + const styleJson = serializeIconLook({ config, kindPolicy: policy, typeOverrides, surfaces }) const result = await bridge('icons.applyBakedCommit', { sessionId, styleJson, restoreIds, label: lookLabel(s.state) }) // The attempted recipe becomes the draft; assemble against the persisted ②③ truth. - const attempted: IconStyleRecipe = { config, kindPolicy: policy, typeOverrides } + const attempted: IconStyleRecipe = { config, kindPolicy: policy, typeOverrides, surfaces } const assembled = assembleIconsState({ draft: attempted, items: get().items, @@ -1117,9 +1146,10 @@ export const useIcons = create((set, get) => { if (!entry || !s.state) return pushUndo() set({ - // 回到此版 restores the FULL recipe — config + kindPolicy (participation) + typeOverrides — - // so a history look never applies A's visuals with B's participation policy (codex Major 2). - state: markDirty({ ...s.state, config: { ...entry.config }, kindPolicy: { ...entry.kindPolicy }, typeOverrides: structuredClone(entry.typeOverrides) }), + // 回到此版 restores the FULL recipe — config + kindPolicy (participation) + surfaces + // (ADR-0024) + typeOverrides — so a history look never applies A's visuals with B's + // participation policy (codex Major 2). + state: markDirty({ ...s.state, config: { ...entry.config }, kindPolicy: { ...entry.kindPolicy }, surfaces: { ...entry.surfaces }, typeOverrides: structuredClone(entry.typeOverrides) }), hoverConfig: null, hoverTypeOverrides: null, bareLook: false, @@ -1141,18 +1171,19 @@ export const useIcons = create((set, get) => { const config = s.state.config const policy = s.state.kindPolicy const overrides = s.state.typeOverrides + const surfaces = s.state.surfaces // Styled tiles first (the sheet advertises the makeover); when nothing is styled yet the // first tiles stand in, exactly the oracle's fallback. const eligible = s.items.filter((i) => i.sourceUrls.length > 0) const styledSet = eligible.filter( - (i) => i.styleable && !s.bareLook && !effectiveTileConfig(i, config, policy, overrides).showOriginal, + (i) => i.styleable && !s.bareLook && !effectiveTileConfig(i, config, policy, overrides, surfaces).showOriginal, ) const pool = (styledSet.length > 0 ? styledSet : eligible).slice(0, 8) if (pool.length === 0) return fail() const tiles: CompareTile[] = [] for (const item of pool) { await compositor.loadSource(item.id, item.sourceUrls[0]) - const eff = effectiveTileConfig(item, config, policy, overrides) + const eff = effectiveTileConfig(item, config, policy, overrides, surfaces) const opts = fieldRenderOpts(item.id) // Before = the untouched original render (codex icons2-🟠7: the oracle draws the // resolved OriginalImage, not the raw source — a shortcut Before still gets its classic diff --git a/tests/arrow-restore-integration.test.ts b/tests/arrow-restore-integration.test.ts index 0b6f2d84..5a29f168 100644 --- a/tests/arrow-restore-integration.test.ts +++ b/tests/arrow-restore-integration.test.ts @@ -255,6 +255,7 @@ describe('store restore logic — fake bridge for deterministic timing', () => { // contracts (owner 2026-07-17): it PRESERVES per-icon overrides, and it holds single-flight. const scanItem = (id: string): IconItemDto => ({ id, label: id, kind: 'Shortcut', isShortcut: true, styleable: true, statusReason: null, + surface: 'desktop', parent: '', x: 0, y: 0, sourceUrls: [], overrideMode: null, overrideTint: null, }) diff --git a/tests/icon-look.test.ts b/tests/icon-look.test.ts index f6ee6f09..aec7db51 100644 --- a/tests/icon-look.test.ts +++ b/tests/icon-look.test.ts @@ -65,6 +65,16 @@ describe('serialize/parse round-trip', () => { expect(back!.config.plateColor).toBeNull() expect(back!.config.shortcutShape).toBeNull() }) + + test('surfaces (ADR-0024) round-trips; an absent leg reads as opted out', () => { + const withSurfaces = { ...recipe, surfaces: { taskbar: true, startMenu: false } } + const back = parseIconLook(serializeIconLook(withSurfaces)) + expect(back!.surfaces).toEqual({ taskbar: true, startMenu: false }) + // Legacy recipes (pre-ADR-0024) carry NO surfaces → they read as opted out — consent + // is never inherited by an existing recipe. + const legacy = JSON.stringify({ config, kindPolicy: recipe.kindPolicy, typeOverrides: {} }) + expect(parseIconLook(legacy)!.surfaces).toEqual({ taskbar: false, startMenu: false }) + }) }) describe('fail-closed behavior (spec 09 §3)', () => { @@ -113,6 +123,16 @@ describe('normalizeIconLook (the import validator)', () => { expect(normalizeIconLook({ config })!.kindPolicy).toBeUndefined() expect(normalizeIconLook({ config, kindPolicy: { App: false } })!.kindPolicy).toEqual({ App: false } as never) }) + + test('surfaces (ADR-0024) is optional, preserved when present, fail-closed when junk', () => { + // Absent → absent (presence-preserving, like kindPolicy): a style-only preset stays clean. + expect(normalizeIconLook({ config })!.surfaces).toBeUndefined() + // A partial leg fills the missing surface with the OPT-IN default (false), never true. + expect(normalizeIconLook({ config, surfaces: { taskbar: true } })!.surfaces).toEqual({ taskbar: true, startMenu: false } as never) + // G11 closed whitelist: a KNOWN leg with a non-boolean REJECTS; unknown legs drop. + expect(normalizeIconLook({ config, surfaces: { taskbar: 'yes' } })).toBeNull() + expect(normalizeIconLook({ config, surfaces: { taskbar: true, futureSurface: true } })!.surfaces).toEqual({ taskbar: true, startMenu: false } as never) + }) }) describe('migration chain mechanics', () => { diff --git a/tests/icons-store.test.ts b/tests/icons-store.test.ts index d3d546e5..c13b6b25 100644 --- a/tests/icons-store.test.ts +++ b/tests/icons-store.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import type { ConfigDto, IconItemDto, IconsStateDto } from '../src/bridge/types' import { __setBridgeForTests, effectiveTileConfig, persistBareLook, readBareLook, resetIconsHistoryForTests, resumeStatusKey, useIcons } from '../src/stores/icons' -import { DEFAULT_KIND_POLICY } from '../src/lib/kind-policy' +import { DEFAULT_KIND_POLICY, DEFAULT_SURFACES } from '../src/lib/kind-policy' import { SYSTEM_DEFAULT_CONFIG } from '../src/lib/icons-assemble' // Undo granularity + override semantics (spec 06 §3.3/§3.4): one step per @@ -27,6 +27,8 @@ const item = (id: string, over: Partial = {}): IconItemDto => ({ isShortcut: true, styleable: true, statusReason: null, + surface: 'desktop', + parent: '', x: 0, y: 0, sourceUrls: [], @@ -54,6 +56,7 @@ const state = (): IconsStateDto => ({ }, wallpaperUrl: null, kindPolicy: { ...DEFAULT_KIND_POLICY }, + surfaces: { ...DEFAULT_SURFACES }, arrowOverlay: 'native', activeUserProfiles: 1, }) @@ -185,6 +188,36 @@ describe('effective tile config (override folding)', () => { expect(forced.showOriginal).toBe(false) expect(forced.config.subject).toBe('Mono') }) + + test('surface gate (ADR-0024): a disabled surface renders original, even under a tint override', () => { + const pin = item('p', { surface: 'taskbar' }) + // Omitted surfaces = the DEFAULT (opted out) — a taskbar pin renders original. + expect(effectiveTileConfig(pin, config, DEFAULT_KIND_POLICY).showOriginal).toBe(true) + // With the leg ON the pin inherits the global config (no per-surface config exists in v1). + const on = effectiveTileConfig(pin, config, DEFAULT_KIND_POLICY, undefined, { taskbar: true, startMenu: false }) + expect(on.showOriginal).toBe(false) + expect(on.config).toEqual({ ...config, autoSeparation: true }) + // OFF again → original; the tile also rides restoreIds in apply() (auto-restore). + expect( + effectiveTileConfig(pin, config, DEFAULT_KIND_POLICY, undefined, { taskbar: false, startMenu: false }).showOriginal, + ).toBe(true) + // A per-icon tint NEVER rescues a surface the user turned off (RESTORE-FIRST). + const tinted = item('p2', { surface: 'taskbar', overrideMode: 'tint', overrideTint: '#3FB6A8' }) + expect(effectiveTileConfig(tinted, config, DEFAULT_KIND_POLICY, undefined, DEFAULT_SURFACES).showOriginal).toBe(true) + }) + + test('surface gate: the desktop is NEVER gated — kindPolicy alone governs it', () => { + const desk = item('a') + for (const surfaces of [DEFAULT_SURFACES, { taskbar: true, startMenu: true }]) { + expect(effectiveTileConfig(desk, config, DEFAULT_KIND_POLICY, undefined, surfaces).showOriginal).toBe(false) + } + }) + + test('surface gate: the start-menu leg gates its items independently of the taskbar leg', () => { + const tile = item('s', { surface: 'startMenu' }) + expect(effectiveTileConfig(tile, config, DEFAULT_KIND_POLICY, undefined, { taskbar: true, startMenu: false }).showOriginal).toBe(true) + expect(effectiveTileConfig(tile, config, DEFAULT_KIND_POLICY, undefined, { taskbar: false, startMenu: true }).showOriginal).toBe(false) + }) }) // Target-resolved shortcuts (owner 2026-07-16): kind carries TARGET semantics @@ -406,6 +439,30 @@ describe('System-Default lens (spec 06 §3.13)', () => { }) }) +// Surface participation (ADR-0024): same undo + lens + persistence discipline as +// kindPolicy — one step per flip, never lifts the System-Default lens. +describe('surface participation (ADR-0024)', () => { + test('setSurface flips ONE leg as one undo step and marks the draft dirty', () => { + // `dirty` only means "the live desktop differs from the draft", so it exists + // once a look is applied — mirror that state here. + useIcons.setState({ state: { ...state(), applied: true } }) + useIcons.getState().setSurface('taskbar', true) + expect(useIcons.getState().state!.surfaces.taskbar).toBe(true) + expect(useIcons.getState().state!.dirty).toBe(true) + expect(useIcons.getState().state!.surfaces.startMenu).toBe(false) // the other leg untouched + useIcons.getState().undo() + expect(useIcons.getState().state!.surfaces.taskbar).toBe(false) + }) + + test('surface toggles are orthogonal to the System-Default lens (lens rule)', () => { + const s = useIcons.getState() + s.selectSystemDefault() + useIcons.getState().setSurface('startMenu', true) + expect(useIcons.getState().state!.surfaces.startMenu).toBe(true) + expect(useIcons.getState().bareLook).toBe(true) + }) +}) + // A3: the resume status line maps the SAME applied/dirty signals every module // reads to an honest phrase — an un-applied draft never silently reads "applied". describe('resume status line (A3)', () => { From 1fb26e6f06483dd59853d88eedf86ba10f594e46 Mon Sep 17 00:00:00 2001 From: Melu Date: Mon, 12 Oct 2026 23:43:18 -0300 Subject: [PATCH 03/16] feat(icons): shell surfaces participate by default (ADR-0024 owner amendment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision 2026-10-12: the taskbar + Start menu legs now default TRUE on both bridge sides (serde `impl Default for SurfacesJson` + `DEFAULT_SURFACES`), the same participate-by-default rule as kindPolicy — system-wide restyling is the app's promise. An explicit OFF leg still gates that surface (RESTORE-FIRST: its items ride restoreIds on the next apply) and the desktop stays never-gated. Round-trip/normalize/store/Rust tests updated for the new default; ADR-0024 §4.2 amended. --- .../dm-operations/src/icons/style_resolve.rs | 67 ++++++++++++++----- docs/decisions/0024-shell-surfaces.md | 7 +- src/bridge/types.ts | 9 +-- src/lib/kind-policy.ts | 15 +++-- tests/icon-look.test.ts | 14 ++-- tests/icons-store.test.ts | 27 ++++---- 6 files changed, 90 insertions(+), 49 deletions(-) diff --git a/crates/dm-operations/src/icons/style_resolve.rs b/crates/dm-operations/src/icons/style_resolve.rs index ef8e0c09..d3714d00 100644 --- a/crates/dm-operations/src/icons/style_resolve.rs +++ b/crates/dm-operations/src/icons/style_resolve.rs @@ -146,12 +146,12 @@ fn yes() -> bool { true } -/// Per-SURFACE participation (ADR-0024). The OPPOSITE default of [`KindPolicyJson`], on purpose: -/// every leg derives FALSE, so a recipe with a MISSING `surfaces` (and a missing leg inside an -/// explicit one) means OPTED OUT — the taskbar and Start menu are NEW write surfaces whose -/// consent must never be inherited from an existing recipe; the user flips them on explicitly. +/// Per-SURFACE participation (ADR-0024). Default amended by the owner 2026-10-12: every leg +/// defaults TRUE, so a recipe with a MISSING `surfaces` (and a missing leg inside an explicit +/// one) PARTICIPATES — system-wide restyling is the app's promise, the same +/// participate-by-default rule as [`KindPolicyJson`]. Opting a surface OUT is explicit. /// The desktop is not a leg here: it is never surface-gated (see `StyleRecipe::participates`). -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct SurfacesJson { /// Taskbar pins (`…\User Pinned\TaskBar\*.lnk`). @@ -160,6 +160,15 @@ pub struct SurfacesJson { pub start_menu: bool, } +impl Default for SurfacesJson { + fn default() -> Self { + Self { + taskbar: true, + start_menu: true, + } + } +} + impl SurfacesJson { fn allows(&self, surface: ItemSurface) -> bool { match surface { @@ -554,14 +563,16 @@ mod tests { } #[test] - fn surfaces_default_to_opted_out_but_the_desktop_is_never_gated() { - // ADR-0024: the NEW surfaces are opt-in CONSENT — a recipe with no `surfaces` key styles - // the taskbar/Start menu NOWHERE. Deliberately the OPPOSITE of kindPolicy's - // participate-by-default: an existing recipe must never inherit consent to write new - // surfaces. The desktop itself is never surface-gated — it is the app's core promise. + fn surfaces_default_to_participate_but_the_desktop_is_never_gated() { + // ADR-0024 default amended by the owner 2026-10-12: every surface participates BY + // DEFAULT (system-wide restyling is the app's promise — same rule as kindPolicy). An + // explicit OFF leg still gates, and the desktop is never surface-gated (core promise). let recipe: StyleRecipe = serde_json::from_value(json!({ "config": base_config(), "typeOverrides": {} })).unwrap(); - assert!(!recipe.surfaces.taskbar && !recipe.surfaces.start_menu, "both legs default off"); + assert!( + recipe.surfaces.taskbar && recipe.surfaces.start_menu, + "both legs default on" + ); assert!(recipe .effective_config(ItemKind::Shortcut, true, ItemSurface::Desktop) .unwrap() @@ -569,32 +580,52 @@ mod tests { assert!(recipe .effective_config(ItemKind::Shortcut, true, ItemSurface::Taskbar) .unwrap() - .is_none()); + .is_some()); assert!(recipe .effective_config(ItemKind::Shortcut, true, ItemSurface::StartMenu) .unwrap() + .is_some()); + // An explicit opt-OUT still gates that surface (RESTORE-FIRST: disabling a surface + // makes its items ride restoreIds on the next apply). + let gated: StyleRecipe = serde_json::from_value(json!({ + "config": base_config(), + "typeOverrides": {}, + "surfaces": { "taskbar": false } + })) + .unwrap(); + assert!(gated + .effective_config(ItemKind::Shortcut, true, ItemSurface::Taskbar) + .unwrap() .is_none()); + assert!(gated + .effective_config(ItemKind::Shortcut, true, ItemSurface::StartMenu) + .unwrap() + .is_some()); + assert!(gated + .effective_config(ItemKind::Shortcut, true, ItemSurface::Desktop) + .unwrap() + .is_some()); } #[test] - fn an_enabled_surface_leg_grants_only_that_surface_and_a_missing_leg_stays_off() { + fn an_explicit_off_leg_grants_only_the_other_surface_and_a_missing_leg_defaults_on() { let s = style(json!({ "config": base_config(), "kindPolicy": {}, "typeOverrides": {}, - "surfaces": { "taskbar": true, "startMenu": true } + "surfaces": { "taskbar": false, "startMenu": true } })); let recipe = StyleRecipe::parse(&s).unwrap(); assert!(recipe .effective_config(ItemKind::Shortcut, true, ItemSurface::Taskbar) .unwrap() - .is_some()); + .is_none()); assert!(recipe .effective_config(ItemKind::Shortcut, true, ItemSurface::StartMenu) .unwrap() .is_some()); - // A leg MISSING from an explicit `surfaces` object still defaults to off (per-field - // default — `#[serde(rename_all = "camelCase", default)]` on the container). + // A leg MISSING from an explicit `surfaces` object takes the struct default + // (participate — `impl Default for SurfacesJson`, 2026-10-12). let s2 = style(json!({ "config": base_config(), "kindPolicy": {}, @@ -605,7 +636,7 @@ mod tests { assert!(recipe2 .effective_config(ItemKind::Shortcut, true, ItemSurface::StartMenu) .unwrap() - .is_none()); + .is_some()); assert!(recipe2 .effective_config(ItemKind::Shortcut, true, ItemSurface::Taskbar) .unwrap() diff --git a/docs/decisions/0024-shell-surfaces.md b/docs/decisions/0024-shell-surfaces.md index edaf2a52..95ff29fb 100644 --- a/docs/decisions/0024-shell-surfaces.md +++ b/docs/decisions/0024-shell-surfaces.md @@ -46,10 +46,13 @@ `FOLDERID_Programs`, `FOLDERID_CommonPrograms`); Start menu walks recursively (depth cap, reparse skip). Desktop behavior is untouched. 2. **Participation, not look**: a persisted `surfaces: { taskbar, startMenu }` axis rides - the module state beside `kindPolicy` (default **OFF — opt-in**), whitelisted by the + the module state beside `kindPolicy` (default **ON — participate**, owner decision + 2026-10-12 amending the original all-OFF opt-in: system-wide restyling is the app's + promise, so the default matches kindPolicy's participate-by-default), whitelisted by the normalizers on BOTH bridge sides, and never in `ConfigDto` (presets/history stay clean, same rule as spec 06 §6 kindPolicy). Cascade: `styleable:false > per-icon override > - kindPolicy > surface gate`. + kindPolicy > surface gate`. Opting a surface OUT is explicit and, as before, + RESTORE-FIRST (a disabled surface's items ride restoreIds on the next apply). 3. **D7 amended, scenery preserved**: the strip's TRAY/clock/Start-flag chrome stays decorative and non-interactive; what changes is that the pinned row renders the USER'S REAL pins as ordinary tiles (hover try-on, owned-verb menu, exception badges — the §3 diff --git a/src/bridge/types.ts b/src/bridge/types.ts index 2f15ed66..88199e91 100644 --- a/src/bridge/types.ts +++ b/src/bridge/types.ts @@ -218,9 +218,10 @@ export type KindPolicy = Record * participation axis, ORTHOGONAL to kindPolicy — kindPolicy asks WHICH KINDS, * this asks WHICH SHELL SURFACES. The desktop itself is not a leg (it is * always on — the app's core promise, governed by kindPolicy alone); the two - * NEW write surfaces each default false: consent to write the taskbar / - * Start menu is opt-in, never inherited by an existing recipe. Not part of - * ConfigDto (same rule as kindPolicy) — rides the recipe's 4th key. */ + * NEW write surfaces each default true (owner decision 2026-10-12, amending + * ADR-0024's original opt-out): a fresh or legacy recipe restyles the whole + * shell; opting a surface OUT is explicit. Not part of ConfigDto (same rule + * as kindPolicy) — rides the recipe's 4th key. */ export interface Surfaces { taskbar: boolean startMenu: boolean @@ -337,7 +338,7 @@ export interface IconsStateDto { * host + future auto-format read the same map. Default: every bucket true. */ kindPolicy: KindPolicy /** Per-surface participation (ADR-0024): which SHELL SURFACES the look writes. - * Default false per leg — opt-in consent. The desktop is always on (not a leg). */ + * Default true per leg (owner decision 2026-10-12). The desktop is always on (not a leg). */ surfaces: Surfaces /** Per-type style overrides (ADR-0017) — the look's type ladder. */ typeOverrides: TypeOverrides diff --git a/src/lib/kind-policy.ts b/src/lib/kind-policy.ts index 24f156ae..201cbdbe 100644 --- a/src/lib/kind-policy.ts +++ b/src/lib/kind-policy.ts @@ -60,10 +60,13 @@ export function kindParticipates(kind: IconKind, policy: KindPolicy): boolean { // ---- Surface participation (ADR-0024) — the second participation axis -------- -/** Every NEW write surface starts OPTED OUT (ADR-0024): consent to write the - * taskbar / Start menu is granted explicitly, never inherited — deliberately - * the OPPOSITE default of kindPolicy's participate-by-default. */ -export const DEFAULT_SURFACES: Surfaces = { taskbar: false, startMenu: false } +/** Every shell surface participates BY DEFAULT (owner decision 2026-10-12, + * amending ADR-0024's original all-OFF opt-in): the app's promise is + * system-wide restyling, so a fresh recipe styles the taskbar and Start menu + * too — the SAME participate-by-default rule as kindPolicy. Opting a surface + * OUT is explicit (and disabling one auto-restores it on the next apply, + * RESTORE-FIRST). */ +export const DEFAULT_SURFACES: Surfaces = { taskbar: true, startMenu: true } /** The `surfaces` legs — a CLOSED whitelist (G11): only these keys exist, any * other key in a `surfaces` object is dropped on read (additive tolerance, @@ -86,8 +89,8 @@ export function surfaceParticipates(surface: Surface, surfaces: Surfaces): boole /** G11 normalizer for a recipe's `surfaces` leg: undefined → undefined (absent * — a style-only preset stays clean); null/non-object → null (REJECT the * recipe, never render garbage); unknown keys dropped; a KNOWN key with a - * non-boolean value rejects the whole leg. Missing known legs default off - * (opt-in consent must never be inferred). */ + * non-boolean value rejects the whole leg. Missing known legs take + * [`DEFAULT_SURFACES`] (participate — the owner's default, 2026-10-12). */ export function normalizeSurfaces(raw: unknown): Surfaces | undefined | null { if (raw === undefined) return undefined if (raw === null || typeof raw !== 'object') return null diff --git a/tests/icon-look.test.ts b/tests/icon-look.test.ts index aec7db51..253659c5 100644 --- a/tests/icon-look.test.ts +++ b/tests/icon-look.test.ts @@ -66,14 +66,14 @@ describe('serialize/parse round-trip', () => { expect(back!.config.shortcutShape).toBeNull() }) - test('surfaces (ADR-0024) round-trips; an absent leg reads as opted out', () => { + test('surfaces (ADR-0024) round-trips; an absent leg reads as the default (participate)', () => { const withSurfaces = { ...recipe, surfaces: { taskbar: true, startMenu: false } } const back = parseIconLook(serializeIconLook(withSurfaces)) expect(back!.surfaces).toEqual({ taskbar: true, startMenu: false }) - // Legacy recipes (pre-ADR-0024) carry NO surfaces → they read as opted out — consent - // is never inherited by an existing recipe. + // Legacy recipes (pre-ADR-0024) carry NO surfaces → they read as the DEFAULT: every + // surface participates (owner decision 2026-10-12, amending the original opt-out). const legacy = JSON.stringify({ config, kindPolicy: recipe.kindPolicy, typeOverrides: {} }) - expect(parseIconLook(legacy)!.surfaces).toEqual({ taskbar: false, startMenu: false }) + expect(parseIconLook(legacy)!.surfaces).toEqual({ taskbar: true, startMenu: true }) }) }) @@ -127,11 +127,11 @@ describe('normalizeIconLook (the import validator)', () => { test('surfaces (ADR-0024) is optional, preserved when present, fail-closed when junk', () => { // Absent → absent (presence-preserving, like kindPolicy): a style-only preset stays clean. expect(normalizeIconLook({ config })!.surfaces).toBeUndefined() - // A partial leg fills the missing surface with the OPT-IN default (false), never true. - expect(normalizeIconLook({ config, surfaces: { taskbar: true } })!.surfaces).toEqual({ taskbar: true, startMenu: false } as never) + // A partial leg fills the missing surface with the DEFAULT (participate — 2026-10-12). + expect(normalizeIconLook({ config, surfaces: { taskbar: false } })!.surfaces).toEqual({ taskbar: false, startMenu: true } as never) // G11 closed whitelist: a KNOWN leg with a non-boolean REJECTS; unknown legs drop. expect(normalizeIconLook({ config, surfaces: { taskbar: 'yes' } })).toBeNull() - expect(normalizeIconLook({ config, surfaces: { taskbar: true, futureSurface: true } })!.surfaces).toEqual({ taskbar: true, startMenu: false } as never) + expect(normalizeIconLook({ config, surfaces: { taskbar: false, futureSurface: true } })!.surfaces).toEqual({ taskbar: false, startMenu: true } as never) }) }) diff --git a/tests/icons-store.test.ts b/tests/icons-store.test.ts index c13b6b25..a1b75bd9 100644 --- a/tests/icons-store.test.ts +++ b/tests/icons-store.test.ts @@ -191,19 +191,21 @@ describe('effective tile config (override folding)', () => { test('surface gate (ADR-0024): a disabled surface renders original, even under a tint override', () => { const pin = item('p', { surface: 'taskbar' }) - // Omitted surfaces = the DEFAULT (opted out) — a taskbar pin renders original. - expect(effectiveTileConfig(pin, config, DEFAULT_KIND_POLICY).showOriginal).toBe(true) - // With the leg ON the pin inherits the global config (no per-surface config exists in v1). + // Omitted surfaces = the DEFAULT (participate — owner decision 2026-10-12) — a pin styles. + expect(effectiveTileConfig(pin, config, DEFAULT_KIND_POLICY).showOriginal).toBe(false) + // With a leg ON the pin inherits the global config (no per-surface config exists in v1). const on = effectiveTileConfig(pin, config, DEFAULT_KIND_POLICY, undefined, { taskbar: true, startMenu: false }) expect(on.showOriginal).toBe(false) expect(on.config).toEqual({ ...config, autoSeparation: true }) // OFF again → original; the tile also rides restoreIds in apply() (auto-restore). expect( - effectiveTileConfig(pin, config, DEFAULT_KIND_POLICY, undefined, { taskbar: false, startMenu: false }).showOriginal, + effectiveTileConfig(pin, config, DEFAULT_KIND_POLICY, undefined, { taskbar: false, startMenu: true }).showOriginal, ).toBe(true) // A per-icon tint NEVER rescues a surface the user turned off (RESTORE-FIRST). const tinted = item('p2', { surface: 'taskbar', overrideMode: 'tint', overrideTint: '#3FB6A8' }) - expect(effectiveTileConfig(tinted, config, DEFAULT_KIND_POLICY, undefined, DEFAULT_SURFACES).showOriginal).toBe(true) + expect( + effectiveTileConfig(tinted, config, DEFAULT_KIND_POLICY, undefined, { taskbar: false, startMenu: true }).showOriginal, + ).toBe(true) }) test('surface gate: the desktop is NEVER gated — kindPolicy alone governs it', () => { @@ -444,21 +446,22 @@ describe('System-Default lens (spec 06 §3.13)', () => { describe('surface participation (ADR-0024)', () => { test('setSurface flips ONE leg as one undo step and marks the draft dirty', () => { // `dirty` only means "the live desktop differs from the draft", so it exists - // once a look is applied — mirror that state here. + // once a look is applied — mirror that state here. Legs default ON (2026-10-12), + // so the flip exercised here is ON → OFF. useIcons.setState({ state: { ...state(), applied: true } }) - useIcons.getState().setSurface('taskbar', true) - expect(useIcons.getState().state!.surfaces.taskbar).toBe(true) + useIcons.getState().setSurface('taskbar', false) + expect(useIcons.getState().state!.surfaces.taskbar).toBe(false) expect(useIcons.getState().state!.dirty).toBe(true) - expect(useIcons.getState().state!.surfaces.startMenu).toBe(false) // the other leg untouched + expect(useIcons.getState().state!.surfaces.startMenu).toBe(true) // the other leg untouched (default on) useIcons.getState().undo() - expect(useIcons.getState().state!.surfaces.taskbar).toBe(false) + expect(useIcons.getState().state!.surfaces.taskbar).toBe(true) }) test('surface toggles are orthogonal to the System-Default lens (lens rule)', () => { const s = useIcons.getState() s.selectSystemDefault() - useIcons.getState().setSurface('startMenu', true) - expect(useIcons.getState().state!.surfaces.startMenu).toBe(true) + useIcons.getState().setSurface('startMenu', false) + expect(useIcons.getState().state!.surfaces.startMenu).toBe(false) expect(useIcons.getState().bareLook).toBe(true) }) }) From e0b12a0967b8a75b08d1f99bd3c9686034b4a552 Mon Sep 17 00:00:00 2001 From: Melu Date: Mon, 12 Oct 2026 23:43:18 -0300 Subject: [PATCH 04/16] =?UTF-8?q?fix(icons):=20worker=20always-acks=20ever?= =?UTF-8?q?y=20message=20=E2=80=94=20dev-loop=20hardening=20(task=209=20po?= =?UTF-8?q?int=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render-wasm.worker.ts posts `sourceReady ok:false` on init/decode failure (main thread re-requests instead of waiting forever), exactly ONE `baked png:null` on every failure path via a `posted` flag, releases `arrowReady` in a finally, and guards drainRenders so a rejected `rendererReady` can neither wedge `draining=true` nor surface as an unhandled promise rejection. Drops the working-tree-only TEMP-DEBUG tracing (command boundaries, UAC path, shell restart, stage beacon) and adds vite's `**/target/**` watch ignore (EBUSY on the linker was crashing the dev loop). Plan doc records the Task 9 field passes — CAS conflict on a hand-edited pin, restore byte-identical 94/94 FileBytes — plus this pass's findings (jpg attrs race, vanish-residue heal, 3 steady conflicts open, WebView2 CDP policy workaround). --- .../2026-10-12-taskbar-startmenu-surfaces.md | 175 +++++++++++++++++- src/icon-wasm/render-wasm.worker.ts | 110 +++++++---- vite.config.ts | 4 +- 3 files changed, 249 insertions(+), 40 deletions(-) diff --git a/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md b/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md index 434deee2..4b8b0de1 100644 --- a/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md +++ b/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md @@ -75,8 +75,8 @@ surface never requires a rescan. ```ts surfaces: { desktop: true /* always-on, not user-toggleable */, - taskbar: boolean, // default FALSE (opt-in) - startMenu: boolean } // default FALSE (opt-in) + taskbar: boolean, // default TRUE (owner decision 2026-10-12) + startMenu: boolean } // default TRUE (owner decision 2026-10-12) ``` Persisted through the SAME channel as `kindPolicy` (`icons.setLook` → @@ -207,6 +207,174 @@ every existing port. sections + gates run + the 5 owner invariants; adversarial review on ledger/CAS/parity. Expect deferral (write-surface reliability is the owner's dominant ship risk) — the fork carries the feature until then. +11. **MSIX AppListEntry wrappers** (owner directive 2026-10-12 "solve Store-app + icons"; recipe owner-verified on-box — see field findings below): the virtual + Start entries become styleable through OWNED wrapper shortcuts, never package + mutation: + - **Scan synthesizes** one item per `FOLDERID_AppsFolder` entry with no + file-backed `.lnk` in either Programs root: `kind = AppxShortcut` (free — the + walker never emits it today), `surface = startMenu`, `parent = ""`, + `path = \.lnk` (prospectual: the file does + not exist yet; id = `ItemId::from_source_path` as usual). The AUMID is + resolved LIVE — at scan for extraction, at write for creation — by display + name → AppsFolder lookup. **No new `IconItemDto` field; schema stays 11.** + - **Extraction — THE SOURCE GLYPH**: the wrapper's source must be the APP's own + icon, never the wrapper's current `IconLocation` (a probe pointing at an + arbitrary asset renders that arbitrary glyph — verified on-box: terminal glyph + on WhatsApp). Rule: an item is `AppxShortcut` iff its shortcut args parse + `shell:AppsFolder\` (classify.rs gains this parse; synthesized items + take the AUMID from the AppsFolder display-name match) → extractor resolves + `SHCreateItemFromRelativeName(FOLDERID_AppsFolder, AUMID)` and renders via + `IShellItemImageFactory::GetImage` (reuse the `shell_item_image` → bytes + tail). File-backed appsFolder shortcuts extract by AUMID too — falling back to + their `IconLocation` would perpetuate whatever it already points at. + - **AUMID round-trip on write**: `set_icon_location` (Load → SetIconLocation → + Save) MUST preserve the property-store block carrying + `System.AppUserModel.ID` — without the AUMID the running-window lookup falls + back to the process exe and the pin goes green again. Assert in a test: + set AUMID → rewrite icon → re-read AUMID == unchanged; fix the writer + (re-apply the property after IShellLink::Save, or round-trip the store) if + Save drops the block. + - **State reader**: `AppxShortcut` with an absent wrapper skips + `require_exists` (the System-CLSID precedent) → fresh + styleable; when the + wrapper exists it reads like any `.lnk` (`IconRef`) so re-apply CAS keeps + chaining. Fingerprint for "absent" = a dedicated sentinel (like the virtual + recycle-bin state), stable both directions. + - **Write**: `shell_link::create_appx_shortcut(path, aumid, icon)` — + `IShellLink` (target `%SystemRoot%\explorer.exe`, + args `shell:AppsFolder\`, icon location, ownership marker) then + `IPropertyStore::SetValue(PKEY_AppUserModel_ID, GPS_READWRITE)`. The AUMID + property is THE mechanism: Windows merges the wrapper with the virtual entry + (2 rows → 1) and renders the wrapper's icon. + - **[WINDOWS-VERIFY] Launch path — VERIFY ON BOX BEFORE SHIP**: the icon + merge/render does NOT depend on the wrapper's target, but clicking DOES. + On the owner's machine `explorer.exe shell:AppsFolder\` does not + activate apps (3 attempts with Calculator → no process; the owner's + busy-cursor loop when clicking the probe wrapper = this exact recipe), + while `IApplicationActivationManager::ActivateApplication` (CLSID + `45BA127D-10A8-46EA-8AB7-56EA9078943C`, **IID + `2E941141-7F97-4756-BA1D-9DECDE894A3D`** — the widely-circulated + `2E97EF3E-…` is wrong → E_NOINTERFACE) returns S_OK and the app stays up. + The recipe is what Windows' own Store-app pins use → box-specific + breakage correlating with the pre-existing COM registration damage + (wallpaper bug `0x80040154`). Task 11 ships only after a wrapper is + clicked end-to-end on-box; if the explorer route still fails, disclose + (system repair `sfc /DISM` is out of repo scope) or re-target the + wrapper — do NOT silently ship an entry that renders but cannot launch. + - **Ledger / restore**: original = wrapper *Absent* → restore REMOVES the + wrapper (the `file_wrapper` Absent→`remove_file` precedent) → the package + icon returns. Zero package mutation; ADR-0024 Never-list intact. + - **Elevation: never** — wrappers always go in the USER Programs root (the + Start view merges both roots; verified per-user on-box), so the elevated + helper gains no new op. + - **Collisions**: a display name matching a walked `.lnk` stem → skip (the + classic item covers it); two virtual apps sharing one display name → second + skipped + scan warn (honest, logged). + - **Tests**: synthesis (injected AppsFolder fixture), absent→styleable state + reader, wrapper create + property-store + restore round-trip, ledger anchor + round-trip, `commit_apply` path for `AppxShortcut` (unelevated), collision + skips. Docs: ADR-0024 records the sanctioned wrapper recipe; the + "silently-absent badge" follow-up is superseded by this task. + +## [WINDOWS-VERIFY] field findings — live pass 2026-10-12 (owner box) + +Verified ✅ (dev loop, `bun run tauri:dev` + `DESKMAKEOVER_ALLOW_UNSIGNED_HELPER=1`): + +- Pipeline end-to-end with surfaces ON: scan 103 items / 104 jobs → bake → 6 chunks + → CAS commit → `restart_shell` → **`commit ok=true`**, including the elevated + ProgramData batch (user-scope items unelevated; ONE batched UAC approved). +- On-disk rewrites confirmed by hand: taskbar pins (`User Pinned\TaskBar\*.lnk`) and + Start-menu shortcuts (user + common) carry `IconLocation` → + `icon-assets/{hash}.ico`; re-applies stay idempotent (re-own via ledger). +- Initial hang root-caused: a fresh clone lacks the gitignored + `public/dm_icon_wasm.wasm` (only `bun run wasm` writes it) → worker init rejects → + the FIFO enqueue chain swallowed the rejection → zero `sourceReady` acks → `apply` + waited forever on the last `bake:ok`. + +Honest exceptions measured on-box: + +- **MSIX package Start entries are AppListEntries with NO file-backed shortcut** → + invisible to the file scan by design (Never-list: package-asset edits break the + package signature). **7 of 97** Start entries here: *Click to Do · Configuración · + Copias de seguridad de Windows · Introducción · Reloj · Trello · WhatsApp*. The + other 90 are `.lnk`-backed and restyle (owner spot-check: only Trello/WhatsApp + reported unchanged). +- **[BREAKTHROUGH, owner-verified 2026-10-12] MSIX wrapper recipe works**: a probe + `.lnk` in the user's Programs root with target `explorer.exe shell:AppsFolder\` + + `System.AppUserModel.ID` = the package AUMID (via `SHGetPropertyStoreFromParsingName` + with `GPS_READWRITE` → `PKEY_AppUserModel_ID` = `{9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3},5`) + + `IconLocation` = styled asset makes Windows MERGE it with the virtual AppListEntry + (Get-StartApps: 2 rows → **1 row** after the AUMID set) and the merged entry renders + **the wrapper's styled icon** (owner's eyes on the real Start menu, not just the + enumeration). Deleting the wrapper restores the package icon → reversible, no package + mutation. → Elevated to product scope as Task 11. +- Running-app pins keep the exe icon while the app runs (issue #8 / ADR-0024 + caveat). **Cold-pin repaint VERIFIED** (owner): Brave's pin had a styled `.lnk` + on disk but showed the original with the app closed after the first restart + (stale pin cache), and repainted to the styled icon after the next shell + restart — no AppUserModelID exception observed on-box. +- **[2026-10-12 night] Probe teardown + launch-route finding**: the AUMID pin + probe made the running WhatsApp taskbar button render OUR asset (mechanism ✅ — + but an arbitrary placeholder glyph, terminal-derived: probes bypass extraction, + the correct glyph needs Task 11's `IShellItemImageFactory` source, see spec). + Launching then broke: clicking the probe wrapper (target = the standard + `explorer.exe shell:AppsFolder\…` recipe) spun a busy cursor and never opened + the app — same failure reproduces for Calculator via that route on this box, + while direct `IApplicationActivationManager::ActivateApplication` activates + WhatsApp fine (S_OK, stable process, window up). Probes (wrapper + pin) + deleted → Start back to 1 row, pins back to the original set; package + activation healthy. Lesson baked into Task 11: **two live shortcuts carrying + the same AUMID + a broken launch route = user-visible breakage — probes on the + real shell stay short-lived, and the wrapper's click-to-launch is a ship + gate** (spec bullet added). +- Stray `File Explorer (2).lnk` in the pin folder = a manual user copy (19:16), + not ours — owner to delete or keep. + +- **Conflict test PASS (2026-10-12, CDP-driven UI)**: hand-edited `Brave.lnk` + (`imageres.dll,-109`) → apply → CAS conflict on that pin only, its bytes and + mtime untouched while the other ~100 items committed. The hand-edit rides the + restore-job path (`restores=1` → `restore_skipped` → counted as a conflict) + and also survives a later Restore (trust-first): skipped as a hand-edit until + the item's fingerprint again equals `original_fingerprint`. +- **Restore test PASS (2026-10-12)**: apply → snapshot ledger → restore → + **94/94 verifiable FileBytes byte-identical** to `original_anchor.bytes` + (0 mismatches; the other 3 are files the user deleted long ago — rows healed + as NotFound, as designed), wrapper `.lnk` sidecars removed and the originals + un-hidden back to `Archive`, ledger 101 → 1 rows, `txn.log` 284 KB → 0 + (checkpointed), zero `icon-assets` residue except the unmanaged + `File Explorer (2).lnk` stray. +- **Vanish-residue heal verified**: the 3 wrapped loose files were found + Hidden|System with their sidecars already gone — a state the apply/restore + code order cannot produce (external deletion: old crash artifact or AV/user + cleanup). Recovery path: un-hide → Restore → the "already pristine" arm + drops the rows → next apply re-wraps cleanly; the re-wrapped→restored cycle + then came back byte-clean. +- **jpg wrap race (open)**: wrapping `wp14409295-….jpg` failed 4/4 with + `0x80070005` at the attrs step — Explorer's thumbnail extraction locks the + target inside the sidecar→hide window, and the rollback (sidecar removal) + hits the same lock → `rollback-incomplete` txn. The recovery abort hit the + same denial twice before landing (`icons … finalize degraded` WARNs), which + **defers a restore until the abort clears** — correct but loud. Arithmetic + match: the owner's historical `99 committed + restores=3 (+1)` gap is this + same item. Disposition: marked **keep original** via the per-item override so + applies run clean (`ok=true`, CTA In-sync); proposed fix (out of v1): retry + `attrs::set` with backoff on `0x80070005` in `file_wrapper::apply`. +- **3 steady apply conflicts (open observation)**: every CDP apply reported + `3 conflicts` (the owner's historical runs show none). The UI/event exposes + the count only, so the ids are unattributed; every directly-inspected item + is styled and the applies end `ok=true`/In-sync. Needs a conflict-id log + line or event payload before it can be chased. +- **Test harness note**: WebView2 Runtime 150+ drops `WEBVIEW2_*` env vars for + elevated hosts (WebView2Feedback #5645) — CDP for the elevated dev app was + brought up via the HKLM policy `AdditionalBrowserArguments` + (`--remote-debugging-port=9222`), removed again after the pass. Permanent + dev-loop suggestion: a non-elevated CDP mode or a scripted test bridge. + +Remaining checklist: hand-edit a pin → CAS conflict untouched ✅ (2026-10-12) · +restore → byte-identical FileBytes on pins/Start ✅ (2026-10-12: 94/94) · +uninstall residue `[pending]` · dev-loop hardening ✅ (worker posts +`ok:false`/`png:null` failure acks instead of swallowing init rejection; +TEMP-DEBUG markers removed; `**/target/**` vite watch ignore landed). ## Follow-ups (documented, out of v1) @@ -218,3 +386,6 @@ every existing port. badge once [WINDOWS-VERIFY] measures which app classes repaint. - Common Start Menu per-item UAC-free path if the helper grows per-surface batching (today: one batched UAC, already disclosed). +- MSIX package Start entries: ~~non-stylable badge in the mirror~~ **superseded by + Task 11** (owned wrappers make them styleable; the badge idea applies only to + entries Task 11 cannot match). diff --git a/src/icon-wasm/render-wasm.worker.ts b/src/icon-wasm/render-wasm.worker.ts index b8e6c41e..fd1b1570 100644 --- a/src/icon-wasm/render-wasm.worker.ts +++ b/src/icon-wasm/render-wasm.worker.ts @@ -54,40 +54,66 @@ function rgbaToBitmap(data: Uint8ClampedArray, size: number): Image // ---- ordered lane: arrow / source / bake keep strict FIFO order ---- async function handleArrow(url: string): Promise { - const renderer = await rendererReady - const arrow = await decodeToRgba(url) - if (arrow) renderer.setArrow(arrow.data, arrow.w, arrow.h) - markArrowReady() // renders may proceed now (or on a null arrow: the drawn fallback, matching a no-badge host) + // Init/decode failure must still release the gate: `arrowReady` blocks the + // ordered lane AND the render drain, so swallowing the rejection here would + // freeze every later message (dev-loop hardening, plan task 9). + try { + const renderer = await rendererReady + const arrow = await decodeToRgba(url) + if (arrow) renderer.setArrow(arrow.data, arrow.w, arrow.h) + } catch { + // No badge — the drawn fallback renders, matching a no-badge host. + } finally { + markArrowReady() // renders may proceed now (or on a null arrow: the drawn fallback, matching a no-badge host) + } } async function handleSource(id: string, url: string): Promise { - const renderer = await rendererReady - const src = await decodeToRgba(url, MASTER_SIZE) + // ALWAYS ack. A swallowed init rejection (WASM load failure) used to leave + // the main thread waiting on `sourceReady` forever — post `ok:false` so the + // existing waiter/re-request path in icon-renderer.ts runs instead (dev-loop + // hardening, plan task 9). let seed: string | null = null let ok = false - if (src) { - seed = renderer.registerSource(id, src.data) - ok = true + try { + const renderer = await rendererReady + const src = await decodeToRgba(url, MASTER_SIZE) + if (src) { + seed = renderer.registerSource(id, src.data) + ok = true + } + } catch { + // ack stays ok:false — the main thread re-requests or fails the source. } wctx.postMessage({ t: 'sourceReady', id, ok, seed, url }) } async function handleBake(msg: BakeMsg): Promise { - const renderer = await rendererReady - await arrowReady - if (!renderer.hasSource(msg.id)) { + // ALWAYS ack exactly once: `png:null` on every failure path (init rejection, + // missing source, render error). An unacked bake hangs the apply's bake wait + // — the main thread's `bakeWaiters` already handles `png:null` as a failed + // bake. + let posted = false + const fail = () => { + if (posted) return + posted = true wctx.postMessage({ t: 'baked', req: msg.req, id: msg.id, png: null }) - return } - const rgba = renderer.render(msg.id, msg.config, msg.isShortcut, msg.showOriginal ?? false, MASTER_SIZE, msg.opts) - if (!rgba) { - wctx.postMessage({ t: 'baked', req: msg.req, id: msg.id, png: null }) - return + try { + const renderer = await rendererReady + await arrowReady + if (!renderer.hasSource(msg.id)) return fail() + const rgba = renderer.render(msg.id, msg.config, msg.isShortcut, msg.showOriginal ?? false, MASTER_SIZE, msg.opts) + if (!rgba) return fail() + const canvas = new OffscreenCanvas(MASTER_SIZE, MASTER_SIZE) + canvas.getContext('2d')!.putImageData(new ImageData(rgba, MASTER_SIZE, MASTER_SIZE), 0, 0) + const png = await (await canvas.convertToBlob({ type: 'image/png' })).arrayBuffer() + if (posted) return + posted = true + wctx.postMessage({ t: 'baked', req: msg.req, id: msg.id, png }, [png]) + } catch { + fail() } - const canvas = new OffscreenCanvas(MASTER_SIZE, MASTER_SIZE) - canvas.getContext('2d')!.putImageData(new ImageData(rgba, MASTER_SIZE, MASTER_SIZE), 0, 0) - const png = await (await canvas.convertToBlob({ type: 'image/png' })).arrayBuffer() - wctx.postMessage({ t: 'baked', req: msg.req, id: msg.id, png }, [png]) } let orderedChain: Promise = Promise.resolve() @@ -107,23 +133,33 @@ function scheduleDrain(): void { } async function drainRenders(): Promise { - const renderer = await rendererReady - await arrowReady - // Snapshot the newest render per slot, then clear so any render that arrives - // mid-drain re-queues (and supersedes) for the next pass. - const batch = [...pendingRenders.values()] - pendingRenders.clear() - for (const msg of batch) { - if (!renderer.hasSource(msg.id)) continue // not decoded yet — main re-requests after sourceReady - const rgba = renderer.render(msg.id, msg.config, msg.isShortcut, msg.showOriginal, msg.size, msg.opts) - if (!rgba) continue - const bitmap = rgbaToBitmap(rgba, msg.size) - const key = slotKeyOf(msg.id, msg.showOriginal, msg.size) - wctx.postMessage({ t: 'rendered', req: msg.req, id: msg.id, key, bitmap }, [bitmap]) - await Promise.resolve() // yield between icons so a fast drag stays responsive + // The whole drain is guarded: this runs from queueMicrotask, so an init + // rejection (WASM load failed) or a render throw would otherwise surface as + // an unhandled promise rejection instead of a dropped batch. Dropped renders + // are safe — the main thread re-dispatches on the next getTile (dev-loop + // hardening, plan task 9). + try { + const renderer = await rendererReady + await arrowReady + // Snapshot the newest render per slot, then clear so any render that arrives + // mid-drain re-queues (and supersedes) for the next pass. + const batch = [...pendingRenders.values()] + pendingRenders.clear() + for (const msg of batch) { + if (!renderer.hasSource(msg.id)) continue // not decoded yet — main re-requests after sourceReady + const rgba = renderer.render(msg.id, msg.config, msg.isShortcut, msg.showOriginal, msg.size, msg.opts) + if (!rgba) continue + const bitmap = rgbaToBitmap(rgba, msg.size) + const key = slotKeyOf(msg.id, msg.showOriginal, msg.size) + wctx.postMessage({ t: 'rendered', req: msg.req, id: msg.id, key, bitmap }, [bitmap]) + await Promise.resolve() // yield between icons so a fast drag stays responsive + } + } catch { + pendingRenders.clear() // no renderer — the batch cannot be served; main re-dispatches + } finally { + draining = false + if (pendingRenders.size > 0) scheduleDrain() } - draining = false - if (pendingRenders.size > 0) scheduleDrain() } wctx.onmessage = (e: MessageEvent) => { diff --git a/vite.config.ts b/vite.config.ts index 8cdbf8e5..eeeb9418 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,7 +34,9 @@ export default defineConfig({ host: host || false, hmr: host ? { protocol: 'ws', host, port: 1421 } : undefined, // src-tauri churn shouldn't retrigger the web HMR. - watch: { ignored: ['**/src-tauri/**'] }, + // target/ is cargo's build output: watching it races the linker on + // Windows (EBUSY on target/debug/deps/*.exe) and crashes the dev loop. + watch: { ignored: ['**/src-tauri/**', '**/target/**'] }, }, build: { // The bundle is served from a WebView2 virtual host on the local disk — From a30123c2485bd02343cb63eab4193cfbc6c4f502 Mon Sep 17 00:00:00 2001 From: Melu Date: Tue, 13 Oct 2026 01:40:00 -0300 Subject: [PATCH 05/16] feat(icons): style Store/MSIX Start entries via owned appx wrappers (task 11) Start entries that are AppListEntries with no file-backed shortcut (7 of 97 on the dev box) become styleable through an OWNED wrapper .lnk in the user Programs root: target %SystemRoot%\explorer.exe + args shell:AppsFolder\, ownership marker DeskMakeover:appx-wrapper:v1, and System.AppUserModel.ID set on the ShellLink OBJECT (QI IPropertyStore -> SetValue VT_LPWSTR -> Commit) so the ONE IPersistFile::Save publishes link + property block atomically - Windows then merges the wrapper with the virtual entry (2 rows -> 1) and renders our icon. A by-path property write is impossible (the save target is a .tmp claim-temp with no shell property handler - verified E_FAIL on-box), and the icon-rewrite path reads the AUMID up front and re-attaches it to the loaded link before Save, so a re-style never un-merges the wrapper (fail closed if a known AUMID will not re-persist). Scan classifies file-backed AppsFolder shortcuts from their arguments and synthesizes items for virtual-only apps via the new shell/appsfolder.rs (AppsFolder enumerate/resolve/shell-item/glyph; collisions with a walked stem or a duplicate name skip + warn). State reader splits the originals: a present wrapper reads IconRef like any .lnk; an absent one reads a dedicated AppxAbsent fingerprint sentinel and anchors RestoreAnchor::FileAbsent, so reset REMOVES the wrapper and the package icon returns. Extraction early-returns for AppxShortcut and renders the AppsFolder item's own glyph via IShellItemImageFactory::GetImage through the shared factory_icon_image tail - never the wrapper's IconLocation. Elevation never (user Programs root only); dm-operations rides the existing kind-agnostic driver, its fake world gains a virtual-item model, and the two orchestration tests live in the Mac-gated icons/tests.rs (verified on-box with the gate lifted: 272/272). Docs: ADR-0024 records the sanctioned wrapper recipe (Decision 6); the plan fixes the SHCreateItemFromRelativeName parent phrasing (IShellItem parent, not FOLDERID), records the implemented atomic recipe, and keeps the on-box ship gates open (enumeration pin, synthesized scan count, click-to-launch end-to-end with the deskmakeover-desktop.exe --activate-aumid fallback if the explorer route still fails; then the HKLM WebView2 CDP dev value is deleted). --- crates/dm-domain/src/restore.rs | 19 ++ crates/dm-operations/src/icons/tests.rs | 86 ++++- crates/dm-operations/src/txn/fakes.rs | 39 ++- crates/dm-windows/Cargo.toml | 4 + crates/dm-windows/src/apply/mod.rs | 15 +- crates/dm-windows/src/apply/shortcut.rs | 115 +++++++ crates/dm-windows/src/classify.rs | 97 +++++- crates/dm-windows/src/fingerprint_surface.rs | 22 ++ crates/dm-windows/src/shell/appsfolder.rs | 199 ++++++++++++ crates/dm-windows/src/shell/known_folders.rs | 11 + crates/dm-windows/src/shell/mod.rs | 1 + crates/dm-windows/src/shell/scan.rs | 177 +++++++++- crates/dm-windows/src/shell/shell_link.rs | 306 +++++++++++++++++- crates/dm-windows/src/source.rs | 56 +++- crates/dm-windows/src/state_reader.rs | 105 +++++- docs/decisions/0024-shell-surfaces.md | 21 ++ .../2026-10-12-taskbar-startmenu-surfaces.md | 74 +++-- 17 files changed, 1298 insertions(+), 49 deletions(-) create mode 100644 crates/dm-windows/src/shell/appsfolder.rs diff --git a/crates/dm-domain/src/restore.rs b/crates/dm-domain/src/restore.rs index 79d246b6..da84827f 100644 --- a/crates/dm-domain/src/restore.rs +++ b/crates/dm-domain/src/restore.rs @@ -123,6 +123,12 @@ pub enum RestoreAnchor { RecycleBin(RecycleBinAnchor), /// System desktop CLSID (This PC / Network / …) per-user `DefaultIcon` registry state. SystemIcon(SystemIconAnchor), + /// The original did not exist on disk at all: a virtual AppsFolder (Store/MSIX) entry whose + /// style target is a wrapper `.lnk` this app CREATES at apply time (Task 11, ADR-0024). + /// Restore removes that wrapper — the desktop returns to "entry not materialized" (the Start + /// menu then shows the app once, from the AppsFolder itself). Carries material: the restore + /// action is fully determined (delete ``), it just needs no captured bytes. + FileAbsent, /// Capture failed — recorded so the presence of restore material can be verified before /// an apply proceeds (oracle: `restore.captureError`). CaptureFailed { reason: String }, @@ -241,6 +247,18 @@ mod tests { } } + #[test] + fn file_absent_anchor_round_trips_and_has_material() { + // Task 11: the virtual AppsFolder entry's original state is "no wrapper existed". The + // internally-tagged serde form must stay stable across restarts (ledger replay). + let anchor = RestoreAnchor::FileAbsent; + assert!(anchor.has_material(), "an absent original still determines a restore action"); + let json = serde_json::to_string(&anchor).unwrap(); + assert_eq!(json, r#"{"anchor":"FileAbsent"}"#); + let back: RestoreAnchor = serde_json::from_str(&json).unwrap(); + assert_eq!(anchor, back); + } + #[test] fn has_material_is_true_for_every_real_variant() { let variants = [ @@ -248,6 +266,7 @@ mod tests { RestoreAnchor::Folder { attributes: 0, desktop_ini: None }, RestoreAnchor::RegularFile(WrapperAnchor { file_attributes: 0, prior_wrapper: PriorWrapper::Absent }), RestoreAnchor::RecycleBin(RecycleBinAnchor { key_existed: false, default: None, empty: None, full: None }), + RestoreAnchor::FileAbsent, ]; for v in variants { assert!(v.has_material(), "{v:?} should carry restore material"); diff --git a/crates/dm-operations/src/icons/tests.rs b/crates/dm-operations/src/icons/tests.rs index 414f273e..7bc91b6c 100644 --- a/crates/dm-operations/src/icons/tests.rs +++ b/crates/dm-operations/src/icons/tests.rs @@ -81,6 +81,12 @@ impl Fixture { self.world.borrow_mut().put(&it.path, original); } + /// Marks an item as VIRTUAL (Task 11): absent from disk, read as the AppxAbsent sentinel, + /// anchored FileAbsent — the Store/MSIX entry before its wrapper exists. + fn seed_virtual(&self, it: &DesktopItem) { + self.world.borrow_mut().mark_virtual(&it.path); + } + /// Runs a commit with the given masters, driving the real engine over the fake platform. /// Converts scanned items into `ScannedItem`s, capturing each one's CURRENT desktop fingerprint /// as its scan-time CAS anchor (what the real host does per scan). @@ -88,12 +94,16 @@ impl Fixture { items .iter() .map(|it| { - let fp = self - .world - .borrow() - .get(&it.path) - .map(|b| dm_domain::Fingerprint::of_bytes(&b)) - .unwrap_or(dm_domain::Fingerprint::of_bytes(b"")); + let world = self.world.borrow(); + let fp = match world.get(&it.path) { + Some(bytes) => dm_domain::Fingerprint::of_bytes(&bytes), + // A virtual entry scans as the sentinel (Task 11) — the same value the fake + // reader returns post-scan, so CAS stays consistent across the apply. + None if world.is_virtual(&it.path) => { + crate::txn::fakes::appx_absent_fingerprint() + } + None => dm_domain::Fingerprint::of_bytes(b""), + }; ScannedItem { item: it.clone(), fingerprint: fp, cas_icon: None, source_ok: true } }) .collect() @@ -1550,3 +1560,67 @@ fn without_an_elevated_port_a_privileged_item_is_an_honest_conflict_never_a_doom assert!(out.error.is_none(), "a skip is not a failure — the desktop was never touched"); assert_eq!(f.world.borrow().get(&shared.path).unwrap(), b"orig-chrome"); } + +// ---- Task 11 (ADR-0024): Store/MSIX AppxShortcut flows ---- + +#[test] +fn an_appx_shortcut_commits_and_resets_unelevated_like_any_file_backed_lnk() { + // A file-backed AppxShortcut (a user's own Store shortcut) takes the ordinary in-process + // path — no elevated helper wired, none needed (user scope). The kind was already in + // `is_elevatable_kind` for PRIVILEGED items; here we pin that with `elevated = None` the + // whole commit/reset cycle runs in-process. + let mut f = Fixture::new(); + let appx = item("store-app.lnk", ItemKind::AppxShortcut); + f.seed(&appx, b"orig-appx"); + + let out = f.apply(&[("store-app.lnk", 0, [3, 4, 5, 255])], style(1), "L", "v1", &[appx.clone()]); + + assert_eq!(out.committed, vec![appx.id.clone()], "AppxShortcut commits unelevated"); + assert!(out.error.is_none() && out.conflicts.is_empty()); + assert_ne!( + f.world.borrow().get(&appx.path).unwrap(), + b"orig-appx", + "the wrapper wears the styled bytes" + ); + + let reset = f.reset(false); + assert_eq!(reset.restored, vec![appx.id.clone()], "one item undone"); + assert_eq!( + f.world.borrow().get(&appx.path).unwrap(), + b"orig-appx", + "byte-replay restore, like any .lnk" + ); +} + +#[test] +fn a_virtual_appx_entry_is_created_by_apply_and_removed_by_reset() { + // Task 11: a Store/MSIX entry with NO wrapper yet. Scan reads the AppxAbsent sentinel (not + // NotFound — NotFound would drop the item before apply could ever create it), the anchor is + // FileAbsent, apply CREATES the wrapper, and reset REMOVES it — the desktop returns to + // "entry not materialized". A ledger row that lost its FileAbsent anchor (or a restore that + // treated it as unsupported) would strand the styled wrapper on disk after reset. + let mut f = Fixture::new(); + let appx = item("virtual-store.lnk", ItemKind::AppxShortcut); + f.seed_virtual(&appx); + + // The scan-time CAS anchor is the sentinel — the value the fake reader also returns. + let scanned = f.scanned(&[appx.clone()]); + assert_eq!(scanned[0].fingerprint, crate::txn::fakes::appx_absent_fingerprint()); + + let out = + f.apply(&[("virtual-store.lnk", 0, [7, 7, 7, 255])], style(1), "L", "v1", &[appx.clone()]); + assert_eq!(out.committed, vec![appx.id.clone()], "the wrapper is created + committed"); + assert!(out.error.is_none() && out.conflicts.is_empty()); + let created = + f.world.borrow().get(&appx.path).expect("apply must CREATE the wrapper for a virtual entry"); + assert!(!created.is_empty(), "the wrapper carries the styled bytes"); + + // The ledger row anchors FileAbsent — restore = remove, not byte-replay of a file that never + // was. + let entry = f.ledger.get(&appx.id).unwrap().expect("styled row present"); + assert_eq!(entry.original_anchor, dm_domain::RestoreAnchor::FileAbsent); + + let reset = f.reset(false); + assert_eq!(reset.restored, vec![appx.id.clone()], "reset knows how to undo it"); + assert!(f.world.borrow().get(&appx.path).is_none(), "reset REMOVES the wrapper"); +} diff --git a/crates/dm-operations/src/txn/fakes.rs b/crates/dm-operations/src/txn/fakes.rs index 7917e1e8..991ae6ef 100644 --- a/crates/dm-operations/src/txn/fakes.rs +++ b/crates/dm-operations/src/txn/fakes.rs @@ -53,6 +53,10 @@ pub struct World { /// reader exposing where an item's icon currently points (the styled-residue provenance input). /// A Vec so a multi-value surface (Recycle Bin's default/empty/full) can be modelled. live_icons: HashMap>, + /// VIRTUAL AppsFolder items (Task 11): absent from disk until an apply CREATES their wrapper, + /// restored by REMOVING it. The reader models them as the real `AppxAbsent` sentinel and the + /// anchor as `FileAbsent`, so the fake mirrors the honest-degrade split the Windows reader has. + virtuals: HashSet, } impl World { @@ -73,6 +77,17 @@ impl World { self.files.remove(path); } + /// Marks `path` as a VIRTUAL AppsFolder item (Task 11): absent until apply creates its wrapper, + /// restored by removing it. + pub fn mark_virtual(&mut self, path: &str) { + self.virtuals.insert(path.to_string()); + } + + /// Whether `path` is a virtual item (absent → AppxAbsent fingerprint / FileAbsent anchor). + pub fn is_virtual(&self, path: &str) -> bool { + self.virtuals.contains(path) + } + pub fn fail_apply(&mut self, path: &str) { self.apply_fails.insert(path.to_string()); } @@ -136,6 +151,13 @@ pub fn paired_empty_path(primary_path: &str) -> String { } } +/// The virtual-item sentinel fingerprint — byte-identical to dm-windows' +/// `SurfaceState::AppxAbsent.fingerprint()` (Task 11), so the fake models the REAL surface +/// instead of inventing a parallel one. +pub fn appx_absent_fingerprint() -> Fingerprint { + Fingerprint::of_parts(&[b"appx-absent"]) +} + /// A reader/applier/asset-store bundle over one shared `World`. #[derive(Clone)] pub struct FakePlatform { @@ -199,8 +221,12 @@ impl ItemStateReader for FakePlatform { if self.world.borrow().read_fails.contains(&target.path) { return Err(PortError::Io(format!("read failed for {}", target.path))); } - match self.world.borrow().get(&target.path) { + let world = self.world.borrow(); + match world.get(&target.path) { Some(bytes) => Ok(Fingerprint::of_bytes(&bytes)), + // A virtual AppsFolder entry with no wrapper yet reads as the sentinel — never + // NotFound (Task 11: the honest-degrade split the real reader has). + None if world.is_virtual(&target.path) => Ok(appx_absent_fingerprint()), None => Err(PortError::NotFound(target.path.clone())), } } @@ -221,8 +247,12 @@ impl ItemStateReader for FakePlatform { if self.capture_fails.borrow().contains(&target.path) { return Ok(RestoreAnchor::CaptureFailed { reason: "locked".into() }); } - match self.world.borrow().get(&target.path) { + let world = self.world.borrow(); + match world.get(&target.path) { Some(bytes) => Ok(RestoreAnchor::FileBytes { bytes }), + // The virtual entry's TRUE original: no file existed, restore = remove the wrapper + // (Task 11, `RestoreAnchor::FileAbsent`). + None if world.is_virtual(&target.path) => Ok(RestoreAnchor::FileAbsent), None => Err(PortError::NotFound(target.path.clone())), } } @@ -272,6 +302,11 @@ impl IconApplier for FakePlatform { world.put(&target.path, bytes); Ok(()) } + // Task 11: an absent original's restore removes the wrapper the apply created. + RestoreAnchor::FileAbsent => { + world.remove(&target.path); + Ok(()) + } RestoreAnchor::CaptureFailed { .. } => { Err(PortError::Unsupported("no restore material".into())) } diff --git a/crates/dm-windows/Cargo.toml b/crates/dm-windows/Cargo.toml index 3e37fa20..32ea97d2 100644 --- a/crates/dm-windows/Cargo.toml +++ b/crates/dm-windows/Cargo.toml @@ -45,6 +45,10 @@ windows = { version = "0.61", features = [ "Win32_System_Pipes", "Win32_UI_Shell", "Win32_UI_Shell_Common", + # Task 11 (ADR-0024): Store/MSIX wrapper `.lnk` recipe — IPropertyStore + + # SHGetPropertyStoreFromParsingName + GPS_READWRITE to persist/read `System.AppUserModel.ID` + # (PKEY_APPUSERMODEL_ID) on the wrapper. + "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", # Judge-1 desktop-activity precision layer (activity.rs): SetWinEventHook / UnhookWinEvent / diff --git a/crates/dm-windows/src/apply/mod.rs b/crates/dm-windows/src/apply/mod.rs index 5b94f8fd..ea8ddf33 100644 --- a/crates/dm-windows/src/apply/mod.rs +++ b/crates/dm-windows/src/apply/mod.rs @@ -38,11 +38,11 @@ impl IconApplier for WindowsIconApplier { let icon = assets.primary.path.clone(); match target.kind { // COM writes run on the STA thread (outer `?` = thread-join error, inner `?` = the - // write's own error). A UWP shortcut's desktop entry is an ordinary `.lnk`, so it styles - // through the exact same mechanism as a Shortcut (spec 06 §6, P1-12). - ItemKind::Shortcut | ItemKind::AppxShortcut => { - self.exec.run(move || shortcut::apply(&path, &icon))?? - } + // write's own error). + ItemKind::Shortcut => self.exec.run(move || shortcut::apply(&path, &icon))??, + // A Store/MSIX wrapper (Task 11): present → the AUMID-preserving IconLocation rewrite; + // absent → CREATE the wrapper at the prospective path (AppsFolder lookup by stem). + ItemKind::AppxShortcut => self.exec.run(move || shortcut::apply_appx(&path, &icon))??, ItemKind::RegularFile => self.exec.run(move || file_wrapper::apply(&path, &icon))??, // Filesystem / registry writes need no COM. ItemKind::UrlShortcut => url_shortcut::apply(&path, &icon, 0)?, @@ -79,6 +79,11 @@ impl IconApplier for WindowsIconApplier { match anchor { // `.lnk`/`.url` restore is a byte replay (oracle `RestoreOriginalContent`) — no COM. RestoreAnchor::FileBytes { bytes } => shortcut::restore_bytes(&target.path, bytes), + // A virtual AppsFolder entry's original state was "no wrapper exists" (Task 11): the + // restore removes the wrapper apply created — the file_wrapper Absent→remove_file + // posture. The driver's CAS (live == last_applied) gates this, so the file removed is + // the verified styled wrapper, never a user's own shortcut. + RestoreAnchor::FileAbsent => shortcut::remove(&target.path), RestoreAnchor::Folder { attributes, desktop_ini } => { folder::restore(&target.path, *attributes, desktop_ini.as_ref()) } diff --git a/crates/dm-windows/src/apply/shortcut.rs b/crates/dm-windows/src/apply/shortcut.rs index f38b460c..2bc5ae6d 100644 --- a/crates/dm-windows/src/apply/shortcut.rs +++ b/crates/dm-windows/src/apply/shortcut.rs @@ -40,3 +40,118 @@ pub fn apply(shortcut_path: &str, icon_path: &str) -> PortResult<()> { pub fn restore_bytes(path: &str, bytes: &[u8]) -> PortResult<()> { crate::durable::write_atomic(path, bytes) } + +/// The Store/MSIX wrapper apply (Task 11, ADR-0024): an EXISTING wrapper is the ordinary +/// IconLocation rewrite (`apply`, AUMID-preserving); an ABSENT one — the virtual AppsFolder entry +/// at its prospective path — is CREATED via the wrapper recipe, the AUMID resolved from the +/// path's file stem (the item's display name) against the live AppsFolder. A stem no AppsFolder +/// entry answers to is an honest NotFound (the app was uninstalled between scan and apply), never +/// a fabricated wrapper. [WINDOWS-VERIFY] runtime. +pub fn apply_appx(shortcut_path: &str, icon_path: &str) -> PortResult<()> { + match Path::new(shortcut_path).try_exists() { + Ok(true) => return apply(shortcut_path, icon_path), + Ok(false) => {} + Err(e) => return Err(PortError::Io(format!("cannot access {shortcut_path}: {e}"))), + } + // try_exists() distinguishes genuine absence from an access failure (audit F3): an unreadable + // PRESENT asset must surface its real IO error, not masquerade as missing. + match Path::new(icon_path).try_exists() { + Ok(true) => {} + Ok(false) => return Err(PortError::AssetMissing(icon_path.to_string())), + Err(e) => return Err(PortError::Io(format!("cannot access {icon_path}: {e}"))), + } + let stem = Path::new(shortcut_path) + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| PortError::Io(format!("no usable file stem on {shortcut_path}")))?; + let aumid = crate::shell::appsfolder::resolve_aumid(stem)?.ok_or_else(|| { + PortError::NotFound(format!("no AppsFolder entry named {stem:?} for {shortcut_path}")) + })?; + shell_link::create_appx_shortcut(shortcut_path, &aumid, icon_path) +} + +/// Removes a wrapper `.lnk` whose original state was ABSENT (Task 11, +/// [`dm_domain::RestoreAnchor::FileAbsent`]): the restore of a virtual AppsFolder entry is "the +/// wrapper we created stops existing". The driver's CAS (live == last_applied) gates this call, so +/// the file being removed is the styled wrapper it verified. `try_exists` distinguishes absence +/// (already restored — nothing to do) from an access fault (propagated, P2-#3 posture). +/// [WINDOWS-VERIFY] runtime. +pub fn remove(path: &str) -> PortResult<()> { + match Path::new(path).try_exists() { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(e) => return Err(PortError::Io(format!("cannot access {path}: {e}"))), + } + std::fs::remove_file(path).map_err(|e| PortError::Io(format!("cannot remove {path}: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::com::StaExecutor; + + #[test] + fn remove_is_idempotent_on_absence_and_honest_about_dirs() { + let dir = tempfile::tempdir().unwrap(); + // Absent → already restored, not an error (the CAS-gated restore may race a prior reset). + let missing = dir.path().join("never.lnk"); + remove(&missing.to_string_lossy()).unwrap(); + + // Present → removed. + let present = dir.path().join("wrapper.lnk"); + std::fs::write(&present, b"w").unwrap(); + remove(&present.to_string_lossy()).unwrap(); + assert!(!present.exists(), "the wrapper is gone"); + + // A DIRECTORY at the path → honest IO error, never a silent "restored". + let adir = dir.path().join("adir.lnk"); + std::fs::create_dir(&adir).unwrap(); + assert!(matches!(remove(&adir.to_string_lossy()), Err(PortError::Io(_)))); + } + + #[test] + fn apply_appx_preflights_the_asset_before_the_appsfolder_lookup() { + // No COM: a missing asset on an ABSENT target must be AssetMissing (audit F3 — absence + // must not mask an asset fault), reported BEFORE any AppsFolder enumeration runs. + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("Ghost App.lnk"); + let asset = dir.path().join("missing.ico"); + let err = apply_appx(&target.to_string_lossy(), &asset.to_string_lossy()).unwrap_err(); + assert!(matches!(err, PortError::AssetMissing(_)), "got {err:?}"); + } + + /// On-box (COM): the present branch is the ordinary AUMID-preserving rewrite; the absent + /// branch with a stem NO AppsFolder entry answers to is an honest NotFound — never a + /// fabricated wrapper (the app was uninstalled between scan and apply). + #[test] + fn apply_appx_rewrites_a_present_wrapper_and_degrades_honestly_for_a_ghost_stem() { + let assets = tempfile::tempdir().unwrap(); + let icon_a = assets.path().join("a.ico"); + std::fs::write(&icon_a, b"a").unwrap(); + let icon_b = assets.path().join("b.ico"); + std::fs::write(&icon_b, b"b").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let lnk = dir.path().join("WhatsApp.lnk").to_string_lossy().into_owned(); + let icon_a = icon_a.to_string_lossy().into_owned(); + let icon_b = icon_b.to_string_lossy().into_owned(); + + let exec = StaExecutor::spawn().unwrap(); + let (l, i) = (lnk.clone(), icon_a); + exec.run(move || shell_link::create_appx_shortcut(&l, "Fake.App_abc!App", &i)) + .unwrap() + .unwrap(); + + // Present → the ordinary rewrite, onto icon_b. + let (l, i) = (lnk.clone(), icon_b.clone()); + exec.run(move || apply_appx(&l, &i)).unwrap().unwrap(); + let l2 = lnk.clone(); + let loc = exec.run(move || shell_link::read_icon_location(&l2)).unwrap().unwrap(); + assert_eq!(loc, Some((icon_b.clone(), 0)), "the present branch rewrote the icon"); + + // Absent + unresolvable stem → honest NotFound (live AppsFolder enumeration, no match). + let ghost = + dir.path().join("Definitely No Such App 9f31.lnk").to_string_lossy().into_owned(); + let err = exec.run(move || apply_appx(&ghost, &icon_b)).unwrap().unwrap_err(); + assert!(matches!(err, PortError::NotFound(_)), "got {err:?}"); + } +} diff --git a/crates/dm-windows/src/classify.rs b/crates/dm-windows/src/classify.rs index 3d88fa6f..a46e1ad0 100644 --- a/crates/dm-windows/src/classify.rs +++ b/crates/dm-windows/src/classify.rs @@ -27,11 +27,51 @@ pub fn classify_entry(file_name: &str, is_dir: bool) -> ItemKind { /// shortcuts drop the extension (oracle: `Path.GetFileName` vs `GetFileNameWithoutExtension`). pub fn display_name(file_name: &str, kind: ItemKind) -> String { match kind { - ItemKind::Shortcut | ItemKind::UrlShortcut => file_stem(file_name).to_string(), + // AppxShortcut included (Task 11): a Store shortcut's on-disk entry is an ordinary `.lnk`, + // so its label drops the extension exactly like any other shortcut. + ItemKind::Shortcut | ItemKind::UrlShortcut | ItemKind::AppxShortcut => { + file_stem(file_name).to_string() + } _ => file_name.to_string(), } } +/// The `shell:AppsFolder\` URI prefix identifying a Store/UWP (MSIX) app entry, case-insensitive. +/// (Task 11, ADR-0024.) +const APPS_FOLDER_PREFIX: &str = "shell:appsfolder\\"; + +/// Parses the AUMID out of a `.lnk`'s ARGUMENTS when the shortcut targets the AppsFolder virtual +/// folder (`shell:AppsFolder\`). The AUMID itself may contain backslashes (classic entries +/// like `{CLSID}\printmanagement.msc`), so only the fixed prefix is matched and the rest of the +/// (trimmed, quote-stripped) argument string is the AUMID. Pure — unit-tested on the Mac host. +pub fn parse_appx_aumid(args: &str) -> Option<&str> { + let trimmed = args.trim().trim_matches('"').trim(); + if trimmed.len() <= APPS_FOLDER_PREFIX.len() { + return None; + } + // `get` (not direct slicing): a multibyte first character must return None, never panic. + if !trimmed + .get(..APPS_FOLDER_PREFIX.len()) + .is_some_and(|p| p.eq_ignore_ascii_case(APPS_FOLDER_PREFIX)) + { + return None; + } + // Safe: the byte prefix already matched, so 17 lands on a char boundary. + let aumid = trimmed[APPS_FOLDER_PREFIX.len()..].trim(); + (!aumid.is_empty()).then_some(aumid) +} + +/// Whether `name` is safe to use as a file STEM on Windows: not empty, no `<>:"/\|?*` or ASCII +/// control characters, and no trailing dot/space (Win32 silently strips those on path +/// normalization, which would make the on-disk wrapper name drift from the item's display name — +/// and with it the item id). Pure — Task 11, unit-tested on the Mac host. +pub fn is_safe_file_stem(name: &str) -> bool { + !name.is_empty() + && !name.chars().any(|c| c < ' ' || "<>:\"/\\|?*".contains(c)) + && !name.ends_with('.') + && !name.ends_with(' ') +} + /// Splits an icon location string `"path",index` into its path and (possibly negative) resource /// index. The index is taken only when the text after the LAST comma parses as an integer (so paths /// that themselves contain commas are preserved), and quotes are stripped from the PATH afterwards — @@ -218,4 +258,59 @@ mod tests { // A quoted path with no index still strips its wrapping quotes. assert_eq!(parse_icon_location(r#""C:\a b\i.dll""#), (r"C:\a b\i.dll".into(), 0)); } + + #[test] + fn parses_appx_aumid_from_shell_appsfolder_arguments() { + // The canonical MSIX shape (Task 11): prefix + `PackageHash!App`. + assert_eq!( + parse_appx_aumid(r"shell:AppsFolder\5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App"), + Some("5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App"), + ); + // Classic entries' AUMIDs contain BACKSLASHES — the parse must keep them whole. + assert_eq!( + parse_appx_aumid(r"shell:AppsFolder\{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\printmanagement.msc"), + Some(r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\printmanagement.msc"), + ); + // A generated classic AUMID, no backslash. + assert_eq!( + parse_appx_aumid("shell:AppsFolder\\Microsoft.AutoGenerated.{8f5f1d44-9a23-4a4d-b19c-9e90ce7c4e2d}"), + Some("Microsoft.AutoGenerated.{8f5f1d44-9a23-4a4d-b19c-9e90ce7c4e2d}"), + ); + } + + #[test] + fn appx_aumid_parse_is_prefix_gated_trimmed_and_quote_tolerant() { + // Case-insensitive prefix (IShellLink argument casing varies across creators). + assert_eq!(parse_appx_aumid(r"SHELL:APPSFOLDER\App_abc!App"), Some("App_abc!App")); + // Quoted argument + surrounding whitespace (argument strings may carry either). + assert_eq!(parse_appx_aumid(r#" "shell:AppsFolder\App_abc!App" "#), Some("App_abc!App")); + // NOT the AppsFolder URI → not an appx shortcut. + assert_eq!(parse_appx_aumid(r"C:\Windows\System32\notepad.exe"), None); + assert_eq!(parse_appx_aumid(r"https://x.example"), None); + // Prefix present but no AUMID after it → None (never a bare `shell:AppsFolder\`). + assert_eq!(parse_appx_aumid(r"shell:AppsFolder\"), None); + assert_eq!(parse_appx_aumid(r"shell:AppsFolder\ "), None); + // Empty / prefix-only inputs. + assert_eq!(parse_appx_aumid(""), None); + // The prefix must START the argument — leading junk is not an appx invocation. + assert_eq!(parse_appx_aumid(r"junk shell:AppsFolder\App_abc!App"), None); + // Multibyte leading characters are rejected without panicking (byte-prefix via `get`). + assert_eq!(parse_appx_aumid("日本語のショートカット"), None); + } + + #[test] + fn safe_file_stem_rejects_windows_illegal_names() { + // Clean names pass (the owner-box AppsFolder probe found exactly these shapes). + for name in ["WhatsApp", "Reloj", "Microsoft Store", "App 2.0", "Trello"] { + assert!(is_safe_file_stem(name), "{name:?} should be a safe stem"); + } + // Illegal filename characters (these CAN appear in exotic display names). + for name in ["A:B", "AB", "A|B", "A?B", "A*B", "A\"B", "A/B", "A\\B", "A\x01B"] { + assert!(!is_safe_file_stem(name), "{name:?} must be rejected"); + } + // Empty, trailing dot, and trailing space normalize to a DIFFERENT on-disk name. + for name in ["", ".", "Name.", "Name "] { + assert!(!is_safe_file_stem(name), "{name:?} must be rejected"); + } + } } diff --git a/crates/dm-windows/src/fingerprint_surface.rs b/crates/dm-windows/src/fingerprint_surface.rs index fd07a3d4..ae42e8ac 100644 --- a/crates/dm-windows/src/fingerprint_surface.rs +++ b/crates/dm-windows/src/fingerprint_surface.rs @@ -51,6 +51,12 @@ pub enum SurfaceState { /// three the apply writes, so a writer that lands the paths but a wrong index or a stale /// `default` fingerprints differently. RecycleBin { default: IconLoc, empty: IconLoc, full: IconLoc }, + /// A Store/MSIX wrapper `.lnk` that does NOT exist yet (Task 11): the virtual AppsFolder entry + /// has no on-disk file until we style it. A DEDICATED sentinel, deliberately NOT + /// `IconRef { path: "", index: 0 }` — "never created" and "created but iconless" are different + /// states with different restores (remove the wrapper vs. replay bytes), and one must never + /// masquerade as the other in CAS. + AppxAbsent, } impl SurfaceState { @@ -92,6 +98,9 @@ impl SurfaceState { full.0.as_bytes(), &full.1.to_le_bytes(), ]), + // A constant sentinel for "no wrapper exists" (Task 11) — its own domain prefix so it + // can never collide with any IconRef, present-but-iconless included. + SurfaceState::AppxAbsent => Fingerprint::of_parts(&[b"appx-absent"]), } } } @@ -182,6 +191,19 @@ mod tests { ); } + #[test] + fn appx_absent_sentinel_never_collides_with_a_present_iconless_wrapper() { + // Task 11: a virtual AppsFolder entry with NO wrapper fingerprints as the AppxAbsent + // sentinel. It must differ from EVERY IconRef — most dangerously `IconRef("", 0)`, the + // surface of a PRESENT-but-unstyled `.lnk` — because the two restore differently (remove + // the wrapper vs. replay its bytes). + let absent = SurfaceState::AppxAbsent.fingerprint(); + assert_ne!(absent, SurfaceState::IconRef { path: String::new(), index: 0 }.fingerprint()); + assert_ne!(absent, SurfaceState::IconRef { path: "a.ico".into(), index: 0 }.fingerprint()); + // Constant both directions: a stable CAS anchor across scans/restarts. + assert_eq!(absent, SurfaceState::AppxAbsent.fingerprint()); + } + #[test] fn dispatch_picks_the_right_surface_per_kind() { // P3-1: the per-kind choice is host-tested, so reverting a dispatch arm goes red. diff --git a/crates/dm-windows/src/shell/appsfolder.rs b/crates/dm-windows/src/shell/appsfolder.rs new file mode 100644 index 00000000..c70bd035 --- /dev/null +++ b/crates/dm-windows/src/shell/appsfolder.rs @@ -0,0 +1,199 @@ +//! The AppsFolder virtual folder (`shell:AppsFolder` — the Start menu's "All apps" namespace): +//! enumeration, display-name → AUMID resolution, and the `IShellItem` lookup behind the Store/MSIX +//! wrapper `.lnk` recipe (Task 11, ADR-0024). Must be called on the STA thread (every entry point +//! is COM). + +use dm_domain::{PortError, PortResult}; +use windows::core::{HSTRING, Interface}; +use windows::Win32::System::Com::{CoTaskMemFree, IBindCtx}; +use windows::Win32::UI::Shell::{ + BHID_EnumItems, IEnumShellItems, IShellItem, IShellItemImageFactory, + SHCreateItemFromParsingName, SHCreateItemFromRelativeName, SIGDN_DESKTOPABSOLUTEPARSING, + SIGDN_NORMALDISPLAY, SIGDN_PARENTRELATIVEPARSING, SIGDN, +}; + +/// One AppsFolder entry: the display name the Start menu shows, and its activation id (the AUMID +/// for MSIX apps; a classic entry's generated AUMID or `{CLSID}\app.msc`-style parsing name). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppsfolderEntry { + pub display_name: String, + pub aumid: String, +} + +/// Enumerates every AppsFolder entry (display name + AUMID). Task 11's scan synthesis and the +/// display-name → AUMID resolution both read this list. +/// +/// [WINDOWS-VERIFY] runtime. +pub fn enumerate() -> PortResult> { + let folder = folder_item()?; + // `BHID_EnumItems` binds the folder's child enumerator (windows-rs exposes it as + // `IEnumShellItems`). + let enumerator: IEnumShellItems = unsafe { + folder + .BindToHandler(None::<&IBindCtx>, &BHID_EnumItems) + .map_err(com)? + }; + let mut out = Vec::new(); + loop { + let mut batch: [Option; 64] = std::array::from_fn(|_| None); + let mut fetched = 0u32; + // `Next` returns Ok on S_FALSE (end of enumeration) — the fetched count is the truth. + unsafe { enumerator.Next(&mut batch, Some(&mut fetched)).map_err(com)? }; + if fetched == 0 { + break; + } + for item in batch.iter().flatten() { + // One unreadable entry is skipped, never a reason to fail the whole enumeration. + if let Some(entry) = entry_of(item) { + out.push(entry); + } + } + if fetched < batch.len() as u32 { + break; + } + } + Ok(out) +} + +/// The AUMID whose AppsFolder entry displays `name` (case-insensitive — the same folding the scan +/// dedupe uses; first match wins, which the scan's duplicate rule already made unique). +/// +/// [WINDOWS-VERIFY] runtime. +pub fn resolve_aumid(name: &str) -> PortResult> { + let wanted = name.to_lowercase(); + Ok(enumerate()? + .into_iter() + .find(|e| e.display_name.to_lowercase() == wanted) + .map(|e| e.aumid)) +} + +/// The `IShellItem` for one AppsFolder entry, addressed by AUMID — what +/// `IShellItemImageFactory::GetImage` is then cast onto to read the package glyph. The relative +/// name form is tried first (the documented AppsFolder-child lookup); a full +/// `shell:AppsFolder\` parsing name is the fallback for ids the relative form rejects. +/// +/// [WINDOWS-VERIFY] runtime. +pub fn shell_item(aumid: &str) -> PortResult { + let folder = folder_item()?; + unsafe { + match SHCreateItemFromRelativeName(&folder, &HSTRING::from(aumid), None::<&IBindCtx>) { + Ok(item) => Ok(item), + Err(relative_err) => { + let full = format!("shell:AppsFolder\\{aumid}"); + SHCreateItemFromParsingName(&HSTRING::from(full), None).map_err(|e| { + PortError::Com(format!( + "AppsFolder item {aumid:?} not found (relative: {relative_err}; parsing: {e})" + )) + }) + } + } + } +} + +/// The package glyph for one AppsFolder entry: the EXACT image Explorer's Start menu renders for +/// the app, read from the shell item factory. This read is anchored in the PACKAGE, never in any +/// wrapper `.lnk` — so it cannot compound `Style(Style(orig))` (Task 11). +/// +/// [WINDOWS-VERIFY] runtime. +pub fn package_glyph_image(aumid: &str) -> PortResult> { + let item = shell_item(aumid)?; + let factory: IShellItemImageFactory = item.cast().map_err(com)?; + crate::source::factory_icon_image(&factory) +} + +/// The AppsFolder as a shell item (`shell:AppsFolder` is a parsing name the shell resolves). +fn folder_item() -> PortResult { + unsafe { + SHCreateItemFromParsingName(&HSTRING::from("shell:AppsFolder"), None).map_err(com) + } +} + +fn entry_of(item: &IShellItem) -> Option { + let display_name = display_name_of(item, SIGDN_NORMALDISPLAY)?; + // The AUMID rides the parsing name. Which SIGDN yields the bare AUMID (vs. a composed + // `shell:AppsFolder\...` form) is shell-version behaviour, so both parsing forms are tried and + // [`strip_appsfolder_prefix`] normalises the result — pinned on-box by the enumeration test. + let raw = display_name_of(item, SIGDN_DESKTOPABSOLUTEPARSING) + .or_else(|| display_name_of(item, SIGDN_PARENTRELATIVEPARSING))?; + let aumid = strip_appsfolder_prefix(&raw).to_string(); + (!display_name.trim().is_empty() && !aumid.trim().is_empty()) + .then(|| AppsfolderEntry { display_name, aumid }) +} + +fn display_name_of(item: &IShellItem, sigdn: SIGDN) -> Option { + // SAFETY: COM on the STA thread; the returned PWSTR is CoTaskMem-owned and freed here. + unsafe { + let pw = item.GetDisplayName(sigdn).ok()?; + let text = pw.to_string().ok(); + CoTaskMemFree(Some(pw.as_ptr() as *const _)); + text + } +} + +/// Strips a leading `shell:AppsFolder\` from a parsing name so the stored AUMID is bare in both +/// shapes. Pure — unit-tested on the Mac host. +fn strip_appsfolder_prefix(name: &str) -> &str { + const PREFIX: &str = "shell:appsfolder\\"; + match name.get(..PREFIX.len()) { + Some(p) if p.eq_ignore_ascii_case(PREFIX) => &name[PREFIX.len()..], + _ => name, + } +} + +fn com(e: windows::core::Error) -> PortError { + PortError::Com(e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_the_appsfolder_prefix_case_insensitively_and_leaves_bare_aumids() { + assert_eq!( + strip_appsfolder_prefix(r"shell:AppsFolder\5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App"), + "5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App", + ); + assert_eq!( + strip_appsfolder_prefix(r"SHELL:APPSFOLDER\App_abc!App"), + "App_abc!App", + ); + // A bare AUMID (or anything else) passes through untouched. + assert_eq!(strip_appsfolder_prefix("Brave"), "Brave"); + assert_eq!( + strip_appsfolder_prefix(r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\printmanagement.msc"), + r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\printmanagement.msc", + ); + // A multibyte name never panics (byte-prefix via `get`). + assert_eq!(strip_appsfolder_prefix("日本語"), "日本語"); + } + + /// On-box pin (Task 11 ship gate): the enumeration resolves the known WhatsApp entry to the + /// AUMID `Get-StartApps` reports, proving which SIGDN the AUMID rode and that the whole + /// enumerate → resolve chain addresses the real AppsFolder. + #[test] + fn enumerates_the_real_appsfolder_and_resolves_whatsapps_aumid() { + // Shell COM runs on the STA thread (crate discipline) — never on the raw test thread. + let exec = crate::com::StaExecutor::spawn().unwrap(); + let entries = exec.run(super::enumerate).unwrap().expect("AppsFolder enumeration failed"); + assert!(entries.len() >= 20, "expected the AppsFolder to enumerate, got {}", entries.len()); + let whatsapp = entries.iter().find(|e| e.display_name == "WhatsApp"); + match whatsapp { + Some(e) => assert_eq!( + e.aumid, "5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App", + "WhatsApp's parsing name must be its canonical AUMID" + ), + None => eprintln!("WhatsApp not installed on this box; enumeration still returned {} entries", entries.len()), + } + // resolve_aumid is the same lookup the apply/extraction paths use. + if let Some(e) = whatsapp { + let name = e.display_name.clone(); + let resolved = exec.run(move || super::resolve_aumid(&name)).unwrap().unwrap(); + assert_eq!(resolved.as_deref(), Some(e.aumid.as_str())); + } + // Every entry carries both halves (a blank one would produce a dead wrapper). + for e in &entries { + assert!(!e.display_name.trim().is_empty() && !e.aumid.trim().is_empty(), "blank entry: {e:?}"); + } + } +} diff --git a/crates/dm-windows/src/shell/known_folders.rs b/crates/dm-windows/src/shell/known_folders.rs index 03112adb..cf65e790 100644 --- a/crates/dm-windows/src/shell/known_folders.rs +++ b/crates/dm-windows/src/shell/known_folders.rs @@ -99,6 +99,17 @@ pub fn start_menu_roots_labeled() -> Vec { roots } +/// The USER Programs root (`FOLDERID_Programs`) — the directory a Store/MSIX wrapper `.lnk` is +/// CREATED into (Task 11, ADR-0024). User scope by construction, so a wrapper apply never routes +/// through the elevated helper. Best-effort by contract: `None` on a locked-down profile (the +/// AppsFolder surface is then skipped, exactly like any other best-effort root). +/// +/// [WINDOWS-VERIFY] runtime. +pub fn user_programs_root() -> Option { + let path = known_folder(&FOLDERID_Programs).ok().flatten()?; + path.is_dir().then_some(path) +} + fn known_folder(id: &GUID) -> PortResult> { // SAFETY: `SHGetKnownFolderPath` allocates a `PWSTR` that we own and free with // `CoTaskMemFree`; `to_string` reads the NUL-terminated buffer before it is freed. diff --git a/crates/dm-windows/src/shell/mod.rs b/crates/dm-windows/src/shell/mod.rs index b5af1987..6486a5e0 100644 --- a/crates/dm-windows/src/shell/mod.rs +++ b/crates/dm-windows/src/shell/mod.rs @@ -1,6 +1,7 @@ //! Shell COM adapters: known-folder resolution, `.lnk` reading/writing, and the desktop scan. //! Every function here must run on the STA thread ([`crate::com::StaExecutor`]). +pub mod appsfolder; pub mod attrs; pub mod known_folders; pub mod layout; diff --git a/crates/dm-windows/src/shell/scan.rs b/crates/dm-windows/src/shell/scan.rs index cc8e2f3a..026ba6f8 100644 --- a/crates/dm-windows/src/shell/scan.rs +++ b/crates/dm-windows/src/shell/scan.rs @@ -117,12 +117,83 @@ fn scan_blocking() -> PortResult> { }); } } + // Store/MSIX (AppsFolder) entries with no walked `.lnk` cover (Task 11, ADR-0024): a virtual + // Start-menu item per uncovered entry, so its icon becomes styleable through the wrapper + // recipe. A walked item's display name covering an entry means the classic `.lnk` (or our + // wrapper) already addresses that app — never synthesize a duplicate. Best-effort per root + // doctrine: a failed enumeration (locked-down shell) or a missing Programs root NARROWS the + // surface instead of failing the scan. [WINDOWS-VERIFY] runtime. + if let Ok(entries) = crate::shell::appsfolder::enumerate() { + if let Some(programs) = known_folders::user_programs_root() { + let walked: std::collections::HashSet = + items.iter().map(|i| i.name.to_lowercase()).collect(); + items.extend(synthesize_appx_items(&walked, &entries, &programs)); + } + } // Oracle sorts by name, current-culture case-insensitive; ASCII-lowercase is the closest // portable approximation and is corrected on the Windows box if a locale diff surfaces. items.sort_by_key(|item| item.name.to_lowercase()); Ok(items) } +/// Synthesizes virtual Start-menu items for AppsFolder entries NOT already covered by a walked +/// item name (Task 11). Pure — unit-tested on the Mac host; [`crate::shell::appsfolder`] +/// enumerates the real list. Rules: +/// * a display name matching any walked item (case-insensitive) → skip silently (the classic +/// `.lnk` / our wrapper already covers that app); +/// * a duplicate display name within the enumeration → first wins, later ones warn (the on-box +/// probe found none, but the shell is third-party-extensible); +/// * a display name that is not a safe file stem → skip + warn (the wrapper `.lnk` could never be +/// created under it, so the prospective path would be a lie). +/// The item's path is the PROSPECTIVE wrapper location `\.lnk` — +/// the addressing anchor (ItemId) apply later writes to. +fn synthesize_appx_items( + walked_names: &std::collections::HashSet, + entries: &[crate::shell::appsfolder::AppsfolderEntry], + programs_root: &Path, +) -> Vec { + let mut out = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for entry in entries { + let key = entry.display_name.to_lowercase(); + if walked_names.contains(&key) { + continue; + } + if !seen.insert(key) { + log::warn!( + "appsfolder: duplicate display name {:?} skipped (first wins)", + entry.display_name + ); + continue; + } + if !crate::classify::is_safe_file_stem(&entry.display_name) { + log::warn!( + "appsfolder: display name {:?} is not a safe file name; skipped", + entry.display_name + ); + continue; + } + let path = programs_root.join(format!("{}.lnk", entry.display_name)); + let path_str = match path.to_str() { + Some(s) => s.to_string(), + None => continue, + }; + out.push(DesktopItem { + id: ItemId::from_source_path("filesystem", &path_str), + name: entry.display_name.clone(), + icon: None, + path: path_str, + kind: ItemKind::AppxShortcut, + surface: ItemSurface::StartMenu, + parent: String::new(), + state: ItemState::Ready, + requires_explicit_consent: false, + status_message: None, + }); + } + out +} + /// Enumerate one root into `items`, honoring its surface + walk mode. The top-level `read_dir` /// failure follows `mandatory` (loud error vs. best-effort skip); subfolder failures during a /// recursive descent are ALWAYS best-effort (one locked app folder must not fail the scan). @@ -203,7 +274,7 @@ fn walk_dir( // and the desktop Folder taxonomy does not apply to it. continue; } - let kind = classify_entry(&file_name, is_dir); + let mut kind = classify_entry(&file_name, is_dir); let path_str = match path.to_str() { Some(s) => s.to_string(), None => continue, @@ -222,6 +293,17 @@ fn walk_dir( items.push(item); continue; } + // A `.lnk` whose ARGUMENTS are the AppsFolder URI is a Store/UWP shortcut (Task 11) — + // the args are the only reliable signal. One extra COM Load per `.lnk`; a failed or + // garbage read (test fixtures included) leaves the item an ordinary Shortcut, never a + // scan error. + let is_appx = shell_link::read_arguments(&path_str) + .ok() + .flatten() + .is_some_and(|args| crate::classify::parse_appx_aumid(&args).is_some()); + if is_appx { + kind = ItemKind::AppxShortcut; + } } items.push(DesktopItem { id: ItemId::from_source_path("filesystem", &path_str), @@ -538,4 +620,97 @@ mod tests { "a junction's foreign contents must never become style targets" ); } + + // ---- Task 11: AppsFolder (Store/MSIX) synthesis + args classification ---- + + use crate::shell::appsfolder::AppsfolderEntry; + + fn entry(display_name: &str, aumid: &str) -> AppsfolderEntry { + AppsfolderEntry { display_name: display_name.into(), aumid: aumid.into() } + } + + #[test] + fn synthesize_appx_items_covers_only_entries_without_a_walked_item() { + let dir = tempfile::tempdir().unwrap(); + // The walked-name set is LOWERCASED names across every surface: an entry whose name any + // real item already carries is addressed by that item — never a duplicate. + let walked: std::collections::HashSet = + ["brave", "whatsapp"].into_iter().map(String::from).collect(); + let entries = vec![ + entry("Brave", "Brave"), + entry("whatsapp", "X!App"), + entry("Trello", "45273LiamForsyth.PawsforTrello_7pb5ddty8z1pa!trello"), + entry("Reloj", "Microsoft.WindowsAlarms_8wekyb3d8bbwe!App"), + ]; + let items = synthesize_appx_items(&walked, &entries, dir.path()); + let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect(); + assert_eq!(names, vec!["Trello", "Reloj"], "walked names (any case) are never synthesized"); + + let trello = &items[0]; + assert_eq!(trello.kind, ItemKind::AppxShortcut); + assert_eq!(trello.surface, ItemSurface::StartMenu); + assert!(trello.parent.is_empty(), "wrappers live at the Programs root"); + assert_eq!(trello.icon, None, "the icon is extracted live from the package glyph"); + let expected_path = + dir.path().join("Trello.lnk").to_string_lossy().into_owned(); + assert_eq!(trello.path, expected_path, "path is the PROSPECTIVE wrapper location"); + assert_eq!( + trello.id, + ItemId::from_source_path("filesystem", &expected_path), + "the id anchors on the path apply will write to" + ); + assert!(trello.can_style()); + } + + #[test] + fn synthesize_appx_items_dedupes_display_names_and_rejects_unsafe_stems() { + let dir = tempfile::tempdir().unwrap(); + let walked = std::collections::HashSet::new(); + let entries = vec![ + entry("Dup", "A!App"), + entry("dup", "B!App"), // duplicate display name → first wins + entry("Bad:Name", "C!App"), // illegal filename char → skipped + entry("Trailing.", "D!App"), // trailing dot normalizes → skipped + entry("With Space", "E!App"), // legal → kept + ]; + let items = synthesize_appx_items(&walked, &entries, dir.path()); + let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect(); + assert_eq!(names, vec!["Dup", "With Space"]); + } + + #[test] + fn a_garbage_fixture_lnk_stays_an_ordinary_shortcut_after_the_args_read() { + // The args-driven AppxShortcut reclassification must be best-effort: a `.lnk` that cannot + // be COM-loaded (every byte fixture here) is an ordinary Shortcut, never a scan error. + let dir = tempfile::tempdir().unwrap(); + touch(dir.path(), "Broken.lnk"); + let items = walk(dir.path(), ItemSurface::Taskbar, false); + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, ItemKind::Shortcut); + } + + /// On-box (COM): a REAL `shell:AppsFolder\...` wrapper walks as an AppxShortcut with a clean + /// display name — the classification the whole Task 11 pipeline keys on. + #[test] + fn a_real_appsfolder_wrapper_lnk_walks_as_an_appx_shortcut() { + // The icon lives OUTSIDE the walked root — it is an asset, not a scanned item. + let assets = tempfile::tempdir().unwrap(); + let icon = assets.path().join("i.ico"); + std::fs::write(&icon, b"icon").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let lnk = dir.path().join("WhatsApp.lnk").to_string_lossy().into_owned(); + let icon = icon.to_string_lossy().into_owned(); + let exec = crate::com::StaExecutor::spawn().unwrap(); + exec.run(move || shell_link::create_appx_shortcut(&lnk, "Fake.App_abc!App", &icon)) + .unwrap() + .unwrap(); + let items = walk(dir.path(), ItemSurface::StartMenu, false); + assert_eq!(items.len(), 1); + assert_eq!( + items[0].kind, + ItemKind::AppxShortcut, + "AppsFolder arguments must reclassify the .lnk" + ); + assert_eq!(items[0].name, "WhatsApp", "the label drops .lnk like any shortcut"); + } } diff --git a/crates/dm-windows/src/shell/shell_link.rs b/crates/dm-windows/src/shell/shell_link.rs index f786316c..e90adef0 100644 --- a/crates/dm-windows/src/shell/shell_link.rs +++ b/crates/dm-windows/src/shell/shell_link.rs @@ -3,9 +3,17 @@ //! `ShellLinkShortcutIconWriter.cs`. Must be called on the STA thread. use dm_domain::{PortError, PortResult}; -use windows::core::{Interface, HSTRING}; +use windows::core::{Interface, BSTR, HSTRING, PWSTR}; +use windows::Win32::Foundation::PROPERTYKEY; +use windows::Win32::System::Com::StructuredStorage::{ + PROPVARIANT, PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0, +}; use windows::Win32::System::Com::{ - CoCreateInstance, IPersistFile, CLSCTX_INPROC_SERVER, STGM_READ, STGM_READWRITE, + CoCreateInstance, CoTaskMemAlloc, IPersistFile, CLSCTX_INPROC_SERVER, STGM_READ, STGM_READWRITE, +}; +use windows::Win32::System::Variant::VT_LPWSTR; +use windows::Win32::UI::Shell::PropertiesSystem::{ + IPropertyStore, SHGetPropertyStoreFromParsingName, GPS_READWRITE, }; use windows::Win32::UI::Shell::{IShellLinkW, ShellLink}; @@ -13,6 +21,15 @@ use crate::classify::extended_length_path; const ICON_BUF: usize = 1024; +/// `PKEY_AppUserModel_ID` ({9f4c2855-9f79-4b39-a8d0-e1d42de1d5f3}, pid 5) — the property the shell's +/// Start/merge logic reads to associate a shortcut with a package (Task 11). Defined locally with +/// the exact key windows-rs happens to expose under `Win32_Storage_EnhancedStorage`, so the wrapper +/// recipe needs no extra crate feature. +const PKEY_APPUSERMODEL_ID: PROPERTYKEY = PROPERTYKEY { + fmtid: windows::core::GUID::from_u128(0x9f4c2855_9f79_4b39_a8d0_e1d42de1d5f3), + pid: 5, +}; + /// Reads the `.lnk`'s explicit icon location `(path, index)`, or `None` when it has none. /// /// [WINDOWS-VERIFY] runtime. @@ -32,6 +49,23 @@ pub fn read_icon_location(shortcut_path: &str) -> PortResult` out of this. [WINDOWS-VERIFY] runtime. +pub fn read_arguments(shortcut_path: &str) -> PortResult> { + // SAFETY: COM object confined to this STA thread. + unsafe { + let link: IShellLinkW = + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).map_err(com)?; + let file: IPersistFile = link.cast().map_err(com)?; + let path = extended_length_path(shortcut_path); + file.Load(&HSTRING::from(path.as_str()), STGM_READ).map_err(com)?; + let mut buf = [0u16; ICON_BUF]; + link.GetArguments(&mut buf).map_err(com)?; + let args = wide_to_string(&buf); + Ok((!args.is_empty()).then_some(args)) + } +} + /// Points the `.lnk` at `icon_path` (with `index`) and persists it durably + atomically: the edit /// is saved to a temp sibling, then flushed and published over the target, so a crash mid-write /// cannot tear the live shortcut and a clean commit is durable (P1-3). Mirrors @@ -39,13 +73,19 @@ pub fn read_icon_location(shortcut_path: &str) -> PortResult PortResult<()> { + // Preserve the AUMID across the rewrite (Task 11): the property store is NOT guaranteed to + // survive an `IPersistFile::Load`/`Save` cycle, and a dropped AUMID would silently UN-MERGE a + // styled Store wrapper back into a duplicate Start menu entry. Read it up front (plain path, + // best-effort: an ordinary shortcut simply has none); `save_icon_to_temp` re-attaches it to the + // loaded link OBJECT so the single Save publishes link + AUMID together. + let aumid = read_appusermodel_id(shortcut_path)?; let target = extended_length_path(shortcut_path); // Pre-claim the temp as a proven-regular O_EXCL file so Save can't be tricked into // creating/truncating through a pre-placed symlink (handoff §8a #1). let tmp = extended_length_path(&crate::durable::claim_temp_for(shortcut_path)?); // A failed Save can return before finalize_saved runs, leaving a partial temp sibling — never // strand it (new-P3). finalize_saved cleans up on ITS own failure. - let saved = save_icon_to_temp(&target, &tmp, icon_path, index); + let saved = save_icon_to_temp(&target, &tmp, icon_path, index, aumid.as_deref()); if saved.is_err() { let _ = std::fs::remove_file(&tmp); } @@ -53,9 +93,16 @@ pub fn set_icon_location(shortcut_path: &str, icon_path: &str, index: i32) -> Po crate::durable::finalize_saved(&tmp, &target) } -/// Loads the `.lnk`, sets its icon location, and Saves it to `tmp` (fRemember = false). Split out so -/// the caller can clean up the temp on any failure. [WINDOWS-VERIFY] runtime. -fn save_icon_to_temp(target: &str, tmp: &str, icon_path: &str, index: i32) -> PortResult<()> { +/// Loads the `.lnk`, sets its icon location — and, when `aumid` is `Some`, re-attaches the AUMID +/// to the loaded ShellLink OBJECT before the save — and Saves it to `tmp` (fRemember = false). +/// Split out so the caller can clean up the temp on any failure. [WINDOWS-VERIFY] runtime. +fn save_icon_to_temp( + target: &str, + tmp: &str, + icon_path: &str, + index: i32, + aumid: Option<&str>, +) -> PortResult<()> { // SAFETY: COM object confined to this STA thread; dropped before we publish, so the target is // no longer held open when ReplaceFileW runs. unsafe { @@ -63,6 +110,13 @@ fn save_icon_to_temp(target: &str, tmp: &str, icon_path: &str, index: i32) -> Po CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).map_err(com)?; let file: IPersistFile = link.cast().map_err(com)?; file.Load(&HSTRING::from(target), STGM_READWRITE).map_err(com)?; + // Preserve the AUMID atomically with the icon rewrite: set it on the OBJECT, so the one + // Save below serialises link + property block together. A by-path property write on the + // `.tmp`-named temp is impossible (no shell property handler for `.tmp`) — and this way a + // crash can never strand a saved-but-un-merged wrapper. + if let Some(aumid) = aumid { + set_appusermodel_id(&link, aumid)?; + } link.SetIconLocation(&HSTRING::from(icon_path), index).map_err(com)?; file.Save(&HSTRING::from(tmp), false).map_err(com) } @@ -154,6 +208,145 @@ pub fn create_shortcut( /// never missed on a case/Unicode path variant. pub const WRAPPER_MARKER: &str = "DeskMakeover:file-wrapper:v1"; +/// The durable ownership signature stamped into a Store/MSIX wrapper `.lnk`'s Description +/// (Task 11, ADR-0024). Deliberately distinct from [`WRAPPER_MARKER`]: reunification keys on +/// `WRAPPER_MARKER` and must never claim an appx wrapper as a file wrapper — the guarantee is the +/// marker, not a structural guess. +pub const APPX_WRAPPER_MARKER: &str = "DeskMakeover:appx-wrapper:v1"; + +/// Creates the Store/MSIX wrapper `.lnk` at `out_lnk` (Task 11, ADR-0024): target = `explorer.exe` +/// (EXPANDED, resolved from `%SystemRoot%`), arguments = `shell:AppsFolder\`, description = +/// the appx marker, icon = the generated asset, AND `System.AppUserModel.ID` = the AUMID in the +/// property store — the field the shell's Start/merge logic actually reads, so the wrapper MERGES +/// with the app's real Start entry instead of duplicating it. Durability: claim-temp + finalize, +/// like every other writer. [WINDOWS-VERIFY] runtime (incl. the click-to-launch gate). +pub fn create_appx_shortcut(out_lnk: &str, aumid: &str, icon_path: &str) -> PortResult<()> { + let out = extended_length_path(out_lnk); + // Pre-claim the temp as a proven-regular O_EXCL file (handoff §8a #1). The AUMID rides the + // SAME save (set on the link object inside `save_new_appx_to_temp`), so there is no second, + // by-path write that could fail independently of the Save. + let tmp = extended_length_path(&crate::durable::claim_temp_for(out_lnk)?); + // A failed Save can strand a partial temp sibling — never leave it (same posture as + // `create_shortcut`). + let saved = save_new_appx_to_temp(&tmp, aumid, icon_path); + if saved.is_err() { + let _ = std::fs::remove_file(&tmp); + } + saved?; + crate::durable::finalize_saved(&tmp, &out) +} + +/// The launch + marker + icon + AUMID fields of an appx wrapper, saved to `tmp` in ONE +/// `IPersistFile::Save`. [WINDOWS-VERIFY] runtime. +fn save_new_appx_to_temp(tmp: &str, aumid: &str, icon_path: &str) -> PortResult<()> { + // SAFETY: COM object created, used, and dropped on this STA thread before we publish. + unsafe { + let link: IShellLinkW = + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).map_err(com)?; + // The launch recipe: explorer.exe + the AppsFolder URI — the same invocation the Start menu + // itself performs. [WINDOWS-VERIFY] click gate (ADR-0024). + let explorer = std::env::var("SystemRoot") + .map(|root| format!(r"{root}\explorer.exe")) + .unwrap_or_else(|_| r"C:\Windows\explorer.exe".to_string()); + link.SetPath(&HSTRING::from(&explorer)).map_err(com)?; + link.SetArguments(&HSTRING::from(format!(r"shell:AppsFolder\{aumid}"))).map_err(com)?; + link.SetIconLocation(&HSTRING::from(icon_path), 0).map_err(com)?; + link.SetDescription(&HSTRING::from(APPX_WRAPPER_MARKER)).map_err(com)?; + // The AUMID (`System.AppUserModel.ID`) — the field the shell's Start/merge logic actually + // reads — set on the OBJECT so this single Save serialises link + property block together + // (a by-path property write on the `.tmp`-named temp is impossible: `.tmp` has no shell + // property handler, and the final publish happens only after this Save lands). + set_appusermodel_id(&link, aumid)?; + let file: IPersistFile = link.cast().map_err(com)?; + file.Save(&HSTRING::from(tmp), false).map_err(com) + } +} + +/// Reads the `System.AppUserModel.ID` property (the AUMID) from a `.lnk`'s property store, or +/// `None` when it has none — an ordinary shortcut, the common case. Best-effort by contract: a +/// failure to OPEN or read the store reads as "no AUMID", so a preserve-attempt can never break an +/// ordinary apply; only a KNOWN AUMID failing to re-attach ([`set_appusermodel_id`], fail closed) +/// is an error. +/// +/// Takes the PLAIN path: `SHGetPropertyStoreFromParsingName` does not address `\\?\` paths, and +/// only reads a real `.lnk` — a `.tmp`-named temp has no shell property handler (which is why the +/// WRITE side is object-attached, not by-path). [WINDOWS-VERIFY] runtime. +pub fn read_appusermodel_id(shortcut_path: &str) -> PortResult> { + // SAFETY: property-store COM confined to this STA thread; the returned PROPVARIANT's Drop + // (PropVariantClear) owns and frees its value. + unsafe { + let store: IPropertyStore = + match SHGetPropertyStoreFromParsingName(&HSTRING::from(shortcut_path), None, GPS_READWRITE) + { + Ok(s) => s, + Err(_) => return Ok(None), + }; + let pv = match store.GetValue(&PKEY_APPUSERMODEL_ID) { + Ok(pv) => pv, + Err(_) => return Ok(None), // property absent — ordinary shortcut + }; + // `PropVariantToBSTR` coerces the canonical VT_LPWSTR (and a BSTR variant, if some other + // writer stored one) without hand-poking union fields. + let text = match BSTR::try_from(&pv) { + Ok(b) => b.to_string(), + Err(_) => return Ok(None), + }; + Ok((!text.is_empty()).then_some(text)) + } +} + +/// Sets `System.AppUserModel.ID` = `aumid` on a LIVE ShellLink OBJECT (`IPropertyStore` QI → +/// SetValue → Commit), so the following `IPersistFile::Save` serialises link + property block +/// together — the canonical AUMID recipe, and the only one that works here: the save target is a +/// `.tmp`-named temp (no shell property handler, so a by-path `SHGetPropertyStoreFromParsingName` +/// cannot open it), and the atomicity means a crash can never strand a saved-but-un-merged +/// wrapper. +/// +/// The PROPVARIANT is built as VT_LPWSTR — the property's canonical type +/// (`InitPropVariantFromString`'s shape) — over a CoTaskMem buffer, which is exactly what +/// `PropVariantClear` (the PROPVARIANT Drop) frees; `PROPVARIANT::from(&str)` would store VT_BSTR, +/// a type the shell's readers do not expect here. [WINDOWS-VERIFY] runtime. +fn set_appusermodel_id(link: &IShellLinkW, aumid: &str) -> PortResult<()> { + // SAFETY: property-store QI confined to this STA thread; the PROPVARIANT owns its CoTaskMem + // buffer (freed on Drop) and the store COPIES the value at SetValue, so the buffer only needs + // to outlive the call. + unsafe { + let store: IPropertyStore = link.cast().map_err(com)?; + let pv = lpwstr_propvariant(aumid)?; + store.SetValue(&PKEY_APPUSERMODEL_ID, &pv).map_err(com)?; + store.Commit().map_err(com) + } +} + +/// A VT_LPWSTR PROPVARIANT over a CoTaskMem copy of `wide` — the ownership pairing +/// `PropVariantClear` (the PROPVARIANT Drop) needs. +fn lpwstr_propvariant(wide: &str) -> PortResult { + let mut buf: Vec = wide.encode_utf16().collect(); + buf.push(0); + // SAFETY: the only fault path is allocation failure; the copy length is the Vec's own. + unsafe { + let mem = CoTaskMemAlloc(buf.len() * 2) as *mut u16; + if mem.is_null() { + return Err(PortError::Io(format!( + "CoTaskMemAlloc failed for {} bytes", + buf.len() * 2 + ))); + } + std::ptr::copy_nonoverlapping(buf.as_ptr(), mem, buf.len()); + Ok(PROPVARIANT { + Anonymous: PROPVARIANT_0 { + Anonymous: core::mem::ManuallyDrop::new(PROPVARIANT_0_0 { + vt: VT_LPWSTR, + wReserved1: 0, + wReserved2: 0, + wReserved3: 0, + Anonymous: PROPVARIANT_0_0_0 { pwszVal: PWSTR(mem) }, + }), + }, + }) + } +} + /// Reads a `.lnk`'s Description (the wrapper ownership marker lives here). `None` when unset. pub fn read_description(shortcut_path: &str) -> PortResult> { // SAFETY: COM object confined to this STA thread. @@ -205,3 +398,104 @@ fn wide_to_string(buf: &[u16]) -> String { fn com(e: windows::core::Error) -> PortError { PortError::Com(e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::com::StaExecutor; + use std::sync::Arc; + + /// A throwaway STA per test — the writer/reader contract demands apartment thread. + fn exec() -> Arc { + Arc::new(StaExecutor::spawn().unwrap()) + } + + #[test] + fn appx_wrapper_round_trips_args_marker_icon_and_aumid_and_survives_an_icon_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let lnk = dir.path().join("WhatsApp.lnk").to_string_lossy().into_owned(); + let icon_a = dir.path().join("a.ico"); + std::fs::write(&icon_a, b"icon-a").unwrap(); + let icon_a = icon_a.to_string_lossy().into_owned(); + let icon_b = dir.path().join("b.ico"); + std::fs::write(&icon_b, b"icon-b").unwrap(); + let icon_b = icon_b.to_string_lossy().into_owned(); + const AUMID: &str = "5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App"; + + let e = exec(); + let (l, i) = (lnk.clone(), icon_a.clone()); + e.run(move || create_appx_shortcut(&l, AUMID, &i)).unwrap().unwrap(); + + let e = exec(); + let l = lnk.clone(); + let (args, desc, aumid, icon_loc) = e + .run(move || { + Ok::<_, PortError>(( + read_arguments(&l)?, + read_description(&l)?, + read_appusermodel_id(&l)?, + read_icon_location(&l)?, + )) + }) + .unwrap() + .unwrap(); + assert_eq!(args.as_deref(), Some(r"shell:AppsFolder\5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App")); + assert_eq!(desc.as_deref(), Some(APPX_WRAPPER_MARKER)); + assert_eq!(aumid.as_deref(), Some(AUMID), "the property store must carry the AUMID"); + assert_eq!(icon_loc, Some((icon_a.clone(), 0))); + + // THE Task 11 round-trip pin: a re-style (icon rewrite via Load→SetIconLocation→Save) must + // NOT drop the AUMID, args, or marker — a dropped AUMID silently UN-MERGES the styled + // wrapper back into a duplicate Start entry. + let e = exec(); + let (l, i) = (lnk.clone(), icon_b.clone()); + e.run(move || set_icon_location(&l, &i, 0)).unwrap().unwrap(); + + let e = exec(); + let l = lnk.clone(); + let (args, desc, aumid, icon_loc) = e + .run(move || { + Ok::<_, PortError>(( + read_arguments(&l)?, + read_description(&l)?, + read_appusermodel_id(&l)?, + read_icon_location(&l)?, + )) + }) + .unwrap() + .unwrap(); + assert_eq!(aumid.as_deref(), Some(AUMID), "the icon rewrite must preserve the AUMID"); + assert_eq!(args.as_deref(), Some(r"shell:AppsFolder\5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App")); + assert_eq!(desc.as_deref(), Some(APPX_WRAPPER_MARKER)); + assert_eq!(icon_loc, Some((icon_b, 0)), "the icon itself was rewritten"); + } + + #[test] + fn an_ordinary_shortcut_has_no_aumid_and_the_two_markers_stay_distinct() { + // Reunification keys on WRAPPER_MARKER: the appx marker must never equal it, or a Store + // wrapper could be claimed as a file wrapper (and vice versa). + assert_ne!(APPX_WRAPPER_MARKER, WRAPPER_MARKER); + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real.bin"); + std::fs::write(&target, b"x").unwrap(); + let target = target.to_string_lossy().into_owned(); + let lnk = dir.path().join("plain.lnk").to_string_lossy().into_owned(); + let icon = dir.path().join("i.ico"); + std::fs::write(&icon, b"icon").unwrap(); + let icon = icon.to_string_lossy().into_owned(); + + let e = exec(); + let (l, t, i) = (lnk.clone(), target, icon); + e.run(move || create_shortcut(&l, &t, "", &i)).unwrap().unwrap(); + + let e = exec(); + let l = lnk.clone(); + let (aumid, args) = e + .run(move || Ok::<_, PortError>((read_appusermodel_id(&l)?, read_arguments(&l)?))) + .unwrap() + .unwrap(); + assert_eq!(aumid, None, "an ordinary shortcut carries no AUMID"); + assert_eq!(args, None, "an ordinary shortcut carries no arguments"); + } +} diff --git a/crates/dm-windows/src/source.rs b/crates/dm-windows/src/source.rs index 09eed403..b3679f00 100644 --- a/crates/dm-windows/src/source.rs +++ b/crates/dm-windows/src/source.rs @@ -57,6 +57,10 @@ impl dm_domain::IconSourceExtractor for WindowsIconSourceExtractor { } } +// The AppsFolder package-glyph read (Task 11) renders through this exact `GetImage` tail. +#[cfg(windows)] +pub(crate) use win::factory_icon_image; + // --------------------------------------------------------------------------------------------- // Pure helpers — cross-platform, unit-tested on the Mac host. // --------------------------------------------------------------------------------------------- @@ -301,6 +305,13 @@ mod win { item: &DesktopItem, original: Option<&RestoreAnchor>, ) -> PortResult> { + // A Store/MSIX entry's TRUE icon is the PACKAGE glyph the AppsFolder renders — resolved + // LIVE from the AUMID, never from the wrapper `.lnk` (whose icon location may be our own + // styled asset). That read cannot compound `Style(Style(orig))`, so it is anchor- + // INDEPENDENT: original and live take the same path, before ANY other branch (Task 11). + if item.kind == ItemKind::AppxShortcut { + return extract_appx(item); + } // Ledger-aware first (codex extractor-review 🔴1): the caller passing an anchor has // PROVEN the live surface is our own styled output — re-reading it would compound // Style(Style(orig)). The anchor path is TERMINAL (codex icons2-🔴3): the host only @@ -355,6 +366,10 @@ mod win { } RestoreAnchor::RecycleBin(a) => original_recycle_bin(a), RestoreAnchor::SystemIcon(a) => original_system(a), + // Unreachable for AppxShortcut — its extraction short-circuits to the package glyph + // before any anchor is consulted (Task 11) — but the match must stay exhaustive: a + // FileAbsent anchor on any OTHER kind has no recoverable original here. + RestoreAnchor::FileAbsent => None, RestoreAnchor::CaptureFailed { .. } => None, } } @@ -504,6 +519,32 @@ mod win { Some(vec![full, empty]) } + /// The AppsFolder package glyph for a Store/MSIX item (Task 11). The AUMID: the WRAPPER's own + /// arguments when the wrapper exists (authoritative — resolved at creation), else the display + /// name resolved against the live AppsFolder enumeration (the virtual entry, pre-apply). A + /// lookup that resolves to nothing is an honest NotFound → per-item degrade upstream. + fn extract_appx(item: &DesktopItem) -> PortResult> { + let from_wrapper = crate::shell::shell_link::read_arguments(&item.path) + .ok() + .flatten() + .and_then(|args| crate::classify::parse_appx_aumid(&args).map(str::to_string)); + let aumid = match from_wrapper { + Some(aumid) => aumid, + None => crate::shell::appsfolder::resolve_aumid(&item.name)?.ok_or_else(|| { + PortError::NotFound(format!( + "no AppsFolder entry named {:?} for {}", + item.name, item.path + )) + })?, + }; + match crate::shell::appsfolder::package_glyph_image(&aumid)? { + Some(img) => Ok(vec![img]), + None => Err(PortError::NotFound(format!( + "no package glyph for AppsFolder entry {aumid}" + ))), + } + } + /// Recycle Bin: `[0]` the FULL-state icon, `[1]` the EMPTY-state icon, both resolved from the /// effective `DefaultIcon` registry values so the pair is state-independent (the live shell /// image would only show the CURRENT state). A missing value degrades to the shell image / @@ -622,13 +663,26 @@ mod win { /// The exact image Explorer shows for `path` (`IShellItemImageFactory`, icon-only). fn shell_item_image(path: &str) -> PortResult> { - // SAFETY: COM on the STA thread; the HBITMAP is deleted below. + // SAFETY: COM on the STA thread. unsafe { let factory: IShellItemImageFactory = match SHCreateItemFromParsingName(&HSTRING::from(path), None) { Ok(f) => f, Err(_) => return Ok(None), }; + factory_icon_image(&factory) + } + } + + /// The icon-only `GetImage` tail shared by EVERY shell-item image read — the live item path + /// (above) AND the AppsFolder package glyph (Task 11) — so both render through the exact same + /// pixel contract (256px, premultiplied→straight alpha, PNG). Also reached from + /// [`crate::shell::appsfolder`]. [WINDOWS-VERIFY] runtime. + pub fn factory_icon_image( + factory: &IShellItemImageFactory, + ) -> PortResult> { + // SAFETY: COM on the STA thread; the HBITMAP is deleted below. + unsafe { let hbm = match factory.GetImage( SIZE { cx: ICON_PX, cy: ICON_PX }, SIIGBF_ICONONLY | SIIGBF_BIGGERSIZEOK, diff --git a/crates/dm-windows/src/state_reader.rs b/crates/dm-windows/src/state_reader.rs index 10c66edf..0be039fa 100644 --- a/crates/dm-windows/src/state_reader.rs +++ b/crates/dm-windows/src/state_reader.rs @@ -53,15 +53,27 @@ impl ItemStateReader for WindowsStateReader { // the live `.lnk` — a COM read, so it runs on the STA thread (outer `?` = thread-join // error, inner `?` = the read's own error). A missing shortcut is NotFound (a deleted // item is skipped, not read as an empty surface). - // A UWP shortcut's desktop entry is an ordinary `.lnk`, so it reads through the same - // IconRef surface as a Shortcut (spec 06 §6, P1-12). - ItemKind::Shortcut | ItemKind::AppxShortcut => { + ItemKind::Shortcut => { pathcheck::require_exists(&target.path)?; let p = target.path.clone(); let (path, index) = self.exec.run(move || shell_link::read_icon_location(&p))??.unwrap_or_default(); Ok(SurfaceState::IconRef { path, index }.fingerprint()) } + // A Store/MSIX wrapper (Task 11): PRESENT it reads like any `.lnk` (IconRef), so + // re-apply CAS keeps chaining; ABSENT — the virtual AppsFolder entry was never + // materialized — it fingerprints as the dedicated AppxAbsent sentinel (never + // `IconRef("", 0)`: "never created" restores differently from "present but iconless"). + // A metadata fault still propagates (`path_exists`), never reads as absent. + ItemKind::AppxShortcut => { + if !pathcheck::path_exists(&target.path)? { + return Ok(SurfaceState::AppxAbsent.fingerprint()); + } + let p = target.path.clone(); + let (path, index) = + self.exec.run(move || shell_link::read_icon_location(&p))??.unwrap_or_default(); + Ok(SurfaceState::IconRef { path, index }.fingerprint()) + } // `.url`: the `[InternetShortcut]` `IconFile`/`IconIndex`. Decoded encoding-aware — // Steam writes these as UTF-16 LE, which read_to_string rejected outright, dropping the // whole shortcut to non-styleable (owner report 2026-07-15). @@ -139,9 +151,8 @@ impl ItemStateReader for WindowsStateReader { ) -> PortResult<(Fingerprint, Vec<(String, i32)>)> { match target.kind { // A shortcut's fingerprint IS its icon location, so read the location ONCE and return BOTH - // — the elevated CAS anchor and the fingerprint can then never disagree (§P1-1). A UWP - // shortcut is an ordinary `.lnk`, same surface. - ItemKind::Shortcut | ItemKind::AppxShortcut => { + // — the elevated CAS anchor and the fingerprint can then never disagree (§P1-1). + ItemKind::Shortcut => { pathcheck::require_exists(&target.path)?; let p = target.path.clone(); let (path, index) = @@ -149,6 +160,20 @@ impl ItemStateReader for WindowsStateReader { let fingerprint = SurfaceState::IconRef { path: path.clone(), index }.fingerprint(); Ok((fingerprint, vec![(path, index)])) } + // Store/MSIX wrapper (Task 11): same single-read contract as a Shortcut when the + // wrapper exists; when it does NOT (virtual AppsFolder entry), the sentinel fingerprint + // with an EMPTY location list — "nothing is styled here", which is exactly what the + // styled-residue provenance guard must see (an empty wrapper can never be ours). + ItemKind::AppxShortcut => { + if !pathcheck::path_exists(&target.path)? { + return Ok((SurfaceState::AppxAbsent.fingerprint(), Vec::new())); + } + let p = target.path.clone(); + let (path, index) = + self.exec.run(move || shell_link::read_icon_location(&p))??.unwrap_or_default(); + let fingerprint = SurfaceState::IconRef { path: path.clone(), index }.fingerprint(); + Ok((fingerprint, vec![(path, index)])) + } // The other icon-reference kinds also expose their live icon location(s) — the // styled-residue provenance check needs them (a live location inside OUR asset store is // this app's output; with no trustworthy anchor it must never be recaptured as "the @@ -215,8 +240,17 @@ impl ItemStateReader for WindowsStateReader { fn capture_anchor(&self, target: &ItemTarget) -> PortResult { match target.kind { - // AppxShortcut is an ordinary `.lnk`: byte-replay restore like a Shortcut (P1-12). - ItemKind::Shortcut | ItemKind::UrlShortcut | ItemKind::AppxShortcut => { + ItemKind::Shortcut | ItemKind::UrlShortcut => { + Ok(RestoreAnchor::FileBytes { bytes: read_bytes(&target.path)? }) + } + // Store/MSIX wrapper (Task 11): a PRESENT `.lnk` (a user's own Store shortcut, or our + // wrapper re-styled after a row loss) byte-replays like any shortcut; an ABSENT one — + // the virtual AppsFolder entry's true original — anchors as FileAbsent, whose restore + // removes the wrapper apply creates. + ItemKind::AppxShortcut => { + if !pathcheck::path_exists(&target.path)? { + return Ok(RestoreAnchor::FileAbsent); + } Ok(RestoreAnchor::FileBytes { bytes: read_bytes(&target.path)? }) } ItemKind::Folder => Ok(capture_folder(&target.path)?), @@ -343,5 +377,60 @@ mod tests { assert_eq!(loc, vec![(String::new(), 0)], "no desktop.ini → empty location"); assert_eq!(fp, r.read_fingerprint(&t).unwrap()); } + + // ---- Task 11: the AppxShortcut absent/present surface split ---- + + /// Creates a REAL appx wrapper `.lnk` at `lnk` (COM, STA). + fn make_wrapper(exec: &StaExecutor, lnk: &str, icon: &str, aumid: &str) { + let (l, i, a) = (lnk.to_string(), icon.to_string(), aumid.to_string()); + exec.run(move || crate::shell::shell_link::create_appx_shortcut(&l, &a, &i)) + .unwrap() + .unwrap(); + } + + #[test] + fn an_absent_appx_wrapper_is_the_absent_sentinel_and_a_file_absent_anchor() { + // The virtual AppsFolder entry BEFORE its first style: no wrapper on disk. The fingerprint + // is the dedicated sentinel (stable both directions), the location list is EMPTY (nothing + // styled), and the anchor says "no file existed" — whose restore removes the wrapper. + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("Never Materialized.lnk"); + let r = reader(); + let t = target(ItemKind::AppxShortcut, &missing.to_string_lossy()); + + let fingerprint = r.read_fingerprint(&t).unwrap(); + assert_eq!(fingerprint, SurfaceState::AppxAbsent.fingerprint()); + let (surface_fp, loc) = r.read_styleable_surface(&t).unwrap(); + assert_eq!(surface_fp, fingerprint, "surface and fingerprint reads agree"); + assert!(loc.is_empty(), "an absent wrapper styles nothing"); + assert_eq!(r.capture_anchor(&t).unwrap(), RestoreAnchor::FileAbsent); + } + + #[test] + fn a_present_appx_wrapper_reads_the_iconref_surface_and_a_bytes_anchor() { + // Once the wrapper exists (styled or user-made) it is an ordinary `.lnk` surface: IconRef + // CAS chaining, byte-replay anchor. + let dir = tempfile::tempdir().unwrap(); + let icon = dir.path().join("i.ico"); + std::fs::write(&icon, b"icon").unwrap(); + let lnk = dir.path().join("WhatsApp.lnk").to_string_lossy().into_owned(); + let icon = icon.to_string_lossy().into_owned(); + let exec = StaExecutor::spawn().unwrap(); + make_wrapper(&exec, &lnk, &icon, "Fake.App_abc!App"); + + let r = reader(); + let t = target(ItemKind::AppxShortcut, &lnk); + let fingerprint = r.read_fingerprint(&t).unwrap(); + assert_eq!(fingerprint, SurfaceState::IconRef { path: icon.clone(), index: 0 }.fingerprint()); + let (surface_fp, loc) = r.read_styleable_surface(&t).unwrap(); + assert_eq!(surface_fp, fingerprint); + assert_eq!(loc, vec![(icon, 0)]); + match r.capture_anchor(&t).unwrap() { + RestoreAnchor::FileBytes { bytes } => { + assert!(!bytes.is_empty(), "the anchor replays the real wrapper bytes"); + } + other => panic!("a present wrapper must byte-replay, got {other:?}"), + } + } } diff --git a/docs/decisions/0024-shell-surfaces.md b/docs/decisions/0024-shell-surfaces.md index 95ff29fb..b1d54a92 100644 --- a/docs/decisions/0024-shell-surfaces.md +++ b/docs/decisions/0024-shell-surfaces.md @@ -68,6 +68,27 @@ per-surface looks (v2 parking lot) · resident auto-format on new surfaces without its own consent gate · any new privileged surface beyond the already-classified `ProgramData` Start menu. +6. **MSIX entries via owned wrapper `.lnk`** (Task 11, owner directive 2026-10-12): + Start entries that are AppListEntries with NO file-backed shortcut (7 of 97 on the + dev box) become styleable through an OWNED wrapper shortcut in the USER Programs + root — target `%SystemRoot%\explorer.exe` + args `shell:AppsFolder\`, + `System.AppUserModel.ID` = the package AUMID (the field Windows' merge logic reads: + 2 Start rows → 1, the merged row renders the WRAPPER's icon), `IconLocation` = our + asset, Description = `DeskMakeover:appx-wrapper:v1` (distinct from the file-wrapper + marker — reunification never claims it). The AUMID is set on the ShellLink OBJECT + (QI `IPropertyStore` → `SetValue` → `Commit`) and published by ONE + `IPersistFile::Save`, so link + property block land atomically; the writer preserves + the AUMID across every icon rewrite (a dropped AUMID silently un-merges the wrapper + back into a duplicate Start row). The wrapper NEVER touches package assets (the + Never-list stands byte-for-byte; deletion restores the package icon — owner-verified + reversible), always lands in the user Programs root (no new elevation, no new + privileged surface), and its absent-original restore REMOVES the wrapper + (`RestoreAnchor::FileAbsent`). Package glyph extraction is the AppsFolder item's + `IShellItemImageFactory::GetImage` — never the wrapper's own IconLocation. Click-to- + launch on-box end-to-end is a ship gate; if the explorer route cannot activate, the + wrapper re-targets `deskmakeover-desktop.exe --activate-aumid ` + (`IApplicationActivationManager`, proven on-box) — an entry that renders but cannot + launch is never shipped. ## Consequences diff --git a/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md b/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md index 4b8b0de1..5a033344 100644 --- a/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md +++ b/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md @@ -223,29 +223,43 @@ every existing port. arbitrary asset renders that arbitrary glyph — verified on-box: terminal glyph on WhatsApp). Rule: an item is `AppxShortcut` iff its shortcut args parse `shell:AppsFolder\` (classify.rs gains this parse; synthesized items - take the AUMID from the AppsFolder display-name match) → extractor resolves - `SHCreateItemFromRelativeName(FOLDERID_AppsFolder, AUMID)` and renders via - `IShellItemImageFactory::GetImage` (reuse the `shell_item_image` → bytes - tail). File-backed appsFolder shortcuts extract by AUMID too — falling back to - their `IconLocation` would perpetuate whatever it already points at. - - **AUMID round-trip on write**: `set_icon_location` (Load → SetIconLocation → - Save) MUST preserve the property-store block carrying - `System.AppUserModel.ID` — without the AUMID the running-window lookup falls - back to the process exe and the pin goes green again. Assert in a test: - set AUMID → rewrite icon → re-read AUMID == unchanged; fix the writer - (re-apply the property after IShellLink::Save, or round-trip the store) if - Save drops the block. + take the AUMID from the AppsFolder display-name match) → the extractor builds + the AppsFolder `IShellItem` (`SHCreateItemFromParsingName("shell:AppsFolder")` + — `SHCreateItemFromRelativeName` takes that **IShellItem parent**, NOT a + `FOLDERID`), resolves the child by AUMID (relative-name first, full + `shell:AppsFolder\` parsing-name fallback) and renders via + `IShellItemImageFactory::GetImage` through the shared `factory_icon_image` + tail (same pixel contract as every shell-item read). File-backed appsFolder + shortcuts extract by AUMID too — falling back to their `IconLocation` would + perpetuate whatever it already points at. + - **AUMID round-trip on write** ✅ *(implemented + pinned by test)*: + `set_icon_location` (Load → SetIconLocation → Save) reads the AUMID up + front (by-path read on the real `.lnk` — `SHGetPropertyStoreFromParsingName` + + `GPS_READWRITE`, best-effort: an ordinary shortcut has none) and + re-attaches it to the loaded ShellLink OBJECT (QI `IPropertyStore` → + `SetValue(PKEY_AppUserModel_ID, VT_LPWSTR)` → `Commit`) BEFORE the single + `Save`, so link + property block publish atomically — without the AUMID the + running-window lookup falls back to the process exe and the pin goes green + again. A by-path write is impossible on the save target: it is a + `.tmp`-named claim-temp with no shell property handler + (`SHGetPropertyStoreFromParsingName` fails on it — verified on-box, E_FAIL), + and a two-step save-then-write could strand an un-merged wrapper. Test: + create wrapper → rewrite icon → AUMID/args/marker unchanged + (`shell_link::tests::appx_wrapper_round_trips…`), green on-box. - **State reader**: `AppxShortcut` with an absent wrapper skips `require_exists` (the System-CLSID precedent) → fresh + styleable; when the wrapper exists it reads like any `.lnk` (`IconRef`) so re-apply CAS keeps chaining. Fingerprint for "absent" = a dedicated sentinel (like the virtual recycle-bin state), stable both directions. - - **Write**: `shell_link::create_appx_shortcut(path, aumid, icon)` — - `IShellLink` (target `%SystemRoot%\explorer.exe`, - args `shell:AppsFolder\`, icon location, ownership marker) then - `IPropertyStore::SetValue(PKEY_AppUserModel_ID, GPS_READWRITE)`. The AUMID - property is THE mechanism: Windows merges the wrapper with the virtual entry - (2 rows → 1) and renders the wrapper's icon. + - **Write**: `shell_link::create_appx_shortcut(path, aumid, icon)` — ONE + `IShellLink` object: target `%SystemRoot%\explorer.exe` (expanded from the + env var), args `shell:AppsFolder\`, icon location, ownership marker + `DeskMakeover:appx-wrapper:v1` (distinct from the file-wrapper marker — + reunification never claims it), then `IPropertyStore` (QI from the SAME + link object) → `SetValue(PKEY_AppUserModel_ID, VT_LPWSTR)` → `Commit`, + then ONE `IPersistFile::Save` (claim-temp + finalize, crash-safe like every + writer). The AUMID property is THE mechanism: Windows merges the wrapper + with the virtual entry (2 rows → 1) and renders the wrapper's icon. - **[WINDOWS-VERIFY] Launch path — VERIFY ON BOX BEFORE SHIP**: the icon merge/render does NOT depend on the wrapper's target, but clicking DOES. On the owner's machine `explorer.exe shell:AppsFolder\` does not @@ -275,6 +289,25 @@ every existing port. round-trip, `commit_apply` path for `AppxShortcut` (unelevated), collision skips. Docs: ADR-0024 records the sanctioned wrapper recipe; the "silently-absent badge" follow-up is superseded by this task. + - **Status 2026-10-13 — implemented, gates green, on-box ship gate + pending**: all Task 11 code landed (scan args-classification + pure + synthesis hook; `shell/appsfolder.rs` enumerate/resolve/shell-item/glyph; + state-reader `AppxAbsent` fingerprint + `FileAbsent` anchor split; + `apply_appx` create-or-rewrite + `remove`; extraction early-return via the + shared `factory_icon_image`; AUMID-preserving writer; fake-world virtual + model). Gates on-box: `cargo test --workspace` 0 failures (dm-windows + 137, dm-domain 59, dm-operations 230), `bun test`/`tsc`/lint/bindings + green. The two ops orchestration tests live in the Mac-gated + `icons/tests.rs` (`#[cfg(all(test, not(windows)))]`, repo convention): + verified once by lifting that gate (272/272, both AppxShortcut flows + pass), gate restored to a zero-diff state after. **Remaining ship gate + (on-box)**: enumeration pin (WhatsApp AUMID literal), scan shows the + synthesized count on the dev box, and click-to-launch end-to-end — if the + explorer route still fails, re-target the wrapper at + `deskmakeover-desktop.exe --activate-aumid ` + (IApplicationActivationManager, proven on-box); do NOT ship an entry that + renders but cannot launch. Afterwards the HKLM WebView2 + `AdditionalBrowserArguments` dev value is deleted (value + key form). ## [WINDOWS-VERIFY] field findings — live pass 2026-10-12 (owner box) @@ -374,7 +407,10 @@ Remaining checklist: hand-edit a pin → CAS conflict untouched ✅ (2026-10-12) restore → byte-identical FileBytes on pins/Start ✅ (2026-10-12: 94/94) · uninstall residue `[pending]` · dev-loop hardening ✅ (worker posts `ok:false`/`png:null` failure acks instead of swallowing init rejection; -TEMP-DEBUG markers removed; `**/target/**` vite watch ignore landed). +TEMP-DEBUG markers removed; `**/target/**` vite watch ignore landed) · +Task 11 on-box ship gates `[pending]` (enumeration pin · synthesized count in +scan · click-to-launch end-to-end · then delete the HKLM WebView2 +`AdditionalBrowserArguments` dev value + key form). ## Follow-ups (documented, out of v1) From dd21623cd2f20981e880a1b8fb3a96eed89ae3d0 Mon Sep 17 00:00:00 2001 From: Melu Date: Tue, 13 Oct 2026 02:42:39 -0300 Subject: [PATCH 06/16] feat(icons): appx wrappers launch via host --activate-aumid fallback (task 11 gate) On-box click-to-launch verification: the documented primary recipe (explorer.exe + shell:AppsFolder\, what Windows' own Store pins use) failed a real-wrapper ShellExecute test, confirming the 3 earlier CLI attempts - pre-existing COM registration damage on this box (same family as the wallpaper 0x80040154). The sanctioned re-target is now implemented: create_appx_shortcut points the wrapper at the HOST binary (current_exe) with args '--activate-aumid ', and main.rs gains a fast-path that calls IApplicationActivationManager::ActivateApplication (CLSID 45BA127D-10A8-46EA-8AB7-56EA9078943C, IID 2E941141-7F97-4756-BA1D-9DECDE894A3D) and exits without booting the GUI (0 activated / 2 missing arg / 3 failed). Host is asInvoker, so no UAC on wrapper click. The AUMID property still drives the Start-menu merge - independent of the launch mechanism. parse_appx_aumid now recognizes BOTH wrapper recipes in a walked .lnk's arguments (launcher arm: first token after the flag is the AUMID, case-insensitive flag, anchored - same posture as the AppsFolder arm); without it a present wrapper would re-scan as an ordinary shortcut and re-style would compound. activate_app lives in shell/appsfolder.rs, owns its COM init, and both recipes are pinned by tests (args + target round-trip through an icon rewrite, launcher-arm parser cases, walk reclassification). Evidence (plan status + checklist + ADR-0024 D6 updated): enumeration pin green; 42 synthesized AppxShortcut with zero walked collisions; apply created 42 wrappers verified field-by-field (target/args/marker/System.AppUserModel.ID/baked asset); click gate PASSED - direct CLI exit 0 with WhatsApp.Root up, then wrapper click -> WhatsApp.Root up; two full apply/restore cycles clean (all 42 removed via RestoreAnchor::FileAbsent, user-root + ProgramData + desktop originals byte-restored); steady-state scans show no duplicate-name warns (transient ones were the watcher rescan racing the deletions, first-wins). Gates: cargo test --workspace 0 failures (dm-windows 138), bun test 670 + banned-colors 146, tsc, lint, check:bindings all green. HKLM WebView2 AdditionalBrowserArguments CDP dev value + WebView2 key deleted after verification. Also lands the plan Task 11 doc corrections (SHCreateItemFromRelativeName IShellItem parent, factory_icon_image extraction tail, object-attached AUMID write recipe). --- crates/dm-windows/src/classify.rs | 71 +++++++++--- crates/dm-windows/src/shell/appsfolder.rs | 42 +++++++- crates/dm-windows/src/shell/scan.rs | 8 +- crates/dm-windows/src/shell/shell_link.rs | 49 ++++++--- docs/decisions/0024-shell-surfaces.md | 56 ++++++---- .../2026-10-12-taskbar-startmenu-surfaces.md | 101 +++++++++++------- src-tauri/src/main.rs | 21 ++++ 7 files changed, 252 insertions(+), 96 deletions(-) diff --git a/crates/dm-windows/src/classify.rs b/crates/dm-windows/src/classify.rs index a46e1ad0..68d7bbc4 100644 --- a/crates/dm-windows/src/classify.rs +++ b/crates/dm-windows/src/classify.rs @@ -40,27 +40,36 @@ pub fn display_name(file_name: &str, kind: ItemKind) -> String { /// (Task 11, ADR-0024.) const APPS_FOLDER_PREFIX: &str = "shell:appsfolder\\"; -/// Parses the AUMID out of a `.lnk`'s ARGUMENTS when the shortcut targets the AppsFolder virtual -/// folder (`shell:AppsFolder\`). The AUMID itself may contain backslashes (classic entries -/// like `{CLSID}\printmanagement.msc`), so only the fixed prefix is matched and the rest of the -/// (trimmed, quote-stripped) argument string is the AUMID. Pure — unit-tested on the Mac host. +/// The launcher flag of the BOX-VERIFIED appx wrapper fallback recipe (Task 11): the host binary +/// activates the AUMID itself because this box's `explorer.exe shell:AppsFolder\…` route fails to +/// activate (click gate 2026-10-13). Case-insensitive like the AppsFolder arm. +const ACTIVATE_FLAG: &str = "--activate-aumid"; + +/// Parses the AUMID out of a `.lnk`'s ARGUMENTS when the shortcut carries one of OUR two appx +/// wrapper recipes: the Windows-native `shell:AppsFolder\` (documented primary) or the +/// launcher fallback `--activate-aumid `. The AUMID itself may contain backslashes (classic +/// entries like `{CLSID}\printmanagement.msc`), so only the fixed prefix/flag is matched; for the +/// launcher arm the FIRST token of the remainder is the id (AUMIDs never contain whitespace). +/// Pure — unit-tested on the Mac host. pub fn parse_appx_aumid(args: &str) -> Option<&str> { let trimmed = args.trim().trim_matches('"').trim(); - if trimmed.len() <= APPS_FOLDER_PREFIX.len() { - return None; + if let Some(rest) = strip_prefix_ci(trimmed, APPS_FOLDER_PREFIX) { + let aumid = rest.trim(); + return (!aumid.is_empty()).then_some(aumid); } - // `get` (not direct slicing): a multibyte first character must return None, never panic. - if !trimmed - .get(..APPS_FOLDER_PREFIX.len()) - .is_some_and(|p| p.eq_ignore_ascii_case(APPS_FOLDER_PREFIX)) - { - return None; - } - // Safe: the byte prefix already matched, so 17 lands on a char boundary. - let aumid = trimmed[APPS_FOLDER_PREFIX.len()..].trim(); + let rest = strip_prefix_ci(trimmed, ACTIVATE_FLAG)?; + // Any later token is the launcher's own noise, never silently swallowed into the id. + let aumid = rest.trim().split_whitespace().next()?; (!aumid.is_empty()).then_some(aumid) } +/// Case-insensitive byte-prefix strip. `get`-based: a multibyte first character returns `None`, +/// never panics; an empty remainder is also `None` (a bare prefix carries no AUMID). +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + (s.len() > prefix.len() && s.get(..prefix.len())?.eq_ignore_ascii_case(prefix)) + .then(|| &s[prefix.len()..]) +} + /// Whether `name` is safe to use as a file STEM on Windows: not empty, no `<>:"/\|?*` or ASCII /// control characters, and no trailing dot/space (Win32 silently strips those on path /// normalization, which would make the on-disk wrapper name drift from the item's display name — @@ -278,6 +287,38 @@ mod tests { ); } + #[test] + fn parses_appx_aumid_from_the_launcher_fallback_arguments() { + // The box-verified fallback recipe (Task 11 click gate): the wrapper's args lead with the + // host's --activate-aumid flag; the first token after it is the AUMID. A present wrapper + // MUST still reclassify as AppxShortcut through this arm — otherwise a re-scan would read + // it as an ordinary shortcut and re-style would compound (Style(Style(orig))). + assert_eq!( + parse_appx_aumid("--activate-aumid 5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App"), + Some("5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App"), + ); + // Case-insensitive flag; surrounding whitespace + quotes tolerated (same posture as the + // AppsFolder arm). + assert_eq!( + parse_appx_aumid(r#" "--ACTIVATE-AUMID App_abc!App" "#), + Some("App_abc!App"), + ); + // Classic AUMIDs keep their backslashes whole. + assert_eq!( + parse_appx_aumid(r"--activate-aumid {1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\printmanagement.msc"), + Some(r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\printmanagement.msc"), + ); + // A trailing token is never swallowed into the id. + assert_eq!( + parse_appx_aumid("--activate-aumid App_abc!App --extra"), + Some("App_abc!App"), + ); + // Bare flag / flag with only whitespace / flag not leading → None. + assert_eq!(parse_appx_aumid("--activate-aumid"), None); + assert_eq!(parse_appx_aumid("--activate-aumid "), None); + assert_eq!(parse_appx_aumid("junk --activate-aumid App_abc!App"), None); + } + #[test] fn appx_aumid_parse_is_prefix_gated_trimmed_and_quote_tolerant() { // Case-insensitive prefix (IShellLink argument casing varies across creators). diff --git a/crates/dm-windows/src/shell/appsfolder.rs b/crates/dm-windows/src/shell/appsfolder.rs index c70bd035..3df553b6 100644 --- a/crates/dm-windows/src/shell/appsfolder.rs +++ b/crates/dm-windows/src/shell/appsfolder.rs @@ -4,12 +4,16 @@ //! is COM). use dm_domain::{PortError, PortResult}; -use windows::core::{HSTRING, Interface}; -use windows::Win32::System::Com::{CoTaskMemFree, IBindCtx}; +use windows::core::{HSTRING, Interface, GUID}; +use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoTaskMemFree, IBindCtx, CLSCTX_ALL, + COINIT_APARTMENTTHREADED, +}; use windows::Win32::UI::Shell::{ - BHID_EnumItems, IEnumShellItems, IShellItem, IShellItemImageFactory, - SHCreateItemFromParsingName, SHCreateItemFromRelativeName, SIGDN_DESKTOPABSOLUTEPARSING, - SIGDN_NORMALDISPLAY, SIGDN_PARENTRELATIVEPARSING, SIGDN, + BHID_EnumItems, IApplicationActivationManager, IEnumShellItems, IShellItem, + IShellItemImageFactory, SHCreateItemFromParsingName, SHCreateItemFromRelativeName, + SIGDN_DESKTOPABSOLUTEPARSING, SIGDN_NORMALDISPLAY, SIGDN_PARENTRELATIVEPARSING, SIGDN, + ACTIVATEOPTIONS, }; /// One AppsFolder entry: the display name the Start menu shows, and its activation id (the AUMID @@ -101,6 +105,34 @@ pub fn package_glyph_image(aumid: &str) -> PortResult PortResult { + // CLSID_IApplicationActivationManager (shell32). + const CLSID: GUID = GUID::from_u128(0x45BA127D_10A8_46EA_8AB7_56EA9078943C); + // SAFETY: the manager is a trusted system COM object; the HSTRING arguments outlive the call + // (the shell copies what it needs at the call boundary). + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + let manager: IApplicationActivationManager = + CoCreateInstance(&CLSID, None, CLSCTX_ALL).map_err(com)?; + let id = HSTRING::from(aumid); + // `ACTIVATEOPTIONS(0)` = AO_NONE (the crate carries no named constant for it). + manager + .ActivateApplication(&id, &HSTRING::new(), ACTIVATEOPTIONS(0)) + .map_err(com) + } +} + /// The AppsFolder as a shell item (`shell:AppsFolder` is a parsing name the shell resolves). fn folder_item() -> PortResult { unsafe { diff --git a/crates/dm-windows/src/shell/scan.rs b/crates/dm-windows/src/shell/scan.rs index 026ba6f8..87d9fab5 100644 --- a/crates/dm-windows/src/shell/scan.rs +++ b/crates/dm-windows/src/shell/scan.rs @@ -689,8 +689,10 @@ mod tests { assert_eq!(items[0].kind, ItemKind::Shortcut); } - /// On-box (COM): a REAL `shell:AppsFolder\...` wrapper walks as an AppxShortcut with a clean - /// display name — the classification the whole Task 11 pipeline keys on. + /// On-box (COM): a REAL appx wrapper (the box-verified launcher recipe — args + /// `--activate-aumid `) walks as an AppxShortcut with a clean display name — the + /// classification the whole Task 11 pipeline keys on, and the guard against a present + /// wrapper re-reading as an ordinary shortcut (which would compound re-styles). #[test] fn a_real_appsfolder_wrapper_lnk_walks_as_an_appx_shortcut() { // The icon lives OUTSIDE the walked root — it is an asset, not a scanned item. @@ -709,7 +711,7 @@ mod tests { assert_eq!( items[0].kind, ItemKind::AppxShortcut, - "AppsFolder arguments must reclassify the .lnk" + "our wrapper arguments (--activate-aumid) must reclassify the .lnk" ); assert_eq!(items[0].name, "WhatsApp", "the label drops .lnk like any shortcut"); } diff --git a/crates/dm-windows/src/shell/shell_link.rs b/crates/dm-windows/src/shell/shell_link.rs index e90adef0..31cb215a 100644 --- a/crates/dm-windows/src/shell/shell_link.rs +++ b/crates/dm-windows/src/shell/shell_link.rs @@ -214,12 +214,15 @@ pub const WRAPPER_MARKER: &str = "DeskMakeover:file-wrapper:v1"; /// marker, not a structural guess. pub const APPX_WRAPPER_MARKER: &str = "DeskMakeover:appx-wrapper:v1"; -/// Creates the Store/MSIX wrapper `.lnk` at `out_lnk` (Task 11, ADR-0024): target = `explorer.exe` -/// (EXPANDED, resolved from `%SystemRoot%`), arguments = `shell:AppsFolder\`, description = +/// Creates the Store/MSIX wrapper `.lnk` at `out_lnk` (Task 11, ADR-0024): target = the HOST +/// binary (resolved from `current_exe`), arguments = `--activate-aumid `, description = /// the appx marker, icon = the generated asset, AND `System.AppUserModel.ID` = the AUMID in the /// property store — the field the shell's Start/merge logic actually reads, so the wrapper MERGES -/// with the app's real Start entry instead of duplicating it. Durability: claim-temp + finalize, -/// like every other writer. [WINDOWS-VERIFY] runtime (incl. the click-to-launch gate). +/// with the app's real Start entry instead of duplicating it. The launch route is the box-verified +/// fallback: this box's `explorer.exe shell:AppsFolder\…` recipe fails to activate (see +/// [`save_new_appx_to_temp`]), while `IApplicationActivationManager` works. Durability: +/// claim-temp + finalize, like every other writer. [WINDOWS-VERIFY] runtime (incl. the +/// click-to-launch gate — PASSED on-box via the launcher route 2026-10-13). pub fn create_appx_shortcut(out_lnk: &str, aumid: &str, icon_path: &str) -> PortResult<()> { let out = extended_length_path(out_lnk); // Pre-claim the temp as a proven-regular O_EXCL file (handoff §8a #1). The AUMID rides the @@ -243,13 +246,20 @@ fn save_new_appx_to_temp(tmp: &str, aumid: &str, icon_path: &str) -> PortResult< unsafe { let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).map_err(com)?; - // The launch recipe: explorer.exe + the AppsFolder URI — the same invocation the Start menu - // itself performs. [WINDOWS-VERIFY] click gate (ADR-0024). - let explorer = std::env::var("SystemRoot") - .map(|root| format!(r"{root}\explorer.exe")) - .unwrap_or_else(|_| r"C:\Windows\explorer.exe".to_string()); - link.SetPath(&HSTRING::from(&explorer)).map_err(com)?; - link.SetArguments(&HSTRING::from(format!(r"shell:AppsFolder\{aumid}"))).map_err(com)?; + // The launch recipe — the box-verified FALLBACK (Task 11 click gate, 2026-10-13): the + // `explorer.exe shell:AppsFolder\` route (Windows' own recipe, still the documented + // primary in ADR-0024) does NOT activate apps on this box — three CLI attempts and a real + // wrapper click all failed to start the target while `IApplicationActivationManager` + // returned S_OK and the app stayed up (pre-existing COM registration damage, same family + // as the wallpaper `0x80040154`). So the wrapper targets the HOST binary as a pure + // launcher: `--activate-aumid ` activates and exits without booting the GUI (the + // CLI fast-path in `src/main.rs`). The AUMID property below keeps the Start-menu merge + // INDEPENDENT of the launch mechanism. Creates run in-host only, so `current_exe` is the + // desktop host — the elevated helper only ever rewrites PRESENT wrappers. + let launcher = std::env::current_exe() + .map_err(|e| dm_domain::PortError::Io(format!("current_exe: {e}")))?; + link.SetPath(&HSTRING::from(launcher.to_string_lossy().as_ref())).map_err(com)?; + link.SetArguments(&HSTRING::from(format!("--activate-aumid {aumid}"))).map_err(com)?; link.SetIconLocation(&HSTRING::from(icon_path), 0).map_err(com)?; link.SetDescription(&HSTRING::from(APPX_WRAPPER_MARKER)).map_err(com)?; // The AUMID (`System.AppUserModel.ID`) — the field the shell's Start/merge logic actually @@ -428,9 +438,10 @@ mod tests { let e = exec(); let l = lnk.clone(); - let (args, desc, aumid, icon_loc) = e + let (target, args, desc, aumid, icon_loc) = e .run(move || { Ok::<_, PortError>(( + read_target(&l)?, read_arguments(&l)?, read_description(&l)?, read_appusermodel_id(&l)?, @@ -439,7 +450,13 @@ mod tests { }) .unwrap() .unwrap(); - assert_eq!(args.as_deref(), Some(r"shell:AppsFolder\5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App")); + assert_eq!( + args.as_deref(), + Some("--activate-aumid 5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App"), + "the wrapper launches through the HOST's --activate-aumid fast-path" + ); + let launcher = std::env::current_exe().unwrap().to_string_lossy().into_owned(); + assert_eq!(target.as_deref(), Some(launcher.as_str()), "target = the host binary"); assert_eq!(desc.as_deref(), Some(APPX_WRAPPER_MARKER)); assert_eq!(aumid.as_deref(), Some(AUMID), "the property store must carry the AUMID"); assert_eq!(icon_loc, Some((icon_a.clone(), 0))); @@ -465,7 +482,11 @@ mod tests { .unwrap() .unwrap(); assert_eq!(aumid.as_deref(), Some(AUMID), "the icon rewrite must preserve the AUMID"); - assert_eq!(args.as_deref(), Some(r"shell:AppsFolder\5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App")); + assert_eq!( + args.as_deref(), + Some("--activate-aumid 5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App"), + "the icon rewrite must preserve the launch recipe" + ); assert_eq!(desc.as_deref(), Some(APPX_WRAPPER_MARKER)); assert_eq!(icon_loc, Some((icon_b, 0)), "the icon itself was rewritten"); } diff --git a/docs/decisions/0024-shell-surfaces.md b/docs/decisions/0024-shell-surfaces.md index b1d54a92..c59b0e35 100644 --- a/docs/decisions/0024-shell-surfaces.md +++ b/docs/decisions/0024-shell-surfaces.md @@ -69,26 +69,42 @@ own consent gate · any new privileged surface beyond the already-classified `ProgramData` Start menu. 6. **MSIX entries via owned wrapper `.lnk`** (Task 11, owner directive 2026-10-12): - Start entries that are AppListEntries with NO file-backed shortcut (7 of 97 on the - dev box) become styleable through an OWNED wrapper shortcut in the USER Programs - root — target `%SystemRoot%\explorer.exe` + args `shell:AppsFolder\`, - `System.AppUserModel.ID` = the package AUMID (the field Windows' merge logic reads: - 2 Start rows → 1, the merged row renders the WRAPPER's icon), `IconLocation` = our - asset, Description = `DeskMakeover:appx-wrapper:v1` (distinct from the file-wrapper - marker — reunification never claims it). The AUMID is set on the ShellLink OBJECT - (QI `IPropertyStore` → `SetValue` → `Commit`) and published by ONE - `IPersistFile::Save`, so link + property block land atomically; the writer preserves - the AUMID across every icon rewrite (a dropped AUMID silently un-merges the wrapper - back into a duplicate Start row). The wrapper NEVER touches package assets (the - Never-list stands byte-for-byte; deletion restores the package icon — owner-verified - reversible), always lands in the user Programs root (no new elevation, no new - privileged surface), and its absent-original restore REMOVES the wrapper - (`RestoreAnchor::FileAbsent`). Package glyph extraction is the AppsFolder item's - `IShellItemImageFactory::GetImage` — never the wrapper's own IconLocation. Click-to- - launch on-box end-to-end is a ship gate; if the explorer route cannot activate, the - wrapper re-targets `deskmakeover-desktop.exe --activate-aumid ` - (`IApplicationActivationManager`, proven on-box) — an entry that renders but cannot - launch is never shipped. + Start entries that are AppListEntries with NO file-backed shortcut (42 of the 98 + AppsFolder entries on the dev box — display-name matched against the full recursive + walk) become styleable through an OWNED wrapper shortcut in the USER Programs root. + The recipe has two parts: + - **Launch (the fork ships the box-verified re-target)**: target = the HOST binary + with args `--activate-aumid ` — a `main.rs` fast-path that calls + `IApplicationActivationManager::ActivateApplication` (CLSID `45BA127D-…`, IID + `2E941141-7F97-4756-BA1D-9DECDE894A3D`) and exits without booting the GUI. + REASON: the documented primary recipe (Windows' own Store-app pins) — target + `%SystemRoot%\explorer.exe` + args `shell:AppsFolder\` — was re-tested + on-box with a real wrapper (ShellExecute) and FAILED a 4th time to activate the + app, consistent with the 3 earlier CLI attempts and the pre-existing COM + registration damage (wallpaper bug `0x80040154` family). The primary stays + documented here for upstream; the re-target is what makes the gate honest + ON-BOX (click-to-launch passed via the launcher route 2026-10-13). Host + manifest is `asInvoker` → no UAC on wrapper click. `parse_appx_aumid` + reclassifies present wrappers from EITHER recipe (a present wrapper read as an + ordinary shortcut would compound re-styles). + - **Identity/merge**: `System.AppUserModel.ID` = the package AUMID (the field + Windows' merge logic reads: 2 Start rows → 1, the merged row renders the + WRAPPER's icon), `IconLocation` = our asset, Description = + `DeskMakeover:appx-wrapper:v1` (distinct from the file-wrapper marker — + reunification never claims it). The AUMID is set on the ShellLink OBJECT + (QI `IPropertyStore` → `SetValue` → `Commit`) and published by ONE + `IPersistFile::Save`, so link + property block land atomically; the writer + preserves the AUMID across every icon rewrite (a dropped AUMID silently + un-merges the wrapper back into a duplicate Start row). The merge is + independent of the launch mechanism. + The wrapper NEVER touches package assets (the Never-list stands byte-for-byte; + deletion restores the package icon — owner-verified reversible), always lands in + the user Programs root (no new elevation, no new privileged surface), and its + absent-original restore REMOVES the wrapper (`RestoreAnchor::FileAbsent`) — + verified on-box across two full apply/restore cycles. Package glyph extraction is + the AppsFolder item's `IShellItemImageFactory::GetImage` — never the wrapper's own + IconLocation. An entry that renders but cannot launch is never shipped: the + click-to-launch gate passed on-box via the re-target (2026-10-13). ## Consequences diff --git a/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md b/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md index 5a033344..f9bfb08b 100644 --- a/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md +++ b/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md @@ -252,29 +252,39 @@ every existing port. chaining. Fingerprint for "absent" = a dedicated sentinel (like the virtual recycle-bin state), stable both directions. - **Write**: `shell_link::create_appx_shortcut(path, aumid, icon)` — ONE - `IShellLink` object: target `%SystemRoot%\explorer.exe` (expanded from the - env var), args `shell:AppsFolder\`, icon location, ownership marker + `IShellLink` object: target = the HOST binary (resolved from + `current_exe`), args `--activate-aumid ` (the box-verified launcher + recipe — see the click gate below), icon location, ownership marker `DeskMakeover:appx-wrapper:v1` (distinct from the file-wrapper marker — reunification never claims it), then `IPropertyStore` (QI from the SAME link object) → `SetValue(PKEY_AppUserModel_ID, VT_LPWSTR)` → `Commit`, then ONE `IPersistFile::Save` (claim-temp + finalize, crash-safe like every writer). The AUMID property is THE mechanism: Windows merges the wrapper - with the virtual entry (2 rows → 1) and renders the wrapper's icon. - - **[WINDOWS-VERIFY] Launch path — VERIFY ON BOX BEFORE SHIP**: the icon - merge/render does NOT depend on the wrapper's target, but clicking DOES. - On the owner's machine `explorer.exe shell:AppsFolder\` does not - activate apps (3 attempts with Calculator → no process; the owner's - busy-cursor loop when clicking the probe wrapper = this exact recipe), - while `IApplicationActivationManager::ActivateApplication` (CLSID - `45BA127D-10A8-46EA-8AB7-56EA9078943C`, **IID + with the virtual entry (2 rows → 1) and renders the wrapper's icon — the + merge is independent of the launch mechanism. A present wrapper must + RECLASSIFY from its args too: `parse_appx_aumid` accepts BOTH wrapper + recipes (`shell:AppsFolder\…` and `--activate-aumid …`), otherwise a + re-scan would read it as an ordinary shortcut and a re-style would + compound (Style(Style(orig))). + - **[WINDOWS-VERIFY] Launch path — VERIFIED ON BOX 2026-10-13, gate PASSED + via the re-target**: the icon merge/render does NOT depend on the wrapper's + target, but clicking DOES. The explorer route was re-tested with a REAL + wrapper (ShellExecute = double-click equivalent) and FAILED again (WhatsApp + never activated), confirming the 3 earlier CLI attempts; the pre-existing + COM registration damage (wallpaper bug `0x80040154` family) stands, so + `sfc /DISM` remains out of repo scope. The sanctioned fallback is therefore + IMPLEMENTED as the shipped recipe: the wrapper targets + `deskmakeover-desktop.exe --activate-aumid `, whose `main.rs` + fast-path calls `IApplicationActivationManager::ActivateApplication` + (CLSID `45BA127D-10A8-46EA-8AB7-56EA9078943C`, **IID `2E941141-7F97-4756-BA1D-9DECDE894A3D`** — the widely-circulated - `2E97EF3E-…` is wrong → E_NOINTERFACE) returns S_OK and the app stays up. - The recipe is what Windows' own Store-app pins use → box-specific - breakage correlating with the pre-existing COM registration damage - (wallpaper bug `0x80040154`). Task 11 ships only after a wrapper is - clicked end-to-end on-box; if the explorer route still fails, disclose - (system repair `sfc /DISM` is out of repo scope) or re-target the - wrapper — do NOT silently ship an entry that renders but cannot launch. + `2E97EF3E-…` is wrong → E_NOINTERFACE) and exits WITHOUT booting the GUI + (exit codes: 0 activated · 2 missing argument · 3 activation failed). + Gate evidence: direct CLI → exit 0 + `WhatsApp.Root` running; wrapper click + → `WhatsApp.Root` running. The explorer.exe + `shell:AppsFolder\…` recipe + stays the DOCUMENTED PRIMARY in ADR-0024 (Windows' own Store-app pins use + it); this fork re-targets until the box is repaired. Host manifest is + `asInvoker` → no UAC on wrapper click. - **Ledger / restore**: original = wrapper *Absent* → restore REMOVES the wrapper (the `file_wrapper` Absent→`remove_file` precedent) → the package icon returns. Zero package mutation; ADR-0024 Never-list intact. @@ -289,25 +299,35 @@ every existing port. round-trip, `commit_apply` path for `AppxShortcut` (unelevated), collision skips. Docs: ADR-0024 records the sanctioned wrapper recipe; the "silently-absent badge" follow-up is superseded by this task. - - **Status 2026-10-13 — implemented, gates green, on-box ship gate - pending**: all Task 11 code landed (scan args-classification + pure - synthesis hook; `shell/appsfolder.rs` enumerate/resolve/shell-item/glyph; - state-reader `AppxAbsent` fingerprint + `FileAbsent` anchor split; - `apply_appx` create-or-rewrite + `remove`; extraction early-return via the - shared `factory_icon_image`; AUMID-preserving writer; fake-world virtual - model). Gates on-box: `cargo test --workspace` 0 failures (dm-windows - 137, dm-domain 59, dm-operations 230), `bun test`/`tsc`/lint/bindings - green. The two ops orchestration tests live in the Mac-gated - `icons/tests.rs` (`#[cfg(all(test, not(windows)))]`, repo convention): - verified once by lifting that gate (272/272, both AppxShortcut flows - pass), gate restored to a zero-diff state after. **Remaining ship gate - (on-box)**: enumeration pin (WhatsApp AUMID literal), scan shows the - synthesized count on the dev box, and click-to-launch end-to-end — if the - explorer route still fails, re-target the wrapper at - `deskmakeover-desktop.exe --activate-aumid ` - (IApplicationActivationManager, proven on-box); do NOT ship an entry that - renders but cannot launch. Afterwards the HKLM WebView2 - `AdditionalBrowserArguments` dev value is deleted (value + key form). + - **Status 2026-10-13 — implemented, ALL gates green (incl. on-box ship + gates)**: all Task 11 code landed (scan args-classification + pure + synthesis hook; `shell/appsfolder.rs` enumerate/resolve/shell-item/glyph/ + `activate_app`; state-reader `AppxAbsent` fingerprint + `FileAbsent` + anchor split; `apply_appx` create-or-rewrite + `remove`; extraction + early-return via the shared `factory_icon_image`; AUMID-preserving writer + + launcher recipe; `--activate-aumid` CLI fast-path in `main.rs`; + fake-world virtual model). Gates on-box: `cargo test --workspace` 0 + failures (dm-windows 138, dm-domain 59, dm-operations 230), `bun test` + 670 / banned-colors 146 / `tsc -b` / lint / check:bindings green. The two + ops orchestration tests live in the Mac-gated `icons/tests.rs` + (`#[cfg(all(test, not(windows)))]`, repo convention): verified once by + lifting that gate (272/272, both AppxShortcut flows pass), gate restored + to a zero-diff state after. **On-box ship gates PASSED (2026-10-13)**: + enumeration pin (the WhatsApp AUMID literal resolves through StaExecutor); + scan shows 42 synthesized AppxShortcut entries with zero collisions + against walked items (only pre-existing WinRAR file↔file duplicate labels + remain, untouched by this task); full apply → 42 wrappers created, each + verified (target = host, args = `--activate-aumid `, marker, + `System.AppUserModel.ID` property, baked asset on disk); click-to-launch + PASSED via the launcher recipe (wrapper click → `WhatsApp.Root` up, + Responding); restore REMOVES all 42 wrappers (`RestoreAnchor::FileAbsent`) + with user-root, ProgramData and desktop originals byte-restored — two + full apply/restore cycles, both directions clean. Transient "duplicate + display name" warns during a post-restore watcher rescan are honest + first-wins dedupe racing the deletions (steady-state scans repeat none). + After this pass the HKLM WebView2 `AdditionalBrowserArguments` dev value + is deleted (value + key form) — the CDP bring-up existed only for this + verification. ## [WINDOWS-VERIFY] field findings — live pass 2026-10-12 (owner box) @@ -408,9 +428,12 @@ restore → byte-identical FileBytes on pins/Start ✅ (2026-10-12: 94/94) · uninstall residue `[pending]` · dev-loop hardening ✅ (worker posts `ok:false`/`png:null` failure acks instead of swallowing init rejection; TEMP-DEBUG markers removed; `**/target/**` vite watch ignore landed) · -Task 11 on-box ship gates `[pending]` (enumeration pin · synthesized count in -scan · click-to-launch end-to-end · then delete the HKLM WebView2 -`AdditionalBrowserArguments` dev value + key form). +Task 11 on-box ship gates ✅ (2026-10-13: enumeration pin · 42 synthesized +AppxShortcut with zero walked collisions · 42 wrappers created — content + +AUMID property verified · click-to-launch PASSED via the implemented +`--activate-aumid` launcher recipe after the explorer route failed a 4th +confirmation · two full apply/restore cycles clean incl. ProgramData · then the +HKLM WebView2 `AdditionalBrowserArguments` dev value + key form deleted). ## Follow-ups (documented, out of v1) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 95289c34..a49af80f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,26 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Task 11 launch fallback (ADR-0024 D6): appx wrapper `.lnk`s launch THROUGH this binary — + // `--activate-aumid ` activates the Store/MSIX app via IApplicationActivationManager + // and exits WITHOUT booting the GUI (this box's `explorer.exe shell:AppsFolder\…` route is + // verified broken on-box — three CLI attempts + a real wrapper click all failed to activate — + // while the COM API returns S_OK; evidence in the plan's Task 11 gate log). Exit codes: 0 + // activated · 2 missing argument · 3 activation failed — the wrapper's own launch reports + // honestly through the process exit code. + let args: Vec = std::env::args().collect(); + if let Some(pos) = args.iter().position(|a| a == "--activate-aumid") { + let code = match args.get(pos + 1) { + Some(aumid) if !aumid.is_empty() => { + if dm_windows::shell::appsfolder::activate_app(aumid).is_ok() { + 0 + } else { + 3 + } + } + _ => 2, + }; + std::process::exit(code); + } deskmakeover_desktop_lib::run() } From 82386c012fd6c6aef94a7c3840de5e22000a80a9 Mon Sep 17 00:00:00 2001 From: Melu Date: Tue, 13 Oct 2026 05:09:10 -0300 Subject: [PATCH 07/16] feat(icons): style File Explorer profile folders via desktop.ini (ADR-0004 D7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the folder half of the `explorer` surface: enumerate the user profile's browsable folders and style them through the existing desktop.ini writer (apply/folder.rs), so folder icons follow a chosen look across File Explorer. - domain: `ItemSurface::Explorer` — one surface covers folders + drives, routing by `kind` (drives land in a follow-up). - ops/contracts: `surfaces.explorer` toggle (default ON) + allows() arm. - windows: `known_folders::profile_root()` (FOLDERID_Profile) + `walk_explorer_folders` — BFS (spends the shared icon-preview budget on shallow content first, never on deep tooling junk), depth cap 8, denylist + dot-prefix + Hidden/System + reparse exclusions, cap 1400; scan dedup drops profile folders the desktop surface already owns. - frontend: participation toggle leg, consent count, en/zh i18n. Verified on-box: profile folders carry our IconResource; AppData and node_modules/target/android-sdk/.bun/... stay clean (0 writes). Gates: cargo test --workspace, bun test (670), tsc, lint, check:bindings all green. --- crates/dm-contracts/src/icons.rs | 9 +- crates/dm-domain/src/item.rs | 4 + .../dm-operations/src/icons/style_resolve.rs | 5 + crates/dm-windows/src/shell/known_folders.rs | 16 +- crates/dm-windows/src/shell/scan.rs | 194 ++++++++++++++++++ .../2026-10-13-explorer-folder-drive-icons.md | 93 +++++++++ src-tauri/src/icon_host/dto.rs | 1 + src/bridge/generated.ts | 9 +- src/bridge/types.ts | 5 +- src/components/common/ceremony.tsx | 12 +- src/components/panels/icons-panel.tsx | 1 + src/components/panels/icons-participation.tsx | 20 +- src/lib/i18n/en.ts | 2 + src/lib/i18n/zh-hans.ts | 2 + src/lib/kind-policy.ts | 6 +- tests/icon-look.test.ts | 10 +- tests/icons-store.test.ts | 12 +- 17 files changed, 371 insertions(+), 30 deletions(-) create mode 100644 docs/plans/2026-10-13-explorer-folder-drive-icons.md diff --git a/crates/dm-contracts/src/icons.rs b/crates/dm-contracts/src/icons.rs index 9298c1fb..38f97bf0 100644 --- a/crates/dm-contracts/src/icons.rs +++ b/crates/dm-contracts/src/icons.rs @@ -49,9 +49,10 @@ pub enum IconKindDto { } /// WHICH shell surface an item was observed on (ADR-0024, fork draft): the desktop grid, a -/// taskbar pin (`…\User Pinned\TaskBar\*.lnk`), or a Start menu shortcut. Serialized camelCase -/// to match the TS union `'desktop' | 'taskbar' | 'startMenu'`. `#[serde(default)]` reads an -/// absent field as `Desktop`, so a downgraded/older host degrades to the classic surface. +/// taskbar pin (`…\User Pinned\TaskBar\*.lnk`), a Start menu shortcut, or File Explorer (the +/// profile's folders + fixed drives, ADR-0004 D7). Serialized camelCase to match the TS union +/// `'desktop' | 'taskbar' | 'startMenu' | 'explorer'`. `#[serde(default)]` reads an absent field +/// as `Desktop`, so a downgraded/older host degrades to the classic surface. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum SurfaceDto { @@ -59,6 +60,7 @@ pub enum SurfaceDto { Desktop, Taskbar, StartMenu, + Explorer, } /// One desktop item as the scanner observed it — raw platform truth ONLY (no override, no style). @@ -244,6 +246,7 @@ mod tests { (SurfaceDto::Desktop, "\"desktop\""), (SurfaceDto::Taskbar, "\"taskbar\""), (SurfaceDto::StartMenu, "\"startMenu\""), + (SurfaceDto::Explorer, "\"explorer\""), ] { assert_eq!(serde_json::to_string(&variant).unwrap(), literal); assert_eq!(serde_json::from_str::(literal).unwrap(), variant); diff --git a/crates/dm-domain/src/item.rs b/crates/dm-domain/src/item.rs index 63a6892e..2861d8af 100644 --- a/crates/dm-domain/src/item.rs +++ b/crates/dm-domain/src/item.rs @@ -140,6 +140,10 @@ pub enum ItemSurface { Desktop, Taskbar, StartMenu, + /// File Explorer (ADR-0004 D7): the folders a user browses (styled via `desktop.ini`) and + /// the fixed drives (styled via the registry `DriveIcons` key). One surface, two write + /// mechanisms — routing is by `kind`, not surface. + Explorer, } impl Default for ItemSurface { diff --git a/crates/dm-operations/src/icons/style_resolve.rs b/crates/dm-operations/src/icons/style_resolve.rs index d3714d00..c8f1f6f5 100644 --- a/crates/dm-operations/src/icons/style_resolve.rs +++ b/crates/dm-operations/src/icons/style_resolve.rs @@ -158,6 +158,9 @@ pub struct SurfacesJson { pub taskbar: bool, /// Start menu shortcuts (user + all-users roots), camelCase `startMenu` on the wire. pub start_menu: bool, + /// File Explorer (ADR-0004 D7): the profile's folders (via `desktop.ini`) + fixed drives (via + /// the registry `DriveIcons` key). camelCase `explorer` on the wire. + pub explorer: bool, } impl Default for SurfacesJson { @@ -165,6 +168,7 @@ impl Default for SurfacesJson { Self { taskbar: true, start_menu: true, + explorer: true, } } } @@ -175,6 +179,7 @@ impl SurfacesJson { ItemSurface::Desktop => true, ItemSurface::Taskbar => self.taskbar, ItemSurface::StartMenu => self.start_menu, + ItemSurface::Explorer => self.explorer, } } } diff --git a/crates/dm-windows/src/shell/known_folders.rs b/crates/dm-windows/src/shell/known_folders.rs index cf65e790..b305de31 100644 --- a/crates/dm-windows/src/shell/known_folders.rs +++ b/crates/dm-windows/src/shell/known_folders.rs @@ -7,8 +7,9 @@ use dm_domain::{PortError, PortResult}; use windows::core::GUID; use windows::Win32::System::Com::CoTaskMemFree; use windows::Win32::UI::Shell::{ - SHGetKnownFolderPath, FOLDERID_CommonPrograms, FOLDERID_Desktop, FOLDERID_Programs, - FOLDERID_ProgramData, FOLDERID_PublicDesktop, FOLDERID_RoamingAppData, KF_FLAG_DEFAULT, + SHGetKnownFolderPath, FOLDERID_CommonPrograms, FOLDERID_Desktop, FOLDERID_Profile, + FOLDERID_Programs, FOLDERID_ProgramData, FOLDERID_PublicDesktop, FOLDERID_RoamingAppData, + KF_FLAG_DEFAULT, }; /// The desktop roots LABELED by identity: `(user desktop, public desktop)`. The USER desktop is @@ -110,6 +111,17 @@ pub fn user_programs_root() -> Option { path.is_dir().then_some(path) } +/// The USER profile root (`FOLDERID_Profile`, e.g. `C:\Users\Melu`) — the recursive root of the +/// `explorer` surface's FOLDER items (ADR-0004 D7). Best-effort by contract: `None` on a +/// locked-down profile (the explorer folder surface is then skipped, like any other best-effort +/// root). The walk beneath it applies the hidden/system/reparse/denylist exclusions. +/// +/// [WINDOWS-VERIFY] runtime. +pub fn profile_root() -> Option { + let path = known_folder(&FOLDERID_Profile).ok().flatten()?; + path.is_dir().then_some(path) +} + fn known_folder(id: &GUID) -> PortResult> { // SAFETY: `SHGetKnownFolderPath` allocates a `PWSTR` that we own and free with // `CoTaskMemFree`; `to_string` reads the NUL-terminated buffer before it is freed. diff --git a/crates/dm-windows/src/shell/scan.rs b/crates/dm-windows/src/shell/scan.rs index 87d9fab5..0163412e 100644 --- a/crates/dm-windows/src/shell/scan.rs +++ b/crates/dm-windows/src/shell/scan.rs @@ -130,6 +130,20 @@ fn scan_blocking() -> PortResult> { items.extend(synthesize_appx_items(&walked, &entries, &programs)); } } + // Explorer folders (ADR-0004 D7): every non-excluded folder under the user profile becomes a + // `Folder` item on the `explorer` surface, styled via `desktop.ini` exactly like a desktop + // folder. Best-effort per-root doctrine: a missing/locked-down profile root NARROWS the + // surface instead of failing the scan. [WINDOWS-VERIFY] runtime. + if let Some(profile) = known_folders::profile_root() { + let mut explorer_items = Vec::new(); + walk_explorer_folders(&profile, &mut explorer_items); + // The DESKTOP surface already styles the top-level folders on the desktop, and the profile + // walk descends into the Desktop folder too — the same folder would otherwise surface twice + // under one `ItemId` (once desktop, once explorer). Keep the FIRST (the desktop-labeled + // item) and drop the explorer duplicate so the ledger/commit never address one folder twice. + let seen: std::collections::HashSet = items.iter().map(|i| i.path.clone()).collect(); + items.extend(explorer_items.into_iter().filter(|i| !seen.contains(&i.path))); + } // Oracle sorts by name, current-culture case-insensitive; ASCII-lowercase is the closest // portable approximation and is corrected on the Windows box if a locale diff surfaces. items.sort_by_key(|item| item.name.to_lowercase()); @@ -320,6 +334,151 @@ fn walk_dir( } } +/// Caps for the `explorer` folder walk (ADR-0004 D7). A depth cap + folder-count cap keep a freak +/// tree from stalling the scan or emitting tens of thousands of items (the owner's profile holds a +/// repo — `target`/`node_modules` alone dwarf everything else, and the denylist below already cuts +/// them, but the caps are the hard backstop). Tunable. +const EXPLORER_MAX_DEPTH: u32 = 8; +const EXPLORER_MAX_FOLDERS: usize = 1400; + +/// Directory names that are build/VCS/cache/package artifacts — never styled. Writing +/// `desktop.ini` into them is catastrophic for perf + restore and invisible to the user anyway. +/// Case-insensitive. The DOT-prefixed entries are additionally caught wholesale by the +/// dot-prefix gate in the walk (belt-and-suspenders); the explosive build trees (`node_modules`, +/// `target`, `bin`, `obj`, …) are cut by name so they never fill the count cap. The DEPTH cap is +/// the backstop for arbitrary tooling internals: the owner box's +/// `Documents\godot\…\android-sdk\…\res\values-*` explosion lives at depth 9 — under this cap it +/// is never reached, so no budget is spent there and no `desktop.ini` lands in the SDK. +const EXPLORER_DENYLIST: &[&str] = &[ + "node_modules", + "target", + "dist", + "build", + "out", + "bin", + "obj", + "__pycache__", + "venv", + "packages", + // Tooling SDKs — a user never browses these for icons and they hold deep explosions + // (`android-sdk\cmdline-tools\latest\lib\zipflinger`, NDK python stdlib, …). Cutting the root + // keeps every `desktop.ini` out of the SDK (owner box: `Documents\godot\…\android-sdk`). + "android-sdk", + "cmdline-tools", + "platform-tools", + "build-tools", + "system-images", + ".git", + ".svn", + ".hg", + ".venv", + ".cache", + ".cargo", + ".rustup", + ".npm", + ".gradle", + "AppData", +]; + +/// Emits the user profile's FOLDER items for the `explorer` surface (ADR-0004 D7). Unlike the +/// Start menu (whose folders are containers only), a folder here is BOTH an item (its +/// `desktop.ini` gets styled) AND a container (we descend to its subfolders). Files are never +/// emitted — global file-type icons are constitutionally banned (ADR-0004 D7). Best-effort: an +/// unreadable subfolder is skipped, never a scan failure. +/// +/// **Breadth-first, not depth-first.** The per-scan icon-preview budget (`SCAN_SOURCE_BUDGET` in +/// the host) is SHARED across every surface and, once exhausted, turns the remaining items +/// `Icons_Reason_TooMany` → not-styleable. A DFS would dive into the first huge subtree — the +/// owner box: `Documents\godot\…\android-sdk\…\res\values-*`, thousands of dirs — and spend the +/// whole budget there, starving the profile's LATER content folders (Pictures, Music, Videos, …) +/// of a preview. BFS emits the shallow, browsable content FIRST, so the budget is spent on what +/// the user actually browses and only ever overflows onto deep tooling junk (fine to leave +/// not-styleable). The depth cap additionally cuts a depth-9+ explosion outright. +fn walk_explorer_folders(root: &Path, items: &mut Vec) { + let mut queue: std::collections::VecDeque<(std::path::PathBuf, u32)> = + std::collections::VecDeque::new(); + queue.push_back((root.to_path_buf(), 0)); + let mut count = 0usize; + while let Some((dir, depth)) = queue.pop_front() { + if count >= EXPLORER_MAX_FOLDERS { + break; + } + // At the depth ceiling: don't even read this dir's children (they'd be depth+1 > cap). + if depth >= EXPLORER_MAX_DEPTH { + continue; + } + let rd = match std::fs::read_dir(&dir) { + Ok(rd) => rd, + Err(e) => { + log::warn!("explorer scan skipped unreadable folder {}: {e}", dir.display()); + continue; + } + }; + for entry in rd.flatten() { + if count >= EXPLORER_MAX_FOLDERS { + break; + } + // The explorer surface styles FOLDERS (+ drives, enumerated separately); files are + // skipped here because file-type icons are banned (ADR-0004 D7). + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let file_name = entry.file_name().to_string_lossy().into_owned(); + if is_ignored_entry(&file_name) { + continue; + } + // Dot-prefixed dirs (`.git`, `.bun`, `.vscode`, `.cargo`, `.venv`, `.cache`, `.local`, …) + // are the Windows convention for tooling/package caches — dot-prefix is NOT an attribute + // hidden bit, so the attribute gate below misses them. They hold thousands of store + // subfolders that blow the preview budget and starve every REAL folder past them into + // `Icons_Reason_TooMany` → not-styleable (owner-box `.bun\install\cache` report + // 2026-10-13). Skip them wholesale — a user never browses them for icons. + if file_name.starts_with('.') + || EXPLORER_DENYLIST.iter().any(|d| file_name.eq_ignore_ascii_case(d)) + { + continue; + } + let path = entry.path(); + let path_str = match path.to_str() { + Some(s) => s.to_string(), + None => continue, + }; + // Attribute exclusions: hidden, system, or a reparse point. Hidden/System covers + // `AppData` and app-managed trees (what Explorer hides by default — the owner never + // sees them). Reparse is skipped so we never write `desktop.ini` *through* a + // junction/symlink and never cycle (OneDrive, redirected Documents). An unreadable + // attribute read fails closed. + match crate::shell::attrs::get(&path_str) { + Ok(a) => { + if a & (crate::shell::attrs::HIDDEN | crate::shell::attrs::SYSTEM) != 0 { + continue; + } + if a & crate::shell::attrs::REPARSE_POINT != 0 { + continue; + } + } + Err(_) => continue, + } + count += 1; + items.push(DesktopItem { + id: ItemId::from_source_path("filesystem", &path_str), + name: display_name(&file_name, ItemKind::Folder), + icon: read_icon_source(ItemKind::Folder, &path_str), + path: path_str, + kind: ItemKind::Folder, + surface: ItemSurface::Explorer, + parent: String::new(), + state: ItemState::Ready, + requires_explicit_consent: false, + status_message: None, + }); + // Enqueue for descent (breadth-first): processed AFTER every already-queued shallower + // folder, so a deep subtree never preempts shallow content for the count cap / budget. + queue.push_back((path, depth + 1)); + } + } +} + /// The shell parsing name for the Recycle Bin (the canonical CLSID form; the oracle's /// `shell:RecycleBinFolder` resolves to the same folder). const RECYCLE_BIN_PARSING: &str = "::{645FF040-5081-101B-9F08-00AA002F954E}"; @@ -502,6 +661,41 @@ mod tests { items } + #[test] + fn the_explorer_walk_emits_folders_recurses_and_honors_exclusions() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + // Visible folders: emitted AND descended into (a folder is both item + container). + touch(root, "Docs/a.txt"); + touch(root, "Docs/Sub/b.txt"); + // A denylist dir: skipped entirely, including its descent (the repo's target/node_modules). + touch(root, "node_modules/dep/c.txt"); + // A loose file at the root: never an explorer item (file-type icons are banned, ADR-0004 D7). + touch(root, "loose.txt"); + // A hidden folder (attribute-gated, like AppData): skipped. + let hidden = root.join("Secret"); + std::fs::create_dir_all(&hidden).unwrap(); + crate::shell::attrs::set(&hidden.to_string_lossy(), crate::shell::attrs::HIDDEN).unwrap(); + + let mut items = Vec::new(); + walk_explorer_folders(root, &mut items); + + let paths: std::collections::HashSet = items.iter().map(|i| i.path.clone()).collect(); + let p = |s: &str| root.join(s).to_string_lossy().into_owned(); + // Emitted: the visible child folder AND the descended subfolder. + assert!(paths.contains(&p("Docs")), "visible folder emitted"); + assert!(paths.contains(&p("Docs\\Sub")), "subfolder emitted via descent"); + // Skipped: the denylist dir + its descent, the hidden folder, and the loose file. + assert!(!paths.contains(&p("node_modules")), "denylist dir skipped"); + assert!(!paths.contains(&p("node_modules\\dep")), "denylist descent skipped"); + assert!(!paths.contains(&p("Secret")), "hidden folder skipped"); + assert!(!paths.contains(&p("loose.txt")), "files are never explorer items"); + // Every emitted item is a Folder on the explorer surface. + assert!(items + .iter() + .all(|i| i.kind == ItemKind::Folder && i.surface == ItemSurface::Explorer)); + } + #[test] fn a_flat_pin_root_yields_taskbar_items_with_an_empty_parent() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/plans/2026-10-13-explorer-folder-drive-icons.md b/docs/plans/2026-10-13-explorer-folder-drive-icons.md new file mode 100644 index 00000000..b4ff10a4 --- /dev/null +++ b/docs/plans/2026-10-13-explorer-folder-drive-icons.md @@ -0,0 +1,93 @@ +# Plan — Explorer module: folder + drive icons (ADR-0004 D7) + +**Date:** 2026-10-13 · **Branch:** `feat/taskbar-startmenu-surfaces` (continues) → lands as the +Explorer module · **Status:** in progress + +## Goal + +Extend the icon look into **File Explorer**: style the **folder** icons a user sees when +browsing (via `desktop.ini`) and the **drive** icons (via the registry `DriveIcons` key), on a +new `explorer` surface with a per-surface toggle and full restore. This is the "资源管理器 +module" already scheduled in `specs/00-roadmap.md` and bounded by **ADR-0004 Decision 7**. + +## Scope decisions (owner 2026-10-13) + +- **In scope:** folder icons (`desktop.ini`) + drive icons (registry `DriveIcons`, **never** + `autorun.inf`). Same shape×colour engine, per-surface toggle, full restore. +- **Out of scope (constitutional, ADR-0004 D7):** global file-type association icons + (ProgId `DefaultIcon`). Permanently banned — association-hijack signature, un-restorable. +- **Folder coverage:** every folder under the **user profile** (`FOLDERID_Profile`), subject to + the exclusions below (owner chose "todas bajo tu perfil"). + +### Folder exclusions (safety + perf — owner informed) + +Writing `desktop.ini` into every directory naively is catastrophic: the profile contains the +owner's repo (`target/`, `node_modules/` = tens of thousands of dirs). The explorer walk SKIPS: + +- **Hidden + System attribute folders** — the whole `AppData` tree, `.git`-style hidden dirs, and + anything app-managed. Matches what Explorer shows by default (owner never sees hidden dirs). +- **Reparse points / junctions / symlinks** — no writing `desktop.ini` *through* a link; also + prevents cycles (OneDrive, redirected Documents). The apply side already refuses reparse + points (`apply/folder.rs` APPLY-2); the scan mirrors it. +- **Build/VCS/cache trees** — `target`, `node_modules`, `.git`, `.venv`, `__pycache__`, `dist`, + `build`, `.cache`, `Cargo.lock`-adjacent junk dirs. A denylist by directory name. +- **Depth + count cap** — a max walk depth and a max folder count so a freak tree can never + stall the scan (mirrors the existing read caps). + +## Architecture (what exists vs what's new) + +| Piece | Status | +|---|---| +| `desktop.ini` folder apply/restore (`apply/folder.rs`, `FolderIconWriter`) | ✅ exists — reused | +| Folder state read (`state_reader.rs` `SurfaceState::Folder`) + original icon (`source.rs`) | ✅ exists — reused | +| `ItemSurface::Explorer` | 🆕 domain enum variant | +| `ItemKind::Drive` (+ bucket) | 🆕 domain | +| Profile root + visible-drive enumeration (`known_folders.rs`) | 🆕 | +| Explorer scan walk (profile folders + drives) | 🆕 `shell/scan.rs` | +| Drive icon extraction (current `DriveIcons`/default) | 🆕 `source.rs` | +| Drive state fingerprint (`state_reader.rs`) | 🆕 | +| Drive apply/restore via `DriveIcons` registry (HKLM) | 🆕 `apply/drive.rs` + elevated helper cmd | +| `explorer` surface toggle (frontend `Surfaces`, serialize, UI) | 🆕 | +| Tests + gates | 🆕 | + +## Phases + +### Phase A — Folders (user-scope, in-host; the visible value) + +1. **Domain:** `ItemSurface::Explorer`; `kindBucket(Folder)` already `Folder`. No `Drive` yet. +2. **Enumerate:** `known_folders.rs::profile_root()`; explorer walk in `shell/scan.rs` — + recursive, exclusions above, `surface=Explorer`, `kind=Folder`. +3. **Scan wiring:** emit explorer Folder items; classify; extraction reuses folder icon path. +4. **State/apply/restore:** reuse existing folder mechanics (surface only gates participation, + not the write mechanism). Verify in-host desktop.ini writes for profile folders. +5. **Frontend:** add `explorer` to `Surface`/`Surfaces`, `surfaceParticipates`, serialize, + `DEFAULT_SURFACES`, toggle UI. + +### Phase B — Drives (elevated, registry `DriveIcons`) + +6. **Enumerate:** `known_folders.rs::visible_fixed_drives()` (`GetLogicalDriveStringsW` + + `GetDriveTypeW`, fixed only). +7. **Domain:** `ItemKind::Drive` + bucket (`App`), `is_styleable`. +8. **Extract/state:** read current `DriveIcons\\DefaultIcon` (or shell default) for the + original + fingerprint. +9. **Apply/restore:** `apply/drive.rs` writes/removes `HKLM\…\Explorer\DriveIcons\\DefaultIcon`; + routed through the **elevated helper** (new command: set/delete a DriveIcons value). +10. **Wires + toggles** for the `explorer` surface covering drives. + +### Gates & docs + +- `bun test`, `cargo test --workspace`, `bun x tsc -b`, `bun run lint`, `bun run check:bindings`, + banned-colors test — green before any commit. +- On-box verification: folders under profile restyled (visible + AppData skipped), a fixed drive + restyled, two clean apply/restore cycles. +- ADR-0004 amendment (Explorer module landed) + roadmap tick. + +## Status log + +- [x] Investigation + scope locked (owner). +- [x] Phase A folders (domain `ItemSurface::Explorer` + `SurfacesJson.explorer` + `SurfaceDto` + + frontend surface toggle + profile walk with exclusions + Desktop-overlap dedup). All gates + green: `cargo test --workspace`, `bun test` 670, `tsc`, `lint`, `check:bindings`, plus a new + `the_explorer_walk_emits_folders_recurses_and_honors_exclusions` scan test. +- [ ] Phase B drives. +- [ ] Gates + on-box + docs. diff --git a/src-tauri/src/icon_host/dto.rs b/src-tauri/src/icon_host/dto.rs index 233249c3..262c539a 100644 --- a/src-tauri/src/icon_host/dto.rs +++ b/src-tauri/src/icon_host/dto.rs @@ -49,6 +49,7 @@ pub(super) fn map_surface(surface: ItemSurface) -> SurfaceDto { ItemSurface::Desktop => SurfaceDto::Desktop, ItemSurface::Taskbar => SurfaceDto::Taskbar, ItemSurface::StartMenu => SurfaceDto::StartMenu, + ItemSurface::Explorer => SurfaceDto::Explorer, } } diff --git a/src/bridge/generated.ts b/src/bridge/generated.ts index 4b0e24a0..cee31004 100644 --- a/src/bridge/generated.ts +++ b/src/bridge/generated.ts @@ -397,11 +397,12 @@ export type SettingsPatch = { /** * WHICH shell surface an item was observed on (ADR-0024, fork draft): the desktop grid, a - * taskbar pin (`…\User Pinned\TaskBar\*.lnk`), or a Start menu shortcut. Serialized camelCase - * to match the TS union `'desktop' | 'taskbar' | 'startMenu'`. `#[serde(default)]` reads an - * absent field as `Desktop`, so a downgraded/older host degrades to the classic surface. + * taskbar pin (`…\User Pinned\TaskBar\*.lnk`), a Start menu shortcut, or File Explorer (the + * profile's folders + fixed drives, ADR-0004 D7). Serialized camelCase to match the TS union + * `'desktop' | 'taskbar' | 'startMenu' | 'explorer'`. `#[serde(default)]` reads an absent field + * as `Desktop`, so a downgraded/older host degrades to the classic surface. */ -export type SurfaceDto = "desktop" | "taskbar" | "startMenu"; +export type SurfaceDto = "desktop" | "taskbar" | "startMenu" | "explorer"; /** * Environment snapshot for the diagnostics report (mirrors the TS `SystemInfoDto`). Returned by diff --git a/src/bridge/types.ts b/src/bridge/types.ts index 88199e91..3fb0aac6 100644 --- a/src/bridge/types.ts +++ b/src/bridge/types.ts @@ -225,6 +225,9 @@ export type KindPolicy = Record export interface Surfaces { taskbar: boolean startMenu: boolean + /** File Explorer (ADR-0004 D7): the profile's folders (`desktop.ini`) + fixed drives + * (registry `DriveIcons`). Same default-true rule as the others. */ + explorer: boolean } /** The per-type style envelope (ADR-0017 D3): ONLY these axes may differ by @@ -266,7 +269,7 @@ export type TypeOverrides = Partial> /** WHICH shell surface an item was observed on (ADR-0024, fork draft): the desktop grid, a * taskbar pin (`…\User Pinned\TaskBar\*.lnk`), or a Start menu shortcut. Mirrors the generated * `SurfaceDto` union; the hand mirror keeps the frontend-owned `IconItemDto` self-contained. */ -export type Surface = 'desktop' | 'taskbar' | 'startMenu' +export type Surface = 'desktop' | 'taskbar' | 'startMenu' | 'explorer' /** One desktop item (icons contract v2, spec 06 §2): the web renders styling * locally from `sourceUrls` (256px, [0] primary; RecycleBin ships TWO — diff --git a/src/components/common/ceremony.tsx b/src/components/common/ceremony.tsx index e6dcc52c..5fa55474 100644 --- a/src/components/common/ceremony.tsx +++ b/src/components/common/ceremony.tsx @@ -110,6 +110,7 @@ export function ConsentSheet({ count, taskbarCount = 0, startMenuCount = 0, + explorerCount = 0, multiUser = false, onAgree, onCancel, @@ -117,9 +118,13 @@ export function ConsentSheet({ open: boolean count: number /** ADR-0024: styleable items on the ENABLED extra surfaces (the caller gates - * them by the surface legs). Both zero keeps the classic desktop-only copy. */ + * them by the surface legs). All zero keeps the classic desktop-only copy. */ taskbarCount?: number startMenuCount?: number + /** ADR-0004 D7: styleable explorer folders (+ drives) on the enabled `explorer` + * leg. A separate honest row — the scope (folders under the profile + drives) + * reads differently from the shortcut surfaces. */ + explorerCount?: number multiUser?: boolean onAgree: () => void onCancel: () => void @@ -139,6 +144,11 @@ export function ConsentSheet({ }, ] : []), + // ADR-0004 D7: the explorer leg styles FOLDERS (+ drives) via desktop.ini / + // the registry — a different mechanism worth its own honest sentence. + ...(explorerCount > 0 + ? [{ glyph: '🗂', text: t('ConsentAlsoExplorer').replace('{0}', String(explorerCount)) }] + : []), { glyph: '🛡', text: t('ConsentNot') }, // Informed consent (owner 2026-07-17): applying restarts the shell so the desktop re-resolves // folder/.url custom icons (the only reliable refresh) — that closes any open folder windows. diff --git a/src/components/panels/icons-panel.tsx b/src/components/panels/icons-panel.tsx index 1e04557f..a37a8388 100644 --- a/src/components/panels/icons-panel.tsx +++ b/src/components/panels/icons-panel.tsx @@ -864,6 +864,7 @@ export function IconsPanel() { // same legs Apply uses — an opted-out surface is never even mentioned. taskbarCount={state.surfaces.taskbar ? items.filter((i) => i.surface === 'taskbar' && i.styleable).length : 0} startMenuCount={state.surfaces.startMenu ? items.filter((i) => i.surface === 'startMenu' && i.styleable).length : 0} + explorerCount={state.surfaces.explorer ? items.filter((i) => i.surface === 'explorer' && i.styleable).length : 0} multiUser={state.activeUserProfiles > 1} onAgree={() => { // v2 records that the machine-wide arrow disclosure was shown; keep diff --git a/src/components/panels/icons-participation.tsx b/src/components/panels/icons-participation.tsx index 32157c16..529b3d11 100644 --- a/src/components/panels/icons-participation.tsx +++ b/src/components/panels/icons-participation.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { AnimatePresence, motion, useReducedMotion } from 'motion/react' -import { Check, ChevronDown, LayoutGrid, Monitor, PanelBottom, RotateCcw } from 'lucide-react' +import { Check, ChevronDown, Folder, LayoutGrid, Monitor, PanelBottom, RotateCcw } from 'lucide-react' import { cn } from '@/lib/utils' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { PropertyRow, SwatchButton, SwatchPicker, swatchButtonClass } from '@/components/common/inspector' @@ -12,7 +12,7 @@ import { useIcons } from '@/stores/icons' import { BUCKET_NAME_KEY, KIND_BUCKETS, kindBucket } from '@/lib/kind-policy' import { typeIsCustom } from '@/lib/type-config' import type { ConfigDto, IconKindBucket, Surfaces, TypeOverrides, TypePatch } from '@/bridge/types' -import { format, useT } from '@/lib/i18n' +import { format, useT, type StringKey } from '@/lib/i18n' // Participation surfaces (chief-UI/UX + owner 2026-07-09), both fed by one // store state — no priority matrix: @@ -28,7 +28,14 @@ import { format, useT } from '@/lib/i18n' // axis, orthogonal to kindPolicy. /** The toggleable surface legs (the desktop is never a leg — always on). */ -const SURFACE_LEGS = ['taskbar', 'startMenu'] as const satisfies readonly (keyof Surfaces)[] +const SURFACE_LEGS = ['taskbar', 'startMenu', 'explorer'] as const satisfies readonly (keyof Surfaces)[] + +/** Per-leg label + icon (the row grammar stays identical across legs). */ +const SURFACE_LEG_META: Record = { + taskbar: { label: 'Surface_Taskbar', Icon: PanelBottom }, + startMenu: { label: 'Surface_StartMenu', Icon: LayoutGrid }, + explorer: { label: 'Surface_Explorer', Icon: Folder }, +} /** The per-icon keep ledger — soft inset chip, pin dot, expandable cancel list. */ export function KeptBar() { @@ -153,10 +160,11 @@ export function KindTypeSection({ tryOn }: { tryOn: TypeTryOn }) { // ADR-0024: live per-surface counts — what the scan actually found on each // write surface (the honest number behind each checkbox). const surfaceCounts = React.useMemo(() => { - const c = { taskbar: 0, startMenu: 0 } + const c = { taskbar: 0, startMenu: 0, explorer: 0 } for (const i of items) { if (i.surface === 'taskbar') c.taskbar++ else if (i.surface === 'startMenu') c.startMenu++ + else if (i.surface === 'explorer') c.explorer++ } return c }, [items]) @@ -498,8 +506,8 @@ export function KindTypeSection({ tryOn }: { tryOn: TypeTryOn }) { {SURFACE_LEGS.map((leg) => { const on = surfaces[leg] - const name = t(leg === 'taskbar' ? 'Surface_Taskbar' : 'Surface_StartMenu') - const Icon = leg === 'taskbar' ? PanelBottom : LayoutGrid + const { label, Icon } = SURFACE_LEG_META[leg] + const name = t(label) return (