diff --git a/crates/dm-contracts/src/icons.rs b/crates/dm-contracts/src/icons.rs index dc3f0033..6e9775f4 100644 --- a/crates/dm-contracts/src/icons.rs +++ b/crates/dm-contracts/src/icons.rs @@ -43,11 +43,34 @@ pub enum IconKindDto { RecycleBin, SystemIcon, Folder, + /// A fixed drive (ADR-0004 D7) — the `explorer` surface's drive half, styled via its per-user + /// registry `DefaultIcon` (routed by `kind`, distinct from the folder `desktop.ini` writer). + Drive, + /// A file TYPE (extension association) styled via its ProgId's per-user `DefaultIcon` — the + /// `explorer` surface's file half (ADR-0004 D7 amendment 2026-10-16, every type except + /// images). One item per ProgId; `path` carries the ProgId. + FileType, RegularFile, ExecutableFile, Unsupported, } +/// WHICH shell surface an item was observed on (ADR-0024, fork draft): the desktop grid, a +/// taskbar pin (`…\User Pinned\TaskBar\*.lnk`), a Start menu shortcut, or File Explorer (every +/// fixed drive's folders + the drives themselves + the file types seen there, ADR-0004 D7 and its +/// 2026-10-16 amendment). 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 { + #[default] + Desktop, + Taskbar, + StartMenu, + Explorer, +} + /// 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 +89,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 @@ -202,6 +232,8 @@ mod tests { (IconKindDto::RecycleBin, "\"RecycleBin\""), (IconKindDto::SystemIcon, "\"SystemIcon\""), (IconKindDto::Folder, "\"Folder\""), + (IconKindDto::Drive, "\"Drive\""), + (IconKindDto::FileType, "\"FileType\""), (IconKindDto::RegularFile, "\"RegularFile\""), (IconKindDto::ExecutableFile, "\"ExecutableFile\""), (IconKindDto::Unsupported, "\"Unsupported\""), @@ -218,6 +250,30 @@ 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\""), + (SurfaceDto::Explorer, "\"explorer\""), + ] { + 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 +283,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 +318,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..d6947083 100644 --- a/crates/dm-domain/src/item.rs +++ b/crates/dm-domain/src/item.rs @@ -58,6 +58,19 @@ pub enum ItemKind { RecycleBin, /// A folder — icon via a `desktop.ini` `IconResource`. Folder, + /// A fixed drive (ADR-0004 D7) — icon via a per-user registry `DefaultIcon` value under + /// `Software\Classes\Applications\Explorer.exe\Drives\` (the current-user override; never + /// `autorun.inf`). Part of the `explorer` surface, routed by `kind`: the write mechanism + /// differs from folders even though both are Explorer items. + Drive, + /// A file TYPE (extension → ProgId association) styled via the ProgId's per-user `DefaultIcon` + /// value under `Software\Classes\\DefaultIcon` (ADR-0004 D7 amendment 2026-10-16 — + /// the association ban lifted for every type EXCEPT images). One item PER PROGID (extensions + /// sharing a ProgId share one registry value, so they must be one item): `path` carries the + /// ProgId itself — the registry key apply writes — and never a filesystem path. Images and + /// per-file types (`%1`) are filtered out at scan, so this kind never collapses a thumbnail + /// or an executable's individual icon. + FileType, /// A loose file — styled by a companion wrapper `.lnk` with the original hidden. RegularFile, /// A system virtual item (This PC / Network / User Files / Control Panel) styled via the @@ -131,6 +144,27 @@ 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, + /// 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 { + 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 +172,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, @@ -193,6 +232,8 @@ mod tests { ItemKind::Shortcut, ItemKind::UrlShortcut, ItemKind::Folder, + ItemKind::Drive, + ItemKind::FileType, ItemKind::RegularFile, ItemKind::RecycleBin, ItemKind::AppxShortcut, @@ -210,6 +251,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, @@ -262,6 +305,8 @@ mod tests { ItemKind::Shortcut, ItemKind::UrlShortcut, ItemKind::Folder, + ItemKind::Drive, + ItemKind::FileType, ItemKind::RegularFile, ItemKind::RecycleBin, ItemKind::AppxShortcut, @@ -299,7 +344,14 @@ mod tests { for kind in [ItemKind::Shortcut, ItemKind::UrlShortcut, ItemKind::AppxShortcut] { assert!(kind.is_shortcut(), "{kind:?} carries the shortcut mark"); } - for kind in [ItemKind::Folder, ItemKind::RegularFile, ItemKind::RecycleBin, ItemKind::System] { + for kind in [ + ItemKind::Folder, + ItemKind::Drive, + ItemKind::FileType, + ItemKind::RegularFile, + ItemKind::RecycleBin, + ItemKind::System, + ] { assert!(!kind.is_shortcut(), "{kind:?} is not a shortcut"); } } @@ -310,6 +362,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..a45f4439 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, }; @@ -34,8 +34,8 @@ pub use ports::{ MonitorTopology, OverlayControl, OverlayOutcome, OverlayStyle, WallpaperApplier, }; pub use restore::{ - DesktopIniAnchor, PriorWrapper, RecycleBinAnchor, RegistryValue, RestoreAnchor, - SystemIconAnchor, WrapperAnchor, + DesktopIniAnchor, DriveAnchor, FileTypeAnchor, PriorWrapper, RecycleBinAnchor, RegistryValue, + RestoreAnchor, SystemIconAnchor, WrapperAnchor, }; pub use wallpaper::{ DecodedImage, MonitorInfo, MonitorRect, MonitorWallpaper, Orientation, WallpaperPosition, diff --git a/crates/dm-domain/src/restore.rs b/crates/dm-domain/src/restore.rs index 79d246b6..1e5552e2 100644 --- a/crates/dm-domain/src/restore.rs +++ b/crates/dm-domain/src/restore.rs @@ -102,6 +102,49 @@ pub struct SystemIconAnchor { pub value: Option, } +/// The captured original per-user `DefaultIcon` for one fixed drive (ADR-0004 D7). A drive has a +/// SINGLE `(Default)` `DefaultIcon` value under the per-user +/// `Software\Classes\Applications\Explorer.exe\Drives\` key — the same one-value model as +/// [`SystemIconAnchor`], but keyed by drive letter instead of CLSID. +/// +/// `letter` is the drive letter WITHOUT a colon (`"C"`), mirroring the registry subkey name; it is +/// carried here so restore can verify it targets the SAME drive the item path names (defence against +/// a mismatched pairing, same discipline as [`SystemIconAnchor::clsid`]). +/// +/// `key_existed` refers ONLY to the PER-USER (HKCU) key apply writes. `true` → rewrite `value`; +/// `false` → REMOVE the value we created. `value` is the EFFECTIVE current `DefaultIcon` (per-user +/// when present, else the machine `DriveIcons` fallback) captured for source extraction; the +/// machine fallback keeps `key_existed:false` + `Some(value)` non-contradictory — the per-user key +/// is absent yet the shell shows the machine icon, whose value source extraction must still read. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DriveAnchor { + /// Drive letter without a colon, e.g. `"C"`. + pub letter: String, + pub key_existed: bool, + pub value: Option, +} + +/// The captured original per-user `DefaultIcon` for one file TYPE (ADR-0004 D7 amendment +/// 2026-10-16). Same one-value model as [`SystemIconAnchor`] / [`DriveAnchor`], keyed by ProgId +/// instead of CLSID/letter. +/// +/// `prog_id` is the ProgId the item's path carries, restated here so restore can verify it +/// targets the SAME association the item names (defence against a mismatched pairing — the value +/// is interpolated into an HKCU key path). +/// +/// `key_existed` refers ONLY to the PER-USER `Software\Classes\\DefaultIcon` key apply +/// writes: `true` → rewrite `value`; `false` → REMOVE the value we created. `value` is the +/// EFFECTIVE current `DefaultIcon` (per-user when the key pre-existed, else the HKCR machine +/// default) captured for source extraction — styling never writes HKCR, so the machine fallback +/// keeps `false` + `Some(value)` non-contradictory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileTypeAnchor { + /// The association ProgId this anchor targets, e.g. `txtfile`. + pub prog_id: String, + pub key_existed: bool, + pub value: Option, +} + /// The exact-restore material for one item, discriminated by capture shape. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "anchor")] @@ -123,6 +166,17 @@ pub enum RestoreAnchor { RecycleBin(RecycleBinAnchor), /// System desktop CLSID (This PC / Network / …) per-user `DefaultIcon` registry state. SystemIcon(SystemIconAnchor), + /// Fixed drive per-user `DefaultIcon` registry state (ADR-0004 D7). + Drive(DriveAnchor), + /// File-type association per-user `DefaultIcon` registry state (ADR-0004 D7 amendment + /// 2026-10-16: every type except images). + FileType(FileTypeAnchor), + /// 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 +295,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,10 +314,41 @@ 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::FileType(FileTypeAnchor { + prog_id: "txtfile".into(), + key_existed: false, + value: None, + }), + RestoreAnchor::FileAbsent, ]; for v in variants { assert!(v.has_material(), "{v:?} should carry restore material"); } assert!(!RestoreAnchor::CaptureFailed { reason: "locked".into() }.has_material()); } + + #[test] + fn file_type_anchor_round_trips_with_and_without_user_key() { + // Per-user key pre-existed (rewrite it exactly) and machine-default fallback (remove only + // the value we created) — the two shapes restore branches on (amendment 2026-10-16). + let with = RestoreAnchor::FileType(FileTypeAnchor { + prog_id: "txtfile".into(), + key_existed: true, + value: Some(RegistryValue { raw: "C:\\Windows\\System32\\notepad.exe,0".into(), kind: 1 }), + }); + let machine_fallback = RestoreAnchor::FileType(FileTypeAnchor { + prog_id: "WinRAR.ZIP".into(), + key_existed: false, + value: Some(RegistryValue { raw: "%SystemRoot%\\System32\\imageres.dll,-153".into(), kind: 2 }), + }); + for anchor in [with.clone(), machine_fallback.clone()] { + assert!(anchor.has_material()); + let json = serde_json::to_string(&anchor).unwrap(); + let back: RestoreAnchor = serde_json::from_str(&json).unwrap(); + assert_eq!(anchor, back); + } + // The ProgId rides the ledger so a restore can be defended against a mismatched pairing. + let json = serde_json::to_string(&machine_fallback).unwrap(); + assert!(json.contains(r#""prog_id":"WinRAR.ZIP""#), "prog id must survive the journal: {json}"); + } } diff --git a/crates/dm-domain/src/source.rs b/crates/dm-domain/src/source.rs index e6d212bf..3298dd88 100644 --- a/crates/dm-domain/src/source.rs +++ b/crates/dm-domain/src/source.rs @@ -180,6 +180,8 @@ fn kind_tag(kind: ItemKind) -> u8 { ItemKind::Folder => 5, ItemKind::RegularFile => 6, ItemKind::System => 7, + ItemKind::Drive => 8, + ItemKind::FileType => 9, } } diff --git a/crates/dm-operations/src/icons/mod.rs b/crates/dm-operations/src/icons/mod.rs index 202d6481..3a8b0402 100644 --- a/crates/dm-operations/src/icons/mod.rs +++ b/crates/dm-operations/src/icons/mod.rs @@ -22,6 +22,7 @@ pub mod version_switch; pub use package::{package_masters, BufferedMaster, PackagedItem}; use std::collections::HashSet; +use std::sync::Arc; use dm_contracts::IconStyle; use dm_domain::{ @@ -91,7 +92,10 @@ pub struct IconApplySession { revision: u32, expected: usize, masters: Vec, - /// Running total of buffered base64 bytes, for the host's cumulative-byte cap (audit F4). + /// Content-dedup index: one shared `Arc` per DISTINCT master payload (see [`Self::push`]). + seen: std::collections::HashSet>, + /// Running total of buffered base64 bytes — DISTINCT payloads only, so it measures the memory + /// the session actually holds and the host's cumulative-byte cap stays a real memory guard. bytes: usize, } @@ -104,18 +108,38 @@ impl IconApplySession { revision, expected: count, masters: Vec::with_capacity(count.min(MAX_PREALLOC_MASTERS)), + seen: std::collections::HashSet::new(), bytes: 0, } } /// Buffers one baked master. `source_index` 0 = primary, 1 = the paired empty (Recycle Bin). - pub fn push(&mut self, item_id: impl Into, source_index: u32, png_base64: impl Into) { - let png_base64 = png_base64.into(); - self.bytes = self.bytes.saturating_add(png_base64.len()); + /// + /// Identical payloads are stored ONCE and shared by `Arc` (owner 2026-10-16, all-drives + /// scale): an apply over thousands of folders uploads the same stock-glyph master once per + /// item, and a per-item copy made the cumulative-byte cap fire on a legitimate apply + /// ("apply session exceeded its 268435456-byte cumulative budget") while the truly distinct + /// data was a few MiB. Deduping by content keeps the cap meaningful: it bounds DISTINCT bytes, + /// which is the memory the session holds. + pub fn push( + &mut self, + item_id: impl Into, + source_index: u32, + png_base64: impl Into>, + ) { + let payload = png_base64.into(); + let shared = match self.seen.get(&payload) { + Some(existing) => Arc::clone(existing), + None => { + self.bytes = self.bytes.saturating_add(payload.len()); + self.seen.insert(Arc::clone(&payload)); + payload + } + }; self.masters.push(BufferedMaster { item_id: item_id.into(), source_index, - png_base64, + png_base64: shared, }); } @@ -404,22 +428,31 @@ impl<'a> IconOps<'a> { } } - // Resolve each packaged item against the live scan; build the driver's requests. An item no - // longer in the scan, or not styleable, or already gone is a benign conflict (skipped, never - // forced). The CAS anchor for a FRESH apply is the fingerprint captured AT SCAN TIME (so a - // hand-edit during the bake fails CAS and is left untouched); a RE-APPLY's anchor is the - // ledger's last-applied, which the driver enforces itself. + // Resolve each packaged item against the live scan and partition it into the user batch + // (applied per-item below), the elevated batch, or a benign conflict. An item no longer in + // the scan, not styleable, or already gone is skipped, never forced. The CAS anchor for a + // FRESH apply is the fingerprint captured AT SCAN TIME (so a hand-edit during the bake fails + // CAS and is left untouched); a RE-APPLY's anchor is the ledger's last-applied, which the + // driver enforces itself. + // + // Request building is DELIBERATELY lazy (owner OOM fix 2026-10-16): each `ApplyRequest` + // carries the item's full ICO bytes, so materializing them all up-front cloned ~300 KiB per + // item (+1.5 GB on a 5000-item apply, on top of the packaged set) and killed the owner box at + // ~3 GB mid-commit. The scope decision needs only the target, so the split runs on + // references and the bytes are built per item, used, and dropped inside the loop below. let by_id: std::collections::HashMap<&str, &ScannedItem> = scan.iter().map(|s| (s.item.id.as_str(), s)).collect(); - let mut requests = Vec::with_capacity(packaged.len()); // The privileged shared items (Public Desktop / ProgramData) that ride ONE elevated batch, // each paired with the scan-observed icon location — the trust-first CAS anchor the helper - // checks before writing (captured at scan, threaded UNCHANGED, never re-read: §P1-1). + // checks before writing (captured at scan, threaded UNCHANGED, never re-read: §P1-1). The + // batch is at most a handful of items, so building its requests here is bounded. let mut privileged: Vec<(ApplyRequest, (String, i32))> = Vec::new(); // Hand-edited restore skips count as conflicts so an apply whose only intent was such a revert // reports a no-effect, not a clean success (codex R7-#3). Folded in before the driver so both // the driver-fault early-return and the normal path carry them. let mut conflicts: Vec = std::mem::take(&mut restore_skipped); + let mut user_pkgs: Vec<(&PackagedItem, &ScannedItem, ItemTarget)> = + Vec::with_capacity(packaged.len()); for pkg in &packaged { let Some(scanned) = by_id.get(pkg.item_id.as_str()) else { conflicts.push(ItemId::from_raw(&pkg.item_id)); @@ -429,34 +462,26 @@ impl<'a> IconOps<'a> { conflicts.push(scanned.item.id.clone()); continue; } - let req = ApplyRequest { - target: scanned.item.target(), - expected_fingerprint: scanned.fingerprint, - owned: OwnedFields::icon_only(), - asset_hash: pkg.primary.content_hash.clone(), - asset_bytes: pkg.primary.bytes.clone(), - empty_asset_bytes: pkg.empty.as_ref().map(|e| e.bytes.clone()), - pinned_seed: None, - }; + let target = scanned.item.target(); // Partition by write scope. A privileged-scope target (Public Desktop / ProgramData) an // unelevated write can NEVER touch (Access Denied) routes to the elevated batch IFF a port // is wired AND its kind is one the helper writes (a real `.lnk`: Shortcut / AppxShortcut). // Any other privileged case is an honest `conflict` (skipped) — never a doomed unelevated // write that always fails, then rolls the WHOLE user-desktop batch back (the on-box bug). - if scope.classify(&req.target.path).is_some() { - if elevated.is_some() && is_elevatable_kind(req.target.kind) { + if scope.classify(&target.path).is_some() { + if elevated.is_some() && is_elevatable_kind(target.kind) { // The CAS anchor is the icon location captured from the SAME read as the accepted // fingerprint (`cas_icon`, not the separate `item.icon` scan read) — so the helper // is never told to "expect" a value the preflight did not accept (§P1-1). `None` // (icon-less) → ("", 0); the helper's `GetIconLocation` reads "" for the same // target, so the `"" == ""` CAS matches. let expect = scanned.cas_icon.clone().unwrap_or_default(); - privileged.push((req, expect)); + privileged.push((packaged_request(pkg, scanned, target), expect)); } else { - conflicts.push(req.target.id.clone()); + conflicts.push(target.id); } } else { - requests.push(req); + user_pkgs.push((pkg, scanned, target)); } } @@ -474,7 +499,9 @@ impl<'a> IconOps<'a> { TxnDriver::new(self.platform.reader, self.platform.applier, self.platform.assets); let mut apply = ApplyOutcome::default(); let mut driver_fault: Option = None; - for req in requests { + for (pkg, scanned, target) in user_pkgs { + // Built here (not up-front): the ICO bytes live only for this one per-item transaction. + let req = packaged_request(pkg, scanned, target); match driver.apply(txn.next_id(), vec![req], journal, ledger) { Ok(one) => { apply.committed.extend(one.committed); @@ -1083,6 +1110,21 @@ fn is_elevatable_kind(kind: ItemKind) -> bool { matches!(kind, ItemKind::Shortcut | ItemKind::AppxShortcut) } +/// One item's driver request from its packaged assets. The ICO bytes are CLONED here and live only +/// as long as the single per-item transaction that consumes them — never accumulated (owner OOM +/// fix 2026-10-16, see the lazy-build note in `commit_apply`). +fn packaged_request(pkg: &PackagedItem, scanned: &ScannedItem, target: ItemTarget) -> ApplyRequest { + ApplyRequest { + target, + expected_fingerprint: scanned.fingerprint, + owned: OwnedFields::icon_only(), + asset_hash: pkg.primary.content_hash.clone(), + asset_bytes: pkg.primary.bytes.clone(), + empty_asset_bytes: pkg.empty.as_ref().map(|e| e.bytes.clone()), + pinned_seed: None, + } +} + /// Joins the in-process and elevated batches' failure reasons into one message (either alone passes /// through; both present are joined so neither is swallowed). fn join_errors(a: Option, b: Option) -> Option { diff --git a/crates/dm-operations/src/icons/package.rs b/crates/dm-operations/src/icons/package.rs index fef1d150..22094339 100644 --- a/crates/dm-operations/src/icons/package.rs +++ b/crates/dm-operations/src/icons/package.rs @@ -9,6 +9,8 @@ //! (the Recycle Bin's empty icon). This mirrors `IconItemDto.sourceUrls` (`[0]` primary, `[1]` //! empty) and is the single convention both sides agree on. +use std::sync::Arc; + use base64::Engine; use dm_icon_codec::{bake_ico, IcoAsset, Raster}; @@ -27,21 +29,28 @@ const MAX_MASTER_B64: usize = 4 * 1024 * 1024; const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; /// One master the frontend baked, addressed to an item + its source slot. `png_base64` is the -/// standard-alphabet base64 of a straight-alpha RGBA PNG (the 256px bake master). +/// standard-alphabet base64 of a straight-alpha RGBA PNG (the 256px bake master), held as a shared +/// `Arc` so items with IDENTICAL masters share one allocation (the apply session dedups by content +/// — see `IconApplySession::push`); at all-drives scale thousands of items carry the same stock +/// glyph, and one heap copy per item is what the session's byte budget exists to prevent. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BufferedMaster { pub item_id: String, pub source_index: u32, - pub png_base64: String, + pub png_base64: Arc, } /// One item's packaged asset(s): the primary laddered ICO plus, for a two-state item, its paired -/// empty ICO. Consumed by the apply path to build the driver's `ApplyRequest`. +/// empty ICO. Consumed by the apply path to build the driver's `ApplyRequest`. Assets are +/// `Arc`-shared across items with identical content (see `package_masters`): at all-drives scale +/// one apply packages thousands of pixel-identical masters, and a per-item copy here (plus the +/// caller's per-item request clone) is what pinned ~3 GB and killed the owner box mid-commit +/// (2026-10-16). #[derive(Debug, Clone)] pub struct PackagedItem { pub item_id: String, - pub primary: IcoAsset, - pub empty: Option, + pub primary: Arc, + pub empty: Option>, } /// Decodes + laddered-ICO-packages the buffered masters, grouped per item id (input order @@ -76,6 +85,15 @@ pub fn package_masters(masters: &[BufferedMaster]) -> Result> } } + // Content-addressed bake dedup (owner OOM fix 2026-10-16): at all-drives scale one apply + // carries thousands of masters that are pixel-identical (every folder shares the stock glyph; + // base64 is deterministic, so identical content is the identical string). Decoding + laddering + // + RETAINING a ~300 KiB ICO PER ITEM pinned ~1.5 GB here — and the callers cloned those bytes + // per item into their request buffers for another ~1.5 GB — the owner box died at ~3 GB + // mid-commit twice. Identical content now bakes ONCE and every item shares the one + // `Arc` (the asset store already dedups on disk by the same content address, + // `icon-assets\.ico`). + let mut baked: std::collections::HashMap<&str, Arc> = Default::default(); let mut packaged = Vec::with_capacity(order.len()); for id in order { let Some(p) = primary.get(&id) else { @@ -83,9 +101,9 @@ pub fn package_masters(masters: &[BufferedMaster]) -> Result> "icon item {id} has an empty-state master but no primary (source 0)" ))); }; - let primary_ico = bake_master(&p.png_base64, &id, PRIMARY_INDEX)?; + let primary_ico = bake_cached(p.png_base64.as_str(), &id, PRIMARY_INDEX, &mut baked)?; let empty_ico = match empty.get(&id) { - Some(e) => Some(bake_master(&e.png_base64, &id, EMPTY_INDEX)?), + Some(e) => Some(bake_cached(e.png_base64.as_str(), &id, EMPTY_INDEX, &mut baked)?), None => None, }; packaged.push(PackagedItem { item_id: id, primary: primary_ico, empty: empty_ico }); @@ -93,6 +111,24 @@ pub fn package_masters(masters: &[BufferedMaster]) -> Result> Ok(packaged) } +/// [`bake_master`] behind the content cache: the first master with a given payload bakes; any later +/// master with the SAME payload shares that ICO through the `Arc` — no second decode, no second +/// ladder, no second retained copy. A master that fails validation is never cached, so the error +/// still fails the whole package (fail-closed, before any desktop write). +fn bake_cached<'a>( + png_base64: &'a str, + item_id: &str, + slot: u32, + cache: &mut std::collections::HashMap<&'a str, Arc>, +) -> Result> { + if let Some(hit) = cache.get(png_base64) { + return Ok(Arc::clone(hit)); + } + let ico = Arc::new(bake_master(png_base64, item_id, slot)?); + cache.insert(png_base64, Arc::clone(&ico)); + Ok(ico) +} + /// Decodes one base64 PNG master into a straight-alpha RGBA raster and bakes it into a laddered /// content-addressed ICO. `image`'s `to_rgba8` yields exactly the straight-alpha, row-major RGBA8 /// buffer [`Raster`] holds, so the conversion is a move, not a re-encode. @@ -155,7 +191,11 @@ mod tests { } fn master(id: &str, slot: u32, rgba: [u8; 4]) -> BufferedMaster { - BufferedMaster { item_id: id.into(), source_index: slot, png_base64: master_png(256, 256, rgba) } + BufferedMaster { + item_id: id.into(), + source_index: slot, + png_base64: Arc::new(master_png(256, 256, rgba)), + } } #[test] @@ -199,6 +239,34 @@ mod tests { assert_eq!(a[0].primary.content_hash, b[0].primary.content_hash, "same pixels → same asset"); } + #[test] + fn identical_masters_bake_once_and_share_one_allocation() { + // Owner OOM fix 2026-10-16: an all-drives apply carries thousands of pixel-identical + // masters (every folder shares the stock glyph). They must produce one PackagedItem per + // item — in order — but a SINGLE baked ICO, shared by pointer, so retained memory is + // content-bounded rather than item-bounded. + let same = master("x", 0, [7, 7, 7, 255]).png_base64; + let masters: Vec = (0..64) + .map(|i| BufferedMaster { + item_id: format!("folder-{i}"), + source_index: 0, + png_base64: same.clone(), + }) + .collect(); + let packaged = package_masters(&masters).unwrap(); + assert_eq!(packaged.len(), 64, "one PackagedItem per item"); + assert_eq!(packaged.iter().map(|p| p.item_id.as_str()).nth(3), Some("folder-3")); + assert!( + packaged.iter().all(|p| Arc::ptr_eq(&p.primary, &packaged[0].primary)), + "identical content shares ONE baked ICO allocation", + ); + // A different master still bakes its own asset. + let other = master("y", 0, [9, 9, 9, 255]); + let packaged = package_masters(&[masters[0].clone(), other]).unwrap(); + assert!(!Arc::ptr_eq(&packaged[0].primary, &packaged[1].primary)); + assert_ne!(packaged[0].primary.content_hash, packaged[1].primary.content_hash); + } + #[test] fn rejects_an_empty_master_without_a_primary() { let err = package_masters(&[master("bin", 1, [0, 0, 0, 255])]).unwrap_err(); @@ -211,7 +279,7 @@ mod tests { let bad = BufferedMaster { item_id: "x".into(), source_index: 0, - png_base64: master_png(w, h, [1, 2, 3, 255]), + png_base64: Arc::new(master_png(w, h, [1, 2, 3, 255])), }; assert!( package_masters(&[bad]).is_err(), @@ -233,7 +301,7 @@ mod tests { let bad = BufferedMaster { item_id: "x".into(), source_index: 0, - png_base64: base64::engine::general_purpose::STANDARD.encode(&jpeg), + png_base64: Arc::new(base64::engine::general_purpose::STANDARD.encode(&jpeg)), }; assert!(package_masters(&[bad]).is_err(), "a JPEG master must be rejected (PNG only)"); } @@ -243,19 +311,23 @@ mod tests { let bad = BufferedMaster { item_id: "x".into(), source_index: 0, - png_base64: "A".repeat(MAX_MASTER_B64 + 1), + png_base64: Arc::new("A".repeat(MAX_MASTER_B64 + 1)), }; assert!(package_masters(&[bad]).is_err(), "an over-cap payload must fail before decode"); } #[test] fn rejects_an_undecodable_master() { - let bad = BufferedMaster { item_id: "x".into(), source_index: 0, png_base64: "not-base64!!".into() }; + let bad = BufferedMaster { + item_id: "x".into(), + source_index: 0, + png_base64: Arc::new("not-base64!!".to_string()), + }; assert!(package_masters(&[bad]).is_err()); let not_png = BufferedMaster { item_id: "y".into(), source_index: 0, - png_base64: base64::engine::general_purpose::STANDARD.encode(b"not a png"), + png_base64: Arc::new(base64::engine::general_purpose::STANDARD.encode(b"not a png")), }; assert!(package_masters(&[not_png]).is_err()); } 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..f68273e7 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}; @@ -43,6 +43,14 @@ pub fn bucket_of(kind: ItemKind) -> Option { | ItemKind::RecycleBin | ItemKind::System => Some(KindBucket::App), ItemKind::Folder => Some(KindBucket::Folder), + // A fixed drive styles and is governed exactly like a folder: it is an Explorer container + // the user browses, and it rides the same `explorer` surface toggle/consent (ADR-0004 D7). + ItemKind::Drive => Some(KindBucket::Folder), + // A file TYPE is a DOCUMENT's icon (a ProgId association), so it rides the File bucket — + // the bucket the user can flip OFF to disable every association icon while keeping + // folders/drives styled (ADR-0004 D7 amendment 2026-10-16: the second, independent kill + // switch for the feature that lifted the ban). Same `explorer` surface as its siblings. + ItemKind::FileType => Some(KindBucket::File), ItemKind::RegularFile => Some(KindBucket::File), ItemKind::Unsupported => None, } @@ -146,6 +154,44 @@ fn yes() -> bool { true } +/// 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, 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, + /// 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 { + fn default() -> Self { + Self { + taskbar: true, + start_menu: true, + explorer: true, + } + } +} + +impl SurfacesJson { + fn allows(&self, surface: ItemSurface) -> bool { + match surface { + ItemSurface::Desktop => true, + ItemSurface::Taskbar => self.taskbar, + ItemSurface::StartMenu => self.start_menu, + ItemSurface::Explorer => self.explorer, + } + } +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TypeOverridesJson { @@ -157,7 +203,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 +214,8 @@ pub struct StyleRecipe { pub kind_policy: KindPolicyJson, #[serde(default)] pub type_overrides: TypeOverridesJson, + #[serde(default)] + pub surfaces: SurfacesJson, } impl StyleRecipe { @@ -183,7 +233,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 +260,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(); @@ -396,11 +457,33 @@ mod tests { }) } + #[test] + fn bucket_of_covers_every_kind() { + // Every kind buckets except Unsupported (ungoverned). A fixed drive rides the Folder + // bucket — it is an Explorer container the user browses, so it shares the Folder policy + // switch and the explorer surface consent (ADR-0004 D7); a file TYPE rides File (the + // association icons the amendment enabled are document icons, second kill switch). + for (kind, bucket) in [ + (ItemKind::Shortcut, Some(KindBucket::App)), + (ItemKind::UrlShortcut, Some(KindBucket::App)), + (ItemKind::AppxShortcut, Some(KindBucket::App)), + (ItemKind::RecycleBin, Some(KindBucket::App)), + (ItemKind::System, Some(KindBucket::App)), + (ItemKind::Folder, Some(KindBucket::Folder)), + (ItemKind::Drive, Some(KindBucket::Folder)), + (ItemKind::RegularFile, Some(KindBucket::File)), + (ItemKind::FileType, Some(KindBucket::File)), + (ItemKind::Unsupported, None), + ] { + assert_eq!(bucket_of(kind), bucket, "{kind:?} bucket"); + } + } + #[test] 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 +500,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 +520,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 +534,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 +558,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 +578,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 +594,88 @@ 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_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 on" + ); + assert!(recipe + .effective_config(ItemKind::Shortcut, true, ItemSurface::Desktop) + .unwrap() + .is_some()); + assert!(recipe + .effective_config(ItemKind::Shortcut, true, ItemSurface::Taskbar) + .unwrap() + .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_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": false, "startMenu": true } + })); + let recipe = StyleRecipe::parse(&s).unwrap(); + assert!(recipe + .effective_config(ItemKind::Shortcut, true, ItemSurface::Taskbar) + .unwrap() + .is_none()); + assert!(recipe + .effective_config(ItemKind::Shortcut, true, ItemSurface::StartMenu) + .unwrap() + .is_some()); + // 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": {}, + "typeOverrides": {}, + "surfaces": { "taskbar": true } + })); + let recipe2 = StyleRecipe::parse(&s2).unwrap(); + assert!(recipe2 + .effective_config(ItemKind::Shortcut, true, ItemSurface::StartMenu) + .unwrap() + .is_some()); + 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..7bc91b6c 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, @@ -79,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). @@ -86,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() @@ -1337,6 +1349,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, @@ -1546,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/icons/version_switch.rs b/crates/dm-operations/src/icons/version_switch.rs index 863affa5..4334c234 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()) { @@ -220,7 +220,7 @@ fn bake_and_apply( Ok(png) => item_masters.push(BufferedMaster { item_id: p.item.id.as_str().to_string(), source_index: slot as u32, - png_base64: png.clone(), + png_base64: std::sync::Arc::new(png.clone()), }), // Record only the FIRST fault per item (mirrors the old break) and skip the item whole. Err(e) => { @@ -255,12 +255,22 @@ fn bake_and_apply( }; let by_id: std::collections::HashMap<&str, &dm_domain::Fingerprint> = anchors.iter().map(|(i, f)| (i.id.as_str(), f)).collect(); - let mut requests = Vec::with_capacity(packaged.len()); + // Per-item transactions, mirroring `commit_apply` (owner 2026-07-17): one item hitting a + // transient fault (an Explorer/AV hold on the live Desktop) must not roll the whole SWITCH + // back — that batch-abort is exactly what the owner saw as "the first switch didn't replace + // the folder". A bare driver Err (possibly torn journal) stops the loop; later items were + // never journaled and stay untouched. + // + // Each request is built INSIDE its iteration (owner OOM fix 2026-10-16): a switch re-applies a + // whole look, so an up-front `Vec` would clone every item's ICO bytes at once — + // the ~3 GB blowup the all-drives apply hit. Built here, the bytes live for one transaction. + let driver = TxnDriver::new(ports.reader, ports.applier, ports.assets); + let mut folded = ApplyOutcome::default(); for pkg in &packaged { let Some((item, _)) = anchors.iter().find(|(i, _)| i.id.as_str() == pkg.item_id) else { continue; }; - requests.push(ApplyRequest { + let req = ApplyRequest { target: item.target(), expected_fingerprint: (*by_id[pkg.item_id.as_str()]).clone(), owned: OwnedFields::icon_only(), @@ -268,16 +278,7 @@ fn bake_and_apply( asset_bytes: pkg.primary.bytes.clone(), empty_asset_bytes: pkg.empty.as_ref().map(|e| e.bytes.clone()), pinned_seed: None, - }); - } - // Per-item transactions, mirroring `commit_apply` (owner 2026-07-17): one item hitting a - // transient fault (an Explorer/AV hold on the live Desktop) must not roll the whole SWITCH - // back — that batch-abort is exactly what the owner saw as "the first switch didn't replace - // the folder". A bare driver Err (possibly torn journal) stops the loop; later items were - // never journaled and stay untouched. - let driver = TxnDriver::new(ports.reader, ports.applier, ports.assets); - let mut folded = ApplyOutcome::default(); - for req in requests { + }; match driver.apply(txn.next_id(), vec![req], journal, ledger) { Ok(one) => { folded.committed.extend(one.committed); @@ -308,8 +309,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 +407,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 +506,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-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-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..36d86c55 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) => { @@ -379,7 +389,7 @@ impl Reconciler { Ok(png) => item_masters.push(BufferedMaster { item_id: p.candidate.item.id.as_str().to_string(), source_index: slot as u32, - png_base64: png.clone(), + png_base64: std::sync::Arc::new(png.clone()), }), // Record only the FIRST fault per item (mirrors the old break) and skip it whole. 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/Cargo.toml b/crates/dm-windows/Cargo.toml index 3e37fa20..d3531e19 100644 --- a/crates/dm-windows/Cargo.toml +++ b/crates/dm-windows/Cargo.toml @@ -45,12 +45,19 @@ 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 / # HWINEVENTHOOK on the desktop SysListView32's drag/capture/move-size WinEvents (spec 07 §11). "Win32_UI_Accessibility", "Win32_System_SystemInformation", + # Explorer drive enumeration (known_folders::visible_fixed_drives): GetDriveTypeW's + # DRIVE_FIXED (3) const lives in the WindowsProgramming consts module. + "Win32_System_WindowsProgramming", "Win32_Storage_FileSystem", "Win32_Security", # A1/C3 signature gate (signing.rs): WinVerifyTrust on dm-elevated.exe BEFORE the `runas`, so a diff --git a/crates/dm-windows/src/apply/drive.rs b/crates/dm-windows/src/apply/drive.rs new file mode 100644 index 00000000..0b168637 --- /dev/null +++ b/crates/dm-windows/src/apply/drive.rs @@ -0,0 +1,142 @@ +//! Fixed-drive styling via the per-user `DefaultIcon` registry value (ADR-0004 D7: drives style +//! via the registry `DriveIcons` mechanism — never `autorun.inf`). Per-user (HKCU) only — no +//! elevation, matching the app's HKCU-only, no-UAC discipline (a current-user override is honoured +//! by Explorer without local-machine permissions). Value kinds are preserved for byte-identical +//! restore; a non-string registry type is refused at read time rather than collapsed to `REG_SZ` +//! (the same APPLY-3 discipline as the Recycle Bin). [WINDOWS-VERIFY] runtime — the exact per-user +//! `Software\Classes\Applications\Explorer.exe\Drives\` key. + +use dm_domain::{DriveAnchor, PortError, PortResult}; +use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; +use winreg::RegKey; + +use crate::apply::reg_icon::{io, read_value, write_or_delete}; + +/// The per-user override apply writes (and restore tears down). `HKCU` — current user only. The +/// `(Default)` value of the per-drive `DefaultIcon` subkey carries `","`, mirroring the +/// machine `DriveIcons` shape exactly (so a machine→per-user handoff is byte-compatible). +fn user_key(letter: &str) -> String { + format!(r"Software\Classes\Applications\Explorer.exe\Drives\{letter}\DefaultIcon") +} + +/// The machine `DriveIcons` `DefaultIcon` — the EFFECTIVE icon when NO per-user override exists. +/// Styling never writes HKLM, so it remains the true original for source extraction (the same +/// live-machine-fallback posture as `recyclebin::machine_state`). +fn machine_key(letter: &str) -> String { + format!(r"Software\Microsoft\Windows\CurrentVersion\Explorer\DriveIcons\{letter}\DefaultIcon") +} + +/// Reads the current effective `DefaultIcon` state for `letter` — both the restore anchor and the +/// source-extraction original. Prefers the per-user override; falls back to the machine `DriveIcons` +/// (so a drive whose icon is set machine-wide still recovers its true original and restores the +/// per-user key we created without touching the machine value). +/// +/// A missing key/value is a benign absence (`None` / `key_existed:false`); any OTHER error +/// PROPAGATES as `Io` rather than collapsing to "absent" — a `read_current` that silently reported +/// absence for an unreadable key would let the restore anchor forget the user's real state +/// (P2-#3). [WINDOWS-VERIFY] runtime. +pub fn read_current(letter: &str) -> PortResult { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + match hkcu.open_subkey(&user_key(letter)) { + Ok(key) => Ok(DriveAnchor { + letter: letter.to_string(), + key_existed: true, + value: read_value(&key, "")?, + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => machine_state(letter), + Err(e) => Err(PortError::Io(format!("open HKCU drive DefaultIcon: {e}"))), + } +} + +/// The machine (HKLM) `DriveIcons` state — the effective original when NO per-user override +/// exists. `key_existed:false` always (apply only ever writes the per-user key, so restore removes +/// it, never the machine value). All-`None` when the machine key is absent (drive uses the shell +/// stock icon). [WINDOWS-VERIFY] runtime (the exact HKLM value name). +pub(crate) fn machine_state(letter: &str) -> PortResult { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + match hklm.open_subkey(&machine_key(letter)) { + Ok(key) => Ok(DriveAnchor { + letter: letter.to_string(), + key_existed: false, + value: read_value(&key, "")?, + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DriveAnchor { + letter: letter.to_string(), + key_existed: false, + value: None, + }), + Err(e) => Err(PortError::Io(format!("open machine drive DefaultIcon: {e}"))), + } +} + +/// Points the per-user `DefaultIcon` at the styled ICO (`",0"`). +pub fn apply(letter: &str, icon: &str) -> PortResult<()> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let (key, _) = hkcu.create_subkey(&user_key(letter)).map_err(io)?; + key.set_value("", &format!("{icon},0")).map_err(io)?; + Ok(()) +} + +/// Restores the captured per-user value (or removes the value we created when none existed). +pub fn restore(anchor: &DriveAnchor) -> PortResult<()> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if !anchor.key_existed { + // We created this value; undo by removing ONLY the `(Default)` value we wrote — never + // `delete_subkey_all`, which would destroy unrelated state another program added to the + // same key. A non-NotFound failure PROPAGATES (a swallowed one is a false success that + // then discards the anchor). Idempotent when the value is already gone. + match hkcu.open_subkey_with_flags(&user_key(&anchor.letter), winreg::enums::KEY_SET_VALUE) { + Ok(key) => match key.delete_value("") { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(io(e)), + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // key already gone + Err(e) => return Err(io(e)), + } + return Ok(()); + } + let (key, _) = hkcu.create_subkey(&user_key(&anchor.letter)).map_err(io)?; + write_or_delete(&key, "", anchor.value.as_ref())?; + Ok(()) +} + +/// Derives the bare drive letter (`"C"`, uppercase, no colon) from a drive-root path (`"C:\"` / +/// `"C:"`). Anything that is not a drive spec — a subfolder (`C:\Users\me`) or a UNC path — is +/// `Unsupported`, never silently styled against the wrong per-drive key. +pub fn letter_from_path(path: &str) -> PortResult { + let mut it = path.trim().chars(); + let c = it + .next() + .filter(|c| c.is_ascii_alphabetic()) + .ok_or_else(|| PortError::Unsupported(format!("path is not a drive root: {path}")))?; + // A drive spec is `:` optionally followed by a single root separator (`\`). + let rest = match it.as_str().strip_prefix(':') { + Some(r) => r, + None => return Err(PortError::Unsupported(format!("path is not a drive root: {path}"))), + }; + if rest.is_empty() || rest == "\\" { + Ok(c.to_ascii_uppercase().to_string()) + } else { + Err(PortError::Unsupported(format!("path is not a drive root: {path}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letter_from_path_accepts_a_drive_root_and_uppercases() { + assert_eq!(letter_from_path("C:\\").unwrap(), "C"); + assert_eq!(letter_from_path("d:").unwrap(), "D"); + assert_eq!(letter_from_path(" e:\\").unwrap(), "E"); + } + + #[test] + fn letter_from_path_rejects_a_non_drive_path() { + assert!(letter_from_path(r"C:\Users\me").is_err()); + assert!(letter_from_path("").is_err()); + assert!(letter_from_path(r"\\server\share").is_err()); + } +} diff --git a/crates/dm-windows/src/apply/filetype.rs b/crates/dm-windows/src/apply/filetype.rs new file mode 100644 index 00000000..615787ed --- /dev/null +++ b/crates/dm-windows/src/apply/filetype.rs @@ -0,0 +1,234 @@ +//! File-TYPE styling via the ProgId's per-user `DefaultIcon` (ADR-0004 D7 amendment 2026-10-16: +//! the association ban is lifted for every type EXCEPT images — the amendment answers the ban's +//! own rationale clause by clause). The single-value sibling of [`super::system`] / +//! [`super::drive`]: apply writes EXACTLY ONE value, +//! `HKCU\Software\Classes\\DefaultIcon` = `",0"`, and never a command verb +//! (`shell\open\command`), never `UserChoice`, never HKCR/HKLM — no launch path can change, no +//! elevation is needed (a per-user `Software\Classes` entry overrides the machine class entry in +//! the merged HKCR view the shell reads). +//! +//! The ProgId rides the item's `path`, so it is **validated as a single registry key component** +//! before any interpolation (the same defence-in-depth discipline as `system::parse_clsid`): a +//! `\`-bearing string could otherwise write an unrelated nested key, and a ledger replay must +//! never mutate a target it was not captured for. [WINDOWS-VERIFY] runtime — the exact per-user +//! `Software\Classes\\DefaultIcon` override of a machine `HKCR\\DefaultIcon`. + +use dm_domain::{FileTypeAnchor, PortError, PortResult, RegistryValue}; +use winreg::enums::{HKEY_CLASSES_ROOT, HKEY_CURRENT_USER}; +use winreg::RegKey; + +use crate::apply::reg_icon::{io, read_value, write_or_delete}; + +/// Validates the item path as the ProgId apply will write under `Software\Classes\`. STRICT by +/// design: a ProgId is ONE registry key component — no path separators (they would climb into an +/// arbitrary subkey), no control characters, no `.`/`..`, no surrounding whitespace, no empty +/// string, and a sane length ceiling. Anything else is `Unsupported`, never used to build a key. +pub fn prog_id_from_path(path: &str) -> PortResult { + if is_valid_prog_id(path) { + Ok(path.to_string()) + } else { + Err(PortError::Unsupported(format!("not a file-type ProgId path: {path:?}"))) + } +} + +fn is_valid_prog_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 200 + && s == s.trim() + && !s.contains('\\') + && !s.contains('/') + && s != "." + && s != ".." + && !s.chars().any(|c| c.is_control()) +} + +/// The per-user override key apply writes and restore tears down — the ONLY key this feature +/// ever writes. `HKCU` — current user only, so no UAC and no machine mutation. +fn user_key(prog_id: &str) -> String { + format!(r"Software\Classes\{prog_id}\DefaultIcon") +} + +/// The machine class `DefaultIcon` (merged HKCR view) — the effective icon when no per-user +/// override exists. Never written. +fn machine_key(prog_id: &str) -> String { + format!(r"{prog_id}\DefaultIcon") +} + +/// Reads the current effective `DefaultIcon` state for `prog_id` — both the restore anchor and the +/// source-extraction original. Prefers the per-user override; falls back to the HKCR merged class +/// default (so a type registered machine-wide still recovers its true original and restores only +/// the per-user value we created). +/// +/// A missing key/value is a benign absence (`None` / `key_existed:false`); any OTHER error +/// PROPAGATES rather than collapsing to "absent" — a `read_current` that silently reported +/// absence for an unreadable key would let the restore anchor forget the user's real state +/// (the same P2-#3 discipline as the drives). [WINDOWS-VERIFY] runtime. +pub fn read_current(prog_id: &str) -> PortResult { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + match hkcu.open_subkey(&user_key(prog_id)) { + Ok(key) => Ok(FileTypeAnchor { + prog_id: prog_id.to_string(), + key_existed: true, + value: read_value(&key, "")?, + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileTypeAnchor { + prog_id: prog_id.to_string(), + key_existed: false, + value: machine_value(prog_id)?, + }), + Err(e) => Err(PortError::Io(format!("open HKCU file-type DefaultIcon {prog_id}: {e}"))), + } +} + +/// The HKCR class-default `DefaultIcon` for `prog_id`, or `None` when absent. A non-NotFound +/// error propagates (P2-#3). `pub(crate)` so source extraction can recover the original for an +/// owned item whose per-user key is empty (our restore leaves it empty). +pub(crate) fn machine_value(prog_id: &str) -> PortResult> { + let hkcr = RegKey::predef(HKEY_CLASSES_ROOT); + match hkcr.open_subkey(&machine_key(prog_id)) { + Ok(key) => read_value(&key, ""), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(PortError::Io(format!("open machine file-type DefaultIcon {prog_id}: {e}"))), + } +} + +/// Points `prog_id`'s per-user `DefaultIcon` at the styled ICO (`",0"`). THE ONLY WRITE — +/// no verb, no `UserChoice`, no machine key (ADR-0004 D7 amendment 2026-10-16). +pub fn apply(prog_id: &str, icon: &str) -> PortResult<()> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let (key, _) = hkcu.create_subkey(&user_key(prog_id)).map_err(io)?; + key.set_value("", &format!("{icon},0")).map_err(io)?; + Ok(()) +} + +/// Restores the captured per-user value (or removes the value we created when none existed), +/// preserving the value's original kind. Byte-identical posture of `system::restore`. +pub fn restore(anchor: &FileTypeAnchor) -> PortResult<()> { + // Defence-in-depth against a corrupt/stale ledger: the anchor's ProgId is interpolated into + // an HKCU key path, so it must be a valid single component (same gate apply used). + prog_id_from_path(&anchor.prog_id)?; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if !anchor.key_existed { + // We created this value; undo by removing ONLY the `(Default)` value we wrote — never + // `delete_subkey_all`, which would destroy unrelated state another program added under + // the same `Software\Classes\` key (the recycle-bin posture). A non-NotFound + // failure PROPAGATES; a missing value is idempotent. + match hkcu.open_subkey_with_flags(&user_key(&anchor.prog_id), winreg::enums::KEY_SET_VALUE) { + Ok(key) => match key.delete_value("") { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(io(e)), + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // key already gone + Err(e) => return Err(io(e)), + } + return Ok(()); + } + let (key, _) = hkcu.create_subkey(&user_key(&anchor.prog_id)).map_err(io)?; + write_or_delete(&key, "", anchor.value.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prog_id_from_path_accepts_a_plain_progid() { + assert_eq!(prog_id_from_path("txtfile").unwrap(), "txtfile"); + assert_eq!(prog_id_from_path("Word.Document.8").unwrap(), "Word.Document.8"); + assert_eq!(prog_id_from_path("WinRAR.ZIP").unwrap(), "WinRAR.ZIP"); + } + + #[test] + fn prog_id_from_path_refuses_anything_that_could_climb_a_key_path() { + // The ProgId is interpolated into `Software\Classes\\DefaultIcon` — a separator + // here would write an arbitrary nested key (the codex System-review 🟠 posture). + assert!(prog_id_from_path(r"exefile\shell\open\command").is_err(), "verb injection"); + assert!(prog_id_from_path("a/b").is_err(), "forward slash"); + assert!(prog_id_from_path("").is_err(), "empty"); + assert!(prog_id_from_path(" txtfile").is_err(), "leading space"); + assert!(prog_id_from_path("txtfile ").is_err(), "trailing space"); + assert!(prog_id_from_path(".").is_err(), "dot"); + assert!(prog_id_from_path("..").is_err(), "dot dot"); + assert!(prog_id_from_path("txt\u{0}file").is_err(), "control char"); + assert!(prog_id_from_path(&"x".repeat(401)).is_err(), "over-long"); + } + + #[test] + fn key_paths_are_per_progid_and_per_user_only() { + // The per-user `Software\Classes` root is what makes this in-host / no-UAC; the machine + // key is HKCR's class root and is read ONLY. + assert_eq!(user_key("txtfile"), r"Software\Classes\txtfile\DefaultIcon"); + assert_eq!(machine_key("txtfile"), r"txtfile\DefaultIcon"); + assert!(user_key("txtfile").starts_with(r"Software\Classes\")); + } + + /// Removes the test-only ProgId subtree so the fixture starts (and ends) with NO per-user + /// key — repeated runs and a crashed run behave identically. + fn scrub_test_progid() { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let _ = hkcu.delete_subkey_all(r"Software\Classes\DeskMakeoverTest.FileType.9f3c"); + } + + #[test] + fn apply_restore_round_trips_a_per_user_value_and_leaves_no_residue() { + // Round-trip on the LIVE HKCU with a dedicated test ProgId (never a real association), + // exactly the shape a real item takes: no per-user key before us → key_existed:false → + // restore must REMOVE the value, leaving the machine class entry (here: none) untouched. + const TEST_PROG: &str = "DeskMakeoverTest.FileType.9f3c"; + scrub_test_progid(); + + let before = read_current(TEST_PROG).expect("readable"); + assert!(!before.key_existed, "fixture must start with no per-user key"); + assert!(before.value.is_none(), "fixture must start with no machine fallback"); + + apply(TEST_PROG, r"C:\gen\styled-icon.ico").unwrap(); + let styled = read_current(TEST_PROG).unwrap(); + assert!(styled.key_existed, "our override now exists"); + assert_eq!( + styled.value.as_ref().map(|v| v.raw.as_str()), + Some(r"C:\gen\styled-icon.ico,0") + ); + + restore(&FileTypeAnchor { + prog_id: TEST_PROG.into(), + key_existed: false, + value: None, + }) + .unwrap(); + let after = read_current(TEST_PROG).unwrap(); + // Exactly `system::restore`'s posture: ONLY the value we wrote is removed; the now-empty + // per-user key is left in place (another program may have added values to it), so the + // post-state reads `key_existed:true, value:None` — inert, because HKCR merges HKCU's + // per-VALUE presence only and the machine class entry still shows through. + assert!(after.value.is_none(), "value removed — no residue left behind"); + // Idempotent: restoring again is not an error (a ledger replay after a crash). + restore(&FileTypeAnchor { + prog_id: TEST_PROG.into(), + key_existed: false, + value: None, + }) + .unwrap(); + // A PRE-EXISTING per-user value is REWRITTEN verbatim, never deleted (the true-original + // path: someone else's override is the user's real state). + apply(TEST_PROG, r"C:\other\pre.ico").unwrap(); + let original = RegistryValue { raw: r"C:\other\pre.ico,0".into(), kind: 1 }; + restore(&FileTypeAnchor { prog_id: TEST_PROG.into(), key_existed: true, value: Some(original.clone()) }) + .unwrap(); + let restored = read_current(TEST_PROG).unwrap(); + assert_eq!(restored.value, Some(original), "byte-identical rewrite of the captured value"); + scrub_test_progid(); + } + + #[test] + fn restore_refuses_a_poisoned_progid_before_touching_the_registry() { + // The anchor's `prog_id` interpolates into an HKCU key path — a `\`-bearing value in a + // corrupt ledger must be refused, never used to mutate an unrelated key. + let poisoned = FileTypeAnchor { + prog_id: r"exefile\shell\open\command".into(), + key_existed: true, + value: Some(RegistryValue { raw: "evil.exe,0".into(), kind: 1 }), + }; + assert!(restore(&poisoned).is_err(), "poisoned ProgId must be refused"); + } +} diff --git a/crates/dm-windows/src/apply/mod.rs b/crates/dm-windows/src/apply/mod.rs index 5b94f8fd..f3b3ef8d 100644 --- a/crates/dm-windows/src/apply/mod.rs +++ b/crates/dm-windows/src/apply/mod.rs @@ -7,7 +7,9 @@ mod reg_icon; mod shortcut; mod url_shortcut; +pub(crate) mod drive; pub(crate) mod file_wrapper; +pub(crate) mod filetype; pub(crate) mod recyclebin; pub(crate) mod system; @@ -38,11 +40,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)?, @@ -60,6 +62,13 @@ impl IconApplier for WindowsIconApplier { // of the Recycle Bin. The CLSID rides the item's parsing path (`::{GUID}`). // [WINDOWS-VERIFY] runtime. ItemKind::System => system::apply(&system::parse_clsid(&target.path)?, &icon)?, + // A fixed drive styles via its per-user `DefaultIcon` registry value (ADR-0004 D7) — + // the letter rides the item's own path (`C:\` → `C`). Registry write, no COM. + ItemKind::Drive => drive::apply(&drive::letter_from_path(&target.path)?, &icon)?, + // A file TYPE styles via its ProgId's per-user `DefaultIcon` (ADR-0004 D7 amendment + // 2026-10-16) — the ProgId rides the item's own path and is validated as one key + // component first. Exactly ONE value is written, never a verb. Registry write, no COM. + ItemKind::FileType => filetype::apply(&filetype::prog_id_from_path(&target.path)?, &icon)?, // Only genuinely un-styleable (broken/unreadable) items remain. ItemKind::Unsupported => { return Err(PortError::Unsupported(format!("apply for {:?}", target.kind))) @@ -79,6 +88,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()) } @@ -103,6 +117,37 @@ impl IconApplier for WindowsIconApplier { } system::restore(state) } + RestoreAnchor::Drive(anchor) => { + // Defence-in-depth (same discipline as the System CLSID check): the anchor's + // letter is interpolated into an HKCU per-drive key path, so it must match the + // drive the target path names — otherwise a mismatched pairing would mutate the + // WRONG drive's key and report success while the real target stays styled. + let target_letter = drive::letter_from_path(&target.path)?; + if !target_letter.eq_ignore_ascii_case(&anchor.letter) { + return Err(PortError::Unsupported(format!( + "Drive restore letter mismatch: target {target_letter} vs anchor {}", + anchor.letter + ))); + } + drive::restore(anchor) + } + RestoreAnchor::FileType(anchor) => { + // Defence-in-depth (same discipline as the System CLSID / Drive letter checks): + // the anchor's ProgId is interpolated into an HKCU `Software\Classes` key path, + // so it must be BOTH a valid single key component AND the association the target + // actually names — otherwise a mismatched pairing (a txtfile target carrying an + // htmlfile anchor) or a poisoned anchor would mutate the WRONG association and + // report success while the real target stays styled. `filetype::restore` + // re-validates the shape; this compares identity. + let target_prog = filetype::prog_id_from_path(&target.path)?; + if target_prog != anchor.prog_id { + return Err(PortError::Unsupported(format!( + "FileType restore ProgId mismatch: target {target_prog} vs anchor {}", + anchor.prog_id + ))); + } + filetype::restore(anchor) + } RestoreAnchor::CaptureFailed { reason } => { Err(PortError::Unsupported(format!("no restore material: {reason}"))) } 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..9484809b 100644 --- a/crates/dm-windows/src/classify.rs +++ b/crates/dm-windows/src/classify.rs @@ -27,11 +27,60 @@ 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\\"; + +/// 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 let Some(rest) = strip_prefix_ci(trimmed, APPS_FOLDER_PREFIX) { + let aumid = rest.trim(); + return (!aumid.is_empty()).then_some(aumid); + } + 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 — +/// 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 — @@ -71,7 +120,7 @@ fn is_drive_absolute(path: &str) -> bool { b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/') } -fn extension_lower(file_name: &str) -> Option { +pub(crate) fn extension_lower(file_name: &str) -> Option { let dot = file_name.rfind('.')?; // A leading dot (dotfile) or trailing dot is not an extension. if dot == 0 || dot + 1 >= file_name.len() { @@ -218,4 +267,91 @@ 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 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). + 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..41b01e52 100644 --- a/crates/dm-windows/src/fingerprint_surface.rs +++ b/crates/dm-windows/src/fingerprint_surface.rs @@ -51,6 +51,16 @@ 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 fixed drive: the per-user `DefaultIcon` `","` location the writer points the + /// drive at — a single registry value. Its own domain prefix so a drive fingerprint can never + /// collide with a shortcut's `IconRef` (they restore differently: per-drive key vs `.lnk`). + Drive { icon: 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 +102,12 @@ impl SurfaceState { full.0.as_bytes(), &full.1.to_le_bytes(), ]), + SurfaceState::Drive { icon } => { + Fingerprint::of_parts(&[b"drive", icon.0.as_bytes(), &icon.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"]), } } } @@ -141,6 +157,8 @@ pub fn expected_after_apply( empty: (empty_icon.unwrap_or_default().to_string(), 0), full: (primary_icon.to_string(), 0), }, + // A fixed drive's surface is its per-user `DefaultIcon` location (path + index 0). + ItemKind::Drive => SurfaceState::Drive { icon: (primary_icon.to_string(), 0) }, // Shortcut, UrlShortcut, AppxShortcut (an ordinary `.lnk`) and any other icon-location kind. _ => SurfaceState::IconRef { path: primary_icon.to_string(), index: 0 }, } @@ -182,6 +200,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/refresh.rs b/crates/dm-windows/src/refresh.rs index 0ac217c8..494f0dd8 100644 --- a/crates/dm-windows/src/refresh.rs +++ b/crates/dm-windows/src/refresh.rs @@ -98,27 +98,63 @@ impl ExplorerRefresher for WindowsExplorerRefresher { run_hidden("ie4uinit.exe", &["-show"]); // 4. Relaunch and VERIFY. AutoRestartShell may have respawned Explorer already (that is - // fine — dedup by liveness, not by launching blind). Retry the spawn once; only a shell - // we cannot confirm alive after both attempts is an error — the caller surfaces it - // rather than leaving the user staring at an empty desktop wondering what happened. + // fine — dedup by liveness, not by launching blind). The success criterion is the SHELL + // (the taskbar window the user sees), never the process alone: a relaunch that comes up + // without initializing the shell leaves an alive explorer.exe, no taskbar and a black + // desktop (owner box 2026-10-16) — a process-liveness check calls that "fine". A + // shell-less Explorer is cleared before relaunching (spawning on top of it never helps). + // Retry once; only a shell we cannot confirm is an error — the caller surfaces it rather + // than leaving the user staring at an empty desktop wondering what happened. for attempt in 0..2 { - if wait_until(EXPLORER_ALIVE_WAIT_MS, || explorer_running(session.as_deref())) { + if wait_until(EXPLORER_ALIVE_WAIT_MS, || { + shell_ready(explorer_running(session.as_deref()), shell_present()) + }) { return Ok(()); } - log::warn!("restart_shell: explorer not alive after wait (attempt {attempt}); launching it"); + if explorer_running(session.as_deref()) && !shell_present() { + log::warn!( + "restart_shell: explorer is alive without its shell windows (attempt {attempt}); \ + clearing it for a clean relaunch" + ); + let mut kill_args = vec!["/F", "/IM", "explorer.exe"]; + if let Some(f) = session.as_ref() { + kill_args.extend(["/FI", f.as_str()]); + } + run_hidden("taskkill", &kill_args); + wait_until(EXPLORER_EXIT_WAIT_MS, || !explorer_running(session.as_deref())); + } else { + log::warn!("restart_shell: explorer not alive after wait (attempt {attempt}); launching it"); + } let _ = std::process::Command::new("explorer.exe").spawn(); } - if wait_until(EXPLORER_ALIVE_WAIT_MS, || explorer_running(session.as_deref())) { + if wait_until(EXPLORER_ALIVE_WAIT_MS, || { + shell_ready(explorer_running(session.as_deref()), shell_present()) + }) { return Ok(()); } Err(dm_domain::PortError::Io( - "explorer did not come back after the shell restart; the desktop may be blank until \ - the user starts explorer.exe (Ctrl+Shift+Esc → run explorer) or signs in again" + "explorer came back without its shell windows (no taskbar); start explorer.exe \ + (Ctrl+Shift+Esc → run explorer) or sign in again to restore the desktop" .to_string(), )) } } +/// The restart's success predicate: the process ALIVE **and** the taskbar window present. Split out +/// as a pure function so the truth table is unit-tested — the 2026-10-16 owner state was exactly +/// "process alive, taskbar missing", which the old process-only check accepted as success. +fn shell_ready(explorer_alive: bool, taskbar_present: bool) -> bool { + explorer_alive && taskbar_present +} + +/// Whether the interactive shell's taskbar window exists. `[WINDOWS-VERIFY]` runtime. +fn shell_present() -> bool { + use windows::core::w; + use windows::Win32::UI::WindowsAndMessaging::FindWindowW; + // SAFETY: a class-name window lookup; no ownership is taken and no message is sent. + unsafe { FindWindowW(w!("Shell_TrayWnd"), None).map(|h| !h.is_invalid()).unwrap_or(false) } +} + const EXPLORER_EXIT_WAIT_MS: u64 = 5_000; const EXPLORER_ALIVE_WAIT_MS: u64 = 8_000; @@ -194,3 +230,19 @@ fn purge_icon_caches() { } } } + +#[cfg(test)] +mod tests { + use super::shell_ready; + + #[test] + fn the_restart_only_succeeds_when_the_taskbar_is_back() { + // Owner box 2026-10-16: explorer.exe alive with NO taskbar window and a black desktop. The + // old process-only check called that success and left the user without a shell, so the + // predicate must demand BOTH halves. + assert!(!shell_ready(true, false), "an alive process without its taskbar is NOT ready"); + assert!(!shell_ready(false, true), "a taskbar without its process is not ready"); + assert!(!shell_ready(false, false)); + assert!(shell_ready(true, true), "process + taskbar = a usable shell"); + } +} diff --git a/crates/dm-windows/src/shell/appsfolder.rs b/crates/dm-windows/src/shell/appsfolder.rs new file mode 100644 index 00000000..3df553b6 --- /dev/null +++ b/crates/dm-windows/src/shell/appsfolder.rs @@ -0,0 +1,231 @@ +//! 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, GUID}; +use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoTaskMemFree, IBindCtx, CLSCTX_ALL, + COINIT_APARTMENTTHREADED, +}; +use windows::Win32::UI::Shell::{ + 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 +/// 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) +} + +/// Activates a Store/MSIX app by AUMID through `IApplicationActivationManager::ActivateApplication` +/// — the Task 11 wrapper's LAUNCH mechanism on boxes whose `explorer.exe shell:AppsFolder\…` route +/// is broken (verified on-box 2026-10-13: three CLI attempts AND a real wrapper click all failed +/// to start the app while this API returned S_OK and the app stayed up — pre-existing COM +/// registration damage on the owner's box, same family as the wallpaper `0x80040154`). Returns the +/// activated process id. Owns its COM init: an unattached thread gets an STA, an already +/// initialized apartment (any mode) simply proceeds. +/// +/// The CLSID has no windows-rs constant (defined here); the IID rides the crate's interface type +/// and is the documented `2E941141-7F97-4756-BA1D-9DECDE894A3D` (the widely-circulated +/// `2E97EF3E-…` is wrong → E_NOINTERFACE). [WINDOWS-VERIFY] runtime. +pub fn activate_app(aumid: &str) -> 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 { + 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 7820ec0d..b56dbb82 100644 --- a/crates/dm-windows/src/shell/known_folders.rs +++ b/crates/dm-windows/src/shell/known_folders.rs @@ -4,10 +4,13 @@ use std::path::PathBuf; use dm_domain::{PortError, PortResult}; -use windows::core::GUID; +use windows::core::{GUID, HSTRING}; +use windows::Win32::Storage::FileSystem::GetDriveTypeW; use windows::Win32::System::Com::CoTaskMemFree; +use windows::Win32::System::WindowsProgramming::DRIVE_FIXED; use windows::Win32::UI::Shell::{ - SHGetKnownFolderPath, FOLDERID_Desktop, FOLDERID_ProgramData, FOLDERID_PublicDesktop, + SHGetKnownFolderPath, FOLDERID_CommonPrograms, FOLDERID_Desktop, FOLDERID_Profile, + FOLDERID_Programs, FOLDERID_ProgramData, FOLDERID_PublicDesktop, FOLDERID_RoamingAppData, KF_FLAG_DEFAULT, }; @@ -68,6 +71,104 @@ 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 +} + +/// 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) +} + +/// 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. +/// +/// Retained as the FALLBACK of [`explorer_folder_roots`] (an empty fixed-drive enumeration still +/// styles the profile) and for callers that need the profile specifically. +/// +/// [WINDOWS-VERIFY] runtime. +pub fn profile_root() -> Option { + let path = known_folder(&FOLDERID_Profile).ok().flatten()?; + path.is_dir().then_some(path) +} + +/// The roots the `explorer` surface's FOLDER walk starts from (ADR-0004 D7 amendment +/// 2026-10-16): **every fixed drive root**, no longer the profile alone — so opening Explorer on +/// `D:\` shows styled folders, not just `C:\Users\`. Drive roots are disjoint by construction +/// (each is a volume root), so no folder can be walked twice; the profile under `C:\Users\` is +/// reached through its own drive. Best-effort per-root doctrine: a box with no probeable fixed +/// root falls back to the profile, and an unreadable root is skipped by the walk, never a scan +/// failure. [WINDOWS-VERIFY] runtime. +pub fn explorer_folder_roots() -> Vec { + let mut roots: Vec = visible_fixed_drives() + .iter() + .map(PathBuf::from) + .filter(|p| p.is_dir()) + .collect(); + if roots.is_empty() { + if let Some(profile) = profile_root() { + roots.push(profile); + } + } + roots +} + +/// The FIXED drives visible on this machine (ADR-0004 D7) — the `explorer` surface's DRIVE half, +/// e.g. `["C:\\", "D:\\", "E:\\"]`. Enumerated by probing every ASCII drive letter with +/// `GetDriveTypeW` and keeping only `DRIVE_FIXED` roots: removable / optical / network / RAM-drive +/// letters are transient or remote (a letter that can swap media or point at a share is the wrong +/// thing to style a per-machine registry key for), so they are never returned. A letter with no +/// volume reports `DRIVE_NO_ROOT_DIR` and simply drops out. Best-effort per-root doctrine: an empty +/// enumeration NARROWS the explorer surface instead of failing the scan. +/// +/// [WINDOWS-VERIFY] runtime (drive-type filtering on the owner box: C:/D:/E: fixed). +pub fn visible_fixed_drives() -> Vec { + (b'A'..=b'Z') + .filter_map(|letter| { + let root = format!("{}:\\", letter as char); + // SAFETY: `GetDriveTypeW` only reads the NUL-terminated path for the duration of the + // call; the `HSTRING` outlives it. + let drive_type = unsafe { GetDriveTypeW(&HSTRING::from(&root)) }; + (drive_type == DRIVE_FIXED).then_some(root) + }) + .collect() +} + 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. @@ -83,3 +184,60 @@ fn known_folder(id: &GUID) -> PortResult> { Ok(Some(PathBuf::from(text.map_err(|e| PortError::Com(e.to_string()))?))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn visible_fixed_drives_includes_the_system_drive_and_is_well_formed() { + // Every Windows install has a fixed `C:` (the system drive), so this is a real smoke check + // that the `GetDriveTypeW` probe + `DRIVE_FIXED` filter work on a live box. Each entry must + // be a bare `:\` root — the exact shape `apply::drive::letter_from_path` parses back + // out (a malformed entry would surface later as a wrong per-drive registry key). + // [WINDOWS-VERIFY] runtime. + let drives = visible_fixed_drives(); + assert!( + drives.iter().any(|d| d == "C:\\"), + "expected the fixed system drive among {drives:?}" + ); + for d in &drives { + assert_eq!(d.len(), 3, "drive root must be exactly X:\\, got {d:?}"); + assert!(d.ends_with(":\\"), "drive root must end in :\\, got {d:?}"); + assert!( + d.starts_with(|c: char| c.is_ascii_alphabetic()), + "drive root must start with a letter, got {d:?}" + ); + } + } + + #[test] + fn explorer_folder_roots_are_every_fixed_drive_root_covering_the_profile() { + // The all-drives explorer walk (ADR-0004 D7 amendment 2026-10-16): every root is an + // existing `:\` volume root, the system drive is always among them, and the user + // profile is REACHED through its drive (the walk no longer starts at the profile, it must + // still contain it — otherwise `C:\Users\` would silently drop out of the surface). + // The no-fixed-drive fallback can only be proven on a box without one, so it is covered by + // the shape assertions + the profile containment below. [WINDOWS-VERIFY] runtime. + let roots = explorer_folder_roots(); + assert!(!roots.is_empty(), "a box with fixed drives always has at least one walk root"); + assert!( + roots.iter().any(|r| r.to_string_lossy() == "C:\\"), + "the system drive is a walk root: {roots:?}" + ); + for r in &roots { + assert!(r.is_dir(), "walk root must exist: {}", r.display()); + assert!( + r.to_string_lossy().ends_with(":\\"), + "walk root must be a bare drive root, got {}", r.display() + ); + } + if let Some(profile) = profile_root() { + assert!( + roots.iter().any(|root| profile.starts_with(root)), + "the profile must live under a walk root: profile {} not under {roots:?}", + profile.display() + ); + } + } +} 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 92162975..95f4ad33 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, @@ -151,16 +117,632 @@ 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)); + } + } + // Explorer folders (ADR-0004 D7, amendment 2026-10-16): every non-excluded folder under EVERY + // FIXED DRIVE 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 root + // NARROWS the surface instead of failing the scan. [WINDOWS-VERIFY] runtime. + let mut explorer_extensions: std::collections::HashSet = std::collections::HashSet::new(); + { + let mut explorer_items = Vec::new(); + // ONE shared BFS across every root (not a per-root walk): a single queue means a huge + // `C:\` can never consume the whole folder cap before `D:\` is seen — depth-0 roots are + // interleaved in enqueue order. + walk_explorer_folders( + &known_folders::explorer_folder_roots(), + &mut explorer_items, + &mut explorer_extensions, + ); + // The DESKTOP surface already styles the top-level folders on the desktop, and a drive 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))); + } + // Explorer DRIVES (ADR-0004 D7): the surface's other half. [WINDOWS-VERIFY] runtime. + items.extend(explorer_drive_items()); + // File TYPES (ADR-0004 D7 amendment 2026-10-16): one `FileType` item per ProgId the drive + // walks SAW a file for — every type except images, exactly the `.ext` set collected by the + // folder walk above, so a type with no file anywhere visible is never styled blind. Emitted + // LAST so the shared per-scan preview budget is spent on desktop/taskbar/folder items first. + items.extend(explorer_file_type_items(&explorer_extensions)); // 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()); + // FILE TYPES sort to the END (second key): their `.ext` names would otherwise sort FIRST + // (ASCII `.` precedes every letter) and consume the shared per-scan preview budget ahead of + // the desktop/taskbar/folder items — the exact starvation the BFS walk order exists to + // prevent. Every other item keeps the oracle's pure name order (FileType is a new kind, so no + // existing position moves), and the emission order above is what survives for equal keys. + items.sort_by_key(|item| (item.kind == ItemKind::FileType, 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). +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 mut 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(); + // A wrapper around an image/model must ALSO stay original (owner carve-out): + // the reunified item is a RegularFile, so the same gate applies. + keep_original_if_listed(&mut item); + 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; + } + } + let mut item = 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, + }; + // Owner carve-outs (2026-10-16 / 2026-10-16b): images and 3D/print model files + // (`NEVER_RESTYLE_EXTENSIONS`) stay exactly as the shell shows them — no companion + // wrapper, no styling. See `keep_original_if_listed`. + keep_original_if_listed(&mut item); + items.push(item); + } +} + +/// Marks a loose-file item that must stay EXACTLY as the shell shows it (owner carve-outs: images +/// and 3D/print models). Non-styleable on purpose: the desktop's loose-file mechanism wraps the +/// original in a styled companion `.lnk` and hides it — the very thing the owner vetoed twice +/// ("que esté como en la versión original"). `PreviewOnly` (not `Ready`) is what makes +/// `can_style()` false; the reason key is localized frontend-side. The item still rides the item +/// list so the RESTORE-FIRST planner reverts any wrapper a previous version created. +fn keep_original_if_listed(item: &mut DesktopItem) { + if item.kind == ItemKind::RegularFile && never_restyle_extension(&item.name) { + item.state = ItemState::PreviewOnly; + item.status_message = Some("Icons_Reason_KeptOriginal".to_string()); + } +} + +/// Caps for the `explorer` folder walk (ADR-0004 D7, rebased for the ALL-DRIVES walk in the +/// 2026-10-16 amendment). The depth cap is measured **from a drive root**, so the old +/// profile-relative depth 8 re-bases to 10 (the profile itself sits at depth 2 under `C:\`); the +/// count cap rises with it (5000) because drive-wide content replaces profile content. Both stay +/// the hard backstop against a freak tree stalling the scan or emitting tens of thousands of +/// items — the denylist below still cuts `target`/`node_modules` first, so real browsable content +/// is what reaches the cap. Tunable. +const EXPLORER_MAX_DEPTH: u32 = 10; +const EXPLORER_MAX_FOLDERS: usize = 5000; + +/// Drive-root children the walk never descends into (ADR-0004 D7 amendment 2026-10-16): trees the +/// current user cannot write `desktop.ini` into without elevation. Emitting them would only +/// produce doomed per-item apply failures (access denied), so they are cut whole — styling them +/// needs a HOT-tier UAC decision that was explicitly NOT taken. Case-insensitive; the attribute +/// gate already cuts the Hidden/System ones (`Windows`, `$Recycle.Bin`, …) — they are listed too +/// as belt-and-suspenders for boxes that lost those attributes. +const EXPLORER_PROTECTED_ROOTS: &[&str] = &[ + "Program Files", + "Program Files (x86)", + "ProgramData", + "Windows", + "$Recycle.Bin", + "System Volume Information", + "Recovery", + "MSOCache", + "PerfLogs", + "Documents and Settings", +]; + +/// Container-only directory names: descended into but NEVER styled as items. `Users` is the case +/// that matters — `C:\Users` itself is ACL-protected (writing `desktop.ini` there is admin-only), +/// yet the profile lives beneath it, so the walk must pass through. Same name deeper on a data +/// drive is skipped as an item too (one unstyled folder name beats a doomed write). +const EXPLORER_CONTAINER_ONLY: &[&str] = &["Users"]; + +/// 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 FOLDER items for the `explorer` surface across `roots` — every fixed-drive root, one +/// shared breadth-first queue (ADR-0004 D7; amendment 2026-10-16). 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 as items — but their +/// EXTENSIONS are collected into `extensions` (lowercase, no dot), the `.ext` universe the +/// file-type pass later resolves to ProgIds: a type is only ever styled when the user actually +/// HAS files of it on a walked drive. 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-11+ explosion outright. Every root is +/// seeded at depth 0 into the ONE queue, so a giant `C:\` never eats the whole count cap before +/// `D:\` gets seen (the roots would otherwise be walked sequentially, last one starved). +fn walk_explorer_folders( + roots: &[std::path::PathBuf], + items: &mut Vec, + extensions: &mut std::collections::HashSet, +) { + let mut queue: std::collections::VecDeque<(std::path::PathBuf, u32)> = + std::collections::VecDeque::new(); + for root in roots { + queue.push_back((root.clone(), 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; + } + let file_name = entry.file_name().to_string_lossy().into_owned(); + if is_ignored_entry(&file_name) { + continue; + } + // The explorer surface styles FOLDERS (+ drives and file TYPES, both enumerated + // separately); a loose file is never itself an item, but its EXTENSION feeds the + // file-type pass (ADR-0004 D7 amendment 2026-10-16). + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + if let Some(ext) = std::path::Path::new(&file_name).extension() { + extensions.insert(ext.to_string_lossy().to_lowercase()); + } + 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; + } + // Admin-only trees: never styled, never descended (ADR-0004 D7 amendment 2026-10-16). + if EXPLORER_PROTECTED_ROOTS.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, + } + // Container-only roots (the profile's `Users` parent): descended, never emitted. + if !EXPLORER_CONTAINER_ONLY.iter().any(|d| file_name.eq_ignore_ascii_case(d)) { + 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 explorer surface's DRIVE half (ADR-0004 D7): one `Drive` item per visible FIXED root, each +/// styled via its per-user registry `DefaultIcon` (routed by `kind`, distinct from the folder +/// `desktop.ini` writer). A drive's display name comes from the shell exactly like the bin / +/// namespace items ("SYSTEM (C:)"); an unresolvable shell name falls back to `(C:)` rather than +/// dropping the drive. `icon` stays `None` by design — the effective icon is a LIVE shell read at +/// extract time (`shell_item_image`), never a scan-time snapshot. Best-effort: an empty fixed-drive +/// enumeration just narrows the surface (the `visible_fixed_drives` test owns the on-box proof). +/// [WINDOWS-VERIFY] runtime. +fn explorer_drive_items() -> Vec { + known_folders::visible_fixed_drives() + .into_iter() + .map(|drive_path| { + let letter = drive_path.chars().next().unwrap_or('?'); + let name = shell_display_name(&drive_path) + .filter(|n| !n.trim().is_empty()) + .unwrap_or_else(|| format!("({letter}:)")); + DesktopItem { + id: ItemId::from_source_path("filesystem", &drive_path), + name, + path: drive_path, + kind: ItemKind::Drive, + surface: ItemSurface::Explorer, + parent: String::new(), + icon: None, + state: ItemState::Ready, + requires_explicit_consent: false, + status_message: None, + } + }) + .collect() +} + /// 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}"; +/// The `explorer` surface's FILE-TYPE half (ADR-0004 D7 amendment 2026-10-16): one `FileType` +/// item per ProgId the drive walk actually SAW files of, filtered to the types whose icon can be +/// restyled. The type set comes from `extensions` (collected by [`walk_explorer_folders`]), never +/// from enumerating the registry wholesale — a type with no file anywhere the user browses is +/// never styled blind, and the item count stays proportional to real content. +/// +/// Gates, in order (each one is part of the amendment's contract, not an optimization): +/// 1. **Images are never restyled** — owner carve-out: `PerceivedType = image` OR membership in +/// [`NEVER_RESTYLE_EXTENSIONS`], evaluated **per ProgId**: one `DefaultIcon` serves every extension of +/// an association, so a ProgId reachable from even ONE image extension is untouchable. A +/// thumbnail/preview type keeps its native identity. +/// 2. **Extension → ProgId** (`HKCR\\(Default)`), and the ProgId must pass the same +/// single-key-component validation apply uses (never a `\`-bearing association name). +/// 3. **One item per ProgId** — extensions sharing an association (`.htm`/`.html`) share ONE +/// registry value, so they must be ONE item; two writers of one value would CAS-conflict +/// forever. +/// 4. **The type must HAVE a `DefaultIcon`** — nothing to style otherwise (`.lnk`-class types +/// resolve through their target instead, so they simply fall out here). +/// 5. **Per-file types are skipped**: a `DefaultIcon` carrying an unexpanded `%1`/`%L`/`%l` +/// resolves to *the file itself* (`exefile` and friends) — a type-level override there would +/// collapse every executable's individual icon into ONE glyph. This is the honest floor of +/// "all program icons": per-program identity lives inside each `.exe` (ADR-0024's running- +/// window limit, same platform fact) and there is no per-file lever. +/// +/// `icon` carries the type's effective `DefaultIcon` resource, captured at scan so extraction and +/// the styled-residue provenance guard work off the same location the shell shows. +/// Best-effort per-type doctrine: any failure skips that type, never the scan. [WINDOWS-VERIFY] +/// runtime (the merged HKCR reads + `PerceivedType`). +fn explorer_file_type_items(extensions: &std::collections::HashSet) -> Vec { + use winreg::enums::HKEY_CLASSES_ROOT; + use winreg::RegKey; + if extensions.is_empty() { + return Vec::new(); + } + let hkcr = RegKey::predef(HKEY_CLASSES_ROOT); + let mut sorted: Vec<&String> = extensions.iter().collect(); + sorted.sort(); // deterministic emission order (the item list is name-sorted later anyway) + + // Pass 1 — resolve every seen extension to its ProgId and mark the image-reachable ones. + // The image carve-out runs PROGID-LEVEL on purpose: one `DefaultIcon` value serves every + // extension of a ProgId, so if a SINGLE image extension reaches it (however odd a mapping), + // styling it would restyle image files too — the carve-out must win over the per-extension + // view. Extensions without a valid association never enter the candidate list. + let mut candidates: Vec<(String, String, bool)> = Vec::new(); // (ext, prog_id, is_image_reachable) + for ext in sorted { + let static_excluded = NEVER_RESTYLE_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)); + let assoc = match hkcr.open_subkey(format!(".{ext}")) { + Ok(k) => k, + Err(_) => continue, // no association at all → nothing to style + }; + // The systematic image filter: `PerceivedType` covers image types the static list misses. + let perceived_image = assoc + .get_value::("PerceivedType") + .map(|p| p.eq_ignore_ascii_case("image")) + .unwrap_or(false); + let prog_id = match assoc.get_value::("") { + Ok(p) if !p.trim().is_empty() => p, + _ => continue, + }; + if crate::apply::filetype::prog_id_from_path(&prog_id).is_err() { + continue; + } + candidates.push((ext.to_string(), prog_id, static_excluded || perceived_image)); + } + + // Pass 2 — one item per non-image ProgId, with the `DefaultIcon` gates. + let image_progs: std::collections::HashSet = candidates + .iter() + .filter(|c| c.2) + .map(|c| c.1.to_lowercase()) + .collect(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut out = Vec::new(); + for (ext, prog_id, _) in candidates { + let prog_key = prog_id.to_lowercase(); + if image_progs.contains(&prog_key) { + continue; // shares its icon value with an image type — never restyled + } + if !seen.insert(prog_key) { + continue; // another extension of the SAME ProgId — one registry target, one item + } + let icon_key = match hkcr.open_subkey(format!(r"{prog_id}\DefaultIcon")) { + Ok(k) => k, + Err(_) => continue, + }; + let value = match icon_key.get_value::("") { + Ok(v) if !v.trim().is_empty() => v, + _ => continue, + }; + if value.contains("%1") || value.contains("%L") || value.contains("%l") { + continue; // per-file type — styling it would flatten every file of the type (gate 5) + } + let (location, index) = crate::classify::parse_icon_location(&value); + if location.trim().is_empty() { + continue; + } + out.push(DesktopItem { + id: ItemId::from_source_path("filetype", &prog_id), + name: format!(".{ext}"), + path: prog_id, + kind: ItemKind::FileType, + surface: ItemSurface::Explorer, + parent: String::new(), + icon: Some(IconRef { kind: icon_ref_kind(&location), location, index }), + state: ItemState::Ready, + requires_explicit_consent: false, + status_message: None, + }); + } + out +} + +/// Extensions whose icons are NEVER restyled — the owner's explicit carve-outs (2026-10-16: +/// "todos los iconos de archivo **menos los de imágenes**"; 2026-10-16b: "también los `.3mf`"). +/// Belt and suspenders over the systematic `PerceivedType = image` check: a thumbnail/preview type +/// keeps its native identity, and some image types carry no perceived type at all. Case-insensitive. +/// Applied to BOTH halves: the per-ProgId file TYPE (via [`explorer_file_type_items`]) and a loose +/// FILE on a styled surface (the desktop's companion-wrapper mechanism must never wrap these). +const NEVER_RESTYLE_EXTENSIONS: &[&str] = &[ + // Images (owner carve-out). + "png", "jpg", "jpeg", "jpe", "jfif", "gif", "bmp", "dib", "ico", "cur", "ani", "apng", + "tif", "tiff", "wmf", "emf", "svg", "svgz", "webp", "heic", "heif", "avif", "avifs", "tga", + "psd", "psb", "xcf", "dng", "cr2", "cr3", "nef", "orf", "rw2", "arw", "mrw", "raf", "pef", + "jxl", "jp2", "j2k", "dds", "exr", "hdr", "qoi", + // 3D / print models (owner 2026-10-16b: `.3mf` "que esté como en la versión original"). + "3mf", +]; + +/// Whether a file name's extension is on the never-restyle list (case-insensitive). The +/// loose-file gate: an image or a 3D/print model is left exactly as the shell shows it. +fn never_restyle_extension(file_name: &str) -> bool { + match crate::classify::extension_lower(file_name) { + Some(ext) => NEVER_RESTYLE_EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(&ext)), + None => false, + } +} + /// The stylable System desktop-namespace CLSIDs (stable ItemId, CLSID GUID). The Recycle Bin is /// injected separately above (it has its own empty/full pair). The GUIDs are the canonical Windows /// desktop namespace class ids. [WINDOWS-VERIFY] runtime. @@ -257,6 +839,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 +897,497 @@ 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 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 (its `.txt` extension, however, feeds + // the file-type pass — ADR-0004 D7 amendment 2026-10-16). + 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(); + let mut exts = std::collections::HashSet::new(); + walk_explorer_folders(&[root.to_path_buf()], &mut items, &mut exts); + + 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"); + // ...but their extensions ARE collected for the file-type pass (amendment 2026-10-16): + // seen files feed it, denylisted/hidden ones are never reached (same budget posture). + assert!(exts.contains("txt"), "seen extension collected for file types"); + assert!(!exts.contains("c"), "files under a denylisted tree never widen the type set"); + // 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 the_never_restyle_list_covers_images_and_3d_models() { + // Owner carve-outs 2026-10-16 (images) + 2026-10-16b (`.3mf`): the list is the ONE source + // the loose-file gate and the file-TYPE pass share, so a gap here is a gap in both. + for name in [ + "gato.png", "foto.JPEG", "x.webp", "a.jxl", "b.psb", "c.svgz", "d.dds", "e.exr", + "modelo.3mf", "MODELO.3MF", + ] { + assert!(never_restyle_extension(name), "{name} must never be restyled"); + } + for name in ["notas.txt", "app.exe", "lib.dll", "archivo.zip", "sin_extension", "punto."] { + assert!(!never_restyle_extension(name), "{name} stays styleable"); + } + } + + #[test] + fn loose_images_and_model_files_are_kept_original() { + // The desktop's loose-file mechanism wraps the original in a styled companion `.lnk` and + // hides it — exactly what the owner vetoed ("que esté como en la versión original"). An + // image or a 3D/print model must arrive NON-styleable, with its stable reason key. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + touch(root, "gato.png"); + touch(root, "foto.JPEG"); // case-insensitive + touch(root, "modelo.3mf"); + touch(root, "notas.txt"); // a normal loose file keeps its Ready state + touch(root, "Docs/readme.txt"); + + let items = walk(root, ItemSurface::Desktop, false); + let by_name = |n: &str| { + items.iter().find(|i| i.name == n).unwrap_or_else(|| panic!("missing {n}")) + }; + for n in ["gato.png", "foto.JPEG", "modelo.3mf"] { + let it = by_name(n); + assert_eq!(it.kind, ItemKind::RegularFile, "{n} stays a loose file (restore identity)"); + assert_eq!(it.state, ItemState::PreviewOnly, "{n} must not be styleable"); + assert!(!it.can_style(), "{n} must not be styleable"); + assert_eq!( + it.status_message.as_deref(), + Some("Icons_Reason_KeptOriginal"), + "{n} carries the stable reason key" + ); + } + let txt = by_name("notas.txt"); + assert_eq!(txt.state, ItemState::Ready); + assert!(txt.can_style(), "an ordinary loose file is untouched by the gate"); + assert!(txt.status_message.is_none()); + assert_eq!(by_name("Docs").state, ItemState::Ready, "folders are unaffected"); + } + + #[test] + fn the_scan_emits_one_drive_item_per_fixed_root() { + // The drive half of the explorer surface: one item per `visible_fixed_drives` root, all + // carrying the shape the apply pipeline keys off — `kind=Drive` (routes to the per-user + // registry writer, not the `desktop.ini` one), `surface=Explorer` (governed by the + // explorer toggle, never rendered in the canvas), a stable filesystem id, and NO scan-time + // icon snapshot (the drive's effective icon is read live at extract time). + let drives = explorer_drive_items(); + let roots = known_folders::visible_fixed_drives(); + assert_eq!(drives.len(), roots.len(), "one item per fixed root"); + assert!(drives.iter().any(|d| d.path == "C:\\"), "the system drive is styled"); + for item in &drives { + assert_eq!(item.kind, ItemKind::Drive); + assert_eq!(item.surface, ItemSurface::Explorer); + assert!(item.path.ends_with(":\\"), "bare drive root, got {}", item.path); + assert_eq!(item.id, ItemId::from_source_path("filesystem", &item.path)); + assert!(!item.name.trim().is_empty(), "every drive shows a name"); + assert_eq!(item.state, ItemState::Ready); + assert!(item.icon.is_none(), "drive icon source is live, not scan-time"); + } + } + + #[test] + fn protected_roots_never_emit_or_descend_and_users_is_container_only() { + // The all-drives walk (ADR-0004 D7 amendment 2026-10-16) must NOT offer trees the current + // user cannot write `desktop.ini` into (each emitted one would only become a doomed + // per-item apply failure), while still walking THROUGH the ACL-protected `Users` parent to + // reach the profile beneath it. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + touch(root, "Program Files/App/readme.txt"); // protected → no emit, no descent + touch(root, "Windows/System32/x.txt"); // protected (also Hidden/System-attribute gated) + touch(root, "Users/Melu/Docs/report.pjy"); // container-only → descended, never an item + touch(root, "Music/note.txt"); // ordinary folder → styled as usual + + let mut items = Vec::new(); + let mut exts = std::collections::HashSet::new(); + walk_explorer_folders(&[root.to_path_buf()], &mut items, &mut exts); + + 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(); + assert!(!paths.contains(&p("Program Files")), "protected root not styled"); + assert!(!paths.contains(&p("Program Files\\App")), "protected root not descended"); + assert!(!paths.contains(&p("Windows")), "protected root not styled"); + assert!(!paths.contains(&p("Windows\\System32")), "protected root not descended"); + assert!(!paths.contains(&p("Users")), "`Users` itself is never styled"); + assert!(paths.contains(&p("Users\\Melu")), "`Users` is still descended to reach the profile"); + assert!(paths.contains(&p("Users\\Melu\\Docs")), "profile content is styled"); + assert!(paths.contains(&p("Music")), "ordinary folders are styled"); + // Files behind a protected root never widen the type set either (never visited). + assert!(!exts.contains("x"), "protected-descent files are unseen"); + assert!(exts.contains("pjy"), "profile files behind `Users` feed the type set"); + assert!(exts.contains("txt"), "ordinary folders feed the type set"); + } + + /// Builds (and later scrubs) a per-user association fixture under `HKCU\Software\Classes` — + /// the exact override layer the file-type walk reads through the merged `HKCR` view, so the + /// gates can be proven WITHOUT touching any real association or needing elevation. + struct ProgIdFixture { + keys: Vec, + } + + impl ProgIdFixture { + fn new() -> Self { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let mut fx = Self { keys: Vec::new() }; + for k in [ + r"Software\Classes\.dmktest9f3ca", + r"Software\Classes\.dmktest9f3cb", + r"Software\Classes\.dmktest9f3cc", + r"Software\Classes\.dmktest9f3cd", + r"Software\Classes\DeskMakeoverTest.Type9f3c", + r"Software\Classes\DeskMakeoverTest.PerFile9f3c", + r"Software\Classes\DeskMakeoverTest.Image9f3c", + ] { + let _ = hkcu.delete_subkey_all(k); + fx.keys.push(k.to_string()); + } + fx + } + + /// `HKCU\` → ProgId, plus an optional `PerceivedType` on the extension key. + fn ext(&self, ext: &str, prog_id: &str, perceived: Option<&str>) { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let (key, _) = hkcu.create_subkey(format!(r"Software\Classes\.{ext}")).unwrap(); + key.set_value("", &prog_id).unwrap(); + if let Some(p) = perceived { + key.set_value("PerceivedType", &p).unwrap(); + } + } + + /// `HKCU\\DefaultIcon` → raw value. + fn icon(&self, prog_id: &str, raw: &str) { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let (key, _) = hkcu.create_subkey(format!(r"Software\Classes\{prog_id}\DefaultIcon")).unwrap(); + key.set_value("", &raw).unwrap(); + } + } + + impl Drop for ProgIdFixture { + fn drop(&mut self) { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + for k in &self.keys { + let _ = hkcu.delete_subkey_all(k); + } + } + } + + #[test] + fn file_type_items_are_one_per_progid_and_never_images_or_per_file_types() { + // The D7 amendment's contract, gate by gate, against a LIVE merged-HKCR read: + // a normal type → emitted once for two extensions sharing its ProgId (case-insensitive), + // a `%1` per-file type → never (it would flatten every executable into one glyph), + // `PerceivedType=image` → never (the systematic image carve-out), + // `.exe`/`.png` (real machine associations) → never (static list + the `%1` gate), + // an unassociated extension → never. + // [WINDOWS-VERIFY] runtime (merged HKCR through HKCU `Software\Classes`). + let fx = ProgIdFixture::new(); + fx.ext("dmktest9f3ca", "DeskMakeoverTest.Type9f3c", None); + fx.ext("dmktest9f3cb", "deskmakovertest.type9f3c", None); // same ProgId, other case + fx.icon("DeskMakeoverTest.Type9f3c", r"C:\Windows\System32\shell32.dll,3"); + fx.ext("dmktest9f3cc", "DeskMakeoverTest.PerFile9f3c", None); + fx.icon("DeskMakeoverTest.PerFile9f3c", "%1"); + fx.ext("dmktest9f3cd", "DeskMakeoverTest.Image9f3c", Some("image")); + fx.icon("DeskMakeoverTest.Image9f3c", r"C:\Windows\System32\imageres.dll,-109"); + + let exts: std::collections::HashSet = ["dmktest9f3ca", "dmktest9f3cb", "dmktest9f3cc", "dmktest9f3cd", "dmktest9f3cz", "exe", "png"] + .iter() + .map(|s| s.to_string()) + .collect(); + // An empty set emits nothing (no files seen → no type is ever styled blind). + assert!(explorer_file_type_items(&std::collections::HashSet::new()).is_empty()); + + let items = explorer_file_type_items(&exts); + assert_eq!(items.len(), 1, "exactly the one styleable type"); + let item = &items[0]; + assert_eq!(item.kind, ItemKind::FileType); + assert_eq!(item.surface, ItemSurface::Explorer); + assert_eq!(item.path, "DeskMakeoverTest.Type9f3c", "path carries the ProgId"); + assert!(!item.path.contains('\\'), "a ProgId is one key component"); + assert_eq!(item.name, ".dmktest9f3ca", "first extension seen labels the type"); + assert_eq!(item.id, ItemId::from_source_path("filetype", &item.path), "filetype id namespace"); + assert_eq!(item.state, ItemState::Ready); + assert!(item.parent.is_empty()); + assert!(!item.requires_explicit_consent, "explorer items ride the surface toggle"); + let icon = item.icon.as_ref().expect("the type's DefaultIcon is captured at scan"); + assert_eq!(icon.location, r"C:\Windows\System32\shell32.dll"); + assert_eq!(icon.index, 3); + assert_eq!(icon.kind, IconSourceKind::ExecutableResource, "dll icon is a resource"); + + // Belt-and-suspenders: no emitted item may name the per-file or the image ProgId. + for i in &items { + assert!(!i.path.eq_ignore_ascii_case("DeskMakeoverTest.PerFile9f3c")); + assert!(!i.path.eq_ignore_ascii_case("DeskMakeoverTest.Image9f3c")); + } + } + + #[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" + ); + } + + // ---- 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 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. + 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, + "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 f786316c..31cb215a 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,155 @@ 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 = 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. 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 + // 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 — 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 + // 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 +408,115 @@ 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 (target, args, desc, aumid, icon_loc) = e + .run(move || { + Ok::<_, PortError>(( + read_target(&l)?, + read_arguments(&l)?, + read_description(&l)?, + read_appusermodel_id(&l)?, + read_icon_location(&l)?, + )) + }) + .unwrap() + .unwrap(); + 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))); + + // 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("--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"); + } + + #[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..14bd5385 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. // --------------------------------------------------------------------------------------------- @@ -273,8 +277,8 @@ fn expand_env(value: &str) -> String { #[cfg(windows)] mod win { use dm_domain::{ - DecodedImage, DesktopItem, DesktopIniAnchor, ItemKind, PortError, PortResult, - RecycleBinAnchor, RestoreAnchor, SystemIconAnchor, + DecodedImage, DesktopItem, DesktopIniAnchor, DriveAnchor, FileTypeAnchor, ItemKind, + PortError, PortResult, RecycleBinAnchor, RestoreAnchor, SystemIconAnchor, }; use windows::core::{HSTRING, PCWSTR}; use windows::Win32::Foundation::{HWND, SIZE}; @@ -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 @@ -320,6 +331,23 @@ mod win { if item.kind == ItemKind::RecycleBin { return extract_recycle_bin(item); } + // A file TYPE has no shell item to ask — its `path` IS the ProgId, not a filesystem path. + // The scan captured the type's effective `DefaultIcon` resource in `item.icon`, so that is + // the live original (styling only ever overrides the per-user value; if that value is + // ours, the caller passed an anchor and this branch was already terminal above). + if item.kind == ItemKind::FileType { + return match item + .icon + .as_ref() + .and_then(|r| icon_resource_image(&r.location, r.index).ok().flatten()) + { + Some(i) => Ok(vec![i]), + None => Err(PortError::NotFound(format!( + "no DefaultIcon resource extractable for {}", + item.path + ))), + }; + } // Shortcut kinds honour their explicit icon resource FIRST (the index matters — one DLL // holds many icons); everything else asks the shell for Explorer's exact image. Either // side falls back to the other, mirroring the oracle's two-way chain. @@ -355,6 +383,17 @@ mod win { } RestoreAnchor::RecycleBin(a) => original_recycle_bin(a), RestoreAnchor::SystemIcon(a) => original_system(a), + // A fixed drive: recover the original from the CAPTURED registry state — the LIVE read + // is unsafe here (the spike proved `SHGetFileInfo` honours our per-user `Drives` key, + // so post-apply live IS our own asset → `Style(Style(orig))`). + RestoreAnchor::Drive(a) => original_drive(a), + // A file TYPE: same reasoning as the drive — the live per-user `DefaultIcon` may be + // our own asset after a ledger-row loss, so the original comes from the anchor only. + RestoreAnchor::FileType(a) => original_file_type(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, } } @@ -374,6 +413,22 @@ mod win { value.and_then(|v| resource_from_value(&v.raw)).map(|i| vec![i]) } + /// The original file-TYPE icon, recovered ONLY from the captured registry state — the LIVE + /// per-user value may be our own styled asset (same compounding guard as drives: a value that + /// resolves to nothing after a ledger-row loss must degrade, never re-read the live surface). + /// `value` is the effective `DefaultIcon` (per-user when the key pre-existed, else the HKCR + /// machine default); when it is empty — our own restore leaves an empty per-user key — the + /// original was the machine class default, which styling never writes, so it is still the true + /// original and is read live (the `original_system` posture). A type with neither → `None` → + /// terminal degrade upstream. + fn original_file_type(a: &FileTypeAnchor) -> Option> { + let value = match &a.value { + Some(v) => Some(v.clone()), + None => crate::apply::filetype::machine_value(&a.prog_id).ok().flatten(), + }; + value.and_then(|v| resource_from_value(&v.raw)).map(|i| vec![i]) + } + /// Atomically creates an unpredictable scratch file under `%TEMP%` and writes `bytes`, /// returning its path (codex icons2-🟠12: the old predictable PID+counter name + plain /// `fs::write` could clobber a pre-planted file or reparse/hardlink). The name mixes PID, a @@ -504,6 +559,57 @@ mod win { Some(vec![full, empty]) } + /// The original fixed-drive icon, recovered WITHOUT reading the live surface (which post-apply + /// IS our own styled asset — `SHGetFileInfo` honours the per-user `Drives` key, so a live read + /// would compound `Style(Style(orig))`). Two cases, mirroring the folder/recycle-bin posture: + /// + /// 1. A captured value exists (per-user override, or the machine `DriveIcons` fallback when no + /// per-user key existed) → resolve it as an icon resource — that is the user's TRUE original. + /// 2. NO value at all → the drive used the shell STOCK icon. Recover it from a STATIC resource + /// (`shell32.dll,8` — "Hard drive / Local fixed disk"), never the live read — the same + /// last-resort posture as `original_folder_image`'s `shell32.dll,3` stock folder. A static + /// resource is override-free by construction, so no compounding is possible. + /// + /// Anchor fully unresolvable → `None` → terminal degrade upstream. + fn original_drive(a: &DriveAnchor) -> Option> { + if let Some(img) = a.value.as_ref().and_then(|v| resource_from_value(&v.raw)) { + return Some(vec![img]); + } + // The drive had NO custom icon: the theme's stock fixed-drive glyph. (The modern imageres + // variant drifts by build; shell32,8 is the stable classic disk icon — the same index the + // Change Icon dialog lists for "Hard drive".) + icon_resource_image(r"%SystemRoot%\system32\shell32.dll", 8) + .ok() + .flatten() + .map(|i| vec![i]) + } + + /// 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 / @@ -552,10 +658,30 @@ mod win { /// Extracts the best ≤`ICON_PX` frame of `location[,index]` as an HICON and rasterizes it. fn icon_resource_image(location: &str, index: i32) -> PortResult> { - let path = expand_env(location); - if path.trim().is_empty() || !std::path::Path::new(&path).exists() { + let mut path = expand_env(location); + if path.trim().is_empty() { return Ok(None); } + if !std::path::Path::new(&path).exists() { + // A BARE file name (`imageres.dll,-109` / `shell32.dll,-30`, common in older + // associations' `DefaultIcon`) resolves relative to the current directory, which never + // exists — so the type would degrade for a reason that has nothing to do with its icon. + // Retry against the system directory, the base Windows itself resolves bare DLL/exe + // names against (ADR-0004 D7 amendment 2026-10-16: file-type styling surfaces many + // such values). Only a SINGLE component qualifies: an explicit relative directory is + // never silently rebased. [WINDOWS-VERIFY] runtime. + if std::path::Path::new(&path).components().count() == 1 { + if let Ok(system_root) = std::env::var("SystemRoot") { + let candidate = std::path::Path::new(&system_root).join("System32").join(&path); + if candidate.exists() { + path = candidate.to_string_lossy().into_owned(); + } + } + } + if !std::path::Path::new(&path).exists() { + return Ok(None); + } + } // `PrivateExtractIconsW` takes a fixed NUL-terminated MAX_PATH (260) wide buffer — a // windows-rs projection limit, not ours. A longer path skips it and rides // `SHDefExtractIconW` below, which takes a plain PCWSTR at any length (codex 🟠7). @@ -622,13 +748,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..46a9be1d 100644 --- a/crates/dm-windows/src/state_reader.rs +++ b/crates/dm-windows/src/state_reader.rs @@ -17,7 +17,7 @@ use dm_domain::{ PriorWrapper, RestoreAnchor, WrapperAnchor, }; -use crate::apply::{file_wrapper, recyclebin, system}; +use crate::apply::{drive, file_wrapper, filetype, recyclebin, system}; use crate::classify::parse_icon_location; use crate::com::StaExecutor; use crate::durable::{read_capped, SHORTCUT_READ_CAP}; @@ -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). @@ -127,6 +139,22 @@ impl ItemStateReader for WindowsStateReader { let (path, index) = a.value.as_ref().map(|v| parse_icon_location(&v.raw)).unwrap_or_default(); Ok(SurfaceState::IconRef { path, index }.fingerprint()) } + // A fixed drive: the effective per-drive `DefaultIcon` location (per-user override + // else the machine `DriveIcons` fallback — exactly what the shell shows). + ItemKind::Drive => { + let a = drive::read_current(&drive::letter_from_path(&target.path)?)?; + let icon = a.value.as_ref().map(|v| parse_icon_location(&v.raw)).unwrap_or_default(); + Ok(SurfaceState::Drive { icon }.fingerprint()) + } + // A file TYPE: the effective per-ProgId `DefaultIcon` location (our per-user override + // else the HKCR machine default — exactly what the shell shows for every file of the + // type). One icon location, so it fingerprints as an `IconRef` exactly like a System + // item's — same `expected_after_apply` surface (P1-12 discipline). + ItemKind::FileType => { + let a = filetype::read_current(&filetype::prog_id_from_path(&target.path)?)?; + let (path, index) = a.value.as_ref().map(|v| parse_icon_location(&v.raw)).unwrap_or_default(); + Ok(SurfaceState::IconRef { path, index }.fingerprint()) + } ItemKind::Unsupported => { Err(PortError::Unsupported(format!("fingerprint for {:?}", target.kind))) } @@ -139,9 +167,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 +176,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 @@ -207,6 +248,24 @@ impl ItemStateReader for WindowsStateReader { let locations = vec![default.clone(), empty.clone(), full.clone()]; Ok((SurfaceState::RecycleBin { default, empty, full }.fingerprint(), locations)) } + // A fixed drive: the effective `DefaultIcon` is the live location, so a drive key + // already pointing into OUR asset store is visible to the provenance guard like any + // other kind (never re-read as "the original" after a ledger-row loss). + ItemKind::Drive => { + let a = drive::read_current(&drive::letter_from_path(&target.path)?)?; + let icon = a.value.as_ref().map(|v| parse_icon_location(&v.raw)).unwrap_or_default(); + let location = icon.clone(); + Ok((SurfaceState::Drive { icon }.fingerprint(), vec![location])) + } + // A file TYPE: the effective `DefaultIcon` is the live location, so a ProgId key + // already pointing into OUR asset store is visible to the provenance guard like any + // other kind (never re-read as "the original" after a ledger-row loss). + ItemKind::FileType => { + let a = filetype::read_current(&filetype::prog_id_from_path(&target.path)?)?; + let (path, index) = a.value.as_ref().map(|v| parse_icon_location(&v.raw)).unwrap_or_default(); + let fingerprint = SurfaceState::IconRef { path: path.clone(), index }.fingerprint(); + Ok((fingerprint, vec![(path, index)])) + } ItemKind::Unsupported => { Err(PortError::Unsupported(format!("fingerprint for {:?}", target.kind))) } @@ -215,8 +274,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)?), @@ -225,6 +293,14 @@ impl ItemStateReader for WindowsStateReader { ItemKind::System => { Ok(RestoreAnchor::SystemIcon(system::read_current(&system::parse_clsid(&target.path)?)?)) } + ItemKind::Drive => { + Ok(RestoreAnchor::Drive(drive::read_current(&drive::letter_from_path(&target.path)?)?)) + } + ItemKind::FileType => { + Ok(RestoreAnchor::FileType(filetype::read_current( + &filetype::prog_id_from_path(&target.path)?, + )?)) + } ItemKind::Unsupported => { Err(PortError::Unsupported(format!("anchor for {:?}", target.kind))) } @@ -343,5 +419,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/0004-platform-form.md b/docs/decisions/0004-platform-form.md index 776625e4..e8833d34 100644 --- a/docs/decisions/0004-platform-form.md +++ b/docs/decisions/0004-platform-form.md @@ -95,3 +95,87 @@ exists in opposition to. The owner resolved the three open conflicts. - The module is named **清爽 / 清爽系统** (ADR-0023 decision 7); 净化/清理/优化 are banned copy for it. §2's "已帮你做的事" checklist grammar is refined by ADR-0023 decision 3 (three-state honest results; verified writes only). + +## Amendment (2026-10-13 — D7 Explorer module landed) + +- **§7's folder half ships:** every folder under `FOLDERID_Profile` on a new `explorer` surface + (per-surface toggle, consent-gated like the others), styled via `desktop.ini` in-host (no + elevation — Warm tier per §4). The walk skips hidden/System-attribute trees (AppData), reparse + points, and build/VCS/deno trees, with depth + count caps, so a repo-heavy profile can never + stall the scan or get residue. Restore removes only our `desktop.ini` (byte-identical return). +- **§7's drive half ships as the PER-USER key, not the HKLM one:** `HKCU\Software\Classes\ + Applications\Explorer.exe\Drives\\DefaultIcon` — an on-box spike proved the shell honors this + key (the `SHGetFileInfo("C:\")` icon changed and reverted), so drives are styled **in-host with + no elevation**, staying inside §4's Warm tier; the machine `HKLM\…\Explorer\DriveIcons` tree is + read ONLY as the original-icon fallback and is never written. `autorun.inf` stays untouched; + fixed drives only (removable/optical/network letters are never keyed). +- **Original-icon recovery for drives never reads the live surface post-apply** (the same spike + showed the live read WOULD return our own asset → compounding): a captured registry value + resolves as an icon resource, and an absent value falls back to the static stock glyph + `shell32.dll,8`. Restore writes HKCU only and removes just the `(Default)` value we created + (recycle-bin posture: never `delete_subkey_all` over a key another program may share). +- **Bucketing:** `ItemKind::Drive` rides the **Folder** bucket (an Explorer container the user + browses) — same policy switch, same `explorer`-surface consent; §7's ProgId `DefaultIcon` ban + is unchanged and the banned association icons were never built. + +## Amendment (2026-10-16 — owner ruling: ALL fixed drives · ALL file types except images) + +Owner directive, fork scope: "al abrir el explorador de archivos **todos** los iconos están +estilizados". Two parts, one of which **lifts §7's constitutional ban**. + +- **§7 folder half BROADENED to every fixed drive root** (`visible_fixed_drives()`, `DRIVE_FIXED` + only), no longer `FOLDERID_Profile` alone. Mechanics unchanged: in-host `desktop.ini`, Warm + tier (§4), no elevation. Two guard additions make whole-drive walks honest: + - **Protected roots are never emitted, nor descended** — `Program Files`, `Program Files (x86)`, + `ProgramData`, `Windows`, `$Recycle.Bin`, `System Volume Information`, `Recovery`, `MSOCache`, + `PerfLogs`, `Documents and Settings`: the current user cannot write `desktop.ini` there without + elevation, so emitting them would only produce doomed per-item apply failures. Styling them + would mean opening a HOT-tier UAC path (writing program-file trees via the elevated helper); + **that is NOT part of this amendment** and remains out of scope until explicitly decided. + - **`Users` is container-only**: descended (the profile lives at `C:\Users\`), never itself + styled (ACL-protected), same posture as before. + - Caps re-based on drive roots: depth 10 (the old profile-relative depth 8, re-measured from a + drive root) and 5000 folders (drive-wide content), denylist + attribute gate unchanged. + - **Source previews are now content-addressed WITHOUT the item id** (`dmicon://`), so the + shared `SCAN_SOURCE_BUDGET` counts *unique pixels once* instead of once per folder — a + 5000-folder walk costs one folder-icon entry, not 5000. The cache doc already promised + "one entry per unique pixel set"; the item-id prefix made that false. Frontend bakes are + memoized on the same key for the same reason (one master PNG per unique source+style). +- **§7's file-type ProgId `DefaultIcon` ban is LIFTED for every file type EXCEPT images** + (owner ruling 2026-10-16, informed — the ban's own rationale is answered below, not ignored). + New `ItemKind::FileType`, same `explorer` surface toggle + consent count, bucketed **File** + (so `kindPolicy.File` remains a second, independent kill switch). The type set is not the + registry enumerated wholesale: it is **the extensions the explorer walk actually SAW on the + walked drives**, resolved to their ProgId (one item per ProgId; extensions sharing a ProgId + are one registry target and must be one item, never two writers of one value). + + Answering the original ban rationale, clause by clause: + + 1. *"association hijack signature"* — hijack signatures live in the **command verbs** + (`HKCU\Software\Classes\\shell\open\command`). This feature writes **exactly one + value**: `HKCU\Software\Classes\\DefaultIcon`, the icon reference only. Verb keys, + `shell\runas`, `UserChoice`, `OpenWithProgids` are never touched, so no launch path can + change. Elevation: none (HKCU, §4 Warm tier); HKCR/HKLM never written. + 2. *"un-restorable merge states"* — the anchor is captured BEFORE the write + (`RestoreAnchor::FileType`: per-user-vs-HKCR effective value + `key_existed`), restore + removes only the `(Default)` value we created (recycle-bin posture, never + `delete_subkey_all`), and the styled-residue provenance guard applies unchanged (a value + pointing into our asset store is never re-captured as "the original"). `run_uninstall_restore` + walks the same ledger path. + 3. **Exclusions are part of the decision, not an afterthought:** + - **Images** (owner's explicit carve-out): `PerceivedType = image` **or** membership in an + explicit list (`png jpg jpe jpeg gif bmp dib ico cur ani tif tiff wmf emf svg webp heic + heif avif jfif …`) — native icons/previews stay untouched. + - **Per-file types**: any `DefaultIcon` containing an unexpanded `%1`/`%L`/`%l`/`%v` + resolves to *the file itself* (`exefile`, `dllfile`, `.scr`, …). A type-level override + there would collapse EVERY executable's individual icon into ONE glyph — strictly worse + than native — so those types are skipped. This is also the honest floor of "los iconos + de todos los programas": per-program identity lives inside each `.exe` (same limit as + ADR-0024 running-window buttons) and cannot be restyled per-file. + - No `DefaultIcon` at all, an invalid ProgId shape (backslash/`\`-bearing or control chars + — defence-in-depth on a key path we interpolate), or an unextractable resource → not + emitted (nothing to style). + 4. **Refresh**: association icons need `SHChangeNotify(SHCNE_ASSOCCHANGED)`, the notify the + apply path already fires. +- **§7 Consequences line ("file-type icons removed permanently") is SUPERSEDED** by this + amendment; the roadmap line follows it. diff --git a/docs/decisions/0024-shell-surfaces.md b/docs/decisions/0024-shell-surfaces.md new file mode 100644 index 00000000..80ffcd41 --- /dev/null +++ b/docs/decisions/0024-shell-surfaces.md @@ -0,0 +1,145 @@ +# 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 **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`. 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 + 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. +6. **MSIX entries via owned wrapper `.lnk`** (Task 11, owner directive 2026-10-12): + 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 + +- 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). + +## Amendment (2026-10-16 — owner ask "quiero que TODOS los iconos de la taskbar se estilicen") + +Re-examined against the ask, in this order: + +1. **What is stylable IS stylable**: every taskbar pin is a `.lnk` under + `User Pinned\TaskBar` → `ItemKind::Shortcut` on the `taskbar` surface (this ADR's decision 1), + plus Store pins via the Task 11 wrapper. There is no third category of pin: Windows builds the + pinned row exclusively from that folder. +2. **Running-window buttons**: context 4 / decision 5 stand as written — no supported lever + exists for exe resources (Authenticode + reversibility), and rewriting `.exe` icons stays on + the Never-list. **On-box observation (2026-10-13, plan §9)**: for a *pinned* app the button + renders the PIN's icon once the shell repaints, so a running pinned app DOES show the styled + icon; only *unpinned* running apps (and system-tray glyphs, which each app draws itself from + its own resources) keep the native icon. The honest coverage is therefore "every pin + every + pinned-then-running button", never an unpinned running exe's own resource. +3. **Not a bug when it looks native**: the desktop/restart path is `restart_shell()` + + `purge_icon_caches()` (decision 4) — a shell whose cache predates an apply shows the old + glyphs until that refresh; pin-cache staleness across restarts was reproduced and fixed by the + same repaint on-box. + +No decision text changes; this records that the ask was checked item-by-item and that the exe +limit is a **platform floor**, not remaining scope. 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..cb73374d --- /dev/null +++ b/docs/plans/2026-10-12-taskbar-startmenu-surfaces.md @@ -0,0 +1,523 @@ +--- +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 TRUE (owner decision 2026-10-12) + startMenu: boolean } // default TRUE (owner decision 2026-10-12) +``` + +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. +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) → 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)` — ONE + `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 — 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) 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. + - **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. + - **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. + - **Status 2026-10-16 — owner asked "quiero que TODOS los iconos de la + taskbar se estilicen"; re-checked item by item, no decision changed** + (ADR-0024 amendment appended): every PIN is already stylable (pins + + Store pins via the Task 11 wrapper — the pinned row is built ONLY from + `User Pinned\TaskBar`, there is no third category); for a *pinned* app + the running button repainted to the styled pin icon after restart + (on-box, 2026-10-13); what genuinely stays native is only (a) UNPINNED + running apps' exe resources — no supported lever, Authenticode + the + reversibility envelope, never-list — and (b) system-tray glyphs each app + draws from its own resources. A desktop whose icons look native after + this date means the system is in RESTORED state (the uninstall-residue + test reverts everything): re-apply before judging coverage. + +## [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 ✅ MEASURED (2026-10-15) — see the finding below · 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 ✅ (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). + +## Uninstall residue — measured 2026-10-15 (Task 9 check) + +**Finding: uninstalling does NOT revert the applied styling.** The Tauri NSIS +uninstaller is configured with NO `installerHooks` (`tauri.conf.json` bundle → +`nsis.installMode: currentUser` only), and the app has no headless restore CLI +(`main.rs` handles only `--activate-aumid`). A per-user uninstall removes the +install dir, the Start-Menu shortcut and the `…\Uninstall\DeskMakeover` entry, +and leaves: + +- **Applied styling** (all surfaces): profile-folder `desktop.ini`, rewritten + `.lnk` `IconLocation`s (desktop / taskbar pins / Start menu), the per-user + `HKCU\Software\Classes\Applications\Explorer.exe\Drives\\DefaultIcon` keys + (drive module), the machine `HKLM\…\Explorer\Shell Icons\29` arrow overlay, + any applied wallpaper, and Task-11 appx wrapper `.lnk`s. +- **App data**: `%APPDATA%\com.xiaominglab.deskmakeover\` (settings.sqlite3, + `ledger.json`, `icon-assets\`, `look-history.json`, markers) and + `%LOCALAPPDATA%\com.xiaominglab.deskmakeover\` (WebView2 cache). +- **ProgramData**: `C:\ProgramData\DeskMakeover\transparent-overlay.ico` + (the elevated overlay copy). + +Evidence on the owner box (2026-10-15): the styled state was live +(`HKLM …Shell Icons\29 → C:\ProgramData\DeskMakeover\transparent-overlay.ico,0`, +the three `HKCU …Drives\` keys present, 418 profile `desktop.ini`, app data +33 MB + 179 MB + 27 MB); the only `…\Uninstall` entry present was the OLD 0.1.1 +install (whose install dir was already gone). No restore path is wired to any +uninstall. + +**Verdict:** a **gap** against the constitutional "one-click reversible with +zero residue" promise — the SAME posture the module ships with today (the fork +adds surfaces but no uninstall hook). It is **not** a regression from the +taskbar/explorer work. + +**Fix — IMPLEMENTED 2026-10-15 (headless `--uninstall-restore` + NSIS hook):** +1. `lib.rs::run_uninstall_restore()` reuses the `setup` composition roots + headlessly: `%APPDATA%\com.xiaominglab.deskmakeover` → `run_startup_recovery` → + `SettingsStore::open` → `build_icon_host` → `icons.restore()` + + `icons.restore_overlay()`, then `build_wallpaper_host` → `restore()` per + monitor, then `TweaksHost::restore()`. `main.rs` runs it on + `--uninstall-restore` (before booting the GUI) and exits with its status code. +2. `bundle.windows.nsis.installerHooks` → `src-tauri/installer-hooks.nsh`, whose + `NSIS_HOOK_PREUNINSTALL` runs `"$INSTDIR\deskmakeover-desktop.exe" + --uninstall-restore` BEFORE files are removed, then drops `$APPDATA\`, + `$LOCALAPPDATA\` and `$PROGRAMDATA\DeskMakeover`. The HKLM overlay restore + raises ONE UAC (the uninstaller is per-user) — an explicit, honest trade. +3. **Tested on-box 2026-10-15** (`deskmakeover-desktop.exe --uninstall-restore` + against a live applied state): exit 0; `HKLM …Shell Icons\29` removed (arrow + native); the three `HKCU …Drives\` values cleared; 417/423 profile + `desktop.ini` removed. Gates green (cargo workspace · bun 670 · tsc · lint · + bindings). The full install→apply→uninstall→verify loop and the UAC-on-uninstall + edge ride the owner-supervised live pass (needs a VM / clean install). + +**Residual caveat (pre-existing, NOT from this hook):** the ledger-based restore +cannot reach ORPHAN `desktop.ini` that exist on disk with no ledger row. On the +owner box 6 such files remained under `…\Documents\chibi-poser\dist\…` (a +denylisted build tree — `node_modules` had ZERO, so the walk denylist works; the +`dist` copies reference our asset but the parent folder is not styled and they are +absent from `ledger.json`). They were likely styled by a pre-denylist explorer +apply whose rows were later dropped, or copied in by an external build. A +*scan-time rescue sweep* (remove `IconResource=…com.xiaominglab.deskmakeover…` +`desktop.ini` the ledger does not own) would close this — recorded as a follow-up. + +## 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). +- 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/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..1266bc0b --- /dev/null +++ b/docs/plans/2026-10-13-explorer-folder-drive-icons.md @@ -0,0 +1,132 @@ +# 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:** ✅ landed — **broadened 2026-10-16** (all fixed drives + file-type +icons) per `2026-10-16-all-drives-filetypes.md` + +## 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 a per-user registry key — resolved to +`HKCU\…\Applications\Explorer.exe\Drives\\DefaultIcon` after the on-box spike, not the machine +`DriveIcons` tree), 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. + **SUPERSEDED 2026-10-16**: the owner lifted this ban for every file type EXCEPT images (and + never `%1` per-file types) — see `2026-10-16-all-drives-filetypes.md` and ADR-0004's amendment. +- **Folder coverage:** every folder under the **user profile** (`FOLDERID_Profile`), subject to + the exclusions below (owner chose "todas bajo tu perfil"). + **BROADENED 2026-10-16** to every fixed-drive root by owner ruling ("al abrir el explorador + **todos** los iconos"), with protected-root and `Users` container-only guards — same plan doc. + +### 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 the per-user `Drives\\DefaultIcon` key (HKCU, in-host) | 🆕 `apply/drive.rs` (no elevation — spike-verified) | 🆕 `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 (HKCU per-user key, in-host — **resolved** vs the original HKLM sketch) + +6. **Enumerate:** `known_folders.rs::visible_fixed_drives()` (probe A–Z with `GetDriveTypeW`, + `DRIVE_FIXED` only); display name from the shell (`"SYSTEM (C:)"`), fallback `(C:)`. +7. **Domain:** `ItemKind::Drive` + bucket (**Folder** — an Explorer container the user browses), + `is_styleable`, serde/TS literal `Drive`. +8. **Extract/state:** original from the CAPTURED anchor (per-user value → icon resource; machine + `DriveIcons` value likewise; absent → static stock `shell32.dll,8`) — never a live read + post-apply (the spike proved `SHGetFileInfo` honors our key → compounding). Fingerprint = + the HKCU value. +9. **Apply/restore:** `apply/drive.rs` writes/removes + `HKCU\Software\Classes\Applications\Explorer.exe\Drives\\DefaultIcon` — **in-host, no + elevation** (on-box spike: the `SHGetFileInfo("C:\")` icon changed and reverted cleanly; the + HKLM `DriveIcons` tree is read as fallback only, never written). Restore removes only the + `(Default)` value we created (recycle-bin posture), key-existed semantics preserved. +10. **Wires + toggles** for the `explorer` surface covering drives (surface toggle + Folder bucket + policy are the only gates; apply routes in-host because `C:\` is outside the privileged scope). + +### 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. +- [x] Phase B drives (domain `ItemKind::Drive` + `DriveAnchor`; `apply/drive.rs` per-user + `HKCU\…\Explorer.exe\Drives\\DefaultIcon` writer, in-host, no elevation; `source.rs` + `original_drive` from the CAPTURED anchor → static `shell32.dll,8`; `visible_fixed_drives` + + scan emits `Drive` items; bucket = Folder; DTO/TS/bindings `"Drive"`). New tests: + `letter_from_path`, `visible_fixed_drives_…`, `the_scan_emits_one_drive_item_per_fixed_root`, + `bucket_of_covers_every_kind`, plus the serde/TS literal + domain-kind coverage. +- [x] Gates + on-box + docs. All gates green (cargo workspace, bun test 670, tsc, lint, + check:bindings, TEMP-DEBUG grep clean). On-box: **two clean apply/restore cycles** — drives + write the 3 per-user keys on apply (C/D/E) and clear them on restore; profile `desktop.ini` + 415 → 0 → 418 → 0. Shell proof: `SHGetFileInfo("C:\")` returns our styled icon at 16px AND + 32px (the navigation-pane size). ADR-0004 amended + roadmap ticked. +- **Known note (not a Phase-B regression):** on this box the per-scan source-preview budget + (`SCAN_SOURCE_BUDGET` = 24 MiB) is exhausted by the profile's ~560 styleable items, so the + alphabetical tail (~22 items, e.g. `xtask`/`worlds`/`zh`) lands `Icons_Reason_TooMany`. This + is the documented bounded-scan cap; the drives add only 3 items. +- **Navigation-pane caveat:** the drive icon renders in the *This PC* content view after apply (the + owner confirmed the main view styled); the left tree can lag on a window opened before the + post-apply Explorer restart, a known Windows icon-cache quirk — the shell itself already + serves our icon at 16px (proven above). +- [x] **2026-10-16 broadening (owner ruling)** — profile-only scope → **every fixed drive root** + (`explorer_folder_roots()` + one shared BFS so `C:\` can't starve `D:\`; protected roots + never emitted/descended, `Users` container-only; depth 10 / 5000 folders; extensions + collected during the walk feed the NEW file-type pass) + **file types except images** + (`ItemKind::FileType`, `apply/filetype.rs`, two-pass ProgId-level image gate, `%1` + per-file exclusion, File bucket kill switch). Budget survival: content-addressed + `dmicon://` keys (drop the item-id prefix) + frontend bake memoization + file types sorted + LAST so desktop/taskbar/folder previews keep priority. See the 2026-10-16 plan. diff --git a/docs/plans/2026-10-16-all-drives-filetypes.md b/docs/plans/2026-10-16-all-drives-filetypes.md new file mode 100644 index 00000000..d9ea348b --- /dev/null +++ b/docs/plans/2026-10-16-all-drives-filetypes.md @@ -0,0 +1,210 @@ +# Plan — Explorer phase 2: all fixed drives + file-type icons (ADR-0004 D7 amendment) + +**Date:** 2026-10-16 · **Branch:** `feat/taskbar-startmenu-surfaces` (continues) · +**Status:** implemented (gates below) + +## Owner rulings (this plan's scope — 2026-10-16, three answers) + +1. **Folder styling: amplify to EVERY fixed drive** — "al abrir el explorador de archivos **todos** + los iconos están estilizados", not just `C:\Users\`. +2. **File-type icons: ALL file types EXCEPT images** — the owner explicitly overrules ADR-0004 D7's + constitutional ban (answered mitigation-by-mitigation in the ADR amendment; images are the + owner's own carve-out). +3. **Taskbar: "no me alcanza, quiero que todos los iconos se estilicen"** — investigated item by + item; pins are already styled, running-window exe resources remain a platform floor + (ADR-0024 amendment appended, no decision changed). The owner's system was in the RESTORED + state at the time (the uninstall-residue test reverts everything), which reads as "nothing is + styled" until the next apply. + +## Goal + +One `explorer` surface whose folder half covers **every fixed drive**, plus a new **file-type +half**: every extension the user actually has files of — except images — restyled through the +per-user `ProgId\DefaultIcon` value, with the same preview pipeline, policy kill switches and +restore posture as every other kind. + +## Scope guards (part of the decision, not an afterthought) + +- **Protected drive roots are never emitted, never descended**: `Program Files`, + `Program Files (x86)`, `Windows`, `ProgramData`, `$Recycle.Bin`, `System Volume Information`, + `Recovery`, `MSOCache`, `PerfLogs`, `Documents and Settings`. The current user cannot write + `desktop.ini` there without elevation; emitting them would only manufacture per-item apply + failures. **Elevation for these trees is NOT part of this plan** (a HOT-tier UAC decision: + writing program-file trees via the elevated helper would widen its `confirm_target` + confinement, which exists to stop desktop.ini-based CLSID hijacks). Pending, owner to decide. +- **`Users` is container-only**: descended (the profile lives under it), itself never styled + (ACL-protected) — the walk's posture toward `Users` does not change. +- **Images never restyled** (`PerceivedType = image` OR an explicit extension list), evaluated + **per ProgId** — one `DefaultIcon` serves every extension of an association, so a ProgId + reachable from even one image extension is untouchable. +- **Per-file types never restyled**: any `DefaultIcon` carrying `%1`/`%L`/`%l` resolves to *the + file itself* (`exefile` and friends) — a type-level override would collapse every executable's + individual icon into one glyph. This is the honest floor of "todos los iconos de los programas": + per-program identity lives inside each `.exe` (the same platform fact as ADR-0024's + running-window limit). + +## Architecture + +| Piece | Status | +|---|---| +| `known_folders::explorer_folder_roots()` — every fixed root, profile fallback | 🆕 | +| Walk: one shared BFS over all roots; protected/container gates; depth 10 / 5000 folders; extension collection | 🆕 edit `shell/scan.rs` | +| Content-addressed `dmicon://` source keys (drop the item-id/slot prefix → dedup by pixels) | 🆕 edit `icon_host/source_cache.rs` | +| Frontend bake memoization (one master per unique source+style+opts, bounded) | 🆕 edit `src/stores/icons.ts` | +| `ItemKind::FileType` + `FileTypeAnchor` + `kind_tag = 9` | 🆕 domain | +| `apply/filetype.rs` — single-value HKCU writer/reader/restore + ProgId validation | 🆕 (mirrors `system.rs`/`drive.rs`) | +| State reader arms (fingerprint / live locations / anchor) → `IconRef` | 🆕 edit | +| Extraction: anchor-driven `original_file_type`; live scan-time `DefaultIcon` resource; System32 fallback for bare names | 🆕 edit `source.rs` | +| Scan enumeration `explorer_file_type_items()` (two-pass: resolve → image-progid gate → value gates) | 🆕 edit `shell/scan.rs` | +| Bucket `File` (`bucket_of` + TS `kindBucket`) — second, independent kill switch | 🆕 edit | +| DTO/TS/bindings: `IconKindDto::FileType` + `IconKind` union | 🆕 edit + `gen:bindings` | +| Budget order: file types sorted LAST (their `.ext` names would otherwise sort first and eat the preview budget) | 🆕 edit | + +### Discovery (why not the registry enumerated wholesale) + +Extensions are **collected during the folder walk** (every file seen on a walked drive → lowercase +extension, deduped). A type with no file anywhere the user browses is never styled blind, and the +item count stays proportional to real content. Extensions then resolve to their ProgId through the +merged `HKCR\` view (HKCU `Software\Classes` participates — the same override layer the writer +uses, so the test fixture needs no elevation). **One item per ProgId**: extensions sharing an +association share ONE registry value and must be one item (two writers of one value would +CAS-conflict forever). `path` carries the ProgId; item id namespace `"filetype"`. + +### Scaling (why 5000 folders don't blow the pipeline) + +- **Scan**: sources are content-addressed WITHOUT the item id — 5000 folders showing the same + stock glyph cost ONE `SCAN_SOURCE_BUDGET` entry (the cache doc always claimed "one entry per + unique pixel set"; the id prefix made it false). +- **Bake**: the apply loop memoizes on `(sourceUrl, tileStyleKey, isShortcut, opts)` — equal + pixels render + PNG-encode once, bounded at 600 entries (a fully-unique item set must not pin + every master in memory). +- **Order**: the final name sort keys on `(isFileType, name)` so `.ext` labels never crowd + desktop/taskbar/folder previews out of the budget. + +### Two scale defects the first full-size apply found — and their fixes (on-box 2026-10-16) + +Both were found by the same ~5000-item apply, each killing the app at ~3 GB: + +1. **Compositor decoded sources per ITEM, not per CONTENT** (`src/icon-compositor/icon-renderer.ts`). + The worker registered one 256² RGBA buffer per item id, so 5042 folders showing one stock glyph + held 5042 copies (~1.3 GB) and the WebView renderer died. Fixed by keying the decode on the + **content URL** (`ready`/`seeds` by URL; `itemSource` alias map item→URL; `skey` on the + render/bake worker messages to look pixels up while the coalescing lane + tile cache stay + per-item). N items sharing a URL now await ONE decode — regression test + `tests/compositor-source-alias.test.ts`. +2. **The Rust commit materialized every master twice** (`crates/dm-operations`). `package_masters` + baked + retained a ~300 KiB laddered ICO per item (`Vec` ≈ 1.5 GB) and + `commit_apply`/`version_switch` then cloned each item's bytes into an up-front + `Vec` (+1.5 GB) → the observed ~3 GB. Fixed by content-deduping the bake cache and + sharing `Arc` across identical masters, and by building each `ApplyRequest` **inside + its own per-item transaction** so the bytes live for one item, not for the whole batch. + Regression test `identical_masters_bake_once_and_share_one_allocation`. + +Neither defect changes any pixel or ledger semantics — both are pure identity/ownership fixes on +pipelines that already meant "content-addressed". + +## Explicit non-goals + +- Elevation for protected trees (pending HOT-tier decision, see guards). +- Per-file / per-program icons (`.exe` resource rewriting — Authenticode + reversibility, Never-list). +- Taskbar running-window buttons (ADR-0024 context 4/decision 5 stand — amended, not changed). +- Images (owner carve-out). + +## Status log + +- [x] ADR-0004 amendment (D7 broadened + ban lifted with mitigations), ADR-0024 amendment (owner + ask re-checked item by item), roadmap tick, both prior plans annotated. +- [x] Phase A — all-drives folders: `explorer_folder_roots()`, one shared BFS, protected/`Users` + gates, depth 10 / 5000 caps, extension collection, content-addressed sources, bake memo. +- [x] Phase B — file types: domain kind + anchor, `apply/filetype.rs` (validation, read_current, + apply, restore, ProgId-poison refusal), state reader + extraction arms, bucket, DTO/bindings, + scan enumeration with ProgId-level image gate, `kindBucket` case, budget ordering. +- [x] Tests: walk protected/container gates + extension collection; file-type gates against a + live HKCU fixture (image / `%1` / dedup / unassociated / `.exe`+`.png`); `explorer_folder_roots` + shape + profile containment; `filetype.rs` round-trip + poisoned-ProgId refusal; domain anchor + round-trips; bucket-of-every-kind + serde literals; TS `kindBucket` cases. +- [x] Gates + on-box verification — see the dedicated section below. +- [x] On-box (2026-10-16): the first full-size apply (~5000 items) surfaced the two scale defects + documented above (compositor per-item decode; Rust per-item master retention + request + cloning) — both fixed with regression tests; restore + re-apply cycles re-run green below. + +### Final gates + on-box verification (2026-10-16) + +- **Gates**: `bun test` 674 pass / 0 fail (2 new regression tests included) · `cargo test + --workspace` 0 failures · `bun x tsc -b` clean · `bun run lint` 0 errors (pre-existing warnings + only) · `bun run check:bindings` passed · banned-colors clean. +- **Restore cycle**: restore → native — folder `desktop.ini` gone across C:/D:/E:, 0 file-type + registry values, drives un-styled. One residual row (`Desktop\Trello Installer.exe`) is left as + found by the trust-first/CAS posture — documented behaviour, self-heals on a later reset. +- **Apply cycle** (full, `files` toggle ON): committed **5213 items in ONE pass, no crash** — + folders 5000 (C: 424 / D: 462 / E: 4286), file types 40 (all 40 `HKCU\...\DefaultIcon` values + present), drives 3 (`HKCU\Software\Classes\Applications\explorer.exe\drives\{C,D,E}`), shortcuts + + Start menu + taskbar 155, Recycle Bin 1. Spot audit: 40/40 folder `desktop.ini` (incl. non-ASCII + paths) and 40/40 file-type keys on disk. App ends "In sync with the desktop"; peak Rust working + set **~300 MB** (was ~3 GB and a dead process before the fixes). +- **Known characteristic**: apply wall-time at ~5200 items is dominated by per-item ledger/journal + persistence (~1.3–4 items/s, tens of minutes). Correct and bounded; a batched ledger flush is a + possible follow-up, out of this plan's scope. + +## Owner follow-up (2026-10-16b): images + `.3mf` stay original, folders lose the white card + +Three owner rulings after seeing the applied desktop, implemented as a scope extension of this plan: + +1. **Loose image files stay original.** The old behaviour wrapped a desktop `.png`/`.jpg` in a + styled companion `.lnk` with the original hidden — which the owner saw as "styled images", both + on the desktop and inside the Desktop folder in Explorer (the wrappers live there). Loose files + now hit the same never-restyle list as the file-TYPE half: `NEVER_RESTYLE_EXTENSIONS` marks them + `PreviewOnly` with `Icons_Reason_KeptOriginal`, so they render original AND ride the + RESTORE-FIRST set — the next apply removes the existing wrappers and un-hides the originals. The + desktop walk is the only surface that emits loose files (the Explorer walk emits folders only), + so this covers the whole reported surface. +2. **`.3mf` joined the never-restyle list** (owner: "también los `.3mf`"), and the static image + list gained the formats that carry no `PerceivedType` (`jxl`, `jp2`, `j2k`, `dds`, `exr`, `hdr`, + `qoi`, `psb`, `svgz`). `PerceivedType = image` stays the systemic gate. +3. **Folders never wear the plate card.** The `Folder` shape is a plate-filled folder silhouette — + under the Mono + white-fallback look it reads as a WHITE card ("la parte blanca no debería + estar"). The Folder bucket's ladder now uses the passthrough `None` shape (the tinted glyph + alone), the built-in preset ladders were updated, and the icon-recipe migration chain gained + **v1 → v2**, rewriting a stored `Folder: { shape: 'Folder' }` override to `None` — so an + already-applied recipe self-heals on the next apply (the saved-style ② load path runs the chain). + +Not in scope (still open): the `File` bucket keeps its document-card shape, and 3D/CAD siblings +(`stl`, `obj`, `gcode`, …) are styleable unless the owner adds them to the list. + +### Third scale defect: the apply session's byte budget (2026-10-16b) + +Re-applying after the restore failed **silently in ~8 s** with +`apply session exceeded its 268435456-byte cumulative budget` — the session's cumulative-base64 +cap (`MAX_SESSION_B64_BYTES = 256 MiB`) was sized for "a real desktop of a few hundred icons", and +an all-drives apply carries ~5200 masters (~50 KiB each ⇒ ~260 MiB) even though the DISTINCT data +is a few MiB (every folder shares the stock glyph). The frontend's apply catch turned it into a +generic "couldn't apply" toast, so the desktop silently stayed native. + +Fixed in `IconApplySession`: identical payloads are stored ONCE and shared by `Arc`, and the +accounted bytes are the DISTINCT payload bytes — so the cap measures the memory the session +actually holds (audit F4 intact) instead of re-counting the same master per item. +`package_masters`' content cache + `Arc` already dedup the post-commit side. + +On-box verification (2026-10-16b, after restore → apply): **5200 items committed in one pass** +(Folder 5000 · Shortcut 112 · AppxShortcut 42 · FileType 40 · Drive 3 · RegularFile **2** — the +images left the styleable set entirely), peak RSS **~205 MB** (vs ~3 GB before the earlier fixes), +images visible with no companion wrappers, folders rendering without the plate card, and the +persisted recipe carrying `Folder: { shape: "None" }`. + +### Fourth defect (owner-visible): the shell restart accepted a taskbar-less Explorer + +After that apply the owner reported **no taskbar and a black desktop**. Diagnosis: `explorer.exe` +was alive but had created neither `Shell_TrayWnd` nor the desktop layer, and `restart_shell`'s +success criterion was the **process** only (`explorer_running`) — an Explorer that came up without +initialising its shell passed the check, so the app reported success over a desktop with no shell. +The wallpaper itself was untouched (the registry + transcoded file were the owner's own, days old): +the black screen was the missing desktop layer. + +Fixed in `crates/dm-windows/src/refresh.rs`: the restart now requires **both** the process and the +`Shell_TrayWnd` window (`shell_ready` — pure predicate, unit-tested), clears a shell-less Explorer +before relaunching instead of spawning on top of it, and its error names the taskbar explicitly. +On-box repair for the already-broken state: restart Explorer (kill + launch) → taskbar and desktop +layer back, wallpaper intact. + + + diff --git a/docs/specs/00-roadmap.md b/docs/specs/00-roadmap.md index 4cd1e07c..200ee596 100644 --- a/docs/specs/00-roadmap.md +++ b/docs/specs/00-roadmap.md @@ -77,10 +77,23 @@ names the version number · repo made public (it exists, currently PRIVATE). ## Later — 第二战场 (Explorer) -- **资源管理器 module**: folder icons (desktop.ini) + drive icons (registry - DriveIcons only — autorun.inf never), same shape×colour engine, per-surface - toggles, full restore. -- Global file-type-association icons: **permanently out** (constitution). +- **资源管理器 module** ✅ (landed 2026-10-13, `feat/taskbar-startmenu-surfaces` — ADR-0004 D7 + amendment): folder icons (desktop.ini under the profile; hidden/reparse/build trees skipped) + + drive icons (per-user `HKCU\…\Applications\Explorer.exe\Drives\\DefaultIcon` — in-host, no + elevation; never autorun.inf; HKLM `DriveIcons` read as original-fallback only), same + shape×colour engine, `explorer` per-surface toggle, full restore. +- **资源管理器 module, phase 2** ✅ (2026-10-16, same branch — ADR-0004 D7 amendment): + folder icons across **every fixed drive root** (protected trees like Program Files never + emitted; `Users` container-only; content-addressed preview dedup so thousands of folders cost + one budget entry) + **file-type icons for every type EXCEPT images** (per-user + `HKCU\Software\Classes\\DefaultIcon`, one item per ProgId, never `%1` per-file types — + the file identity of `.exe`/per-app icons stays native, see ADR-0024 for the same exe-resource + floor on the taskbar). See `docs/plans/2026-10-16-all-drives-filetypes.md`. +- ~~Global file-type-association icons: **permanently out** (constitution).~~ **SUPERSEDED** — + the constitutional ban was lifted by owner ruling 2026-10-16 with the mitigation set recorded + in ADR-0004's amendment (images excluded, `%1` per-file types excluded, single-value HKCU write + only, recycle-bin restore posture). Elevation for Program Files-style protected trees remains + out (pending HOT-tier decision). ## Later — 代差 (moat) 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/installer-hooks.nsh b/src-tauri/installer-hooks.nsh new file mode 100644 index 00000000..55d118a6 --- /dev/null +++ b/src-tauri/installer-hooks.nsh @@ -0,0 +1,19 @@ +; DeskMakeover NSIS installer hooks (Tauri `bundle.windows.nsis.installerHooks`). +; +; PREUNINSTALL: a per-user uninstall must leave NO residue (the module constitution's +; "one-click reversible with zero residue" promise). BEFORE the uninstaller removes our files we run +; the app's headless full restore — it reverts every surface we style (desktop `.lnk`, profile-folder +; `desktop.ini`, taskbar pins, Start-menu shortcuts, fixed drives, appx wrappers), the wallpaper, and +; the machine-wide arrow overlay. The HKLM overlay restore raises ONE UAC (the uninstaller is +; per-user); every other path is HKCU/user-scope. Best-effort: an app that cannot restore must still +; let the uninstall proceed, and the leftover data dirs are removed afterwards. + +!macro NSIS_HOOK_PREUNINSTALL + DetailPrint "DeskMakeover: restoring your desktop before uninstall..." + ExecWait '"$INSTDIR\deskmakeover-desktop.exe" --uninstall-restore' $0 + ; Drop the per-user data (settings, ledger, cached icon-assets, markers) and the elevated overlay + ; copy under ProgramData. The restore above already reverted the styling these pointed at. + RMDir /r "$APPDATA\com.xiaominglab.deskmakeover" + RMDir /r "$LOCALAPPDATA\com.xiaominglab.deskmakeover" + RMDir /r "$PROGRAMDATA\DeskMakeover" +!macroend 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..ad415391 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; @@ -33,12 +35,26 @@ pub(super) fn map_kind(k: ItemKind) -> IconKindDto { ItemKind::AppxShortcut => IconKindDto::AppxShortcut, ItemKind::RecycleBin => IconKindDto::RecycleBin, ItemKind::Folder => IconKindDto::Folder, + ItemKind::Drive => IconKindDto::Drive, + ItemKind::FileType => IconKindDto::FileType, ItemKind::RegularFile => IconKindDto::RegularFile, ItemKind::System => IconKindDto::SystemIcon, ItemKind::Unsupported => IconKindDto::Unsupported, } } +/// 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, + ItemSurface::Explorer => SurfaceDto::Explorer, + } +} + /// 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/mod.rs b/src-tauri/src/icon_host/mod.rs index 671b9a4d..3335d2d1 100644 --- a/src-tauri/src/icon_host/mod.rs +++ b/src-tauri/src/icon_host/mod.rs @@ -103,8 +103,11 @@ pub struct IconHost { /// (scan / get_persisted) never take it, so a mutation-in-flight never blocks a refresh. Lock /// order is ALWAYS op_gate → mut_state; no reader ever takes op_gate, so there is no cycle. op_gate: Mutex<()>, - /// The `dmicon://` protocol cache: `"//"` → the extracted PNG bytes, kept as + /// The `dmicon://` protocol cache: `s` → the extracted PNG bytes, kept as /// TWO generations so an old frame's in-flight request survives a scan swap (codex R3-Major 5). + /// The key is PURELY content-addressed (no item/slot prefix): identical pixels across every + /// styled Explorer folder share ONE entry and ONE `SCAN_SOURCE_BUDGET` charge, and the + /// `immutable` header stays honest (ADR-0004 D7 amendment 2026-10-16). sources: Mutex, /// Monotonic revision: bumps each scan, cache-busting every source URL. revision: AtomicU32, @@ -232,8 +235,9 @@ impl IconHost { std::fs::write(&self.arrow_marker, text) } - /// Protocol lookup: the PNG bytes for `dmicon://…///`, resolved against the - /// live generation then the prior one so a swap→adopt handoff never 404s (codex R3-Major 5). + /// Protocol lookup: the PNG bytes for a content-addressed `dmicon://…/s` key, resolved + /// against the live generation then the prior one so a swap→adopt handoff never 404s (codex + /// R3-Major 5). pub fn png_for(&self, key: &str) -> Option> { self.sources.lock().unwrap().get(key) } diff --git a/src-tauri/src/icon_host/scan.rs b/src-tauri/src/icon_host/scan.rs index 63ad2124..4dfe747f 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; @@ -182,13 +183,8 @@ impl IconHost { // being cloned (codex R2 B-12) — `extract` already handed us owned `DecodedImage`s // and the Vec is dropped right after, so a large desktop pays one fewer full copy // per source. Each unique source's bytes count against the per-scan budget. - for (slot, src) in sources.into_iter().enumerate() { - let (url, added) = cache_source_into( - &mut next_sources, - item.id.as_str(), - slot as u32, - src, - ); + for src in sources { + let (url, added) = cache_source_into(&mut next_sources, src); source_bytes += added; urls.push(url); } @@ -211,7 +207,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 +225,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-tauri/src/icon_host/source_cache.rs b/src-tauri/src/icon_host/source_cache.rs index 5a750796..47a923bd 100644 --- a/src-tauri/src/icon_host/source_cache.rs +++ b/src-tauri/src/icon_host/source_cache.rs @@ -102,13 +102,17 @@ impl SourceCache { /// Caches one source's PNG (moving the buffer) and returns its protocol URL plus the NEW bytes it /// added to `sources` — 0 when content addressing dedups an already-present key, so the caller's /// running per-scan budget counts each unique source once (codex R2 B-5). -pub(super) fn cache_source_into( - sources: &mut HashMap>, - item_id: &str, - slot: u32, - src: DecodedImage, -) -> (String, usize) { - let key = format!("{item_id}/{slot}/{}", &dm_icon_codec::content_hash(&src.png)[..16]); +/// +/// The key is the pure content hash — NO `item_id`/`slot` prefix (ADR-0004 D7 amendment +/// 2026-10-16). The prefix made "one entry per unique pixel set" false: 5000 Explorer folders all +/// showing the same stock folder glyph cost 5000× its bytes against `SCAN_SOURCE_BUDGET` and +/// pushed everything past it to `Icons_Reason_TooMany`. Identical pixels are the same source by +/// definition, so they now share one URL, one cache entry, one budget charge — which is exactly +/// what the `immutable` cache header already assumed. Slots are subsumed too (two slots with the +/// same pixels ARE the same bytes; differing pixels are differing hashes). [WINDOWS-VERIFY] +/// runtime: identical-URL dedup serving two tiles over `dmicon://`. +pub(super) fn cache_source_into(sources: &mut HashMap>, src: DecodedImage) -> (String, usize) { + let key = format!("s{}", &dm_icon_codec::content_hash(&src.png)[..16]); let added = if sources.contains_key(&key) { 0 } else { src.png.len() }; sources.insert(key.clone(), src.png); // MOVE the owned PNG buffer — no clone (codex R2 B-12) (icon_protocol_url(&key), added) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a78d6e7c..b0873243 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -198,6 +198,57 @@ pub fn export_bindings(path: &Path) -> Result<(), String> { .map_err(|e| e.to_string()) } +/// The Tauri app-data dir WITHOUT a running Tauri app (`app.path().app_data_dir()` is +/// `%APPDATA%\`): `%APPDATA%\com.xiaominglab.deskmakeover`. `None` when the +/// environment cannot be read. +fn uninstall_data_dir() -> Option { + let appdata = std::env::var_os("APPDATA")?; + Some(PathBuf::from(appdata).join("com.xiaominglab.deskmakeover")) +} + +/// Headless FULL RESTORE behind the NSIS `NSIS_HOOK_PREUNINSTALL` hook (see the plan's "Uninstall +/// residue" finding): reverts EVERY surface we style BEFORE the uninstaller removes our files, so a +/// per-user uninstall leaves no residue. No webview is created — it reuses the SAME composition +/// roots as `setup`. Best-effort per surface (a failed wallpaper restore must never block the icon +/// restore). Returns the process exit code (0 on completion, 1 when the app-data dir is unusable). +/// +/// The HKLM arrow overlay needs elevation: `restore_overlay` routes through the helper, which on a +/// per-user (non-elevated) uninstaller raises ONE UAC — an explicit, honest trade for zero residue. +pub fn run_uninstall_restore() -> i32 { + let Some(data_dir) = uninstall_data_dir() else { + return 1; + }; + let _ = std::fs::create_dir_all(&data_dir); + // Drive any interrupted transaction to a terminal state first (the same invariant as startup). + let _ = run_startup_recovery(&data_dir); + let settings = match SettingsStore::open(&data_dir.join(SETTINGS_DB_FILE)) { + Ok(s) => Arc::new(s), + Err(_) => return 1, + }; + // Icons cover every surface we write: desktop `.lnk`, profile-folder `desktop.ini`, taskbar + // pins, Start-menu shortcuts (user + all-users, elevated), fixed drives, and appx wrappers. + if let Ok(icons) = build_icon_host(&data_dir, settings.clone()) { + let _ = icons.restore(); + // The machine-wide transparent-arrow overlay (HKLM) is its own verb. + let _ = icons.restore_overlay(); + } + // Wallpaper: restore every monitor we may have replaced. + if let Ok(wallpaper) = build_wallpaper_host(&data_dir) { + if let Ok(screens) = wallpaper.screens() { + for m in &screens.screens { + let _ = wallpaper.restore(&m.monitor_id); + } + } + } + // Calm/tweaks (fail-closed manifest today → usually a no-op, but restores any applied row). + #[cfg(windows)] + let tweaks = TweaksHost::new_windows(); + #[cfg(not(windows))] + let tweaks = TweaksHost::new_devhost(); + let _ = tweaks.restore(); + 0 +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { sanitize_webview_env(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 95289c34..ff1668ea 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,33 @@ #![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(); + // Uninstall-residue cleanup (plan "Uninstall residue" finding): the NSIS `NSIS_HOOK_PREUNINSTALL` + // hook runs this BEFORE the uninstaller removes our files, so a per-user uninstall REVERTS every + // surface we styled (icons across all surfaces + wallpaper + arrow overlay) instead of leaving + // residue. Never boots the GUI; exits with the restore's own status code. + if args.iter().any(|a| a == "--uninstall-restore") { + std::process::exit(deskmakeover_desktop_lib::run_uninstall_restore()); + } + 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() } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3f49f785..31a3a915 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -47,7 +47,8 @@ "windows": { "webviewInstallMode": { "type": "downloadBootstrapper" }, "nsis": { - "installMode": "currentUser" + "installMode": "currentUser", + "installerHooks": "installer-hooks.nsh" } } } diff --git a/src/bridge/generated.ts b/src/bridge/generated.ts index 6f51d6a1..c7bce536 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, /** @@ -159,7 +166,18 @@ export type IconItemDto = { * the TS `IconKind` union. `SystemIcon`/`ExecutableFile` are host-classification refinements the * scanner assigns; the operations layer maps them onto its `ItemKind` write mechanisms. */ -export type IconKindDto = "Shortcut" | "UrlShortcut" | "AppxShortcut" | "RecycleBin" | "SystemIcon" | "Folder" | "RegularFile" | "ExecutableFile" | "Unsupported"; +export type IconKindDto = "Shortcut" | "UrlShortcut" | "AppxShortcut" | "RecycleBin" | "SystemIcon" | "Folder" | +/** + * A fixed drive (ADR-0004 D7) — the `explorer` surface's drive half, styled via its per-user + * registry `DefaultIcon` (routed by `kind`, distinct from the folder `desktop.ini` writer). + */ +"Drive" | +/** + * A file TYPE (extension association) styled via its ProgId's per-user `DefaultIcon` — the + * `explorer` surface's file half (ADR-0004 D7 amendment 2026-10-16, every type except + * images). One item per ProgId; `path` carries the ProgId. + */ +"FileType" | "RegularFile" | "ExecutableFile" | "Unsupported"; /** * The THIN result of a mutating icon op (`applyBaked*` commit / `restore` / `restoreOverlay` / @@ -388,6 +406,16 @@ 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`), a Start menu shortcut, or File Explorer (every + * fixed drive's folders + the drives themselves + the file types seen there, ADR-0004 D7 and its + * 2026-10-16 amendment). 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" | "explorer"; + /** * 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..fdd9508d 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 @@ -189,7 +192,18 @@ export interface GridDto { * immutable thing is the PACKAGE asset, not the shortcut). The C# enum + * writers + CanStyle updates land in the Windows batch. */ export type IconKind = - | 'Shortcut' | 'UrlShortcut' | 'AppxShortcut' | 'RecycleBin' | 'SystemIcon' | 'Folder' | 'RegularFile' + | 'Shortcut' | 'UrlShortcut' | 'AppxShortcut' | 'RecycleBin' | 'SystemIcon' | 'Folder' + /** A fixed drive (ADR-0004 D7) — the explorer surface's drive half, styled + * via its per-user registry DefaultIcon (routed by kind, distinct from the + * folder desktop.ini writer). Buckets with Folder (same explorer consent). */ + | 'Drive' + /** A file TYPE (extension association → ProgId) styled via the ProgId's + * per-user registry DefaultIcon — the explorer surface's file half + * (ADR-0004 D7 amendment 2026-10-16: every type EXCEPT images, and never + * per-file `%1` types). One item per ProgId; `path` carries the ProgId. + * Buckets with File (the second, independent kill switch). */ + | 'FileType' + | 'RegularFile' /** Bare launcher on the desktop (绿色软件 .exe) — a PROGRAM in the user's * mind, so it buckets with App, never with documents (ADR-0017 D1; host * classifies by extension in the Windows batch, v1 scope `.exe` only). */ @@ -211,6 +225,22 @@ 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 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 + /** 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 * type — shape (the findability axis), saliency (colorMode limited to the * desaturation family — never Original islands), its tint/band companions, @@ -247,6 +277,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' | 'explorer' + /** 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 +293,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 +319,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 +351,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 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 /** [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..5fa55474 100644 --- a/src/components/common/ceremony.tsx +++ b/src/components/common/ceremony.tsx @@ -108,12 +108,23 @@ export function ConfirmSheet({ export function ConsentSheet({ open, count, + taskbarCount = 0, + startMenuCount = 0, + explorerCount = 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). 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 @@ -121,6 +132,23 @@ 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)), + }, + ] + : []), + // 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 37bdbeaa..a37a8388 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,11 @@ export function IconsPanel() { 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 522db68b..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, 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' @@ -11,8 +11,8 @@ 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 { format, useT } from '@/lib/i18n' +import type { ConfigDto, IconKindBucket, Surfaces, TypeOverrides, TypePatch } from '@/bridge/types' +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: @@ -23,6 +23,19 @@ 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', '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() { @@ -110,6 +123,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 +143,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 +157,25 @@ 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, 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]) + // 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 +487,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 { label, Icon } = SURFACE_LEG_META[leg] + const name = t(label) + return ( +
+ + + + {name} + + {surfaceCounts[leg]} +
+ ) + })} +
) diff --git a/src/icon-compositor/icon-renderer.ts b/src/icon-compositor/icon-renderer.ts index ff410e77..791462d5 100644 --- a/src/icon-compositor/icon-renderer.ts +++ b/src/icon-compositor/icon-renderer.ts @@ -7,14 +7,16 @@ import type { FromWorker, RenderOpts, ToWorker } from '../icon-wasm/protocol' import { WasmIconRenderer } from '../icon-wasm/wasm-loader' // The icon-compositor facade: a Web Worker POOL renders tiles off the main -// thread (spec 06 deps note; owner perf order 2026-07-09). Items shard across -// workers by stable id hash — each worker holds only its shard's sources and -// analysis caches, so nothing is cloned N times. Results come back as -// zero-copy ImageBitmaps; the main thread only ever drawImage()s. A newer -// request for the same tile slot SUPERSEDES the older one (stale responses -// are dropped, not painted). Falls back to an in-thread WASM renderer (the -// same dm-icon-wasm kernel, run on the main thread) where Worker/OffscreenCanvas -// are unavailable (bun tests / non-worker envs). +// thread (spec 06 deps note; owner perf order 2026-07-09). Decoded sources +// shard across workers by stable CONTENT-key hash (the source URL) — each +// worker holds only its shard's decoded buffers and analysis caches, so a +// pixel set exists exactly ONCE no matter how many items show it (ADR-0004 D7 +// amendment). Results come back as zero-copy ImageBitmaps; the main thread +// only ever drawImage()s. A newer request for the same tile slot SUPERSEDES +// the older one (stale responses are dropped, not painted). Falls back to an +// in-thread WASM renderer (the same dm-icon-wasm kernel, run on the main +// thread) where Worker/OffscreenCanvas are unavailable (bun tests / +// non-worker envs). /** The style inputs that change a rendered tile — the cache key. */ export function tileStyleKey(config: ConfigDto, isShortcut: boolean, showOriginal: boolean, size: number): string { @@ -59,10 +61,18 @@ export class IconCompositor { private sourceWaiters = new Map void>>() // ---- shared state ---- - /** id → url of the ack'd (decoded) source. */ + /** content key → the same url once ack'd (decoded). The content key IS the + * source URL (sources are content-addressed — ADR-0004 D7 amendment). */ private ready = new Map() - /** id → dominant-colour seed hex (null = no-hue tail) from decode. */ + /** content key → dominant-colour seed hex (null = no-hue tail) from decode. */ private seeds = new Map() + /** item/bake id → its content key (the source URL). Decoded pixels live in + * the worker ONCE PER CONTENT, never once per item: the explorer expansion + * styles thousands of folders sharing one stock-folder source, and keying + * the decode by item registered N copies of the same RGBA — the owner box's + * WebView renderer reached 2.8GB and the app died mid-apply (2026-10-16). + * Later items alias instantly: only the FIRST item for a URL pays a decode. */ + private itemSource = new Map() /** styleKey (epoch-prefixed) → per-item bitmaps. Map iteration order = LRU. */ private styleLru = new Map>() private epoch = 0 @@ -117,21 +127,12 @@ export class IconCompositor { private onWorkerMessage(msg: FromWorker): void { if (msg.t === 'sourceReady') { - // Generation gate (codex #4): a rescan can swap the URL for an id while - // the old decode is in flight — only the ack matching the CURRENT url - // may resolve waiters or write the seed; stale acks are dropped (the - // newer load's own ack follows on the same worker's sequential chain). + // Generation gate (codex #4): with content keys an ack's id IS its url, + // so it always names its own entry — the only thing it can miss is a + // claim a FAILED decode already cleared while a retry re-claimed it (that + // retry's own ack follows on the same worker's sequential chain). if (this.ready.get(msg.id) !== msg.url) return - const waiters = this.sourceWaiters.get(msg.id) ?? [] - this.sourceWaiters.delete(msg.id) - if (!msg.ok) { - this.ready.delete(msg.id) - this.seeds.delete(msg.id) // never let a dead source keep voting (codex #6) - } else { - this.seeds.set(msg.id, msg.seed) - } - for (const w of waiters) w(msg.ok) - this.notify() + this.ackSource(msg.id, msg.ok, msg.seed) return } if (msg.t === 'rendered') { @@ -188,30 +189,62 @@ export class IconCompositor { return perItem } - /** Decode a source (worker-side fetch+decode). Resolves when usable. */ + /** Decode a source (worker-side fetch+decode). Resolves when usable. The + * decode is keyed by CONTENT (the URL): N items sharing one source await ONE + * decode — the first call dispatches, later ones join the waiter list (or + * return instantly once the ack landed). `id` only records the alias. */ async loadSource(id: string, url: string): Promise { - if (this.ready.get(id) === url) return - if (!workersSupported) { - await this.loadSourceLocal(id, url) - return - } - // Join an in-flight decode only when it is for the SAME url; a new url - // always dispatches its own source message (codex #4 — the old code let a - // rescan's new url silently ride the outgoing decode's waiter list). - const sameInFlight = this.ready.get(id) === url && this.sourceWaiters.has(id) - this.ready.set(id, url) // claim; the ack gate checks against this value + this.itemSource.set(id, url) + if (this.ready.get(url) === url && !this.sourceWaiters.has(url)) return + const inFlight = this.sourceWaiters.has(url) + this.ready.set(url, url) // claim; the ack gate checks against this value await new Promise((resolve) => { - const waiters = this.sourceWaiters.get(id) + const waiters = this.sourceWaiters.get(url) if (waiters) waiters.push(resolve) - else this.sourceWaiters.set(id, [resolve]) - if (!sameInFlight) this.post(id, { t: 'source', id, url }) + else this.sourceWaiters.set(url, [resolve]) + // Join an in-flight decode only when it is for the SAME content; new + // content always dispatches its own decode (codex #4 — the old code let a + // rescan's new url silently ride the outgoing decode's waiter list). + if (!inFlight) this.dispatchSource(url) }).then((ok) => { if (!ok) throw new Error(`source failed: ${id}`) }) } + /** Route ONE content-keyed decode to its lane — the worker pool, or the + * in-thread fallback. Exactly one dispatch per content per in-flight + * window; every other item with the same URL joins `sourceWaiters`. */ + private dispatchSource(url: string): void { + if (workersSupported) { + this.post(url, { t: 'source', id: url, url }) + return + } + this.loadSourceLocal(url).then( + () => this.ackSource(url, true), + () => this.ackSource(url, false), + ) + } + + /** Settle a content key's claim: resolve every waiter, and on failure clear + * ready+seeds so the NEXT load re-dispatches (never let a dead source keep + * voting — codex #6). `seed` undefined = the in-thread lane keeps its seed + * in the renderer itself (queried via `seedOf`). */ + private ackSource(url: string, ok: boolean, seed?: string | null): void { + const waiters = this.sourceWaiters.get(url) ?? [] + this.sourceWaiters.delete(url) + if (!ok) { + this.ready.delete(url) + this.seeds.delete(url) + } else if (seed !== undefined) { + this.seeds.set(url, seed) + } + for (const w of waiters) w(ok) + this.notify() + } + hasSource(id: string, url: string): boolean { - return this.ready.get(id) === url && !this.sourceWaiters.has(id) + void id // content-keyed: the answer depends on the URL alone + return this.ready.get(url) === url && !this.sourceWaiters.has(url) } /** Drop every cached render (rescan / config-independent invalidation). */ @@ -242,49 +275,62 @@ export class IconCompositor { if (!workersSupported) { const renderer = this.localRenderer - if (!renderer || !renderer.hasSource(id)) return null - const rgba = renderer.render(id, config, isShortcut, showOriginal, size, opts) + const skey = this.itemSource.get(id) // content key: where the pixels live + if (!renderer || !skey || !renderer.hasSource(skey)) return null + const rgba = renderer.render(skey, config, isShortcut, showOriginal, size, opts) if (!rgba) return null const canvas = rgbaToCanvas(rgba, size) this.storeInCache(id, fullKey, canvas) return canvas } - if (!this.ready.has(id)) return null + // Source identity is CONTENT (`itemSource`); the tile cache above and the + // slot/want gates below stay per-ITEM — opts ride every render, and the + // desktop asked for THIS item's tile. + const skey = this.itemSource.get(id) + if (!skey || !this.ready.has(skey)) return null const slot = slotKey(id, showOriginal, size) if (this.wanted.get(slot) === styleKey) return null // already in flight/queued this.wanted.set(slot, styleKey) const req = ++this.reqSeq this.inflight.set(req, { slot, styleKey, epoch: this.epoch }) - this.post(id, { t: 'render', req, id, config, isShortcut, showOriginal, size, opts }) + // Shard + worker lookup by CONTENT (the pixels live there); `id` rides so + // the worker's coalescing lane and the reply stay item-scoped. + this.post(skey, { t: 'render', req, id, skey, config, isShortcut, showOriginal, size, opts }) return null } /** The decode-time dominant-colour seed (hex) for the hue-spread pass; - * null = no-hue tail, undefined = source not decoded yet. */ + * null = no-hue tail, undefined = source not decoded yet. Content-keyed: + * every item showing this source shares the seed (hue-spread already groups + * them by artKey = the source URL, so the sharing is by design). */ seedOf(id: string): string | null | undefined { + const skey = this.itemSource.get(id) + if (!skey) return undefined if (!workersSupported) { const renderer = this.localRenderer - if (!renderer || !renderer.hasSource(id)) return undefined - return renderer.seedOf(id) + if (!renderer || !renderer.hasSource(skey)) return undefined + return renderer.seedOf(skey) } - return this.seeds.get(id) + return this.seeds.get(skey) } /** The 256px bake master as PNG base64 — the ONLY size the bridge accepts. */ async bakeMasterPng(id: string, config: ConfigDto, isShortcut: boolean, opts?: RenderOpts, showOriginal = false): Promise { + const skey = this.itemSource.get(id) + if (!skey) return null if (!workersSupported) { const renderer = this.localRenderer - if (!renderer || !renderer.hasSource(id)) return null - const rgba = renderer.render(id, config, isShortcut, showOriginal, MASTER_SIZE, opts) + if (!renderer || !renderer.hasSource(skey)) return null + const rgba = renderer.render(skey, config, isShortcut, showOriginal, MASTER_SIZE, opts) if (!rgba) return null return canvasToPngBase64(rgbaToCanvas(rgba, MASTER_SIZE)) } - if (!this.ready.has(id)) return null + if (!this.ready.has(skey)) return null const req = ++this.reqSeq const png = await new Promise((resolve) => { this.bakeWaiters.set(req, resolve) - this.post(id, { t: 'bake', req, id, config, isShortcut, showOriginal, opts }) + this.post(skey, { t: 'bake', req, id, skey, config, isShortcut, showOriginal, opts }) }) if (!png) return null let binary = '' @@ -319,13 +365,12 @@ export class IconCompositor { return this.localRendererReady } - private async loadSourceLocal(id: string, url: string): Promise { - if (this.ready.get(id) === url) return + private async loadSourceLocal(url: string): Promise { const renderer = await this.ensureLocalRenderer() const decoded = await decodeRgba(url, MASTER_SIZE) - if (!decoded) throw new Error(`source failed: ${id}`) - renderer.registerSource(id, decoded.data) - this.ready.set(id, url) + if (!decoded) throw new Error(`source failed: ${url}`) + renderer.registerSource(url, decoded.data) // one buffer per content + // Claim + waiter settlement are the CALLER's (`dispatchSource` → `ackSource`). } } diff --git a/src/icon-wasm/protocol.ts b/src/icon-wasm/protocol.ts index 838d0912..1e4587fa 100644 --- a/src/icon-wasm/protocol.ts +++ b/src/icon-wasm/protocol.ts @@ -27,7 +27,14 @@ export interface SourceMsg { export interface RenderMsg { t: 'render' req: number + /** Item identity — the worker's coalescing slot key and the reply id (per + * item, so two items sharing one source keep their own lane). */ id: string + /** Content key of the source pixels to look up in the worker — the + * content-addressed source URL (ADR-0004 D7 amendment 2026-10-16). The + * worker holds ONE decoded buffer per content, shared by every item that + * shows that artwork; never one copy per item. */ + skey: string config: ConfigDto isShortcut: boolean showOriginal: boolean @@ -37,7 +44,10 @@ export interface RenderMsg { export interface BakeMsg { t: 'bake' req: number + /** Caller identity (item / bake id); echoed in the ack. */ id: string + /** Content key of the source pixels (see `RenderMsg.skey`). */ + skey: string config: ConfigDto isShortcut: boolean /** Render the untouched original (compare-sheet Before) rather than the styled master. diff --git a/src/icon-wasm/render-wasm.worker.ts b/src/icon-wasm/render-wasm.worker.ts index b8e6c41e..c6a6c61d 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.skey)) return fail() + const rgba = renderer.render(msg.skey, 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,35 @@ 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) { + // `skey` = content key (ONE decoded buffer per artwork, shared across + // items); `id` stays the item for the coalescing slot + reply identity. + if (!renderer.hasSource(msg.skey)) continue // not decoded yet — main re-requests after sourceReady + const rgba = renderer.render(msg.skey, 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/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index bf3b6693..307f4526 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -189,6 +189,17 @@ 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_Explorer": "File Explorer", + "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 +214,8 @@ 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) + "ConsentAlsoExplorer": "It also styles {0} folders and drives in File Explorer — via each folder's own icon setting, fully reversible", // PENDING-RESX (ADR-0004 D7) "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", @@ -302,6 +315,7 @@ export const en = { "Icons_Reason_StyledResidue": "Styled residue: the original-icon record is missing, so it was skipped to avoid stacking on the old style", // PENDING-RESX "Icons_Reason_ExtractFailed": "Couldn't read the icon: {0}", // PENDING-RESX "Icons_Reason_TooMany": "Too many icons: this scan's preview budget is used up, so some icons have no preview", // PENDING-RESX + "Icons_Reason_KeptOriginal": "Kept original: images and 3D/print files stay exactly as Windows shows them", // PENDING-RESX "Ink_Auto": "Auto", "Ink_Black": "Black", "Ink_White": "White", diff --git a/src/lib/i18n/zh-hans.ts b/src/lib/i18n/zh-hans.ts index 72cfba26..35b88838 100644 --- a/src/lib/i18n/zh-hans.ts +++ b/src/lib/i18n/zh-hans.ts @@ -191,6 +191,17 @@ 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_Explorer": "文件资源管理器", + "Surface_Always": "始终开启", + "Start_Pinned": "已固定", + "Start_Empty": "未找到开始菜单快捷方式", + "Taskbar_NoPins": "未找到任务栏固定快捷方式", "Color_Mono": "单色", "Color_Orig": "原彩", "ComboLabel": "风格", @@ -205,6 +216,8 @@ export const zhHans: Record = { "Compare_Short": "对比", "ComingSoon": "即将推出", // PENDING-RESX (v3 filter roadmap slot) "ConsentAgree": "好,开始美化", + "ConsentAlsoSurfaces": "同时还会美化 {0} 个任务栏固定图标和 {1} 个开始菜单快捷方式——只改快捷方式的图标,绝不动应用本身的文件", // PENDING-RESX (ADR-0024) + "ConsentAlsoExplorer": "同时还会美化文件资源管理器中的 {0} 个文件夹和驱动器——通过各自的图标设置,完全可还原", // PENDING-RESX (ADR-0004 D7) "ConsentArrow": "应用美化时,我们会隐藏 Windows 自带的快捷方式小箭头,改由 DeskMakeover 统一绘制,让图标更清爽。这项改动对整台电脑生效,也会影响桌面以外、以及本机其他账户的快捷方式;随时可以在设置里一键恢复。", // PENDING-RESX (arrow-restore panel 2026-07-11) "ConsentCancel": "再想想", "ConsentNot": "不会发生:不删除、不移动、不修改你的任何文件", @@ -304,6 +317,7 @@ export const zhHans: Record = { "Icons_Reason_StyledResidue": "美化残留:原始图标记录缺失,已跳过以免在旧样式上叠加", // PENDING-RESX "Icons_Reason_ExtractFailed": "图标读取失败:{0}", // PENDING-RESX "Icons_Reason_TooMany": "图标过多:已达本次扫描的预览内存预算,部分图标暂无预览", // PENDING-RESX + "Icons_Reason_KeptOriginal": "保持原样:图片与 3D/打印文件按你的要求不改动图标", // PENDING-RESX "Ink_Auto": "自动", "Ink_Black": "黑", "Ink_White": "白", 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..5ee37680 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) ---- @@ -51,15 +55,15 @@ export const BASE_CONFIGS: Record = { // diecut + blueprint deliberately ship NONE (the global look covers every type). export const PRESET_TYPE_OVERRIDES: Record = { squircle: { - Folder: { source: 'custom', patch: { shape: 'Folder' } }, + Folder: { source: 'custom', patch: { shape: 'None' } }, File: { source: 'custom', patch: { shape: 'File' } }, }, porthole: { - Folder: { source: 'custom', patch: { shape: 'Folder' } }, + Folder: { source: 'custom', patch: { shape: 'None' } }, File: { source: 'custom', patch: { shape: 'Apple' } }, }, pixel: { - Folder: { source: 'custom', patch: { shape: 'Folder', plateColor: '#E7E7E5' } }, + Folder: { source: 'custom', patch: { shape: 'None' } }, File: { source: 'custom', patch: { shape: 'File', plateColor: '#E7E7E5' } }, }, creek: { @@ -67,11 +71,11 @@ export const PRESET_TYPE_OVERRIDES: Record = { File: { source: 'custom', patch: { shape: 'Samsung' } }, }, scrapbook: { - Folder: { source: 'custom', patch: { shape: 'Folder' } }, + Folder: { source: 'custom', patch: { shape: 'None' } }, File: { source: 'custom', patch: { shape: 'Pebble', plateColor: '#E7E7E5' } }, }, gleam: { - Folder: { source: 'custom', patch: { shape: 'Folder' } }, + Folder: { source: 'custom', patch: { shape: 'None' } }, File: { source: 'custom', patch: { shape: 'File' } }, }, glaze: { @@ -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..1e030ebe 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 @@ -35,8 +35,16 @@ export function kindBucket(kind: IconKind): IconKindBucket | null { case 'SystemIcon': return 'App' case 'Folder': + // A fixed drive is an Explorer container the user browses — it rides the + // Folder bucket: same policy switch, same explorer consent (ADR-0004 D7). + case 'Drive': return 'Folder' case 'RegularFile': + // A file TYPE is a DOCUMENT's icon (an extension association, ADR-0004 D7 + // amendment 2026-10-16) — it rides the File bucket, so flipping File OFF + // disables every association icon while folders/drives stay styled. Same + // explorer surface/consent as its siblings. + case 'FileType': return 'File' default: return null @@ -57,3 +65,52 @@ 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 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, explorer: 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, + * mirroring unknown kind buckets). */ +const SURFACE_KEYS = ['taskbar', 'startMenu', 'explorer'] 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 + case 'explorer': + return surfaces.explorer + } +} + +/** 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 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 + 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/lib/preset-migrations.ts b/src/lib/preset-migrations.ts index 6327def9..8a065b26 100644 --- a/src/lib/preset-migrations.ts +++ b/src/lib/preset-migrations.ts @@ -6,7 +6,7 @@ // chain's first citizens. /** Current icon recipe schema version — bump WITH a new migration step. */ -export const ICON_LOOK_VERSION = 1 +export const ICON_LOOK_VERSION = 2 type RawObject = Record type Migration = (raw: RawObject) => RawObject @@ -31,6 +31,25 @@ const ICON_MIGRATIONS: Record = { } return raw }, + // v1 → v2 (owner 2026-10-16): folders never wear the plate CARD. A v1 recipe could set the + // Folder bucket's shape to 'Folder' — a plate-filled folder silhouette that reads as a WHITE + // card under the Mono + white-fallback look ("las carpetas no se ven bien, la parte blanca no + // debería estar"). Rewrite ONLY the Folder bucket's override to the passthrough 'None' (the + // glyph stands alone, no plate); 'Folder' stays a legal shape for any other bucket, and a user + // who re-picks a carded shape later keeps it (this migration runs once, on load). + 1: (raw) => { + const overrides = raw.typeOverrides + if (overrides && typeof overrides === 'object') { + const folder = (overrides as RawObject).Folder + if (folder && typeof folder === 'object') { + const patch = (folder as RawObject).patch + if (patch && typeof patch === 'object' && (patch as RawObject).shape === 'Folder') { + ;(patch as RawObject).shape = 'None' + } + } + } + return raw + }, } /** Lift a raw icon payload from `from` to ICON_LOOK_VERSION. Returns null when diff --git a/src/stores/icons.ts b/src/stores/icons.ts index d40f6592..61105cbe 100644 --- a/src/stores/icons.ts +++ b/src/stores/icons.ts @@ -1,13 +1,13 @@ 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' import { recordObservedGrid } from '@/lib/observed-grid' import { useWallpaper } from '@/stores/wallpaper' -import { getIconCompositor } from '@/icon-compositor/icon-renderer' +import { getIconCompositor, tileStyleKey } from '@/icon-compositor/icon-renderer' import type { RenderOpts } from '@/icon-wasm/protocol' import type { SpreadEntry } from '@/icon-wasm/hue-spread' import { computeHueSpread } from '@/icon-wasm/hue-spread' @@ -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 { @@ -969,6 +997,16 @@ export const useIcons = create((set, get) => { await sourcesSettled() recomputeHueSpread() const frozenOpts = new Map(s.items.map((i) => [i.id, fieldRenderOpts(i.id)])) + // One master per unique (source pixels, style, opts) — NOT one per item (ADR-0004 D7 + // amendment 2026-10-16): the explorer expansion styles thousands of folders that all + // share ONE stock-folder source, and re-rendering + re-encoding that identical PNG for + // every item makes an apply minutes of pure waste. The key is content+style based + // (`j.url` is content-addressed after the same amendment, so equal pixels → equal url), + // so two items that genuinely differ — different bucket in `opts`, shortcut mark, tint + // override — still bake their OWN master. Bounded: entries are the unique masters (a + // fully-unique item set must not pin every master in memory), oldest evicted first. + const bakeMemo = new Map() + const BAKE_MEMO_MAX = 600 // The session token binds every chunk + the commit to THIS begin, so a stale/superseded // apply's masters can never land in another apply's buffer (codex R3-Block 1). const sessionId = await bridge('icons.applyBakedBegin', { revision: s.revision, count: jobs.length }) @@ -976,8 +1014,20 @@ 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 png = await compositor.bakeMasterPng(bakeSourceId(j.item.id, j.sourceIndex), eff.config, j.item.isShortcut, frozenOpts.get(j.item.id)) + const eff = effectiveTileConfig(j.item, config, policy, typeOverrides, surfaces) + const opts = frozenOpts.get(j.item.id) + const memoKey = `${j.url}|${j.item.isShortcut}|${tileStyleKey(eff.config, j.item.isShortcut, false, 256)}|${JSON.stringify(opts ?? null)}` + let png: string | null | undefined = bakeMemo.get(memoKey) + if (png === undefined) { + png = await compositor.bakeMasterPng(bakeSourceId(j.item.id, j.sourceIndex), eff.config, j.item.isShortcut, opts) + if (png) { + if (bakeMemo.size >= BAKE_MEMO_MAX) { + const oldest = bakeMemo.keys().next() + if (!oldest.done) bakeMemo.delete(oldest.value) + } + bakeMemo.set(memoKey, png) + } + } 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 +1038,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 +1168,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 +1193,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/compositor-source-alias.test.ts b/tests/compositor-source-alias.test.ts new file mode 100644 index 00000000..289609a3 --- /dev/null +++ b/tests/compositor-source-alias.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'bun:test' +import type { ConfigDto } from '../src/bridge/types' +import { IconCompositor } from '../src/icon-compositor/icon-renderer' + +// Content-keyed sources (ADR-0004 D7 amendment 2026-10-16): the decoded pixels +// live ONCE PER CONTENT — the source URL — never once per item. The all-drives +// explorer expansion styles thousands of folders sharing ONE stock-folder +// source; keying the decode by item registered N copies of the same RGBA and +// the owner box's WebView renderer died at 2.8GB mid-apply. These tests pin +// the three halves of the fix: ONE dispatch per content (joiners share it), +// the item→content alias every lookup resolves through, and a failed decode +// clearing its claim so a retry can dispatch again. + +const U = 'dmicon://localhost/sAliasFixture' +const U2 = 'dmicon://localhost/sDeadFixture' + +type Priv = { + dispatchSource: (url: string) => void + ackSource: (url: string, ok: boolean, seed?: string | null) => void + itemSource: Map + ready: Map + seeds: Map + loadSource: (id: string, url: string) => Promise +} + +describe('content-keyed source aliasing', () => { + test('N items sharing one URL join a SINGLE decode dispatch', async () => { + const r = new IconCompositor() as unknown as Priv + let dispatches = 0 + r.dispatchSource = (url) => { + dispatches++ + setTimeout(() => r.ackSource(url, true, '#aabbcc'), 5) + } + await Promise.all([ + r.loadSource('folder-1', U), + r.loadSource('folder-2', U), + r.loadSource('folder-3', U), + ]) + expect(dispatches).toBe(1) // not one per item — this IS the memory fix + expect(r.itemSource.get('folder-2')).toBe(U) + expect(r.ready.get(U)).toBe(U) + expect(r.seeds.get(U)).toBe('#aabbcc') + // Decoded already: a later item aliases instantly, no dispatch. + await r.loadSource('folder-4', U) + expect(dispatches).toBe(1) + }) + + test('a failed decode rejects every joiner and clears its claim (retry dispatches)', async () => { + const r = new IconCompositor() as unknown as Priv + let dispatches = 0 + r.dispatchSource = (url) => { + dispatches++ + setTimeout(() => r.ackSource(url, false), 0) + } + let err = '' + try { + await Promise.all([r.loadSource('a', U2), r.loadSource('b', U2)]) + } catch (e) { + err = String(e) + } + expect(err).toContain('source failed') + expect(r.ready.has(U2)).toBe(false) + expect(r.seeds.has(U2)).toBe(false) + // Claim cleared → a fresh load dispatches again (no permanent wedge). + try { + await r.loadSource('a', U2) + } catch { + // expected — the fixture decode always fails + } + expect(dispatches).toBe(2) + }) + + test('tile and bake lookups resolve through the item→content alias', async () => { + const r = new IconCompositor() as unknown as { + itemSource: Map + ready: Map + seeds: Map + hasSource: (id: string, url: string) => boolean + seedOf: (id: string) => string | null | undefined + getTile: (id: string, cfg: ConfigDto, sc: boolean, orig: boolean, size: number) => unknown + bakeMasterPng: (id: string, cfg: ConfigDto, sc: boolean) => Promise + poolSize: number + post: (shard: string, msg: unknown) => void + localRenderer: unknown + onWorkerMessage: (msg: unknown) => void + } + r.itemSource.set('explorer-7', U) + r.ready.set(U, U) + r.seeds.set(U, '#feed10') + expect(r.hasSource('explorer-7', U)).toBe(true) + // Capture BOTH lanes so the assertions hold whether bun runs the in-thread + // fallback (poolSize 0) or a spawned pool: the in-thread renderer asks for + // the content key, the worker message SHARDS by it and carries it as skey + // while keeping the item id for its coalescing lane + reply. + const seen: string[] = [] + const posted: Array<{ shard: string; msg: Record }> = [] + r.post = (shard, msg) => { + posted.push({ shard, msg }) + if (msg.t === 'bake') { + setTimeout(() => r.onWorkerMessage({ t: 'baked', req: msg.req, id: msg.id, png: null }), 0) + } + } + r.localRenderer = { + hasSource: (k: string) => { + seen.push(k) + return true // real renderer answers for the content key; render() returns null below + }, + render: () => null, + seedOf: (k: string) => (k === U ? '#feed10' : null), + registerSource: () => null, + } + expect(r.seedOf('explorer-7')).toBe('#feed10') // both lanes answer via CONTENT + seen.length = 0 + const cfg = { shape: 'Circle' } as unknown as ConfigDto + expect(r.getTile('explorer-7', cfg, false, false, 96)).toBeNull() + if (r.poolSize === 0) { + expect(seen).toEqual([U]) // in-thread lane looked up CONTENT + expect(posted).toHaveLength(0) + } else { + expect(posted).toHaveLength(1) + expect(posted[0].shard).toBe(U) // pixels live under the content key + expect(posted[0].msg.skey).toBe(U) + expect(posted[0].msg.id).toBe('explorer-7') // per-item lane + reply identity + } + const seenBefore = seen.length + const postedBefore = posted.length + expect(await r.bakeMasterPng('explorer-7', cfg, false)).toBeNull() + if (r.poolSize === 0) { + expect(seen.length).toBe(seenBefore + 1) + expect(seen[seen.length - 1]).toBe(U) + } else { + expect(posted.length).toBe(postedBefore + 1) + expect(posted[posted.length - 1].msg.skey).toBe(U) + expect(posted[posted.length - 1].msg.t).toBe('bake') + } + }) +}) diff --git a/tests/icon-look.test.ts b/tests/icon-look.test.ts index f6ee6f09..916445c2 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 the default (participate)', () => { + const withSurfaces = { ...recipe, surfaces: { taskbar: true, startMenu: false, explorer: true } } + const back = parseIconLook(serializeIconLook(withSurfaces)) + expect(back!.surfaces).toEqual({ taskbar: true, startMenu: false, explorer: true }) + // 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: true, startMenu: true, explorer: true }) + }) }) 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 DEFAULT (participate — 2026-10-12). + expect(normalizeIconLook({ config, surfaces: { taskbar: false } })!.surfaces).toEqual({ taskbar: false, startMenu: true, explorer: 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: false, futureSurface: true } })!.surfaces).toEqual({ taskbar: false, startMenu: true, explorer: true } as never) + }) }) describe('migration chain mechanics', () => { @@ -125,6 +145,38 @@ describe('migration chain mechanics', () => { expect(migrateIconLook({}, -1)).toBeNull() expect(migrateIconLook({}, ICON_LOOK_VERSION + 5)).toBeNull() }) + + test('v1 → v2 rewrites the Folder bucket off the plate-card shape (owner 2026-10-16)', () => { + // "las carpetas no se ven bien, la parte blanca no debería estar": a v1 recipe could carry a + // Folder-bucket `shape: 'Folder'` override — a plate-filled folder silhouette that reads as a + // WHITE card under the Mono + white-fallback look. v2 rewrites ONLY that one value. + type RawFolderPatch = { shape?: string; plateColor?: string | null } + const v1 = { + v: 1, + config, + kindPolicy: { ...DEFAULT_KIND_POLICY }, + typeOverrides: { + Folder: { source: 'custom', patch: { shape: 'Folder', plateColor: '#EAD6A8' } }, + File: { source: 'custom', patch: { shape: 'File' } }, + }, + } + const out = migrateIconLook(v1, 1) as { + typeOverrides: { Folder: { patch: RawFolderPatch }; File: { patch: RawFolderPatch } } + } + expect(out.typeOverrides.Folder.patch.shape).toBe('None') // the card is gone (passthrough) + expect(out.typeOverrides.Folder.patch.plateColor).toBe('#EAD6A8') // sibling fields survive + expect(out.typeOverrides.File.patch.shape).toBe('File') // other buckets untouched + + // A Folder shape the USER chose deliberately (not the card) stays exactly as saved. + const samsung = migrateIconLook( + { v: 1, config, typeOverrides: { Folder: { source: 'custom', patch: { shape: 'Samsung' } } } }, + 1, + ) as { typeOverrides: { Folder: { patch: RawFolderPatch } } } + expect(samsung.typeOverrides.Folder.patch.shape).toBe('Samsung') + + // parseIconLook routes a v1 payload through the chain — the saved-style ② load path. + expect(parseIconLook(JSON.stringify(v1))!.typeOverrides.Folder!.patch!.shape).toBe('None') + }) }) describe('wallpaper enum migrations (graduated MATERIAL_MIGRATION)', () => { diff --git a/tests/icons-store.test.ts b/tests/icons-store.test.ts index d3d546e5..da21e11a 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,38 @@ 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 (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, explorer: true }) + 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: true, explorer: 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, { taskbar: false, startMenu: true, explorer: true }).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, explorer: 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, explorer: true }).showOriginal).toBe(true) + expect(effectiveTileConfig(tile, config, DEFAULT_KIND_POLICY, undefined, { taskbar: false, startMenu: true, explorer: true }).showOriginal).toBe(false) + }) }) // Target-resolved shortcuts (owner 2026-07-16): kind carries TARGET semantics @@ -406,6 +441,31 @@ 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. Legs default ON (2026-10-12), + // so the flip exercised here is ON → OFF. + useIcons.setState({ state: { ...state(), applied: 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(true) // the other leg untouched (default on) + useIcons.getState().undo() + 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', false) + expect(useIcons.getState().state!.surfaces.startMenu).toBe(false) + 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)', () => { diff --git a/tests/type-config.test.ts b/tests/type-config.test.ts index 0b2b563a..94d45f87 100644 --- a/tests/type-config.test.ts +++ b/tests/type-config.test.ts @@ -84,6 +84,18 @@ describe('kindBucket (ADR-0017 taxonomy)', () => { expect(kindBucket('RecycleBin')).toBe('App') expect(kindBucket('SystemIcon')).toBe('App') }) + + test('explorer kinds: drives ride Folder, file types ride File (ADR-0004 D7)', () => { + // A fixed drive is an Explorer container the user browses → Folder (same + // policy switch + explorer consent as folders). A file TYPE is a document + // association (D7 amendment 2026-10-16) → File, so the File switch is an + // INDEPENDENT kill switch: turning it off disables every association icon + // while folders/drives stay styled. Unsupported stays ungoverned. + expect(kindBucket('Folder')).toBe('Folder') + expect(kindBucket('Drive')).toBe('Folder') + expect(kindBucket('FileType')).toBe('File') + expect(kindBucket('Unsupported')).toBe(null) + }) }) describe('typeAssertsShape', () => { 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 —