Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions crates/dm-contracts/src/icons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -66,6 +89,13 @@ pub struct IconItemDto {
/// error detail) is `\t`-joined with it: `"Icons_Reason_ExtractFailed\t<detail>"`. The host has
/// no locale, so it must never emit display text here (an English user saw Chinese otherwise).
pub status_reason: Option<String>,
/// 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
Expand Down Expand Up @@ -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\""),
Expand All @@ -218,6 +250,30 @@ mod tests {
assert_eq!(serde_json::from_str::<ArrowOverlayDto>("\"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::<SurfaceDto>(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 {
Expand All @@ -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()],
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion crates/dm-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 55 additions & 1 deletion crates/dm-domain/src/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\<L>` (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\<ProgId>\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
Expand Down Expand Up @@ -131,13 +144,39 @@ 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 {
pub id: ItemId,
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<IconRef>,
pub state: ItemState,
pub requires_explicit_consent: bool,
Expand Down Expand Up @@ -193,6 +232,8 @@ mod tests {
ItemKind::Shortcut,
ItemKind::UrlShortcut,
ItemKind::Folder,
ItemKind::Drive,
ItemKind::FileType,
ItemKind::RegularFile,
ItemKind::RecycleBin,
ItemKind::AppxShortcut,
Expand All @@ -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,
Expand Down Expand Up @@ -262,6 +305,8 @@ mod tests {
ItemKind::Shortcut,
ItemKind::UrlShortcut,
ItemKind::Folder,
ItemKind::Drive,
ItemKind::FileType,
ItemKind::RegularFile,
ItemKind::RecycleBin,
ItemKind::AppxShortcut,
Expand Down Expand Up @@ -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");
}
}
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions crates/dm-domain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions crates/dm-domain/src/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,49 @@ pub struct SystemIconAnchor {
pub value: Option<RegistryValue>,
}

/// 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\<L>` 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<RegistryValue>,
}

/// 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\<ProgId>\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<RegistryValue>,
}

/// The exact-restore material for one item, discriminated by capture shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "anchor")]
Expand All @@ -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 `<prospective path>`), 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 },
Expand Down Expand Up @@ -241,17 +295,60 @@ 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 = [
RestoreAnchor::FileBytes { bytes: b"x".to_vec() },
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}");
}
}
2 changes: 2 additions & 0 deletions crates/dm-domain/src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
Loading