From 2b7995a941649946cfd343c85d14b889f76058e5 Mon Sep 17 00:00:00 2001 From: nicolasestrem <103498114+nicolasestrem@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:57:12 +0200 Subject: [PATCH 1/3] fix(wpf): harden restore completion and consent --- CHANGELOG.md | 10 +++ .../BackupRestoreOrchestrator.cs | 7 +- src/WinRestoreKit.Core/Results/RestorePlan.cs | 81 +++++++++++++++---- .../BackupWorkspaceViewModelTests.cs | 32 ++++++++ .../BackupWorkspaceViewTests.cs | 47 ++++++++--- .../ConfirmViewModelTests.cs | 45 ++++++++++- .../RebrandIdentityTests.cs | 10 +++ .../RestoreConsentDialogTests.cs | 36 +++++++++ src/WinRestoreKit.Tests/RestorePlanTests.cs | 59 ++++++++++++-- .../ResultWorkspaceViewModelTests.cs | 38 +++++++-- .../Navigation/CompareWorkflowNavigator.cs | 3 +- .../ViewModels/BackupWorkspaceViewModel.cs | 35 +++++++- .../ViewModels/ConfirmViewModel.cs | 29 ++++++- .../ViewModels/ResultWorkspaceViewModel.cs | 3 + .../ViewModels/ShellViewModel.cs | 4 + .../Views/BackupWorkspaceView.xaml | 6 +- src/WinRestoreKit.Wpf/Views/ConfirmView.xaml | 10 +-- .../Views/Dialogs/RestoreConsentDialog.xaml | 42 +++++++--- .../Dialogs/RestoreConsentDialog.xaml.cs | 14 +++- .../Views/ResultWorkspaceView.xaml | 9 ++- .../WinRestoreKit.Wpf.csproj | 6 ++ 21 files changed, 453 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e05459..3f48cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ version numbers, because that is what those releases were called. ## [Unreleased] +### Fixed: WPF restore completion and release truth + +- Aligned the WPF shell assembly and displayed product version with the canonical 0.0.1 release. +- Cleared snapshot validation as soon as invalid input is corrected and exposed the full validation, + severity, headline, and detail text to assistive technology. +- Made final restore consent resizable and scrollable so long paths wrap without clipping, and only + promised a pre-restore snapshot for modules that actually write settings. +- Routed completed restores to the result workspace and prevented the same confirmation page from + accidentally starting a second restore. + ### Fixed: restore and snapshot integrity - Verified successful manifest rows against their physical artifacts and kept unavailable modules out diff --git a/src/WinRestoreKit.Application/Orchestration/BackupRestoreOrchestrator.cs b/src/WinRestoreKit.Application/Orchestration/BackupRestoreOrchestrator.cs index a3a20dc..4fb2e4a 100644 --- a/src/WinRestoreKit.Application/Orchestration/BackupRestoreOrchestrator.cs +++ b/src/WinRestoreKit.Application/Orchestration/BackupRestoreOrchestrator.cs @@ -1152,9 +1152,10 @@ private async Task RunRestoreCore(IReadOnlyList selection) logger.LogMessage("Could not choose a snapshot folder name: " + ex.Message); } - // Composing the plan reads four virtual members off every selected module - - // RestoreTargets, ProcessesToCloseBeforeRestore, Title and WarningMessage - and any of - // the four can throw from a module written later. This stage sits between the try above + // Composing the plan reads five virtual members off every selected module: + // RestoreTargets, ProcessesToCloseBeforeRestore, Title, WarningMessage, and + // RestoreMakesChanges. Any of them can throw from a module written later. This stage sits + // between the try above // and the confirmation dialog, and the whole chain up to the async void click handler // has no catch, so an escaping exception here would surface as WinForms' unhandled // exception dialog mid-restore. diff --git a/src/WinRestoreKit.Core/Results/RestorePlan.cs b/src/WinRestoreKit.Core/Results/RestorePlan.cs index 02a1092..92fa062 100644 --- a/src/WinRestoreKit.Core/Results/RestorePlan.cs +++ b/src/WinRestoreKit.Core/Results/RestorePlan.cs @@ -48,15 +48,15 @@ public sealed class RestorePlan /// strongly in one place than in another. Both restore mechanisms are additive - regedit /s /// merges, and CopyFolder leaves destination files absent from the source in place - so /// offering "rollback" without this sentence would claim a guarantee the app does not hold. - /// - /// Written with an escaped dash so the sentence survives being compiled from a source file - /// with no byte order mark. /// public const string FidelityCaveat = "The snapshot can put back settings this restore overwrites. It cannot remove registry " + - "values or files that this restore adds \u2014 restoring the snapshot merges it over the " + + "values or files that this restore adds; restoring the snapshot merges it over the " + "current state rather than resetting to it."; + public const string NoSnapshotNotice = + "No pre-restore snapshot is created because the selected items do not write settings by themselves."; + public IReadOnlyList Modules { get; } /// The folder the restore reads from. @@ -68,6 +68,12 @@ public sealed class RestorePlan /// The module-by-module text: title, declared targets, and any warning. public string ConfirmationText { get; } + /// Whether any selected module writes settings and therefore needs a safety snapshot. + public bool NeedsSnapshot { get; } + + /// The exact safety-snapshot promise, or the reason no snapshot is created. + public string SnapshotNotice { get; } + /// One entry per distinct process that may only be closed with consent. public IReadOnlyList ConsentEntries { get; } @@ -99,7 +105,12 @@ public RestorePlan(IReadOnlyList modules, string restoreSourcePath, ConsentEntries = consent; InformationalCloseLines = informational; - ConfirmationText = Compose(Modules, restoreSourcePath, snapshotDestination); + List changeMaking = new List(); + List interactive = new List(); + ClassifyModules(Modules, changeMaking, interactive); + NeedsSnapshot = changeMaking.Count > 0; + SnapshotNotice = NeedsSnapshot ? FidelityCaveat : NoSnapshotNotice; + ConfirmationText = Compose(changeMaking, interactive, restoreSourcePath, snapshotDestination); } private static IReadOnlyList Compact(IReadOnlyList modules) @@ -144,17 +155,64 @@ private static void CollectCloses(IReadOnlyList modules, } } - private static string Compose(IReadOnlyList modules, string restoreSourcePath, - string snapshotDestination) + private static void ClassifyModules(IReadOnlyList modules, + ICollection changeMaking, + ICollection interactive) + { + foreach (BackupBase module in modules) + { + if (module.RestoreMakesChanges) + changeMaking.Add(module); + else + interactive.Add(module); + } + } + + private static string Compose(IReadOnlyList changeMaking, + IReadOnlyList interactive, + string restoreSourcePath, string snapshotDestination) { StringBuilder sb = new StringBuilder(); sb.AppendLine("Restoring from:"); sb.AppendLine(" " + restoreSourcePath); sb.AppendLine(); - sb.AppendLine("These items will be overwritten with what was backed up:"); - sb.AppendLine(); + if (changeMaking.Count > 0) + { + sb.AppendLine("These items will be overwritten with what was backed up:"); + sb.AppendLine(); + AppendModules(sb, changeMaking); + } + + if (interactive.Count > 0) + { + sb.AppendLine("These items open an interactive restore step and do not write settings by themselves:"); + sb.AppendLine(); + AppendModules(sb, interactive); + } + + if (changeMaking.Count == 0 && interactive.Count == 0) + { + sb.AppendLine("No restore items were selected."); + sb.AppendLine(); + } + + if (changeMaking.Count > 0) + { + sb.AppendLine("The current settings will first be copied to:"); + sb.AppendLine(" " + snapshotDestination); + } + else + { + sb.AppendLine(NoSnapshotNotice); + } + + return sb.ToString(); + } + + private static void AppendModules(StringBuilder sb, IReadOnlyList modules) + { foreach (BackupBase module in modules) { sb.AppendLine(module.Title); @@ -167,11 +225,6 @@ private static string Compose(IReadOnlyList modules, string restoreS sb.AppendLine(); } - - sb.AppendLine("The current settings will first be copied to:"); - sb.AppendLine(" " + snapshotDestination); - - return sb.ToString(); } /// diff --git a/src/WinRestoreKit.Tests/BackupWorkspaceViewModelTests.cs b/src/WinRestoreKit.Tests/BackupWorkspaceViewModelTests.cs index a2f2b9d..e92c1a2 100644 --- a/src/WinRestoreKit.Tests/BackupWorkspaceViewModelTests.cs +++ b/src/WinRestoreKit.Tests/BackupWorkspaceViewModelTests.cs @@ -72,6 +72,38 @@ public async Task StartAsync_BlankDestinationWithSelection_ShowsDestinationValid Assert.Equal("Choose a destination folder before capturing.", vm.ValidationMessage); } + [Fact] + public async Task Validation_ClearsAsSoonAsTheInvalidFieldOrSelectionIsCorrected() + { + var vm = new BackupWorkspaceViewModel(_ => Task.CompletedTask, string.Empty); + foreach (BackupScopeItemViewModel scope in vm.Scopes) + scope.IsSelected = false; + + await vm.StartAsync(); + Assert.NotNull(vm.ValidationMessage); + + vm.Scopes.Single(scope => scope.Name == "Explorer & shell").IsSelected = true; + Assert.Null(vm.ValidationMessage); + + await vm.StartAsync(); + Assert.NotNull(vm.ValidationMessage); + + vm.Destination = @"C:\snapshots"; + Assert.Null(vm.ValidationMessage); + } + + [Fact] + public async Task Validation_AutomationNameCarriesTheActualDiagnostic() + { + var vm = new BackupWorkspaceViewModel(_ => Task.CompletedTask, string.Empty); + foreach (BackupScopeItemViewModel scope in vm.Scopes) + scope.IsSelected = false; + + await vm.StartAsync(); + + Assert.Equal("Snapshot validation: " + vm.ValidationMessage, vm.ValidationAutomationName); + } + [Fact] public void UnfilteredEnvironmentScope_StartsUnselectedAndExposesItsCaution() { diff --git a/src/WinRestoreKit.Tests/BackupWorkspaceViewTests.cs b/src/WinRestoreKit.Tests/BackupWorkspaceViewTests.cs index bcbebae..d6033db 100644 --- a/src/WinRestoreKit.Tests/BackupWorkspaceViewTests.cs +++ b/src/WinRestoreKit.Tests/BackupWorkspaceViewTests.cs @@ -1,6 +1,9 @@ +using System; using System.Threading.Tasks; +using System.Windows; using System.Windows.Automation; using System.Windows.Controls; +using System.Windows.Threading; using WinRestoreKit.Wpf.ViewModels; using WinRestoreKit.Wpf.Views; using Xunit; @@ -14,21 +17,43 @@ public void View_ExposesScopeWarningsLabeledCompressionAndExactAutomationIds() { WpfTestHost.Run(() => { + var viewModel = new BackupWorkspaceViewModel(_ => Task.CompletedTask, @"C:\snapshots"); + foreach (BackupScopeItemViewModel scope in viewModel.Scopes) + scope.IsSelected = false; + viewModel.StartAsync().GetAwaiter().GetResult(); + var view = new BackupWorkspaceView { - DataContext = new BackupWorkspaceViewModel(_ => Task.CompletedTask, @"C:\snapshots") + DataContext = viewModel }; + var host = new Window { Content = view, Width = 1024, Height = 720 }; + host.Show(); + host.UpdateLayout(); + Dispatcher.CurrentDispatcher.Invoke( + DispatcherPriority.ApplicationIdle, + new Action(() => { })); + + try + { + Assert.Equal("BackupWorkspace", AutomationProperties.GetAutomationId(view)); + Assert.NotNull(view.FindName("BackupScopeList")); + Assert.NotNull(view.FindName("CreateSnapshotButton")); + Assert.NotNull(view.FindName("CompressionComboBox")); + Assert.Equal("BackupDestinationTextBox", AutomationProperties.GetAutomationId( + Assert.IsType(view.FindName("DestinationTextBox")))); + Assert.Equal("CreateSnapshotButton", AutomationProperties.GetAutomationId( + Assert.IsType