diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f659458..9493685 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -129,8 +129,8 @@ jobs:
run: |
Copy-Item -Path publish-fx -Destination installer\publish-fx -Recurse
Invoke-WebRequest `
- -Uri "https://github.com/winfsp/winfsp/releases/download/v2.2B3/winfsp-2.2.26194.msi" `
- -OutFile "installer\winfsp-2.2.26194.msi"
+ -Uri "https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi" `
+ -OutFile "installer\winfsp-2.2.26215.msi"
shell: pwsh
- name: Install Inno Setup
diff --git a/CLAUDE.md b/CLAUDE.md
index 7d5a29b..d2061c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,193 +1,102 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Build & Run
-
-```powershell
-# Build all projects
-dotnet build
-
-# Run the app (Release avoids debug-build overhead)
-dotnet run --project src/ManagedDrive.App -c Release
-
-# Run tests
-dotnet test tests/ManagedDrive.Tests
-
-# Run a single test class
-dotnet test tests/ManagedDrive.Tests --filter "FullyQualifiedName~FileNodeTests"
-
-# Run the mdrive CLI against an already-running ManagedDrive.exe
-dotnet run --project src/ManagedDrive.Cli -- list
-```
-
-The solution file is `ManagedDrive.slnx` (Visual Studio 2022+ format).
-
-**WinFsp prerequisite:** `winfsp-msil.dll` must be present at `C:\Program Files (x86)\WinFsp\bin\`. Install exactly [WinFsp 2.2.26194 (2026 Beta3)](https://github.com/winfsp/winfsp/releases/tag/v2.2B3) before building or running — download the MSI directly; do not use `winget install WinFsp.WinFsp`, as the winget package lags behind this release.
-
-## Architecture
-
-`Directory.Build.props` sets `Nullable enable` and `ImplicitUsings enable` globally — do not add explicit `using` directives for implicitly imported namespaces, and follow nullable annotation conventions throughout.
-
-`ManagedDrive.App/GlobalUsings.cs` adds project-wide `global using` directives for namespaces referenced across most files: all `ManagedDrive.App.*` sub-namespaces, all `ManagedDrive.Core.*` sub-namespaces (see Core layer below — `Core` itself has no types at its root, only sub-namespaces), `Microsoft.Win32`, `System.ComponentModel`, `System.Diagnostics`, `System.IO`, `System.Windows`, and `System.Windows.Threading`. Don't re-add explicit `using` directives for these in individual files — only add file-specific ones (e.g. `System.Windows.Controls`, `System.Windows.Input`, `System.Text.Json`). `ManagedDrive.Tests` and `ManagedDrive.Benchmarks` mirror this via `` items in their `.csproj` rather than a `GlobalUsings.cs` file.
-
-The solution has nine projects, all inheriting `net10.0-windows` from `Directory.Build.props` (none override the TFM):
-
-- **`ManagedDrive.Core`** — Pure file-system engine, no UI dependency.
-- **`ManagedDrive.App`** — WPF + WinForms (`UseWindowsForms=true` for `System.Windows.Forms.NotifyIcon` tray icon) desktop app. References Core, Cli.Core, and HelperProtocol. The implicit `System.Windows.Forms` using is removed in the csproj to avoid ambiguity with WPF types; use fully qualified names when accessing WinForms types.
-- **`ManagedDrive.Cli.Core`** — Shared CLI parsing/protocol library (System.CommandLine + Spectre.Console), referenced by both `ManagedDrive.App` (server side) and `ManagedDrive.Cli` (client side). See CLI layer below.
-- **`ManagedDrive.Cli`** — `mdrive.exe`, the console-subsystem entry point users invoke from a shell.
-- **`ManagedDrive.HelperProtocol`** — Tiny dependency-free shared library (named-pipe protocol + client) between the app and the SYSTEM helper service. Kept dependency-free so the service doesn't transitively load Core's mixed-mode `winfsp-msil.dll`. See the SYSTEM helper service note under App layer.
-- **`ManagedDrive.WingetExtension`** — `wingetx.exe`, a standalone transparent `winget` wrapper with no dependency on any other project in the solution. See "wingetx: winget wrapper" below.
-- **`ManagedDrive.Service`** — `ManagedDriveHelper.exe`, the optional LocalSystem Windows service (Microsoft.Extensions.Hosting.WindowsServices + `BackgroundService`) that publishes global DOS-device symlinks. References HelperProtocol only.
-- **`ManagedDrive.Tests`** — xUnit v3 unit tests. Only tests pure-managed code (no WinFsp driver needed).
-- **`ManagedDrive.Benchmarks`** — BenchmarkDotNet throughput/latency comparisons against physical disk; part of the `.slnx` solution but not shipped. See Benchmarks below.
-
-### Core layer (`ManagedDrive.Core`)
-
-Types are organized into sub-namespace folders (no types live directly in the bare `ManagedDrive.Core` namespace):
-`FileSystem` (`FileNode`, `FileNodeMap`, `MemoryFileSystem`, `WildcardMatcher`, `DirectoryEnumeration`, `ContentAccessInfo`),
-`Mounting` (`DiskOptions`, `ImageCompressionLevel`, `RamDisk`, `MountManager`, `MountOptionsFactory`),
-`Persistence` (`DiskImageSerializer`, the image-encryption exceptions),
-`Snapshots` (`SnapshotManager`, `SnapshotStore`),
-`Archive` (`ArchiveNodeMapBuilder`, `ArchiveNodeMapWriter` — the export-direction inverse, writing a `FileNodeMap` out to a new zip or 7z file),
-`DiskCreation` (`CreateDiskOptionsBuilder`, `ByteUnitConverter` — pure validation logic for the App layer's create-disk dialog),
-`Diagnostics` (`AppLog` — static logging entry point for Core types that can't take a constructor-injected `ILogger` without breaking public API, namely the static `SnapshotManager` and the static-factory `RamDisk.Create`; returns a no-op logger until `AppLog.Configure` is called).
-Core's own `GlobalUsings.cs` global-uses all six sub-namespaces so files can reference each other without per-file `using` directives; consumers (`App`, `Tests`, `Benchmarks`) do the same at the project level (see above).
-
-Data flows: `MountManager` → `RamDisk.Create()` → `MemoryFileSystem` + `FileSystemHost` (WinFsp).
-
-- `DiskOptions` — immutable `record` carrying mount configuration (mount point, capacity, label, read-only, auto-mount, optional `.mdr` image path, optional `AutoSaveIntervalMinutes`, `CompressionLevel`, optional `SourceArchivePath` for archive-imported disks).
-- `ArchiveNodeMapBuilder` — builds a `FileNodeMap` by extracting an archive (zip, 7z, rar, tar, or any other format `SharpCompress` can read) into memory, synthesizing directory nodes the archive doesn't explicitly list. `BuildNodeMap(archivePath)` does the actual extraction; `PeekArchive(archivePath, out totalBytes, out suggestedLabel)` sums uncompressed entry sizes and derives a label from the file name without extracting content, used to pre-fill capacity/label before the user commits. Archive-sourced disks are always read-only, since none of the supported formats support random read/write access.
-- `ImageCompressionLevel` (in `DiskOptions.cs`) — enum `None`/`Fastest`/`Optimal`/`SmallestSize` with stable explicit values (0-3, persisted to disk/JSON — do not renumber). Defaults to `Fastest`. `DiskImageSerializer` maps it to `System.IO.Compression.CompressionLevel` when writing.
-- `FileNode` — a single file or directory node: `Fsp.Interop.FileInfo` metadata + `byte[]` data buffer + a `LeafName` field (the path's last segment, kept in sync by `FileNodeMap` — avoids re-deriving it via substring on every directory listing).
-- `FileNodeMap` — `SortedDictionary` (case-insensitive) mapping full paths to nodes, guarded by a single `Lock`. `GetTotalAllocated()` is O(1): an incrementally-maintained `_totalAllocated` field is updated inside `Add`/`Remove`/`ClearAll`, and via `UpdateAllocationSize(node, newSize)` — the *only* supported way to change a node's `AllocationSize` outside `Add`/`Remove` (used by `MemoryFileSystem.SetFileSizeCore`/`Overwrite`); do not assign `node.FileInfo.AllocationSize` directly anywhere else, or the cached total will drift. `GetChildren()` does a bounded walk of the sorted map (skip until the directory's key prefix starts, collect while it holds, stop once it no longer matches) rather than copying the whole map — relies on `SortedDictionary`'s `OrdinalIgnoreCase` ordering keeping same-prefix keys contiguous.
-- `MemoryFileSystem : FileSystemBase` — implements all WinFsp callbacks (`Create`, `Open`, `Read`, `Write`, `Rename`, `CanDelete`, `ReadDirectoryEntry`, etc.). Enforces capacity ceiling; returns `STATUS_DISK_FULL` when exceeded. In read-only mode, all mutating operations return `STATUS_MEDIA_WRITE_PROTECTED`. `ReadDirectoryEntry` builds a snapshot list on the first call (including `.` and `..`), then iterates it statelessly on subsequent calls. `Init()` auto-creates the root directory `\` with a standard security descriptor. `FileSystemHost` is configured with 4 KB sector/allocation size, `FileInfoTimeout=1000 ms`, `CasePreservedNames=true`, `CaseSensitiveSearch=false`, and a randomized `VolumeSerialNumber`. Note: `FileNode.AllocationUnit` (the actual byte-alignment granularity for `AllocationSize` rounding) is 512 bytes, not 4 KB — don't conflate the two.
- - **Dirty tracking** — every mutating callback (`Create`, `Write`, `Overwrite`, `SetFileSize`, `SetBasicInfo`, `SetSecurity`, `SetVolumeLabel`, `Rename`, and the delete/timestamp/allocation-size branches of `Cleanup`) calls `MarkDirty()` on success, setting an internal `volatile bool`. `RamDisk.Format()` bypasses these callbacks (calls `_fs.NodeMap.ClearAll()` directly) so it calls `_fs.MarkDirty()` itself. `IsDirty`/`ClearDirty()` are exposed internally for `RamDisk` to gate periodic auto-save (see below). Read-only callbacks never mark dirty.
-- `RamDisk` — wraps `MemoryFileSystem` + `FileSystemHost`. `RamDisk.Create(options)` mounts the volume; `Dispose()` unmounts. After mounting, polls `DriveInfo.GetDrives()` up to 25 × 100 ms (2.5 s total) until the drive letter appears, then broadcasts `SHCNE_DRIVEADD` via `SHChangeNotify` so Explorer refreshes immediately. If a loaded image's actual content (`FileNodeMap.GetTotalAllocated()`) exceeds the capacity that would otherwise apply, `Create` silently raises the effective capacity to fit the existing data instead of failing the mount or leaving the disk permanently over capacity — the pre-adjustment value is recorded in `RamDisk.OriginalCapacityBytesOnLoad` (a one-time mount-time diagnostic, not persisted back to `Options` history or any saved profile). `DiskViewModel` mirrors this via `CapacityAdjustedOnLoad`/`OriginalCapacityBytesOnLoad`, and `DiskNotificationService`'s `Disks.CollectionChanged` handler (the same spot that wires up `SaveFailed`/`HighUsageWarning`) checks it once per newly-added disk and surfaces a tray balloon + status-bar message (`Tray.CapacityAdjustedTitle/Body`, `Status.CapacityAdjusted`) if it fired.
-- **Auto-save** (`RamDisk`) — when `DiskOptions.AutoSaveIntervalMinutes` and `PersistImagePath` are both set, `ConfigureAutoSaveTimer()` (called from `Create()` and `TryApplyOptions()`) starts a `System.Threading.Timer` with `dueTime=TimeSpan.Zero` so the first save fires immediately on a background thread, then repeats every interval. `TryAutoSave()` guards against overlapping saves with a non-blocking `_autoSaveLock.TryEnter()` (a C# 13 `Lock`) — if a save is already running, that tick is skipped rather than queued. The shared skip condition — `!_fs.IsDirty && Options.PersistImagePath == _lastSavedImagePath`, i.e. nothing changed since the last save and the target path hasn't changed either — is centralized in the private `NeedsSave()` helper, checked directly by `TryAutoSave`; manual saves (`SaveToImage()` directly) stay unconditional. `SaveToImage()` clears the dirty flag and records `_lastSavedImagePath` after every successful save, regardless of caller.
-- **Save-on-exit opt-out** — `DiskOptions.SaveImageOnExit` (`bool`, default `true`) gates whether a disk is saved on app exit / OS shutdown, independent of periodic auto-save. The private `NeedsExitSave()` helper (`Options.SaveImageOnExit && NeedsSave()`) layers this on top of the shared skip condition and is what `Dispose()` and `SaveToImageSafe()` actually check (not `NeedsSave()` directly) — a disk with save-on-exit disabled is left untouched on unmount/edit-remount/app-exit/OS shutdown even if dirty, while periodic auto-save saves it as usual. `Dispose()` takes `_autoSaveLock` with a blocking `lock` statement (waits for any in-flight save, then checks `NeedsExitSave()` before writing) before unmounting. `SaveToImageSafe()` wraps `SaveToImage()` with the `NeedsExitSave()` skip plus exception swallowing (logs and returns rather than throwing), for callers that must not fail loudly — see `OnSessionEnding` below.
-- **OS shutdown save path** (`App.xaml.cs` + `Services/SessionEndingSaveHandler`) — `App` subscribes `SessionEndingSaveHandler.OnSessionEnding` to `SystemEvents.SessionEnding` (Windows logoff/shutdown, distinct from the app's own tray-exit path). `OnSessionEnding` fires `disk.SaveToImageSafe()` for every mounted disk concurrently (one `Task.Run` each, not sequential) and blocks with `Task.WaitAll(saveTasks, SessionEndingSaveTimeout)` (10 s) so a single stuck save can't block the OS shutdown callback indefinitely; disks with nothing to save are skipped via `NeedsExitSave()` inside `SaveToImageSafe()` rather than spawning a task at all.
-- `DiskImageSerializer` — reads/writes the `.mdr` binary format (magic `MDRD`, little-endian; version currently `3`). In v3, capacity and volume label are always plaintext header fields; only the node region (node count + node entries, including security descriptors) is gzip-compressed whenever `ImageCompressionLevel != None`, and AES-256-GCM-encrypted on top of that when the image is password-protected. `Save()` creates any missing parent directories before opening the `FileStream`. `Load()` also reads legacy version-1 (no compression) and version-2 (the whole node region compressed, no encryption) images for backward compatibility — do not remove those branches; the "compress everything after the header" behavior is legacy-only, not how v3 works. `PeekHeader()` reads capacity/label/`isEncrypted` without needing a password, since capacity and label stay plaintext in the header even on an encrypted image.
- - **Image encryption** — optional per-disk password protection via envelope encryption: a random 256-bit content-encryption key (CEK, from `DiskImageSerializer.GenerateCek()`) encrypts the node region with AES-256-GCM; the user's password only wraps/unwraps that CEK (PBKDF2-SHA256, 210,000 iterations, 16-byte salt, then AES-256-GCM key-wrap) via an `ImageEncryptionInfo` record struct (`Password` + `Cek`) passed into `Save`/`Load`. Changing the password just re-wraps the CEK — the node data is never re-encrypted. `ImageEncryptionExceptions.cs` defines `ImagePasswordRequiredException` (no password supplied for an encrypted image) and `ImagePasswordIncorrectException` (wrong password / GCM tag mismatch), thrown from `Load`.
-- `MountOptionsFactory` (`Mounting`) — centralizes the CLI headless-mount merge previously duplicated between the image and archive mount paths: precedence order is the header/archive-derived mount point + capacity + label (always win) → explicit CLI `MountOverrides` → a matching saved profile → built-in `DiskOptions` defaults. `BuildImageOptions`/`BuildArchiveOptions` are unit-tested directly in Core.
-- `MountManager` — thread-safe registry of active `RamDisk` instances. `Mount(options)` calls `RamDisk.Create`; `Unmount(mountPoint)` disposes the disk. Fires `DiskMounted` / `DiskUnmounted` events consumed by the App layer to update the UI.
-- `RamDisk.TryApplyOptions(options)` — applies non-destructive changes (label, capacity, auto-mount, image path, auto-save interval, compression level) to a live disk without unmounting; returns `false` if the change requires a full remount (drive letter or read-only flag changed). In `MainViewModel`, both this call and `MountManager.Unmount`/`Dispose` (which may perform a final auto-save write) are dispatched via `Task.Run` so periodic or final saves never block the UI thread.
-- **Disk cloning** — `FileNode.Clone()` deep-copies a node (independent `FileData`/`FileSecurity` buffers) so two disks never share mutable state. `MemoryFileSystem.TryReplaceContents(sourceMap, out error)` clears its own `NodeMap` and repopulates it with clones of every node in `sourceMap`; fails without mutating the target when the target is read-only or `sourceMap.GetTotalAllocated()` exceeds the target's capacity. `RamDisk.TryCloneFrom(source, out error)` exposes this to copy one mounted disk's contents onto another (overwriting the target). `RamDisk.ExportToImage(imagePath, level)` writes the disk's current contents to an arbitrary new `.mdr` file via `DiskImageSerializer.Save` directly — unlike `SaveToImage()`, it is independent of `DiskOptions.PersistImagePath` and does not touch the dirty flag or `_lastSavedImagePath`, since it's a one-off export rather than the disk's own persistence target.
-- **Disk snapshots** (`SnapshotManager` + `SnapshotStore`) — a separate, deliberately code-isolated format from `DiskImageSerializer`, used for timestamped version history alongside a disk's main `.mdr` image. `SnapshotStore` (internal) reads/writes the per-snapshot index file format (magic `MDRS`, version 1): a small binary index listing every `FileNode`'s metadata plus, for non-empty files, a SHA-256 hash pointing into a shared **content-addressed blob store** (`{baseName}.snapblobs/`, sharded into 2-char hex subfolders) — identical file content across snapshots of the same image is stored once. Individual blobs (not the index) are gzip-compressed per `ImageCompressionLevel`. `SnapshotManager` (public) is the entry point: `BuildSnapshotPath`/`WriteSnapshot` name and write snapshots as `{baseName}.{yyyyMMdd-HHmmss}.mdr` next to the main image (colliding timestamps get a `-N` suffix); `ListSnapshots` enumerates them sorted oldest-first, reading only cheap header summaries via `SnapshotStore.ReadSummary` (no blob I/O); `LoadSnapshot` resolves a full `FileNodeMap` for restore; `Prune(mainImagePath, maxCount, maxTotalBytes)` deletes oldest snapshots until both limits are satisfied, then mark-and-sweep garbage-collects any blob no longer referenced by a remaining snapshot (no persistent refcounts); `DeleteSnapshot(mainImagePath, snapshotPath)` deletes exactly one snapshot index file and runs the same GC sweep (throws `ArgumentException` if `snapshotPath` doesn't match the snapshot naming scheme; a missing file is a silent no-op); `DeleteAllSnapshots` removes every snapshot index plus the entire blob directory (called when the main image itself is deleted). `RestoreSnapshotDialog` exposes `DeleteSnapshot` via its own "Delete..." button (next to "View Changes..."), calling `SnapshotManager.DeleteSnapshot` directly and refreshing its list in place — this one doesn't go through `MainViewModel`, since it doesn't touch the live mounted disk and the dialog can only be opened when `IsReadOnly:false` already gates it. `RamDisk.TryWriteSnapshot()` (private) runs at the end of every successful `TryAutoSave()`/periodic save, and also after a manual save via `RamDisk.SaveToImageWithSnapshot()` (used by `MainViewModel.ExecuteSaveImage`), when either `DiskOptions.MaxSnapshotCount` or `MaxSnapshotSizeBytes` is set, under the same `_autoSaveLock`; `RamDisk.TryRestoreFromSnapshot(snapshotPath, out error)` loads a snapshot and calls `TryReplaceContents` on the live filesystem, marking it dirty for the next save rather than writing to `PersistImagePath` immediately. App layer exposes this via `MainViewModel.RestoreSnapshotCommand` and `Views/RestoreSnapshotDialog`; its "View Changes..." button opens `Views/SnapshotDiffDialog`, a read-only summary/grouped-list (added/removed/modified, plus unchanged count) comparing a snapshot against the disk's current live contents before committing to a restore. When a disk is encrypted, each blob file gets a flag byte (bit 0 = compressed, bit 1 = encrypted); encrypted blobs prepend a 12-byte nonce + 16-byte tag and are AES-256-GCM encrypted with the same CEK as the parent disk — `WriteSnapshot`/`LoadSnapshot`/`SnapshotManager.Snapshot` take an optional `cek` parameter for this.
-- **Disk password protection** (`RamDisk`) — `_password`/`_cek` are held in memory only, never persisted. `RamDisk.Create(options, password)` loads/unwraps the CEK on mount, throwing `ImagePasswordRequiredException`/`ImagePasswordIncorrectException` up through `MountManager.Mount(options, password)` if the password is missing or wrong. `IsPasswordProtected` and `CurrentPassword` expose current state (`CurrentPassword` is used to hand the password across a same-session remount, e.g. after a non-destructive `TryApplyOptions` that still needs re-mounting). `SetPassword(string? newPassword)` re-wraps the existing CEK when set/changed; setting to `null` discards the CEK and **deletes all snapshots** for that disk, since old snapshot blobs are unrecoverable without it. `ExportToImage` accepts an independent password (generates its own fresh CEK, decoupled from the source disk's encryption state).
-- **Content access tracking** (`MemoryFileSystem`/`RamDisk`/`MountManager`) — `ContentAccessInfo` (a `Time` + `Path` record) is captured via `Interlocked.Exchange` on every successful `Read`/`Write` in `MemoryFileSystem`, exposed as `LastContentReadAccess`/`LastContentWriteAccess` and mirrored one level up on `RamDisk`; a parallel `internal event Action? ContentAccessed` (bool = isWrite) fires on the same WinFsp driver thread right after each snapshot updates, forwarded verbatim by `RamDisk.ContentAccessed`. `MountManager` aggregates every mounted disk's `ContentAccessed` into its own `ActivityDetected` event, consumed by `Services/TrayIconController.OnActivityDetected` to flash the tray icon. Distinct from the older `LastContentReadTimeUtc`/`LastContentWriteTimeUtc` (`DateTimeOffset?`, write-only-for-writes counterpart still used for the disk card's "last written" display) — the `ContentAccessInfo` pair also carries the touched path and updates on reads, not just writes.
-
-### App layer (`ManagedDrive.App`)
-
-Standard WPF MVVM:
-
-- `App.xaml.cs` — application entry point, now limited to startup/shutdown orchestration and window navigation; tray icon, tray tooltip, disk notifications, TEMP compatibility, session-ending save, and the WinFsp prerequisite check are each delegated to a dedicated `Services/` class (below). `App_Startup` creates `MountManager` + `SettingsStore`, constructs `MainViewModel`, constructs those services in dependency order, auto-mounts profiles with `AutoMount = true`, and saves settings on exit. Enforces single-instance via a named `Mutex` (GUID `Global\ManagedDrive-4A7C2E1B-…`; bypassed when `DOTNET_ENVIRONMENT="Development"`).
- - **Logging** — `ConfigureServices()` (called before `RegisterGlobalExceptionHandlers`, so unhandled exceptions from that point on are captured) builds a Serilog file logger (async sink, `%APPDATA%\ManagedDrive\logs\log-*.txt`, rolls at 20 MB, keeps 5 files) wrapped in a DI `ServiceCollection`/`ILoggerFactory`, then bridges it into Core via `AppLog.Configure` (see `Diagnostics.AppLog` above) so both layers log through the same sink. All user-initiated operations (mount/unmount/format/save/etc.) log through `ILogger` (App) or `AppLog.CreateLogger()` (Core) rather than `Console.WriteLine`.
- - `Services/WinFspPrerequisite` — static `IsInstalled()` check (registry + DLL file version); `App` still owns showing the install-prompt dialog and calling `Shutdown()` when missing.
- - `Services/TrayIconController` — owns the `System.Windows.Forms.NotifyIcon`, its context menu (theme/language-aware), and the three-icon (normal/read/write) activity-flash indicator driven by `MountManager.ActivityDetected`. Exposes `MouseMoved`, `Visible`, and `ShowBalloonTip` for the other tray-related services to consume without reaching into WinForms types directly.
- - `Services/TrayTooltipController` — owns the hover popup (`TrayTooltipView`) shown near the tray icon: cursor-tracking timers, show/hide/cooldown state, and popup positioning. Depends only on `TrayIconController.MouseMoved` for the cursor's screen position.
- - `Services/DiskNotificationService` — wires each `DiskViewModel`'s `HighUsageWarning`/`SaveFailed`/`ActivityObserved` events (plus one-time `CapacityAdjustedOnLoad` handling) to tray balloon tips and `MainViewModel.StatusText`, as disks are added to/removed from `MainViewModel.Disks`.
- - `Services/TempDirCompatChecker` — the one-time startup TEMP/TMP compatibility check (resets TEMP if it points to a non-auto-mount RAM disk drive, warns once for an auto-mount one) and the tray "Reset TEMP Dirs" action; `IsTempOnAnyDisk` is a public static helper reused by `App.ExitApplication`.
- - `Services/SessionEndingSaveHandler` — subscribes to `SystemEvents.SessionEnding` (see below); takes a `Func` for the main window handle rather than a fixed value, since the handler is constructed before that handle is captured.
- - `Services/UpdateCheckService` — checks the GitHub Releases API for a newer formal (non-prerelease/non-draft) release than the running version, gated by `AppConfiguration.AutoCheckForUpdates` and a once-per-day throttle (`LastUpdateCheckUtc`). `CheckOnStartupAsync` runs fire-and-forget at app startup and, on a hit, shows a tray balloon plus `Views/UpdateAvailableDialog` if the main window is visible; `CheckSilentlyAsync` is called whenever `AboutDialog` opens, bypassing the throttle and any previously-skipped version so the dialog always renders a fresh inline result instead of a popup. `AppConfiguration.SkippedVersion` persists a user's "Skip this version" choice from the dialog.
- Both `App` and each of these services hold a reference to `TrayIconController` where they need balloon tips or visibility control — it, `TrayTooltipController`, and `DiskNotificationService` are stored as `App` fields purely to keep them rooted (their event subscriptions are the only thing that would otherwise keep them alive).
-- `MainViewModel` — owns `ObservableCollection` sorted by mount point. Commands: `CreateDiskCommand` (opens `CreateDiskDialog`), `EditDiskCommand` (edit label/capacity/flags; non-destructive changes apply live via `RamDisk.TryApplyOptions()`; mount-point or read-only changes trigger full remount — during a full remount the existing `DiskViewModel` stays in `Disks` with `IsRemounting=true` until the new mount succeeds, rather than being removed up front, so the card doesn't disappear if the remount fails), `UnmountCommand` (auto-resets TEMP if it points to the disk before unmounting), `FormatCommand` (deletes all files on the disk; blocked when read-only), `SaveImageCommand` (`ExecuteSaveImage` is `async void` and runs `RamDisk.SaveToImage()` via `Task.Run` — it must stay off the UI thread since serialization + gzip compression of a large disk is CPU/IO-bound), `RefreshCommand`, `SettingsCommand`, `ResetTempDirsCommand` (resets TEMP/TMP to Windows defaults), `ToggleTempDirCommand` (toggles TEMP/TMP between the selected disk's `Temp` folder and the Windows default). Mount operations, `MountManager.Unmount`, and `MountManager.Dispose` (in `App.xaml.cs`) are dispatched to `Task.Run` to keep the UI responsive, since unmounting may perform a synchronous final auto-save write. `GetOtherDiskOptions(excluding)` returns the `DiskOptions` of every other active disk, passed into `CreateDiskDialog` so it can validate that a new/edited disk's image path doesn't collide with another disk's mount point or image file. `MountWithPasswordRetryAsync` (private) wraps a mount attempt in a retry loop: on `ImagePasswordRequiredException`/`ImagePasswordIncorrectException` it shows `PasswordPromptDialog` and retries with the entered password, used by both fresh mounts and profile-based (auto-)mounts; `App.AutoMountDisksAsync` mounts `AutoMount=true` profiles sequentially (`foreach`/`await`, not `Task.WhenAll`) specifically so these password prompts appear one at a time instead of overlapping.
-- `DiskViewModel` — wraps a `RamDisk`; exposes bindable properties (mount point, used/free/total bytes, `IsCurrentTempDir`, `IsReadOnly`/`IsNotReadOnly`, `PersistImagePath`, `IsHighUsage`, `IsPasswordProtected`, `IsRemounting`). A `DispatcherTimer` refreshes usage stats every 2 s automatically; `Refresh()` triggers a manual refresh. High-usage warning is a per-disk setting (`DiskOptions.HighUsageWarnPercent`, `double?`; `null` disables it entirely for that disk, default `90.0`) rather than a global one — `Refresh()` reads it directly off `Disk.Options` on every tick, clearing `IsHighUsage` immediately if the setting is `null`. When set, `IsHighUsage` is raised once usage reaches the threshold and cleared again once usage drops below `threshold - 5` (a fixed hysteresis gap, `HighUsageResetGap`), firing the `HighUsageWarning` event only on the rising edge to avoid rapid flip-flopping. `IsCurrentTempDir` compares `[MountPoint]\Temp` against the user-level TEMP variable (case-insensitive).
- - **Status-bar activity push** — `ActivityObserved` (`EventHandler`) is driven directly off `RamDisk.ContentAccessed` rather than polled, with `OnContentAccessed` dispatching to the UI thread via `Application.Current.Dispatcher.BeginInvoke` (the source event fires on WinFsp driver threads). `ReportActivity` throttles to at most one `ActivityObserved` raise per `ActivityThrottleWindow` (300 ms) using a leading + trailing pattern: the first access in a burst is reported immediately (starting `_activityThrottleTimer`), further accesses within the window only update `_pendingActivity` (writes take priority over a pending read), and the timer's `Tick` flushes whatever is pending, if anything, when the window elapses. `SetActivityTrackingEnabled(bool)` gates the underlying `Disk.ContentAccessed` subscription itself — disabling it also stops the throttle timer and clears `_pendingActivity` so a stale pre-disable snapshot can't surface once re-enabled. `App.xaml.cs` is what actually calls this: it subscribes to `_mainWindow.IsVisibleChanged` and toggles tracking for every `DiskViewModel` in `_mainViewModel.Disks` to match visibility (nothing is bound to the status bar while the window is hidden in the tray), and `DiskNotificationService`'s `Disks.CollectionChanged` handler applies the current visibility to newly-added disks immediately rather than waiting for the next visibility change. `DiskNotificationService`'s activity handler forwards each raised event to `MainViewModel.ShowDiskActivityStatus`, which sets `StatusText` and restarts a separate `_diskActivityStatusTimer` (2.5 s) that reverts it to `Status.Ready` once things go quiet — that timer debounces when the message *clears*, distinct from the 300 ms throttle above which caps how often it's *set*.
-- `Services/SystemMemoryInfo` — P/Invokes `GlobalMemoryStatusEx` to read true system-wide available physical RAM (unlike `GC.GetGCMemoryInfo()`, which reflects the .NET GC's own limit, not what Task Manager reports). `MainViewModel` polls it on a 2 s `DispatcherTimer` into `AvailableMemoryFormatted`, shown in the status bar next to `StatusText`.
-- `MainWindow` — uses `WindowStyle="None"` + `WindowChrome` (custom app bar as title bar). Closing the window hides it to the tray; exit is only via the tray menu or the toolbar overflow menu. Both exit paths converge on `App.ShutdownAsync()`, which sets `MainViewModel.IsExiting = true` (showing a full-window saving overlay with spinner), then `await Task.Run(() => _mountManager?.Dispose())` so the UI thread stays responsive during final auto-save writes. `App_Exit` remains as a synchronous safety net. Any interactive element inside the `WindowChrome` caption area must have `WindowChrome.IsHitTestVisibleInChrome="True"`. The overflow menu uses a `Button` + `ContextMenu` pattern (opened in code-behind). A `TrayTooltipView` popup appears on tray icon hover and auto-hides after 2 s. The disk card `DataTemplate` overlays read-only/current-temp-dir/password-protected status as small corner icons on the drive-letter badge (bound to `IsReadOnly`/`IsCurrentTempDir`/`IsPasswordProtected`), shows a percentage next to the usage `ProgressBar` and switches its `Foreground` (plus the free-space text) to `AppWarning` via a `DataTrigger` on `IsHighUsage`, collapses the capacity/free-space block into a single "read-only · image path" line for read-only disks (`IsReadOnly`/`IsNotReadOnly` toggle which block is visible), and shows a spinner overlay while `IsRemounting` is true (set by `MainViewModel.ExecuteEditDisk` during a full remount).
-- `SettingsStore` — persists `AppConfiguration` (JSON) to `%APPDATA%\ManagedDrive\settings.json`. `AppConfiguration` holds `RunAtStartup`, `StartMinimized`, `Language` (BCP-47 tag or `null` for system default), `Theme` (`"light"`/`"dark"`/`null` for system default), `TempDirCompatWarningShown` (one-time startup warning flag), and the list of `DiskProfile` records. `DiskProfile` is the serializable counterpart of `DiskOptions`.
-- `RelayCommand` — thin `ICommand` wrapper in `Infrastructure/`; constructor takes `execute` action and optional `canExecute` predicate. Used for all ViewModel commands.
-- `StartupManager` — reads/writes the `HKCU\...\Run` registry key to control Windows startup.
-- `TempDirResetService` — static helper that reads/writes `HKCU\Environment` (TEMP and TMP) and broadcasts `WM_SETTINGCHANGE` so running processes pick up the change immediately. `Set(path)` writes `String` values (absolute paths); `Reset()` writes `ExpandString` values (unexpanded `%USERPROFILE%\AppData\Local\Temp`) for portability. Uses `SendMessageTimeoutAbortIfHung` with a 5 s timeout.
-- **Known TEMP limitation:** WinFsp mounts into a session-specific namespace (`\Sessions\{id}\...`), so processes in another session/logon can't resolve the drive letter. Two distinct failure modes: **(1) cross-session drive-letter visibility** — installers whose helper runs in another session fail with `0x800704b3` (WeChat, 7-Zip, Git for Windows via winget); this class is fixed by the optional SYSTEM helper service (see below), which publishes a `\GLOBAL??` symlink for the current-TEMP disk. **(2) MSI installers** — `msiexec`'s SYSTEM server (session 0) does a Mount-Manager volume-identity query on the source volume before reading it; WinFsp's per-session mount isn't Mount-Manager-registered, so it fails with system error `1005` → MSI `2755`/`1603`. **The helper service does NOT fix mode 2** — a global drive-letter symlink to WinFsp's device object doesn't make the volume a Mount-Manager-registered system volume; that would require mount-manager-based mounting (a larger service-hosted rearchitecture). This was verified end-to-end via `experiments/DosDeviceExperiment/` and a full `msiexec /l*v` trace. The app shows a one-time warning on startup when TEMP points to a non-auto-mount RAM disk.
-
-- **SYSTEM helper service (cross-session visibility)** — an optional, separately-installed LocalSystem Windows service (`ManagedDriveHelper.exe`, project `ManagedDrive.Service`) that publishes/removes global (`\GLOBAL??`) DOS-device symlinks so a RAM disk is reachable across sessions (addresses TEMP failure mode 1 above only). The user-mode app talks to it over a dedicated ACL'd named pipe (`ManagedDrive.HelperProtocol`: `HelperPipeProtocol`/`HelperPipeClient`, line-delimited JSON mirroring the CLI pipe). Publish/unpublish is triggered automatically off `DiskViewModel.IsCurrentTempDir` transitions by `App/Services/GlobalMountCoordinator` — no user-facing toggle. The app resolves the NT device path via `RamDisk.TryGetVolumeDevicePath` (`QueryDosDevice`, must run in the user's session) and hands it to the service, which does the privileged `DefineDosDevice` and persists a `letter → devicePath` map under `HKLM\SOFTWARE\ManagedDrive\GlobalMounts` for startup + 60 s-periodic reconciliation (removes symlinks whose backing device is gone). All calls are best-effort: if the service isn't installed/running the disk still mounts, just without cross-session visibility. **Wired into the installer** (`installer/ManagedDrive.iss`, `[Code]`): `sc create`/`sc start` on install (or `sc stop`/`sc start` to reload the binary on upgrade), `sc stop`/`sc delete` in `CurUninstallStepChanged` on uninstall — all best-effort, only `Log`s on failure, never aborts setup. Separately (and unconditionally, unlike the best-effort service teardown), the shared `IsTempOnManagedDriveMountPoint()` helper checks whether `HKCU\Environment\TEMP` currently points at a saved ManagedDrive RAM-disk mount point (read from `settings.json` via substring search, since neither Setup nor the uninstaller can assume the app is running) — `InitializeUninstall` aborts the uninstall if so (otherwise uninstalling would leave TEMP pointing at a drive letter that no longer exists), and `InitializeSetup` aborts install/upgrade the same way (since Setup's own temp-file use — WinFsp MSI extraction, the .NET runtime downloader — would break the same way if the app gets closed/the disk unmounted mid-setup while TEMP still points at it). Both show the same bilingual (EN/中文) message directing the user to reset TEMP first (Tray menu > Reset TEMP Dirs) and re-run. `.github/workflows/ci.yml`'s release job publishes `ManagedDrive.Service` into `publish-fx/` alongside App/Cli so the exe ships in both the installer and the portable ZIP. The **portable ZIP does not auto-install the service** (no `[Code]` logic runs for it) — README documents the manual `sc create ... start= auto` / `sc start` (and `sc stop`/`sc delete` to remove) steps for that case. Detailed design/verification steps live in the plan at `~/.claude/plans/`.
-- **Dialogs** (`Views/`) — `CreateDiskDialog` collects drive letter, capacity, label, read-only, auto-mount, optional high-usage-warning threshold, optional image path, compression level, and optional auto-save/snapshot-retention settings, laid out in a 3-tab `TabControl` (`AppTabControl`/`AppTabItem` styles in `AppTheme.xaml`): Basic Info (drive letter, capacity, volume label, high-usage warning), Access (auto-mount, read-only), and Persistence (image path, compression, auto-save, snapshot limits). `MainTabControl` has a fixed `MinHeight` (empirically set to the tallest tab's content, Persistence) so the window (`SizeToContent="Height"`) doesn't resize/jump when switching tabs; re-measure and bump this value if a tab's content grows. Capacity maximum is derived from `GC.GetGCMemoryInfo().TotalAvailableMemoryBytes` at open time; switching MB/GB units recalculates the maximum. `ImagePathBox` is `IsReadOnly` — clicking it (via `PreviewMouseLeftButtonDown`) always opens a `SaveFileDialog`; the path cannot be typed directly. Checking "Read Only" force-unchecks and disables auto-save and the compression-level `ComboBox` (a read-only disk's contents never change, so there's nothing to save or compress); `TryBuildOptions()` additionally requires a read-only disk to reference an image path that already exists on disk (`File.Exists`) — an empty read-only disk with no backing image is meaningless. Compression level is a plain `ComboBox` (`CompressionLevelBox`) populated in code-behind with `CompressionLevelItem` wrapper records (one per `ImageCompressionLevel` value, `ToString()` returns the localized label), defaulting to `Fastest`. The auto-save interval (1–60 minutes, default 10) uses the same textbox + up/down `RepeatButton` pattern as capacity. `TryBuildOptions()` validates: image path is rooted with an existing parent directory (`IsValidImagePath`), doesn't fall under any active disk's mount point, and isn't already used as another disk's image path (checked against the `otherDisks` list passed into the constructor) — this last check applies regardless of read-only state, so a read-only disk's image file still can't be one another disk is actively persisting to. The dialog has an edit-mode constructor overload that pre-populates all fields (the in-use drive letter is excluded from the unavailable-drives check), and a third **import mode** (`_isImportMode`, entered via `MainViewModel.ImportDiskCommand`) that first prompts with an `OpenFileDialog` for an existing `.mdr` image, reads its capacity/label directly from the file (shown read-only, not re-entered), and shows an `ImportNoteText` banner; invalid or already-in-use image files are rejected up front with `Val.ImportInvalidImage`. A fourth **archive import mode** (`_isArchiveImportMode`, layered on top of `_isImportMode`; entered via `MainViewModel.ImportArchiveCommand`) works the same way but sources capacity/label from `ArchiveNodeMapBuilder.PeekArchive` instead of an `.mdr` header, shows `ArchiveImportNoteText`, and always forces read-only (`TryBuildArchiveImportOptions` sets `SourceArchivePath`); invalid or already-in-use archive paths are rejected with `Val.ImportInvalidArchive`/`Val.ArchivePathInUse`. Editing an existing archive-sourced disk (`Options.SourceArchivePath != null`) re-enters this same archive-import mode rather than the normal edit path, since the disk's content is not independently editable. An "Encrypt Image" checkbox + password/confirm `PasswordBox` pair (disabled for read-only/import/no-image-path cases) shows a live strength hint from `Infrastructure/PasswordStrengthEstimator.Estimate()` (Weak/Medium/Strong, derived from length + character-class variety — a pure UI hint, independent of `CreateDiskOptionsBuilder`'s actual length-based validation) and exposes `Password` and a tri-state `PasswordChanged` (unchanged / set-or-changed / removed) — in edit mode, blank password fields mean "keep unchanged", while unchecking on a previously-encrypted disk means "remove protection" (confirmed via `ConfirmDialog` in `MainViewModel`, since it deletes existing snapshots). `PasswordPromptDialog` (`Views/PasswordPromptDialog.xaml(.cs)`) is the shared password-entry dialog reused for auto-mount at startup, manual mount/import failures (catching `ImagePasswordRequiredException`/`ImagePasswordIncorrectException`), and Import Disk when the source image is detected encrypted via `DiskImageSerializer.PeekHeader`. `CloneDiskDialog` (opened via `MainViewModel.CloneDiskCommand`) offers two mutually exclusive `RadioButton` modes: clone onto another mounted, writable disk (picked from a `ComboBox` populated with `DiskViewModel`s passed into the constructor) or export to a new `.mdr` file (via the same `SaveFileDialog` + `CompressionLevelBox` pattern as `CreateDiskDialog`); `OK_Click` sets exactly one of `TargetDisk`/`ExportPath` depending on the selected mode. `MainViewModel.ExecuteCloneDisk` then calls `RamDisk.TryCloneFrom` (after a destructive-overwrite confirmation via `ConfirmDialog`) or `RamDisk.ExportToImage` on a background thread. `DiskContentDialog` (opened via `MainViewModel.ViewDiskContentsCommand`, read-only) shows a mounted disk's files/directories as a "poor man's TreeListView": a flat `ListView`/`GridView` (Name/Size/Type columns) whose rows are a flattened, expand/collapse-driven projection of a directory tree, built in code-behind from `RamDisk.GetAllNodes()`. Only the Name cell's content indents per nesting depth (via `DepthToIndentConverter`), so the Size/Type columns stay aligned across all levels — a real `TreeView` would indent the whole row and misalign them. Directory sizes are the summed sizes of their descendant files (`PropagateSizes`); clicking a column header sorts every tree level in place. Size values use `ByteFormatter.Format` (from `ManagedDrive.Cli.Core`). Note the GridView cell quirk documented in-file: `GridViewRowPresenter` sizes each cell to its content's desired width (left-aligned), so right-aligning the Size column requires binding a wrapper `Border`'s `Width` to `SizeColumn.ActualWidth` — a plain `TextAlignment="Right"` has nothing to align within. `SettingsDialog` handles language and startup preferences. `ConfirmDialog` is a generic title + body confirmation. `AboutDialog` shows the app version (trimmed at `+` to strip the git hash suffix), GitHub/WinFsp/SharpCompress hyperlinks, and an inline update-check result via `UpdateCheckService.CheckSilentlyAsync`. `UpdateAvailableDialog` (shown by `UpdateCheckService` on a startup hit) displays the new version and release link with Skip/Remind Later/View Release actions. All dialogs are shown with `ShowDialog()` from their respective ViewModel commands.
-
-### CLI layer (`ManagedDrive.Cli.Core` + `ManagedDrive.Cli` + `App/Cli`)
-
-`mdrive` is a thin client that forwards commands to the running `ManagedDrive.exe` tray instance rather than duplicating its logic:
-
-- `ManagedDrive.Cli.Core.CliCommandProcessor` — parses subcommands (`mount`, `mount-archive`, `unmount`, `format`, `save`, `list`, `exit`) with `System.CommandLine` and renders output via `Spectre.Console` into an in-memory buffer (not the real console), so the same rendered text can be shipped back over the named pipe or printed directly on a fresh launch. Executes against the `ICliDiskController` abstraction rather than any concrete disk type. `mount-archive`'s `drive-letter` argument is optional (`ArgumentArity.ZeroOrOne`); when omitted, `MainViewModel.FindFreeDriveLetter()` searches from `Z:` down to `D:` for the first free letter, returning a `Val.NoFreeDriveLetter` failure if none is free — this lets a caller that can't prompt for a drive letter (the Explorer right-click context menu below) invoke `mdrive mount-archive "%1"` with no drive letter at all.
-- `ICliDiskController` — the seam between `Cli.Core` and the app layer, avoiding a circular reference (the app layer hosts the pipe server that depends on `Cli.Core`, so `Cli.Core` cannot depend back on the app layer). `ManagedDrive.App.Cli.MainViewModelCliDiskController` implements it against `MainViewModel`.
-- `CliPipeProtocol` / `CliPipeClient` — shared named-pipe wire format and client-side send logic.
-- `ManagedDrive.App.Cli.CliPipeServer` — hosted from `App.xaml.cs`, accepts one pipe connection at a time on a background task and marshals each request onto the WPF `Dispatcher` before calling `CliCommandProcessor.ExecuteAsync`, since disk commands mutate `MainViewModel`'s WPF-bound `Disks` collection.
-- `ManagedDrive.Cli.Program` — the `mdrive.exe` entry point. First tries `CliPipeClient.TrySend`; if no server is listening, launches `ManagedDrive.exe` and polls (200 ms interval, 10 s timeout) until the pipe accepts a connection. Being a real console-subsystem exe (unlike the `WinExe` `ManagedDrive.exe`), the invoking shell naturally blocks until it exits.
-- `CliMountOverrides` — per-field CLI flag values for `mount`; any field left unset (`null`) defers to a saved profile for that image path if one exists, else the built-in default — never to a hardcoded value. `mount` also accepts `--password` and `--password-file` (mutually exclusive; `--password-file` reads the first line of a file and is recommended over `--password` to avoid exposing the password via shell history or the process list) for images encrypted per the Core layer's image-encryption feature above.
-- `ManagedDrive.App.Services.ShellContextMenuManager` — registers/unregisters a Windows Explorer right-click context menu entry ("Import as RAM disk") for `.zip`/`.7z`/`.rar`/`.tar` files, toggled by a checkbox in `SettingsDialog` (persisted as `AppConfiguration.ContextMenuEnabled`, default off). Writes under `HKCU\Software\Classes\SystemFileAssociations\{ext}\shell\ManagedDriveImportArchive\command` (per-user, no elevation needed, mirroring `StartupManager`'s HKCU Run-key pattern rather than a machine-wide HKLM/HKCR registration) with a command string of `"\mdrive.exe" mount-archive "%1"` — no drive letter, since Explorer has no way to prompt for one; `mdrive.exe`'s existing launch-then-retry logic (see above) transparently starts `ManagedDrive.exe` first if it isn't already running.
-
-### wingetx: winget wrapper (`ManagedDrive.WingetExtension`)
-
-`wingetx.exe` is a standalone transparent wrapper around `winget.exe`, addressing the "MSI installers" and cross-session exe-installer failure modes documented under the TEMP limitation note above (the SYSTEM helper service does not fix either). It has no dependency on Core/App/Cli — it only needs to detect whether `%TEMP%` is on a WinFsp volume and shell out to `winget`/`msiexec`/the installer directly.
-
-- `Program.cs` — entry point. If the subcommand is `install`/`upgrade` **and** `%TEMP%` is currently on a WinFsp volume (`WinFspVolumeDetector.IsCurrentTempOnWinFspVolume()`), it tries `SilentInstaller.TryInstall`; otherwise (or if that returns "unhandled"), it forwards the arguments to `winget.exe` unchanged via `ProcessForwarder.Run`. This makes `wingetx` a safe drop-in alias for `winget` in all other cases (`list`, `search`, `show`, non-WinFsp TEMP, etc.).
-- `WinFspVolumeDetector` — resolves `%TEMP%`'s drive letter via `QueryDosDevice` (P/Invoke) and regex-matches the WinFsp device-path pattern (`\Device\Volume{GUID}`) vs. an ordinary partition's `\Device\HarddiskVolumeN`.
-- `SilentInstaller.TryInstall(args, useFullSilent, out exitCode)` — runs `winget download` into a fresh directory under `%LOCALAPPDATA%\Temp\wingetx` (a real, non-WinFsp volume, chosen over `%WINDIR%\Temp` to stay per-user), reads the downloaded manifest YAML via `WingetManifestReader`, and launches the installer directly (`msiexec /i` for MSI/WiX, the exe itself for exe/Inno/Nullsoft/Burn) with silent or silent-with-progress switches depending on `useFullSilent` (mirrors winget's own `--silent`/`--disable-interactivity` semantics). Returns `false` (unhandled, caller should fall back to plain `winget install`) for installer types it doesn't recognize (msix, appx, zip, portable, ...). Always cleans up the download directory in a `finally`.
-- `WingetManifestReader` — deserializes the manifest YAML with YamlDotNet into untyped `Dictionary