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`/`List`/`string` (not typed POCOs — typed deserialization needs reflection-based `Activator.CreateInstance`, which a trimmed publish could break; BCL collection types are trimmer-safe regardless). Falls back to winget's documented built-in default switches per installer type (`DefaultSilentSwitches`/`DefaultSilentWithProgressSwitches`) when the manifest doesn't specify `InstallerSwitches.Silent`/`SilentWithProgress`. -- `ProcessForwarder.Run` — thin `Process.Start` + `WaitForExit` wrapper with inherited stdio, used both for the final `winget`/`msiexec`/installer launch and for `winget download` itself. - -### Localization - -Strings live in `Localization/Strings.{tag}.xaml` resource dictionaries. `LanguageManager` swaps the active dictionary at runtime by removing the old one and adding the new one to `Application.Current.Resources.MergedDictionaries`. - -- `Loc.Get(key)` / `Loc.Format(key, args)` — retrieve strings from the current resource dictionary. -- `LanguageManager.Instance.Apply(string? saved)` — `null` or empty means "system default"; the method resolves to a concrete tag via `LanguageManager.Resolve()` (matches system locale against `SupportedLanguages`, falls back to `"en-US"`). -- `LanguageManager.Instance.SavedLanguage` — the raw persisted choice (`null` = system default). `CurrentLanguage` is always the resolved concrete tag. Always persist `SavedLanguage`, not `CurrentLanguage`. -- XAML strings use `{DynamicResource Key}` bindings so that a runtime language switch propagates without restart. -- Custom styles are defined in `Themes/AppTheme.xaml`; the app supports light and dark palettes (see Theming below). Icons use the **Segoe Fluent Icons** font (built into Windows 10/11). There is no third-party UI framework — all controls are native WPF with custom styles. -- `Helpers/HintHelper.cs` provides a `Hint.Text` attached property for watermark/placeholder text in TextBox and ComboBox controls. - -**Adding a new language:** create `Localization/Strings.{tag}.xaml` (copy an existing one), add the BCP-47 tag to `LanguageManager.SupportedLanguages`, and add the tag to `` in `Directory.Build.props`. - -### Theming - -`Themes/AppTheme.xaml` defines structural styles/templates that reference color keys by `{DynamicResource}`; the actual colors live in separate palette dictionaries swapped at runtime: - -- `ThemeManager.Instance` — analogous to `LanguageManager`. `Apply(saved)` takes `"light"`, `"dark"`, or `null` (system default) and merges `Themes/AppTheme.Colors.{Light,Dark}.xaml` into `Application.Current.Resources.MergedDictionaries`, removing the previous palette dictionary first. `SavedTheme` is the raw persisted choice (`null` = system default); `CurrentTheme` is always the resolved concrete value. Always persist `SavedTheme`, not `CurrentTheme`, mirroring the localization convention. -- System-default resolution reads `HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme` and falls back to light on any failure. When `SavedTheme` is `null`, `ThemeManager` subscribes to `SystemEvents.UserPreferenceChanged` (`UserPreferenceCategory.General`) to live-follow OS theme switches. -- `TrayColorTable` supplies theme-aware colors for tray-menu/tooltip rendering that can't use `DynamicResource` bindings (e.g. WinForms `ContextMenuStrip`). - -### Tests (`ManagedDrive.Tests`) - -Tests are synchronous xUnit v3 unit tests for pure-managed code (no WinFsp driver required). Mount/unmount integration tests requiring the actual WinFsp driver are not part of this suite and must be run manually. `FileNodeTests` covers `FileNode` metadata; `FileNodeMapTests` covers all map operations; `MemoryFileSystemCloneTests` covers `TryReplaceContents` (cross-disk cloning, target-too-small/read-only rejection, clone independence); `DiskImageSerializerTests` round-trips `Save`/`Load` across every `ImageCompressionLevel` and covers loading a hand-written legacy version-1 (uncompressed) image; `RamDiskCapacityTests` covers the auto-raise-capacity-on-load behavior (`OriginalCapacityBytesOnLoad`); `SnapshotManagerTests` covers snapshot writing, listing, pruning (count/size limits), single-snapshot deletion, blob deduplication/GC, and restore; `ArchiveNodeMapBuilderTests`/`ArchiveNodeMapWriterTests` cover archive import/export (extraction into a `FileNodeMap`, `PeekArchive`'s capacity/label derivation, and writing back to zip/7z); `WildcardMatchTests` covers the wildcard glob matcher used by directory listing; `MountOptionsFactoryTests` covers the CLI headless-mount merge precedence; `CreateDiskOptionsBuilderTests`/`ByteUnitConverterTests` cover the create-disk dialog's validation logic; `PasswordStrengthEstimatorTests` covers the Weak/Medium/Strong thresholds; `DirectoryEnumerationTests` covers directory listing. Most of these use local `MakeDir()`/`MakeFile()` helpers to construct test nodes with appropriate `FileAttributes`. - -### Threading model - -- `FileNodeMap`, `MountManager`, and `RamDisk` (its `_autoSaveLock`) use the C# 13 `Lock` type (not the older `lock` statement) for thread safety. -- WinFsp callbacks in `MemoryFileSystem` are invoked on WinFsp driver threads; all state access goes through `FileNodeMap`'s `Lock`. -- `RamDisk`'s auto-save timer fires on a `System.Threading.Timer` threadpool thread; the periodic path uses `Lock.TryEnter()` (skip if a save is already running) while `Dispose()`'s final save uses a blocking `lock` (wait for any in-flight save, then save once more) — see the Core layer section above. - -### Disk image format (`.mdr`) - -Little-endian binary: magic `MDRD` (`byte[4]`) → version (`int32`, currently `3`) → `CompressionLevel` (`byte`, `ImageCompressionLevel` value; when not `None`, everything below is gzip-compressed) → capacity (`uint64`) → volume label (length-prefixed UTF-8 string) → node count (`int32`) → node entries (path, metadata, security descriptor, file data). When encrypted, the wrapped-CEK material sits alongside the header (plaintext) and the node region is AES-256-GCM encrypted under the CEK — see the Image encryption bullet above. Snapshots (`SnapshotStore`) use an unrelated format (magic `MDRS`) — don't conflate the two when touching serialization code. - -### Central package management - -Package versions are pinned in `Directory.Packages.props` (Central Package Management). Do not add `Version=` attributes to `` elements in individual `.csproj` files — add versions only to `Directory.Packages.props`. - -### Versioning - -`MinVer` derives the assembly version from git tags (`v`-prefixed, e.g. `v0.1.0`). The test project sets `true` to avoid version errors without a tag. - -### Output layout - -`true` in `Directory.Build.props` routes all build outputs to the `artifacts/` directory (SDK-style artifacts layout) rather than `bin/` and `obj/` per project. - -### Benchmarks - -`ManagedDrive.Benchmarks` (separate project, not in the solution) uses BenchmarkDotNet to compare RamDisk vs physical disk. `DriveLetterHelper.FindFreeMountPoint()` auto-selects the first free drive letter between `D:` and `Z:` (no fixed letter required). Three benchmark classes, all using `[SimpleJob(warmupCount: 2, iterationCount: 3)]` to keep total run time low: -- `SequentialReadWriteBenchmarks` — sequential read/write at 4 KB and 1 MB. -- `RandomAccessBenchmarks` — random-seek reads and small-file high-frequency writes. -- `ConcurrentAccessBenchmarks` — multi-threaded reads/writes to disjoint files, measuring `FileNodeMap` lock contention. - -Run in Release mode: `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` (prompts to pick which class(es) to run), or pass `--filter '*ClassName*'` to run one non-interactively. - -### Release pipeline - -`.github/workflows/ci.yml` builds and runs tests on every push. Pushing a `v*` tag additionally publishes a framework-dependent Windows build (`-r win-x64 --no-self-contained`) of App, Cli, and WingetExtension, and creates a GitHub Release with two artifacts attached: `ManagedDrive-{tag}-win-x64-portable.zip` (contains `ManagedDrive.exe`, `mdrive.exe`, and `wingetx.exe`; requires the .NET 10 Desktop Runtime on the target machine) and `ManagedDrive-Setup-{version}.exe` (an Inno Setup installer built from `installer/ManagedDrive.iss`). There is no self-contained ZIP variant — the installer is the recommended no-manual-prerequisites path instead. The installer adds its install directory to the machine-wide `PATH` (HKLM) so `mdrive`/`wingetx` resolve from any shell; the portable ZIP does not, so the README documents adding the extraction folder to `PATH` manually. - -**Installer** (`installer/ManagedDrive.iss`) — packages the framework-dependent publish output (copied into `installer/publish-fx/` by the CI release job) plus a downloaded copy of the WinFsp MSI (`installer/winfsp-2.2.26194.msi`, fetched from the [WinFsp v2.2B3 release](https://github.com/winfsp/winfsp/releases/tag/v2.2B3) — not committed to git; `*.msi` is gitignored). Its `[Code]` section mirrors `Services/WinFspPrerequisite.IsInstalled()`'s detection logic exactly (same `HKLM\SOFTWARE\WOW6432Node\WinFsp`/`HKLM\SOFTWARE\WinFsp` registry lookup + `winfsp-msil.dll` file-version check) to decide whether to silently run the bundled MSI via `msiexec /qn`; it separately checks `HKLM\SOFTWARE\WOW6432Node\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App` for a `10.*` subkey and, if absent, silently downloads the current win-x64 Desktop Runtime installer via Microsoft's evergreen redirect (`https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe`, since the exact patch build isn't ours to pin/redistribute) and runs it with `/install /quiet /norestart` — falling back to a message box offering to open the official .NET download page only if that download or silent install itself fails. `iscc.exe` is invoked with `/DAppVersion=`, reusing the same tag-derived version as the ZIPs rather than invoking MinVer separately. Local build/test: `dotnet publish` the App and CLI projects (`-r win-x64 --no-self-contained`) into `installer/publish-fx/`, then run `iscc.exe /DAppVersion=0.0.0-test installer/ManagedDrive.iss` — output lands in `installer/Output/`. - -`ManagedDrive.App.csproj` sets `true`. The `winfsp.net` package's managed assembly (`winfsp-msil.dll`) is mixed-mode (C++/CLI) and cannot be loaded from the in-memory single-file bundle — a `Target Name="ExcludeWinFspMsilFromSingleFile" BeforeTargets="_ComputeFilesToBundle"` marks it `ExcludeFromSingleFile`, so the publish output is one `ManagedDrive.exe` plus `winfsp-msil.dll` sitting alongside it (not embedded). Do not remove this target or the app throws `The type initializer for 'Fsp.Interop.Api' threw an exception` at startup. This `winfsp-msil.dll` (shipped by the NuGet package, copied next to the exe) is distinct from the one `CheckWinFspPrerequisite()` checks for at `C:\Program Files (x86)\WinFsp\bin\winfsp-msil.dll` (the system-installed WinFsp runtime) — both must be present, for different reasons. +# 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.26215 (2026 Beta4)](https://github.com/winfsp/winfsp/releases/tag/v2.2B4) before building or running — download the MSI directly; do not use `winget install WinFsp.WinFsp`, as the winget package lags behind this release. + +## Architecture + +Nine projects, all inheriting `net10.0-windows` from `Directory.Build.props`: + +- **`ManagedDrive.Core`** — Pure file-system engine, no UI. Sub-namespaces: `FileSystem`, `Mounting`, `Persistence`, `Snapshots`, `Archive`, `DiskCreation`, `Diagnostics`. No types at the bare `ManagedDrive.Core` namespace. +- **`ManagedDrive.App`** — WPF + WinForms (`UseWindowsForms=true` for tray icon) desktop app. References Core, Cli.Core, HelperProtocol. +- **`ManagedDrive.Cli.Core`** — Shared CLI parsing/protocol (System.CommandLine + Spectre.Console). +- **`ManagedDrive.Cli`** — `mdrive.exe`, thin client forwarding commands to the running app via named pipe. +- **`ManagedDrive.HelperProtocol`** — Dependency-free named-pipe protocol between app and SYSTEM helper service. +- **`ManagedDrive.Service`** — Optional LocalSystem Windows service for cross-session drive-letter visibility. +- **`ManagedDrive.WingetExtension`** — `wingetx.exe`, standalone `winget` wrapper (no dependency on other projects). +- **`ManagedDrive.Tests`** — xUnit v3 unit tests (pure-managed, no WinFsp driver needed). +- **`ManagedDrive.Benchmarks`** — BenchmarkDotNet comparisons; part of `.slnx` but not shipped. + +Data flow: `MountManager` → `RamDisk.Create()` → `MemoryFileSystem` + WinFsp `FileSystemHost`. + +### Key conventions + +- `Directory.Build.props` sets `Nullable enable`, `ImplicitUsings enable`, `UseArtifactsOutput` (builds go to `artifacts/`, not per-project `bin/`). +- `GlobalUsings.cs` (App and Core each have one) covers all sub-namespaces + common BCL namespaces. Don't re-add `using` for these; only add file-specific ones. +- The implicit `System.Windows.Forms` using is **removed** in `ManagedDrive.App.csproj` — use fully qualified names for WinForms types. +- Central Package Management: versions live in `Directory.Packages.props` only. Never add `Version=` to `` in `.csproj`. +- `MinVer` derives version from git tags (`v`-prefixed). Tests set `true`. + +### Disk image format (`.mdr`) and compression + +Binary format: magic `MDRD`, version `3` (current). Capacity and volume label are always plaintext header fields; only the node region is compressed and optionally encrypted. + +**Compression uses Zstd** (via `ZstdSharp.Port`) with parallel chunked encoding (`ParallelZstd`). `ImageCompressionLevel` enum (`None=0`/`Fastest=1`/`Optimal=2`/`SmallestSize=3`) has stable explicit values persisted to disk/JSON — do not renumber. `DiskOptions.CustomZstdLevel` (`int?`) overrides the preset's Zstd level (1-22) when set. Legacy gzip-compressed v1/v2 images are still readable — do not remove those `Load()` branches. + +Encryption: AES-256-GCM envelope encryption (random CEK wrapped by user password via PBKDF2). The wrapped-CEK material is plaintext header; node region is encrypted under the CEK. + +Snapshots use a separate format (magic `MDRS`) with content-addressed blob store — don't conflate with `DiskImageSerializer`. + +### Threading model + +- `FileNodeMap`, `MountManager`, `RamDisk._autoSaveLock` use C# 13 `Lock` type. +- WinFsp callbacks fire on driver threads; state access through `FileNodeMap`'s lock. +- Auto-save timer: periodic path uses `Lock.TryEnter()` (skip if busy); `Dispose()` uses blocking `lock` (wait then final save). +- `FileNodeMap.GetTotalAllocated()` is O(1) via incremental `_totalAllocated`. Only mutate `AllocationSize` through `UpdateAllocationSize()` — direct assignment drifts the cached total. + +### App layer patterns + +- Standard WPF MVVM. `App.xaml.cs` orchestrates startup/shutdown; specific concerns delegated to `Services/` classes (`TrayIconController`, `TrayTooltipController`, `DiskNotificationService`, `TempDirCompatChecker`, `SessionEndingSaveHandler`, `UpdateCheckService`, `GlobalMountCoordinator`, `ShellContextMenuManager`). +- Disk operations (`Mount`/`Unmount`/`Save`/`Dispose`) dispatched via `Task.Run` to keep UI responsive. +- `RamDisk.TryApplyOptions()` applies non-destructive changes live; drive-letter or read-only changes require full remount. +- `CreateDiskDialog` has four modes: create, edit, import `.mdr`, import archive. Its `MainTabControl` has a fixed `MinHeight` (set to tallest tab) — bump if a tab's content grows. +- Custom window chrome (`WindowStyle="None"` + `WindowChrome`): interactive elements in caption area need `WindowChrome.IsHitTestVisibleInChrome="True"`. +- Logging: Serilog file logger (`%APPDATA%\ManagedDrive\logs/`), bridged to Core via `AppLog.Configure`. + +### CLI layer + +`mdrive.exe` is a thin pipe client → running `ManagedDrive.exe`. If no server, it launches the app and polls until connected. `CliCommandProcessor` renders via `Spectre.Console` into memory buffers (not real console). `ICliDiskController` is the seam avoiding circular references. + +### Localization & Theming + +- Strings: `Localization/Strings.{tag}.xaml`, swapped at runtime via `LanguageManager`. Use `{DynamicResource Key}` in XAML. +- Themes: `Themes/AppTheme.Colors.{Light,Dark}.xaml` palettes, swapped via `ThemeManager`. Structural styles in `AppTheme.xaml` reference colors by `{DynamicResource}`. +- Persist `SavedLanguage`/`SavedTheme` (raw user choice, `null` = system default), not `CurrentLanguage`/`CurrentTheme` (resolved concrete value). +- Icons: **Segoe Fluent Icons** font. No third-party UI framework. +- Adding a language: create `Strings.{tag}.xaml`, add tag to `LanguageManager.SupportedLanguages` and `` in `Directory.Build.props`. + +### SingleFile publish caveat + +`ManagedDrive.App.csproj` has `PublishSingleFile=true`. `winfsp-msil.dll` is mixed-mode (C++/CLI) and excluded via `ExcludeWinFspMsilFromSingleFile` target — do not remove it or the app throws at startup. This DLL (from NuGet, next to the exe) is distinct from the system-installed one at `C:\Program Files (x86)\WinFsp\bin\`. + +### Release pipeline + +`.github/workflows/ci.yml`: build + test on every push. `v*` tag → framework-dependent publish (`win-x64`, not self-contained) → GitHub Release with portable ZIP + Inno Setup installer. Installer bundles WinFsp MSI and auto-downloads .NET 10 Desktop Runtime if missing. + +Local installer test: publish App/Cli into `installer/publish-fx/`, then `iscc.exe /DAppVersion=0.0.0-test installer/ManagedDrive.iss`. + +### Benchmarks + +`dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` — three classes: `SequentialReadWriteBenchmarks`, `RandomAccessBenchmarks`, `ConcurrentAccessBenchmarks`. Pass `--filter '*ClassName*'` to run non-interactively. diff --git a/Directory.Packages.props b/Directory.Packages.props index 00e66b6..2c53a63 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + diff --git a/README.md b/README.md index 2c5d8aa..9fed4af 100644 --- a/README.md +++ b/README.md @@ -71,15 +71,15 @@ The ZIP also includes `mdrive.exe`, a companion CLI (see [CLI Usage](#cli-usage) | Requirement | Notes | |---|---| | **Windows 10 / 11 (64-bit)** | ARM64 is not currently tested | -| **[WinFsp 2.2.26194 (2026 Beta3)](https://github.com/winfsp/winfsp/releases/tag/v2.2B3)** | Must be installed before running ManagedDrive. Download the installer directly: [winfsp-2.2.26194.msi](https://github.com/winfsp/winfsp/releases/download/v2.2B3/winfsp-2.2.26194.msi) — do not use `winget install WinFsp.WinFsp`, as the winget package lags behind the latest release. The managed assembly `winfsp-msil.dll` is installed to `C:\Program Files (x86)\WinFsp\bin\` and is referenced by the project automatically. | +| **[WinFsp 2.2.26215 (2026 Beta4)](https://github.com/winfsp/winfsp/releases/tag/v2.2B4)** | Must be installed before running ManagedDrive. Download the installer directly: [winfsp-2.2.26215.msi](https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi) — do not use `winget install WinFsp.WinFsp`, as the winget package lags behind the latest release. The managed assembly `winfsp-msil.dll` is installed to `C:\Program Files (x86)\WinFsp\bin\` and is referenced by the project automatically. | | **[.NET 10 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/10.0)** | Required for the `-portable` ZIP (framework-dependent). | | **.NET 10 SDK** | Required to build. | ### Getting Started ```powershell -# 1. Download and install WinFsp 2.2.26194 (2026 Beta3) -# https://github.com/winfsp/winfsp/releases/download/v2.2B3/winfsp-2.2.26194.msi +# 1. Download and install WinFsp 2.2.26215 (2026 Beta4) +# https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi # 2. Clone the repository git clone https://github.com/coldhighsun/ManagedDrive @@ -138,13 +138,15 @@ Measured with [BenchmarkDotNet](https://benchmarkdotnet.org/) (Intel Core i9-139 | Scenario | RAM Disk | NVMe SSD | Ratio | |---|---:|---:|---:| -| Sequential write, 4 KB | 1.31 MB/s | 0.87 MB/s | **1.5× faster** | -| Sequential write, 1 MB | 321.9 MB/s | 84.6 MB/s | **3.8× faster** | -| Sequential read (OS cache), 4 KB / 1 MB | 3.4 MB/s / 531.6 MB/s | 4.5 MB/s / 955.5 MB/s | ~1.3–1.8× slower | -| Random 4 KB read, 30 seeks over 16 MB | 1.7× slower (OS cache) – 1.6× faster (uncached) | — | mixed | -| 30× small-file (4 KB) create+write | 84.1 ms (2.81 ms/file) | 144.8 ms (4.83 ms/file) | **1.7× faster** | +| Sequential write, 4 KB | 2.4 MB/s | 1.3 MB/s | **RAM 1.9× faster** | +| Sequential write, 1 MB | 561.8 MB/s | 137.4 MB/s | **RAM 4.1× faster** | +| Sequential read (OS cache), 4 KB | 6.0 MB/s | 8.7 MB/s | NVMe 1.4× faster | +| Sequential read (OS cache), 1 MB | 938.5 MB/s | 2,143.3 MB/s | NVMe 2.3× faster | +| Random 4 KB read (uncached), 30 seeks | 1.36 ms | 2.18 ms | **RAM 1.6× faster** | +| Random 4 KB read (OS cache), 30 seeks | 1.36 ms | 0.52 ms | NVMe 2.6× faster | +| 30× small-file (4 KB) create+write | 47.4 ms (1.58 ms/file) | 79.9 ms (2.66 ms/file) | **RAM 1.7× faster** | -Writes and small-file create+write win big by skipping block allocation, journaling, and the physical write; sequential reads trail the NVMe drive even on an OS page-cache hit (a user-mode file system can't consistently beat the kernel's own DRAM cache, and every WinFsp callback pays a kernel–userspace round trip); random reads land close to parity, faster or slower depending on whether the physical disk's own cache is warm. Run `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` for current numbers on your own hardware (see [Running Benchmarks](#running-benchmarks) below). +Writes win big (up to 4.1×) by skipping block allocation, journaling, and the physical write. Uncached random reads benefit from zero seek latency (1.6× faster). Small-file creates are also faster (1.7×) because metadata operations stay in memory. Cached reads, however, favor the NVMe path — NTFS reads from the OS page cache stay entirely in-kernel, while the RAM disk incurs an extra kernel–userspace round trip through WinFsp. Run `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` for current numbers on your own hardware (see [Running Benchmarks](#running-benchmarks) below). ### Running Tests @@ -298,15 +300,15 @@ ZIP 中还包含 `mdrive.exe`(配套命令行工具,见下方[命令行用 | 要求 | 说明 | |---|---| | **Windows 10 / 11(64 位)** | 暂未测试 ARM64 | -| **[WinFsp 2.2.26194(2026 Beta3)](https://github.com/winfsp/winfsp/releases/tag/v2.2B3)** | 必须安装此版本才能运行 ManagedDrive。请直接下载安装包:[winfsp-2.2.26194.msi](https://github.com/winfsp/winfsp/releases/download/v2.2B3/winfsp-2.2.26194.msi)——不要使用 `winget install WinFsp.WinFsp` 安装,因为该 winget 包更新不及时,落后于最新发布版本。托管程序集 `winfsp-msil.dll` 将安装至 `C:\Program Files (x86)\WinFsp\bin\`,项目会自动引用。 | +| **[WinFsp 2.2.26215(2026 Beta4)](https://github.com/winfsp/winfsp/releases/tag/v2.2B4)** | 必须安装此版本才能运行 ManagedDrive。请直接下载安装包:[winfsp-2.2.26215.msi](https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi)——不要使用 `winget install WinFsp.WinFsp` 安装,因为该 winget 包更新不及时,落后于最新发布版本。托管程序集 `winfsp-msil.dll` 将安装至 `C:\Program Files (x86)\WinFsp\bin\`,项目会自动引用。 | | **[.NET 10 桌面运行时](https://dotnet.microsoft.com/download/dotnet/10.0)** | "绿色版"(框架依赖型)ZIP 需要。 | | **.NET 10 SDK** | 编译所需。 | ### 快速开始 ```powershell -# 1. 下载并安装 WinFsp 2.2.26194(2026 Beta3) -# https://github.com/winfsp/winfsp/releases/download/v2.2B3/winfsp-2.2.26194.msi +# 1. 下载并安装 WinFsp 2.2.26215(2026 Beta4) +# https://github.com/winfsp/winfsp/releases/download/v2.2B4/winfsp-2.2.26215.msi # 2. 克隆仓库 git clone https://github.com/coldhighsun/ManagedDrive @@ -365,13 +367,15 @@ ManagedDrive 使用 **WinFsp**(Windows 文件系统代理)将内存目录树 | 场景 | 内存盘 | NVMe SSD | 倍率 | |---|---:|---:|---:| -| 顺序写入,4 KB | 1.31 MB/s | 0.87 MB/s | **快 1.5×** | -| 顺序写入,1 MB | 321.9 MB/s | 84.6 MB/s | **快 3.8×** | -| 顺序读取(OS 缓存),4 KB / 1 MB | 3.4 MB/s / 531.6 MB/s | 4.5 MB/s / 955.5 MB/s | 慢 ~1.3–1.8× | -| 随机 4 KB 读取,对 16 MB 文件寻址 30 次 | 慢 1.7×(OS 缓存)~快 1.6×(未缓存) | — | 不一致 | -| 30 次小文件(4 KB)创建+写入 | 84.1 ms(2.81 ms/文件) | 144.8 ms(4.83 ms/文件) | **快 1.7×** | - -写入及小文件创建+写入优势明显,因为跳过了物理块分配、日志记录和实际落盘;顺序读取即便命中 OS 页缓存也不及 NVMe 硬盘(用户态文件系统无法稳定超越内核自身的 DRAM 缓存,且每次 WinFsp 回调都要经过一次内核–用户态往返);随机读取基本持平,具体快慢取决于物理磁盘自身缓存是否命中。运行 `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` 可在你自己的硬件上获取当前数据(见下方[运行基准测试](#running-benchmarks-zh))。 +| 顺序写入,4 KB | 2.4 MB/s | 1.3 MB/s | **内存盘快 1.9×** | +| 顺序写入,1 MB | 561.8 MB/s | 137.4 MB/s | **内存盘快 4.1×** | +| 顺序读取(OS 缓存),4 KB | 6.0 MB/s | 8.7 MB/s | NVMe 快 1.4× | +| 顺序读取(OS 缓存),1 MB | 938.5 MB/s | 2,143.3 MB/s | NVMe 快 2.3× | +| 随机 4 KB 读取(未缓存),30 次寻址 | 1.36 ms | 2.18 ms | **内存盘快 1.6×** | +| 随机 4 KB 读取(OS 缓存),30 次寻址 | 1.36 ms | 0.52 ms | NVMe 快 2.6× | +| 30 次小文件(4 KB)创建+写入 | 47.4 ms(1.58 ms/文件) | 79.9 ms(2.66 ms/文件) | **内存盘快 1.7×** | + +写入优势显著(最高 4.1×),因为跳过了物理块分配、日志记录和实际落盘。未缓存的随机读取受益于零寻址延迟(快 1.6×)。小文件创建也更快(1.7×),因为元数据操作全在内存中完成。但缓存读取方面 NVMe 更优——NTFS 从 OS 页缓存读取时全程在内核态完成,而内存盘需要经过 WinFsp 的内核–用户态往返,增加了额外开销。运行 `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` 可在你自己的硬件上获取当前数据(见下方[运行基准测试](#running-benchmarks-zh))。 ### 运行测试 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index a9c4815..8162e36 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -4,10 +4,10 @@ ManagedDrive uses the following open-source software. ## WinFsp -- **Package:** `winfsp.net` 2.2.26194 +- **Package:** `winfsp.net` 2.2.26215 - **Copyright:** © 2015-2026 Bill Zissimopoulos - **License:** Dual-licensed under the GNU General Public License v2 (with a FUSE linking exception) or a commercial license. See [License.txt](https://github.com/winfsp/winfsp/blob/master/License.txt) and [winfsp.dev](https://winfsp.dev/) for the authoritative terms. -- The `ManagedDrive-Setup-*.exe` installer bundles and redistributes the official, unmodified WinFsp installer (`winfsp-2.2.26194.msi`, downloaded from the [WinFsp v2.2B3 release](https://github.com/winfsp/winfsp/releases/tag/v2.2B3)) so it can be installed automatically alongside ManagedDrive. +- The `ManagedDrive-Setup-*.exe` installer bundles and redistributes the official, unmodified WinFsp installer (`winfsp-2.2.26215.msi`, downloaded from the [WinFsp v2.2B4 release](https://github.com/winfsp/winfsp/releases/tag/v2.2B4)) so it can be installed automatically alongside ManagedDrive. ## SharpCompress diff --git a/benchmarks/ManagedDrive.Benchmarks/ConcurrentAccessBenchmarks.cs b/benchmarks/ManagedDrive.Benchmarks/ConcurrentAccessBenchmarks.cs index 29e9d9b..0571df5 100644 --- a/benchmarks/ManagedDrive.Benchmarks/ConcurrentAccessBenchmarks.cs +++ b/benchmarks/ManagedDrive.Benchmarks/ConcurrentAccessBenchmarks.cs @@ -8,7 +8,7 @@ namespace ManagedDrive.Benchmarks; /// for evaluating whether swapping to a is /// worth the added complexity — see the performance optimization plan. /// -[SimpleJob(warmupCount: 2, iterationCount: 3)] +[SimpleJob(warmupCount: 3, iterationCount: 10)] [MemoryDiagnoser] [MinColumn, MaxColumn] public class ConcurrentAccessBenchmarks diff --git a/benchmarks/ManagedDrive.Benchmarks/RandomAccessBenchmarks.cs b/benchmarks/ManagedDrive.Benchmarks/RandomAccessBenchmarks.cs index f2c49cf..1fb4e35 100644 --- a/benchmarks/ManagedDrive.Benchmarks/RandomAccessBenchmarks.cs +++ b/benchmarks/ManagedDrive.Benchmarks/RandomAccessBenchmarks.cs @@ -2,7 +2,7 @@ namespace ManagedDrive.Benchmarks; -[SimpleJob(warmupCount: 2, iterationCount: 3)] +[SimpleJob(warmupCount: 3, iterationCount: 10)] [MemoryDiagnoser] [MinColumn, MaxColumn] public class RandomAccessBenchmarks diff --git a/benchmarks/ManagedDrive.Benchmarks/SequentialReadWriteBenchmarks.cs b/benchmarks/ManagedDrive.Benchmarks/SequentialReadWriteBenchmarks.cs index b05bd08..0c05e1b 100644 --- a/benchmarks/ManagedDrive.Benchmarks/SequentialReadWriteBenchmarks.cs +++ b/benchmarks/ManagedDrive.Benchmarks/SequentialReadWriteBenchmarks.cs @@ -2,7 +2,7 @@ namespace ManagedDrive.Benchmarks; -[SimpleJob(warmupCount: 2, iterationCount: 3)] +[SimpleJob(warmupCount: 3, iterationCount: 10)] [MemoryDiagnoser] [MinColumn, MaxColumn] public class SequentialReadWriteBenchmarks diff --git a/installer/ManagedDrive.iss b/installer/ManagedDrive.iss index 2bd4bec..a274a96 100644 --- a/installer/ManagedDrive.iss +++ b/installer/ManagedDrive.iss @@ -10,7 +10,7 @@ #define AppVersion "0.0.0" #endif -#define WinFspMsiName "winfsp-2.2.26194.msi" +#define WinFspMsiName "winfsp-2.2.26215.msi" #define HelperServiceName "ManagedDriveHelper" #define HelperServiceExeName "ManagedDriveHelper.exe" ; Must match App.xaml.cs::SingleInstanceMutexName exactly - this is how Setup detects a running diff --git a/src/ManagedDrive.App/App.xaml.cs b/src/ManagedDrive.App/App.xaml.cs index be9e2da..41845b5 100644 --- a/src/ManagedDrive.App/App.xaml.cs +++ b/src/ManagedDrive.App/App.xaml.cs @@ -243,7 +243,7 @@ private void CheckWinFspPrerequisite() if (result == MessageBoxResult.Yes) { - Process.Start(new ProcessStartInfo("https://github.com/winfsp/winfsp/releases/tag/v2.2B3") { UseShellExecute = true }); + Process.Start(new ProcessStartInfo("https://github.com/winfsp/winfsp/releases/tag/v2.2B4") { UseShellExecute = true }); } Shutdown();