diff --git a/CLAUDE.md b/CLAUDE.md index 802d4df..cc78d4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,8 +51,9 @@ Data flows: `MountManager` → `RamDisk.Create()` → `MemoryFileSystem` + `File - `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 `App.xaml.cs`'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 and checked by `TryAutoSave`, `Dispose()`, and `SaveToImageSafe()` alike; manual saves (`SaveToImage()` directly) stay unconditional. `Dispose()` takes the same lock with a blocking `lock` statement (waits for any in-flight save, then checks `NeedsSave()` before writing) before unmounting, so a disk is never left unsaved on unmount/edit-remount/app-exit, without performing a redundant write when nothing changed. `SaveToImage()` clears the dirty flag and records `_lastSavedImagePath` after every successful save, regardless of caller. `SaveToImageSafe()` wraps `SaveToImage()` with the `NeedsSave()` 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`) — subscribes 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 `NeedsSave()` inside `SaveToImageSafe()` rather than spawning a task at all. +- **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`) — subscribes 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`. - `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. diff --git a/README.md b/README.md index 0de67ca..88b55f1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Create, mount and manage in-memory volumes that appear as normal drive letters i **Persistence, snapshots & cloning** - Save disk contents to a `.mdr` image file and restore it on next mount, or import an existing image directly (**Import Disk...**) - Import an archive (zip, 7z, rar, tar, and other formats [SharpCompress](https://github.com/adamhathcock/sharpcompress) can read) directly as a read-only disk (**Import Archive...**) — capacity and label are derived from the archive up front -- Optional auto-save on a 1–60 minute interval, plus an automatic final save before unmount/exit; both are skipped when nothing has changed, and failures raise a tray/status-bar notification +- Optional auto-save on a 1–60 minute interval, plus an automatic final save before unmount/exit (individually disableable per disk via **Save on exit**); both are skipped when nothing has changed, and failures raise a tray/status-bar notification - Selectable image compression (Off / Fast / Balanced / Max, default Fast) - Snapshot / version history — cap retained snapshots by count and/or size; deduplicated by content hash so many snapshots cost little extra space; restore any snapshot via **Restore Snapshot...** - Clone a disk onto another mounted disk or export it to a new `.mdr` file (**Clone Disk...**) @@ -48,7 +48,8 @@ Create, mount and manage in-memory volumes that appear as normal drive letters i **UI** - Bilingual (English / Simplified Chinese) and light/dark themes, both auto-detected with manual override in Settings, switching instantly without restart -- At-a-glance disk cards with status badges (read-only, current-TEMP, backing image) and a usage bar that turns warning-colored past the high-usage threshold +- At-a-glance disk cards with status badges (read-only, current-TEMP, backing image, password-protected) and a usage bar that turns warning-colored past the high-usage threshold +- Maximize/restore button on the main window toolbar, alongside minimize and close - About dialog with app version and GitHub link ### Installation @@ -164,7 +165,7 @@ Key classes: - **`FileNode`** — holds `Fsp.Interop.FileInfo` metadata, a `byte[]` data buffer, a cached leaf name, and a security descriptor. - **`FileNodeMap`** — a case-insensitive `SortedDictionary` that maps full paths to nodes, supports paginated child enumeration via a bounded prefix walk, and tracks total allocated bytes with an incrementally-maintained counter (O(1) reads instead of a full scan). Thread-safe via the C# 13 `Lock` type. - **`MemoryFileSystem : FileSystemBase`** — overrides all 21 required WinFsp callbacks (`Create`, `Open`, `Read`, `Write`, `Rename`, `CanDelete`, `ReadDirectoryEntry`, etc.) and enforces a configurable capacity ceiling, returning `STATUS_DISK_FULL` when exceeded; memory is not pre-allocated — each `FileNode` holds only the bytes actually written. -- **`RamDisk`** — composes `MemoryFileSystem` with a `FileSystemHost`. The static `Create()` factory mounts the volume and polls until the drive letter is visible in the OS (up to 2.5 s), then broadcasts `SHCNE_DRIVEADD` to refresh Explorer. `Dispose()` unmounts. When an auto-save interval is configured, a background timer saves the image immediately and then on every interval, skipping ticks where nothing has changed since the last save. Independently of that timer, whenever an image path is configured `Dispose()` always performs a final save before unmounting — even if no auto-save interval was set — skipped only when nothing changed since the last save, so unmounting, remounting, or exiting never loses an edit or performs a redundant write. A `SaveFailed` event fires whenever an image save or snapshot write fails, including on background auto-save ticks and the final save in `Dispose()` that would otherwise fail silently, so the App layer can surface the error via a tray balloon and the status bar. +- **`RamDisk`** — composes `MemoryFileSystem` with a `FileSystemHost`. The static `Create()` factory mounts the volume and polls until the drive letter is visible in the OS (up to 2.5 s), then broadcasts `SHCNE_DRIVEADD` to refresh Explorer. `Dispose()` unmounts. When an auto-save interval is configured, a background timer saves the image immediately and then on every interval, skipping ticks where nothing has changed since the last save. Independently of that timer, whenever an image path is configured and save-on-exit isn't disabled for the disk, `Dispose()` performs a final save before unmounting — even if no auto-save interval was set — skipped only when nothing changed since the last save, so unmounting, remounting, or exiting never loses an edit or performs a redundant write. A `SaveFailed` event fires whenever an image save or snapshot write fails, including on background auto-save ticks and the final save in `Dispose()` that would otherwise fail silently, so the App layer can surface the error via a tray balloon and the status bar. Optional per-disk password protection keeps a random content-encryption key in memory only, wrapped by the user's password. - **`MountManager`** — thread-safe registry of active `RamDisk` instances. Fires `DiskMounted` / `DiskUnmounted` events. - **`DiskImageSerializer`** — reads/writes `.mdr` files (full FS state including metadata, ACLs, and file data), optionally gzip-compressed at a user-selectable level. - **`SnapshotManager` / `SnapshotStore`** — write a timestamped, read-only copy of the disk's contents next to the main `.mdr` image after every save (when snapshot limits are configured), list/prune them, and restore one back onto a live disk. File content is deduplicated by SHA-256 hash into a shared blob store, so snapshots of a mostly-unchanged disk cost little extra space. @@ -176,18 +177,19 @@ A little-endian binary format: | Field | Type | Description | |---|---|---| | Magic | `byte[4]` | `MDRD` | -| Version | `int32` | Currently `2` | -| CompressionLevel | `byte` | `ImageCompressionLevel` value (0=None/1=Fastest/2=Optimal/3=SmallestSize); when not `None`, everything below is gzip-compressed | -| Capacity | `uint64` | Configured capacity in bytes | -| VolumeLabel | `string` | Length-prefixed UTF-8 | +| Version | `int32` | Currently `3` | +| CompressionLevel | `byte` | `ImageCompressionLevel` value (0=None/1=Fastest/2=Optimal/3=SmallestSize) | +| Capacity | `uint64` | Configured capacity in bytes (always plaintext, even when encrypted) | +| VolumeLabel | `string` | Length-prefixed UTF-8 (always plaintext, even when encrypted) | +| *Encryption info* | — | Present only when the image is password-protected: PBKDF2 salt and the wrapped content-encryption key | | NodeCount | `int32` | Number of nodes that follow | -| *Node entries* | — | Path, metadata (10 fields), security descriptor, file data | +| *Node entries* | — | Path, metadata (10 fields), security descriptor, file data — gzip-compressed as a block when `CompressionLevel != None`, then AES-256-GCM encrypted on top when the image is password-protected | -Version `1` images (no `CompressionLevel` byte, always uncompressed) remain readable for backward compatibility. +Version `1` images (no `CompressionLevel` byte, always uncompressed) and version `2` images (whole node region compressed the same way, no encryption support) remain readable for backward compatibility. ### Snapshot Format -Snapshots use a separate, independent format from `.mdr` images. For a main image `disk.mdr`, snapshots are named `disk.yyyyMMdd-HHmmss.mdr` in the same folder, each a small binary index file (magic `MDRS`) listing every file/directory's metadata plus, for non-empty files, a SHA-256 hash. The actual file content lives in a shared, content-addressed blob store at `disk.snapblobs/` (sharded into 2-character hex subfolders), gzip-compressed per-blob at the disk's configured compression level — identical content across snapshots is stored only once. Pruning old snapshots also garbage-collects any blob no longer referenced by a remaining snapshot. +Snapshots use a separate, independent format from `.mdr` images. For a main image `disk.mdr`, snapshots are named `disk.yyyyMMdd-HHmmss.mdr` in the same folder, each a small binary index file (magic `MDRS`) listing every file/directory's metadata plus, for non-empty files, a SHA-256 hash. The actual file content lives in a shared, content-addressed blob store at `disk.snapblobs/` (sharded into 2-character hex subfolders), gzip-compressed per-blob at the disk's configured compression level — identical content across snapshots is stored only once. When the parent disk is password-protected, each blob is additionally AES-256-GCM encrypted with the same key. Pruning old snapshots also garbage-collects any blob no longer referenced by a remaining snapshot; clearing a disk's password deletes all of its snapshots outright, since the old blobs are unrecoverable without the discarded key. ### Settings & Persistence @@ -324,7 +326,7 @@ This project bundles [WinFsp](https://winfsp.dev/) and [SharpCompress](https://g **持久化、快照与克隆** - 将磁盘内容保存为 `.mdr` 镜像文件,下次挂载时自动还原;也可直接导入已有镜像(**导入磁盘...**) - 直接导入压缩包(zip、7z、rar、tar 等 [SharpCompress](https://github.com/adamhathcock/sharpcompress) 支持的格式)作为只读磁盘(**导入压缩包...**)——容量与卷标从压缩包内容自动推算 -- 可选自动保存(1-60 分钟间隔),并在卸载/退出前自动执行一次收尾保存;内容未变化时自动跳过,保存失败会通过托盘/状态栏提示 +- 可选自动保存(1-60 分钟间隔),并在卸载/退出前自动执行一次收尾保存(可按磁盘通过**退出时保存**单独关闭);内容未变化时自动跳过,保存失败会通过托盘/状态栏提示 - 可选镜像压缩级别(不压缩/快速/均衡/最高,默认快速) - 快照/版本历史——按数量和/或大小上限保留快照,相同内容跨快照去重存储,占用空间远小于逻辑大小之和;可随时通过**还原快照...**还原到某个历史版本 - 克隆磁盘到另一已挂载磁盘,或导出为新的 `.mdr` 文件(**克隆磁盘...**) @@ -341,7 +343,8 @@ This project bundles [WinFsp](https://winfsp.dev/) and [SharpCompress](https://g **界面** - 双语界面(中文/英文)与浅色/深色主题,均可自动检测或在设置中手动切换,即时生效无需重启 -- 一目了然的磁盘卡片,带状态角标(只读、当前临时目录、是否绑定镜像)及使用率超阈值时变色的进度条 +- 一目了然的磁盘卡片,带状态角标(只读、当前临时目录、是否绑定镜像、密码保护)及使用率超阈值时变色的进度条 +- 主窗口工具栏新增最大化/还原按钮,与最小化、关闭按钮并列 - 关于对话框,显示应用版本及 GitHub 仓库链接 **命令行** @@ -461,7 +464,7 @@ ManagedDrive 使用 **WinFsp**(Windows 文件系统代理)将内存目录树 - **`FileNode`** — 持有 `Fsp.Interop.FileInfo` 元数据、`byte[]` 数据缓冲区、缓存的叶子节点名称及安全描述符。 - **`FileNodeMap`** — 不区分大小写的 `SortedDictionary`,将完整路径映射到节点,通过有界前缀遍历支持分页子节点枚举,并通过增量维护的计数器追踪已分配字节总量(O(1) 读取,无需全量扫描)。通过 C# 13 `Lock` 类型保证线程安全。 - **`MemoryFileSystem : FileSystemBase`** — 覆写全部 21 个所需的 WinFsp 回调(`Create`、`Open`、`Read`、`Write`、`Rename`、`CanDelete`、`ReadDirectoryEntry` 等),并强制执行可配置的容量上限,超出时返回 `STATUS_DISK_FULL`;内存不预分配——每个 `FileNode` 仅保留实际写入的字节数。 -- **`RamDisk`** — 组合 `MemoryFileSystem` 与 `FileSystemHost`。静态工厂方法 `Create()` 挂载卷,并轮询直至驱动器号在系统中可见(最长 2.5 秒),随后向资源管理器广播 `SHCNE_DRIVEADD`。`Dispose()` 执行卸载。配置了自动保存间隔时,后台计时器会立即保存一次镜像,随后按间隔重复保存,若自上次保存后内容未变化则跳过该次保存。与该计时器无关,只要配置了镜像路径,`Dispose()` 在卸载前总会执行一次收尾保存——即使从未设置自动保存间隔;同样只在内容未变化时跳过,确保卸载、重新挂载或退出应用时既不遗漏最新的改动,也不产生多余的写入。镜像保存或快照写入失败时会触发 `SaveFailed` 事件——包括原本会被静默吞掉的后台自动保存和 `Dispose()` 收尾保存失败——供 App 层通过托盘气泡通知和状态栏呈现错误。 +- **`RamDisk`** — 组合 `MemoryFileSystem` 与 `FileSystemHost`。静态工厂方法 `Create()` 挂载卷,并轮询直至驱动器号在系统中可见(最长 2.5 秒),随后向资源管理器广播 `SHCNE_DRIVEADD`。`Dispose()` 执行卸载。配置了自动保存间隔时,后台计时器会立即保存一次镜像,随后按间隔重复保存,若自上次保存后内容未变化则跳过该次保存。与该计时器无关,只要配置了镜像路径且该磁盘未关闭"退出时保存",`Dispose()` 在卸载前就会执行一次收尾保存——即使从未设置自动保存间隔;同样只在内容未变化时跳过,确保卸载、重新挂载或退出应用时既不遗漏最新的改动,也不产生多余的写入。镜像保存或快照写入失败时会触发 `SaveFailed` 事件——包括原本会被静默吞掉的后台自动保存和 `Dispose()` 收尾保存失败——供 App 层通过托盘气泡通知和状态栏呈现错误。可选的每磁盘密码保护仅在内存中保存一个随机生成的内容加密密钥,由用户密码进行包裹。 - **`MountManager`** — 线程安全的活动 `RamDisk` 实例注册表,提供 `DiskMounted` / `DiskUnmounted` 事件。 - **`DiskImageSerializer`** — 读写 `.mdr` 文件(保存完整文件系统状态,包含元数据、ACL 和文件数据),可选按用户指定的级别进行 gzip 压缩。 - **`SnapshotManager` / `SnapshotStore`** — 在配置了快照上限的情况下,每次保存后都会在主 `.mdr` 镜像旁写入一份带时间戳的只读快照副本,并支持列出、清理旧快照及将某个快照还原到实时磁盘。文件内容按 SHA-256 哈希去重存储于共享的块存储中,因此对一个大部分内容未变化的磁盘做快照,额外占用的空间非常小。 @@ -473,18 +476,19 @@ ManagedDrive 使用 **WinFsp**(Windows 文件系统代理)将内存目录树 | 字段 | 类型 | 说明 | |---|---|---| | 魔数 | `byte[4]` | `MDRD` | -| 版本 | `int32` | 当前为 `2` | -| 压缩级别 | `byte` | `ImageCompressionLevel` 取值(0=不压缩/1=快速/2=均衡/3=最高);非 0 时,后续内容整体经 gzip 压缩 | -| 容量 | `uint64` | 配置的容量(字节) | -| 卷标 | `string` | 长度前缀 UTF-8 | +| 版本 | `int32` | 当前为 `3` | +| 压缩级别 | `byte` | `ImageCompressionLevel` 取值(0=不压缩/1=快速/2=均衡/3=最高) | +| 容量 | `uint64` | 配置的容量(字节,即便镜像已加密也始终为明文) | +| 卷标 | `string` | 长度前缀 UTF-8(即便镜像已加密也始终为明文) | +| *加密信息* | — | 仅当镜像已加密时存在:PBKDF2 盐值及被包裹的内容加密密钥 | | 节点数 | `int32` | 后续节点数量 | -| *节点条目* | — | 路径、元数据(10 个字段)、安全描述符、文件数据 | +| *节点条目* | — | 路径、元数据(10 个字段)、安全描述符、文件数据——压缩级别非 0 时整体经 gzip 压缩,镜像已加密时再整体经 AES-256-GCM 加密 | -版本 `1` 的镜像(不含压缩级别字段,始终不压缩)仍可正常读取,保持向后兼容。 +版本 `1` 的镜像(不含压缩级别字段,始终不压缩)和版本 `2` 的镜像(节点区整体按同样方式压缩,不支持加密)仍可正常读取,保持向后兼容。 ### 快照格式 -快照采用与 `.mdr` 镜像完全独立的格式。对于主镜像 `disk.mdr`,其快照命名为同目录下的 `disk.yyyyMMdd-HHmmss.mdr`,每个都是一个小型二进制索引文件(魔数 `MDRS`),列出所有文件/目录的元数据,非空文件还会记录一个 SHA-256 哈希。实际文件内容存储在共享的内容寻址块存储 `disk.snapblobs/` 中(按哈希前 2 位十六进制分片到子文件夹),按磁盘配置的压缩级别逐块 gzip 压缩——多个快照间相同的内容只保存一份。清理旧快照时,还会一并垃圾回收不再被任何剩余快照引用的块。 +快照采用与 `.mdr` 镜像完全独立的格式。对于主镜像 `disk.mdr`,其快照命名为同目录下的 `disk.yyyyMMdd-HHmmss.mdr`,每个都是一个小型二进制索引文件(魔数 `MDRS`),列出所有文件/目录的元数据,非空文件还会记录一个 SHA-256 哈希。实际文件内容存储在共享的内容寻址块存储 `disk.snapblobs/` 中(按哈希前 2 位十六进制分片到子文件夹),按磁盘配置的压缩级别逐块 gzip 压缩——多个快照间相同的内容只保存一份。当所属磁盘已设置密码保护时,每个块还会额外使用同一密钥进行 AES-256-GCM 加密。清理旧快照时,还会一并垃圾回收不再被任何剩余快照引用的块;清除磁盘密码时会直接删除该磁盘的所有快照,因为旧的块在密钥丢弃后已无法恢复。 ### 配置与持久化 diff --git a/src/ManagedDrive.App/App.xaml.cs b/src/ManagedDrive.App/App.xaml.cs index ff34ee2..1709c71 100644 --- a/src/ManagedDrive.App/App.xaml.cs +++ b/src/ManagedDrive.App/App.xaml.cs @@ -1,5 +1,7 @@ using ManagedDrive.Cli.Core; +using System.Runtime.InteropServices; using System.Windows.Controls.Primitives; +using System.Windows.Interop; namespace ManagedDrive.App; @@ -24,6 +26,13 @@ public partial class App private bool _isExiting; private MainViewModel? _mainViewModel; private MainWindow? _mainWindow; + + /// + /// Cached handle of the main window, captured on the UI thread at startup. Used by + /// (which runs on the thread, not the + /// UI thread) to register a shutdown block reason without touching WPF objects cross-thread. + /// + private IntPtr _mainWindowHandle; private MountManager? _mountManager; private SettingsStore? _settings; private Mutex? _singleInstanceMutex; @@ -106,6 +115,10 @@ private async void App_Startup(object sender, StartupEventArgs e) _mainWindow = new(_mainViewModel); _mainWindow.Closing += MainWindow_Closing; + // Force the HWND to exist now (on the UI thread) so OnSessionEnding can reference it from + // the SystemEvents thread even when the window stays hidden in the tray. + _mainWindowHandle = new WindowInteropHelper(_mainWindow).EnsureHandle(); + SetupTrayIcon(); SetupUsageWarnings(); CheckTempDirectoryOnStartup(config); @@ -427,23 +440,52 @@ private void OnSessionEnding(object sender, SessionEndingEventArgs e) return; } - var saveTasks = _mountManager.GetAll() - .Select(disk => Task.Run(() => - { - try - { - disk.SaveToImageSafe(); - } - catch + var hasHandle = _mainWindowHandle != IntPtr.Zero; + if (hasHandle) + { + // Tell Windows we are busy saving so it does not force-kill us before the save loop + // below finishes. Without this, the OS honours only its ~5 s HungAppTimeout, which the + // save can exceed for large/compressed/encrypted disks. + ShutdownBlockReasonCreate(_mainWindowHandle, Loc.Get("Shutdown.SavingReason")); + } + + try + { + var saveTasks = _mountManager.GetAll() + .Select(disk => Task.Run(() => { - // Best-effort, matches RamDisk.Dispose()/TryAutoSave() swallow pattern. - } - })) - .ToArray(); + try + { + disk.SaveToImageSafe(); + } + catch + { + // Best-effort, matches RamDisk.Dispose()/TryAutoSave() swallow pattern. + } + })) + .ToArray(); - Task.WaitAll(saveTasks, SessionEndingSaveTimeout); + Task.WaitAll(saveTasks, SessionEndingSaveTimeout); + } + finally + { + if (hasHandle) + { + // Always clear the block reason so we never hold up the shutdown once saving is + // done (or has timed out). + ShutdownBlockReasonDestroy(_mainWindowHandle); + } + } } + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShutdownBlockReasonCreate(IntPtr hWnd, string pwszReason); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShutdownBlockReasonDestroy(IntPtr hWnd); + private void PositionTrayPopup() { var workArea = SystemParameters.WorkArea; diff --git a/src/ManagedDrive.App/Localization/Strings.en-US.xaml b/src/ManagedDrive.App/Localization/Strings.en-US.xaml index c9b172f..d955184 100644 --- a/src/ManagedDrive.App/Localization/Strings.en-US.xaml +++ b/src/ManagedDrive.App/Localization/Strings.en-US.xaml @@ -73,16 +73,17 @@ Balanced Max Balanced/Max compression may significantly increase save times. Choose carefully. + Save image on exit Auto-save the image, interval (minutes): Interval (min) {0} min - Periodically save the disk contents to the image file while mounted + Save this disk's image when the app closes or Windows shuts down. Independent of periodic auto-save. + Periodically save the disk contents to the image file while mounted. Only the current image is kept (overwritten each save); enable a snapshot limit below to also keep timestamped snapshots you can restore to later. How often to auto-save, in minutes Max number of snapshots to keep: Limit total size to - Delete the oldest snapshots once this many are stored - Delete the oldest snapshots once their combined size exceeds this limit - With only auto-save enabled, just the current image file is kept (overwritten each time). Checking either limit below also creates a timestamped snapshot on every auto-save that you can restore to later; the limits control how many are kept: + Keep at most this many snapshots; the oldest are deleted once the count is exceeded. + Cap the combined size of all snapshots; the oldest are deleted once this size is exceeded. Last modified: {0} Not yet modified Last image save: {0} @@ -192,6 +193,9 @@ Capacity Adjusted {0} ({1}): configured capacity {2} MB was smaller than the loaded image ({3} MB); capacity was temporarily raised to fit existing data. + + Saving disk images before shutdown… + Ready. {0} available diff --git a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml index 9b4858a..db6d842 100644 --- a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml +++ b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml @@ -73,16 +73,17 @@ 均衡 最高 均衡/最高压缩可能会显著增加保存时间,请谨慎选择。 + 退出时保存镜像 自动保存镜像,间隔(分钟): 间隔(分钟) {0} 分钟 - 挂载期间按间隔自动将磁盘内容保存到镜像文件 + 在程序关闭或 Windows 关机时保存该磁盘镜像。与周期性自动保存相互独立。 + 挂载期间按设定的时间间隔自动将磁盘内容保存到镜像文件。默认只保留当前这一份镜像(每次保存都会覆盖);勾选下方的快照限制后,每次自动保存还会额外保留带时间戳的历史快照,可用于恢复到某个时间点。 自动保存的时间间隔(分钟) 最多保留的快照数量: 总大小不超过 - 快照数量达到该值后,自动删除最旧的快照 - 快照总大小超过该值后,自动删除最旧的快照 - 仅启用自动保存时,只会保留当前这一份镜像文件(每次都会被覆盖)。勾选下面任一限制后,每次自动保存还会额外生成一份带时间戳的历史快照,可用于恢复到某个时间点;以下限制控制最多保留多少份: + 最多保留这么多份快照;超出后自动删除最旧的。 + 限制所有快照的总大小;超出后自动删除最旧的。 上次写入:{0} 尚未写入 上次保存镜像:{0} @@ -192,6 +193,9 @@ 容量已自动调整 {0}({1}):配置容量 {2} MB 小于已加载镜像的实际占用({3} MB),已临时提升容量以容纳现有数据。 + + 正在关机前保存磁盘镜像… + 就绪。 可用 {0} diff --git a/src/ManagedDrive.App/Models/DiskProfile.cs b/src/ManagedDrive.App/Models/DiskProfile.cs index 0f144bb..e268ecf 100644 --- a/src/ManagedDrive.App/Models/DiskProfile.cs +++ b/src/ManagedDrive.App/Models/DiskProfile.cs @@ -93,4 +93,10 @@ public ulong? MaxSnapshotSizeBytes /// high-usage. null disables the warning for this disk. /// public double? HighUsageWarnPercent { get; init; } = 90.0; + + /// + /// Gets or sets a value indicating whether the disk image is saved on application exit and + /// OS shutdown. Defaults to true. + /// + public bool SaveImageOnExit { get; init; } = true; } \ No newline at end of file diff --git a/src/ManagedDrive.App/ViewModels/MainViewModel.cs b/src/ManagedDrive.App/ViewModels/MainViewModel.cs index e008975..2f7e9a0 100644 --- a/src/ManagedDrive.App/ViewModels/MainViewModel.cs +++ b/src/ManagedDrive.App/ViewModels/MainViewModel.cs @@ -344,6 +344,7 @@ public IEnumerable GetProfiles() MaxSnapshotCount = vm.Disk.Options.MaxSnapshotCount, MaxSnapshotSizeBytes = vm.Disk.Options.MaxSnapshotSizeBytes, HighUsageWarnPercent = vm.Disk.Options.HighUsageWarnPercent, + SaveImageOnExit = vm.Disk.Options.SaveImageOnExit, }); } @@ -741,6 +742,7 @@ private static async Task DeleteDiskImageIfRequestedAsync(bool deleteImage, stri MaxSnapshotCount = p.MaxSnapshotCount, MaxSnapshotSizeBytes = p.MaxSnapshotSizeBytes, HighUsageWarnPercent = p.HighUsageWarnPercent, + SaveImageOnExit = p.SaveImageOnExit, }; private static void ResetTempIfPointingAt(string mountPoint) diff --git a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml index d00e4ad..a9713da 100644 --- a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml +++ b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml @@ -14,7 +14,7 @@ - + @@ -140,15 +140,17 @@ + + + - @@ -191,7 +193,7 @@ Visibility="Collapsed"/> - + @@ -207,23 +209,29 @@ - - + + + - + - - + @@ -243,11 +251,11 @@ - - + @@ -276,13 +284,8 @@ - - - - + @@ -311,7 +314,7 @@ - + diff --git a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs index 2ae5172..7f3e95c 100644 --- a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs +++ b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs @@ -133,6 +133,7 @@ public CreateDiskDialog(DiskOptions existing, IReadOnlyList? otherD AutoMountBox.IsChecked = existing.AutoMount; ImagePathBox.Text = existing.PersistImagePath ?? string.Empty; CompressionLevelBox.SelectedIndex = CompressionLevels.IndexOf(existing.CompressionLevel); + SaveOnExitBox.IsChecked = existing.SaveImageOnExit; UpdateCompressionLevelState(); UpdateAutoSaveEnabledState(); @@ -453,6 +454,34 @@ private static bool IsValidImagePath(string path) } } + /// + /// Validates that the given image path is not a snapshot file name and is not already used as + /// another active disk's persistence target. These checks do not depend on the selected drive + /// letter, so they can run the moment the user picks an image file (see + /// ) as well as at build time. + /// + /// The image file path to validate. + /// The localized error message when validation fails; empty otherwise. + /// true when the path is available; otherwise false. + private bool TryValidateImagePathAvailable(string imagePath, out string error) + { + if (SnapshotManager.IsSnapshotFileName(Path.GetFileName(imagePath))) + { + error = Loc.Get("Val.ImagePathIsSnapshot"); + return false; + } + + if (_otherDisks.Any(d => d.PersistImagePath != null && + string.Equals(d.PersistImagePath, imagePath, StringComparison.OrdinalIgnoreCase))) + { + error = Loc.Get("Val.ImagePathInUse"); + return false; + } + + error = string.Empty; + return true; + } + private void AutoSaveBox_CheckedChanged(object sender, RoutedEventArgs e) { UpdateAutoSaveIntervalPanelState(); @@ -584,6 +613,12 @@ private void OpenImagePathDialog() if (dlg.ShowDialog() == true) { + if (!TryValidateImagePathAvailable(dlg.FileName, out var error)) + { + MessageBox.Show(error, Loc.Get("Val.Title"), MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + ImagePathBox.Text = dlg.FileName; UpdateCompressionLevelState(); UpdateAutoSaveEnabledState(); @@ -699,25 +734,19 @@ private bool TryBuildOptions(out DiskOptions? options, out string error) return false; } - if (SnapshotManager.IsSnapshotFileName(Path.GetFileName(imagePath))) + if (!TryValidateImagePathAvailable(imagePath, out error)) { - error = Loc.Get("Val.ImagePathIsSnapshot"); return false; } + // Depends on the currently selected drive letter (which may change after the image + // file is picked), so this check stays here rather than at selection time. var allMountPoints = _otherDisks.Select(d => d.MountPoint).Append(mountPoint); if (allMountPoints.Any(mp => imagePath.StartsWith(mp, StringComparison.OrdinalIgnoreCase))) { error = Loc.Get("Val.ImagePathOnRamDisk"); return false; } - - if (_otherDisks.Any(d => d.PersistImagePath != null && - string.Equals(d.PersistImagePath, imagePath, StringComparison.OrdinalIgnoreCase))) - { - error = Loc.Get("Val.ImagePathInUse"); - return false; - } } var isReadOnly = ReadOnlyBox.IsChecked == true; @@ -816,6 +845,7 @@ private bool TryBuildOptions(out DiskOptions? options, out string error) MaxSnapshotCount = maxSnapshotCount, MaxSnapshotSizeBytes = maxSnapshotSizeBytes, HighUsageWarnPercent = highUsageWarnPercent, + SaveImageOnExit = SaveOnExitBox.IsChecked == true, }; error = string.Empty; @@ -886,6 +916,10 @@ private void UpdateAutoSaveEnabledState() AutoSaveBox.IsChecked = false; } + // Save-on-exit only matters for a writable, persisted disk. Leave its checked state + // untouched when disabled so the default (on) survives toggling read-only / image path. + SaveOnExitBox.IsEnabled = hasImagePath && ReadOnlyBox.IsChecked != true; + EncryptImageBox.IsEnabled = hasImagePath && ReadOnlyBox.IsChecked != true && !_isImportMode; if (!EncryptImageBox.IsEnabled) { @@ -917,7 +951,9 @@ private void UpdateCapacityDisplay() private void UpdateCompressionLevelState() { - CompressionLevelBox.IsEnabled = !string.IsNullOrEmpty(ImagePathBox.Text) && ReadOnlyBox.IsChecked != true; + // Toggle the whole row (label + combo) so the label greys out with the control when a + // read-only disk has nothing to compress. + CompressionLevelRow.IsEnabled = !string.IsNullOrEmpty(ImagePathBox.Text) && ReadOnlyBox.IsChecked != true; UpdateCompressionWarning(); } @@ -926,7 +962,7 @@ private void UpdateCompressionWarning() if (CompressionWarningText is null) return; var level = (CompressionLevelBox.SelectedItem as CompressionLevelItem)?.Level; - var show = CompressionLevelBox.IsEnabled + var show = CompressionLevelRow.IsEnabled && level is ImageCompressionLevel.Optimal or ImageCompressionLevel.SmallestSize; CompressionWarningText.Visibility = show ? Visibility.Visible : Visibility.Collapsed; } diff --git a/src/ManagedDrive.Core/DiskOptions.cs b/src/ManagedDrive.Core/DiskOptions.cs index d2db48a..e3af1f3 100644 --- a/src/ManagedDrive.Core/DiskOptions.cs +++ b/src/ManagedDrive.Core/DiskOptions.cs @@ -131,4 +131,11 @@ public ulong? MaxSnapshotSizeBytes /// warning is shown. null disables the warning for this disk. Defaults to 90%. /// public double? HighUsageWarnPercent { get; init; } = 90.0; + + /// + /// When true (the default), the disk image is saved on application exit and OS + /// shutdown. Has no effect on read-only disks or when is + /// null. Independent of periodic auto-save (). + /// + public bool SaveImageOnExit { get; init; } = true; } \ No newline at end of file diff --git a/src/ManagedDrive.Core/RamDisk.cs b/src/ManagedDrive.Core/RamDisk.cs index 1f551a6..be3bea5 100644 --- a/src/ManagedDrive.Core/RamDisk.cs +++ b/src/ManagedDrive.Core/RamDisk.cs @@ -275,7 +275,7 @@ public void Dispose() { try { - if (NeedsSave()) + if (NeedsExitSave()) { SaveToImage(); } @@ -369,17 +369,17 @@ public void SaveToImage() /// /// Saves the disk image while holding , without unmounting. /// Intended for external shutdown-notification callers (e.g. Windows session-ending) that - /// need a quick, safe save that can't race the periodic auto-save tick. Does nothing if - /// is null, or if the disk's content hasn't - /// changed since the last successful save and the configured image path hasn't changed - /// either (same skip condition as ) — this keeps unmodified disks - /// out of the OS shutdown time budget. + /// need a quick, safe save that can't race the periodic auto-save tick. Does nothing when + /// is false — i.e. save-on-exit is disabled for this disk + /// (), or the disk's content hasn't changed since the + /// last successful save and the configured image path hasn't changed either. This keeps + /// opted-out or unmodified disks out of the OS shutdown time budget. /// public void SaveToImageSafe() { lock (_autoSaveLock) { - if (NeedsSave()) + if (NeedsExitSave()) { SaveToImage(); } @@ -613,6 +613,14 @@ private void ConfigureAutoSaveTimer() /// private bool NeedsSave() => _fs.IsDirty || Options.PersistImagePath != _lastSavedImagePath; + /// + /// Whether an exit/shutdown save should run: gated by + /// on top of the shared condition. Used by and + /// so a disk with save-on-exit disabled is left untouched when + /// the app exits or Windows shuts down, while periodic auto-save is unaffected. + /// + private bool NeedsExitSave() => Options.SaveImageOnExit && NeedsSave(); + /// /// Saves the disk image on the periodic timer tick, swallowing any exception so a failed /// save does not affect the mounted disk or crash the timer thread. If the previous tick's