Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Integrity/hardening notes: a v6 segment's plaintext is verified against its inde
- Disk presets: `DiskPreset`/`BuiltInPresets`/`PresetComposer`/`EnvRedirectPolicy` (`Core/DiskCreation`, pure) describe combinable templates; a disk card's context-menu "Presets" submenu (filled in `MainWindow.DiskContextMenu_Opened`) ticks/unticks presets live through `MainViewModel.GetActivePresetIds`/`SetPresetAsync`; the edit dialog ticks the presets a disk already has (`PresetSelection.Detect`), where for variable-redirecting presets the options are first reconciled with what the user's environment really points at (`MainViewModel.WithEnvironmentPresets` -> `PresetSelection.Reconcile`/`UserEnvironmentRedirector.PointsInto`) and ticking/unticking only changes folders and variables, keeping what no preset owns (`PresetSelection.Apply`); a live (no-remount) edit syncs the mounted disk via `DiskEffectsDiff` and `MainViewModel.SyncDiskEffectsAsync` (new variables applied, dropped ones restored, dropped folders left alone); a preset that redirects variables (`PresetSelection.IsExclusive`; not the folder-only browser one) lives on one disk only: creating or editing a disk to have it takes it from the others first (`MainViewModel.ReleaseClaimedPresetsAsync` -> `PresetSelection.Release`, variables restored, folders kept, unmounted profiles trimmed too), whereas auto-mount never takes over and still rejects a variable another disk owns; `DiskProfile` saves a disk's variable-redirecting presets as ids only (`DiskProfile.PresetIds`, via `PresetSelection.Split`/`Expand` in `ToProfile`/`ProfileToOptions`; older expanded profiles still load and are re-saved as ids), while the runtime `DiskOptions` keeps the expanded `Folders`/`EnvRedirects`; a disk keeps the result in `DiskOptions.Folders`/`EnvRedirects` (mirrored in `DiskProfile`, and `MainViewModel.ToProfile`/`ProfileToOptions` must carry any new field). A disk without an image is empty on every mount, so `MainViewModel.AddDiskSorted` runs `UserEnvironmentRedirector.ApplyDiskEffects` after each mount (creates the folders, points the per-user environment variables into the disk). The redirector stores each variable's previous registry value and kind in `EnvRedirectBackupStore` (`env-redirects.json` next to `settings.json`, so a damaged settings file cannot lose it) *before* writing, restores it when the disk leaves `Disks`, on exit (`RestoreAllEnvRedirects` in `ShutdownAsync`, and on Windows logoff/shutdown via `SessionEndingSaveHandler`'s `beforeSave` hook, which also resets TEMP and skips the broadcast) and for leftovers after a crash (`RestoreDanglingEnvRedirects` at startup, before the auto-mount); a variable is only restored while it still holds the value we wrote, and a variable already owned by another disk is refused. TEMP/TMP are ordinary redirects of the temp preset (`EnvRedirectPolicy` no longer reserves them), so they get the same backup/restore, and the context-menu "set as temp" action applies the same redirects; `MainViewModel.RestoreUserTemp` restores them from the backup and falls back to `TempDirResetService.Reset` (Windows defaults) only when nothing was recorded, e.g. a TEMP set by an older version. Ticking a temp-redirecting preset in the create dialog asks the one-time compatibility warning (`ConfirmTempDirWarning`). `IUserEnvironment` is the registry seam for tests. The `WM_SETTINGCHANGE` broadcast waits on every top-level window, so `TempDirResetService` sends it through `CoalescingBroadcaster` on a background thread (merged requests, `WaitForPendingBroadcasts` at exit) instead of on the caller's thread. Browsers have no cache environment variable, so that preset only creates a folder. The user-triggered restore (toolbar menu and tray submenu, `MainViewModel.GetEnvRestoreGroups`/`RestoreEnvAsync`) is a second path on the same backups: `EnvRestoreGroups` (Core, pure) groups the redirected variables by preset, `EnvRestoreService` restores one group (TEMP/TMP through `RestoreRecordedTemp`, which also covers a legacy TEMP without backup), and `DiskRestoreCoordinator` plus `DiskRestorePlanner` (Core, pure) make the mounted disks agree afterwards: before the restore it notes which presets/custom redirects point into each disk (`Plan`), afterwards it keeps only what really stopped pointing there (`Settle`, so a partly failed restore never leaves a disk claiming less than the environment backs) and drops it from the disk's options (`Apply`, same release as unticking the preset, folders on the disk stay). Call `DiskRestoreCoordinator.RunAsync`, not `Plan`/`TrimAsync` directly: it plans, runs the restore, trims even when the restore throws midway (some variables may already be back) and then rethrows (as an `AggregateException` with the report failure if the after-trim callback fails too). It runs on the UI thread under the busy overlay; `EnvRestoreMenuEntry` lays out the menu rows for both the window and the tray (`EnvRestoreTrayAction`). Keep these in step with the preset and backup rules above.
- Read-only disks apply no presets and never warn about usage: `DiskOptions.AppliesPresets()`/`GetEffectiveHighUsageWarnPercent()` (and `DiskProfile.AppliesPresets()`) are the one rule every consumer reads (`ApplyDiskEffects`, `GetActivePresetIds`, the preset context menu, `SetPresetAsync`, the release of exclusive presets, `DiskViewModel`), and `DiskOptions.WithoutReadOnlyExtras` strips the entries where a disk is made read-only on purpose (`CreateDiskOptionsBuilder`, `MountOptionsFactory.BuildArchiveOptions`). It is deliberately *not* applied in `ProfileToOptions` or `MountOptionsFactory.BuildImageOptions`: a saved profile and a one-time CLI `--read-only` override keep their presets and warning so they are not written back stripped; do not "fix" that asymmetry. The edit dialog refuses to tick Read Only while a preset is ticked or TEMP/TMP is redirected (`CreateDiskDialog.ShouldRefuseReadOnly`), and warns that saving drops what the disk has.
- Notifications: `DiskNotificationService` turns `DiskViewModel` events into status-bar text and tray balloons (balloons only while the main window is hidden). Besides high usage and save failures it reports refused writes: `MemoryFileSystem` raises `WriteRejected` (`WriteRejectionReason.LowMemory`/`DiskFull`, only on the refusal paths, at most once a second per reason) which `RamDisk` and `DiskViewModel` forward to the UI thread; the balloon is limited by `NotificationCooldown` (10 minutes per disk and reason, spent only when a balloon is actually shown). A 10 s poll feeds `LowMemoryMonitor` (pure; warns under 512 MB, recovers above 768 MB, 10-minute warning cooldown) so the user hears about low memory before the write guard starts refusing. `DiskViewModel.SaveRecovered` clears the sticky save-failure status once a save succeeds again. The disk card's redirect badge (`DiskViewModel.HasActiveRedirects`/`ActiveRedirectsTooltip`) shows what the environment really redirects into the disk, not only TEMP: `PresetSelection.DetectActive` (variable-redirecting presets whose variables all point into the disk, except that Temp also counts when TEMP alone does (`PresetSelection.TempPointsIntoDisk`, shared with `Reconcile`, which then syncs TMP too); plus custom variables) fed by `UserEnvVarCache` (5 s shared cache that bumps `Generation` only when a value changed or the app wrote the user environment) through `RedirectBadgeTracker`; all matching goes through `EnvRedirectPolicy.IsRedirectedInto`, also for `IsCurrentTempDir`, which stays TEMP-only and drives the unmount/reset logic. The status-bar file name is throttled to 500 ms (`DiskViewModel.ActivityThrottleWindow`).
- Exit save progress: `ShutdownAsync` calls `BeginExitSave` (sizes of the disks with `RamDisk.WillSaveOnDispose`, via `CollectExitSaveSizes`) *before* `MountManager.Dispose(Action<RamDisk, double>)`, whose callback fires from the parallel save threads (concurrently, possibly out of order) with just the disk and its own fraction. The callback only feeds `ExitSaveProgressTracker` (thread-safe, size-weighted, monotonic, ignores disks it was not given; until `Complete` bar and bytes stay under 99% because a disk clean at collection time may still be saved untracked) and pokes a `CoalescingSignal`, which refreshes the overlay (`ShowExitSaveProgress`) at `Background` priority; the overlay shows the aggregate ("Saving N disk images (k/N done)", the disk's name only when there is one), never the last reporter's own name/bytes, which made it flicker. `SaveFailed` calls `MarkExitSaveFailed`, which only counts once that disk's own save has begun (the periodic auto-save raises `SaveFailed` too); a failed disk never shows as finished. After `Dispose`, `CoalescingSignal.Complete()` then `CompleteExitSave(markAllFinished)` run (the latter with `false` when `Dispose` threw). Do not show per-disk text from the callback or restore an overall value in `MountManager`.
- Self-update: `UpdateCheckService` (check) -> `UpdateDialog` (prompt/progress) -> `UpdateInstaller` (download via GitHubReleaseUpdater into `%LOCALAPPDATA%\ManagedDrive\updates`, never `%TEMP%`, then start the Inno Setup installer with `/SILENT`). The app does not exit itself: `installer/ManagedDrive.iss` asks the running app to exit via `mdrive.exe exit` (saves disks) and restarts it afterwards. A TEMP on a RAM disk is reset first because the installer aborts silently in that case. Generic download/verify/launch logic lives in the separate GitHubReleaseUpdater library; keep only ManagedDrive-specific policy here.
- `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.
Expand Down
57 changes: 53 additions & 4 deletions src/ManagedDrive.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,11 @@ private async Task ShutdownAsync()
foreach (var disk in observedDisks)
{
var mountPoint = disk.Options.MountPoint;
disk.SaveFailed += (_, ex) => saveFailures.Enqueue(Loc.Format("Status.SaveFailed", mountPoint, ex.Message));
disk.SaveFailed += (_, ex) =>
{
saveFailures.Enqueue(Loc.Format("Status.SaveFailed", mountPoint, ex.Message));
_mainViewModel?.MarkExitSaveFailed(mountPoint);
};
}

TeardownBeforeMountManagerDispose();
Expand All @@ -888,11 +892,26 @@ private async Task ShutdownAsync()
await _mainViewModel.WaitForPendingUnmountsAsync();
}

// Isolated: only the progress display depends on it, so a failure must not skip the save.
RunTeardownStep(() => _mainViewModel?.BeginExitSave(CollectExitSaveSizes()));

// The disks report from their save threads, many times a second. The reports only update the
// view model's state; the screen is refreshed through a coalescing, below-input-priority
// delivery (like the startup splash) so they cannot flood the dispatcher or delay input.
var exitSaveRefresh = new CoalescingSignal(
() => _mainViewModel?.ShowExitSaveProgress(),
action => Dispatcher.BeginInvoke(DispatcherPriority.Background, action));
var disposed = false;
try
{
await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction, totalBytes) =>
Current.Dispatcher.BeginInvoke(() =>
_mainViewModel?.ReportExitSaveProgress(disk.Options.MountPoint, overallFraction, diskFraction, totalBytes))));
await Task.Run(() => _mountManager?.Dispose((disk, diskFraction) =>
{
if (_mainViewModel?.ReportExitSaveProgress(disk.Options.MountPoint, diskFraction) == true)
{
exitSaveRefresh.Notify();
}
}));
disposed = true;
}
catch (Exception ex)
{
Expand All @@ -901,6 +920,10 @@ await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction
_logger.LogError(ex, "Disposing the mounted disks failed during shutdown.");
}

// Shows the last value and drops deliveries still queued, then the final state.
exitSaveRefresh.Complete();
RunTeardownStep(() => _mainViewModel?.CompleteExitSave(markAllFinished: disposed));

if (!saveFailures.IsEmpty)
{
MessageBox.Show(
Expand All @@ -918,6 +941,32 @@ await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction
Shutdown();
}

/// <summary>
/// Sizes of the mounted disks whose final save will write an image, keyed by mount point, for
/// the exit progress. A disk that cannot be read (e.g. disposed by a concurrent unmount) is
/// left out and a repeated mount point is ignored, so this never throws.
/// </summary>
private Dictionary<string, ulong> CollectExitSaveSizes()
{
var sizes = new Dictionary<string, ulong>(StringComparer.OrdinalIgnoreCase);
foreach (var disk in _mountManager?.GetAll() ?? [])
{
try
{
if (disk.WillSaveOnDispose)
{
sizes.TryAdd(disk.Options.MountPoint, disk.UsedBytes);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not read a disk's size for the exit progress.");
}
}

return sizes;
}

/// <summary>
/// Common teardown shared by <see cref="App_Exit"/> and <see cref="ShutdownAsync"/>, run
/// before either one disposes <see cref="_mountManager"/> (which they do differently: a
Expand Down
3 changes: 3 additions & 0 deletions src/ManagedDrive.App/Localization/Strings.en-US.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
<sys:String x:Key="Card.ApplyingChanges">Applying changes...</sys:String>
<sys:String x:Key="Msg.ExitSaving">Saving disk images, please wait...</sys:String>
<sys:String x:Key="Msg.ExitSavingDisk">Saving "{0}"...</sys:String>
<sys:String x:Key="Msg.ExitSavingDisks">Saving {0} disk images ({1}/{0} done)...</sys:String>
<sys:String x:Key="Msg.ExitSavingFinishing">Finishing up...</sys:String>
<sys:String x:Key="Msg.ExitSavingFailed">{0} of {1} disk images could not be saved</sys:String>
<sys:String x:Key="Busy.SavingImage">Saving disk image...</sys:String>
<sys:String x:Key="Busy.CreatingSnapshot">Creating snapshot...</sys:String>
<sys:String x:Key="Busy.LoadingDisks">Loading disk {0} of {1}...</sys:String>
Expand Down
3 changes: 3 additions & 0 deletions src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
<sys:String x:Key="Card.ApplyingChanges">正在应用更改...</sys:String>
<sys:String x:Key="Msg.ExitSaving">正在保存磁盘镜像,请稍候...</sys:String>
<sys:String x:Key="Msg.ExitSavingDisk">正在保存 "{0}"...</sys:String>
<sys:String x:Key="Msg.ExitSavingDisks">正在保存 {0} 个磁盘镜像(已完成 {1}/{0})...</sys:String>
<sys:String x:Key="Msg.ExitSavingFinishing">正在完成...</sys:String>
<sys:String x:Key="Msg.ExitSavingFailed">{1} 个磁盘镜像中有 {0} 个保存失败</sys:String>
<sys:String x:Key="Busy.SavingImage">正在保存磁盘映像…</sys:String>
<sys:String x:Key="Busy.CreatingSnapshot">正在创建快照…</sys:String>
<sys:String x:Key="Busy.LoadingDisks">正在加载磁盘 {0}/{1}…</sys:String>
Expand Down
28 changes: 28 additions & 0 deletions src/ManagedDrive.App/Services/CoalescingSignal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace ManagedDrive.App.Services;

/// <summary>
/// Tells another thread (the UI thread) that "something changed, look again", merging signals that
/// arrive faster than it can act: while one refresh is still waiting, further signals add nothing.
/// For callers whose state lives elsewhere (read when the refresh runs); a value-carrying report
/// is <see cref="CoalescingProgress"/>.
/// </summary>
/// <param name="refresh">Runs on the thread chosen by <paramref name="post"/>, at most once per burst.</param>
/// <param name="post">Schedules the refresh on that thread.</param>
public sealed class CoalescingSignal(Action refresh, Action<Action> post)
{
/// <summary>
/// The coalescer doing the work; the value it carries is not used.
/// </summary>
private readonly CoalescingProgress _inner = new(_ => refresh(), post);

/// <summary>
/// Requests a refresh. Safe to call from any thread.
/// </summary>
public void Notify() => _inner.Report(0);

/// <summary>
/// Runs the refresh now if one is still waiting, on the calling thread (which must be the one
/// the refresh expects), and ignores every later signal and any refresh still queued.
/// </summary>
public void Complete() => _inner.Complete();
}
Loading
Loading