From af43fe70144dd2f211ed53200d33d9c15c75924a Mon Sep 17 00:00:00 2001
From: nicolasestrem <103498114+nicolasestrem@users.noreply.github.com>
Date: Mon, 3 Aug 2026 07:22:04 +0200
Subject: [PATCH 1/9] feat: rebuild Industry UI
---
CHANGELOG.md | 6 +
src/WinRestoreKit.Core/BackupBase.cs | 70 ++
src/WinRestoreKit.Core/CompressionLevel.cs | 9 +
.../Conf/EEnvironmentFiltered.cs | 4 +
.../Conf/MultiKeyRegistryModule.cs | 32 +
src/WinRestoreKit.Core/Conf/RegistryModule.cs | 11 +
.../Results/BackupManifest.cs | 47 +-
.../Results/BackupNaming.cs | 63 ++
.../Results/BackupPayload.cs | 286 ++++++
.../Results/DriftDetector.cs | 37 +
src/WinRestoreKit.Core/Results/RegFile.cs | 39 +
.../Results/RestoreContents.cs | 34 +-
.../BackupManifestTests.cs | 39 +
.../BackupPageViewTests.cs | 61 ++
src/WinRestoreKit.Tests/BackupPayloadTests.cs | 46 +
.../HomePageViewDriftTests.cs | 109 +++
.../MainFormRunAdmissionTests.cs | 56 ++
.../NavigationServiceTests.cs | 31 +
src/WinRestoreKit.Tests/OsVersionTests.cs | 18 -
.../ProgressMetricsTests.cs | 50 +
.../ProgressPageViewTests.cs | 29 +
src/WinRestoreKit.Tests/RegistryDriftTests.cs | 111 +++
src/WinRestoreKit.Tests/RunControlTests.cs | 89 ++
.../RunCoordinatorTests.cs | 72 ++
src/WinRestoreKit.Tests/ShellLayoutTests.cs | 44 +
.../SnapshotCompressionTests.cs | 19 +
.../SnapshotFolderPathTests.cs | 64 ++
src/WinRestoreKit.Tests/SnapshotNameTests.cs | 49 +
.../ViewDataHelperTests.cs | 114 +++
src/WinRestoreKit/Controls/BlueprintFrame.cs | 69 ++
src/WinRestoreKit/Controls/CustomCheckbox.cs | 139 +++
src/WinRestoreKit/Controls/KeyedMark.cs | 63 ++
src/WinRestoreKit/Controls/NavButton.cs | 339 +++++++
.../Controls/SegmentedControl.cs | 141 +++
src/WinRestoreKit/Controls/TagChip.cs | 97 ++
src/WinRestoreKit/Fonts/Barlow-Medium.otf | Bin 0 -> 135264 bytes
src/WinRestoreKit/Fonts/Barlow-Regular.otf | Bin 0 -> 136284 bytes
src/WinRestoreKit/Fonts/Barlow-SemiBold.otf | Bin 0 -> 135084 bytes
.../Fonts/BarlowCondensed-Bold.otf | Bin 0 -> 144380 bytes
.../Fonts/BarlowCondensed-Regular.otf | Bin 0 -> 140412 bytes
.../Fonts/BarlowCondensed-SemiBold.otf | Bin 0 -> 144004 bytes
.../Fonts/IBMPlexMono-Medium.ttf | Bin 0 -> 156996 bytes
.../Fonts/IBMPlexMono-Regular.ttf | Bin 0 -> 155940 bytes
.../Forms/RestoreConfirmForm.Designer.cs | 20 +-
src/WinRestoreKit/Forms/RestoreConfirmForm.cs | 29 +-
src/WinRestoreKit/Helpers/FontLoader.cs | 70 ++
src/WinRestoreKit/Helpers/ProgressLogSink.cs | 47 +
src/WinRestoreKit/Helpers/Theme.cs | 426 ++++++---
src/WinRestoreKit/Helpers/Ui.cs | 101 +-
src/WinRestoreKit/MainForm.Designer.cs | 507 +++++-----
src/WinRestoreKit/MainForm.cs | 471 +++++-----
.../BackupRestoreOrchestrator.cs | 402 +++++++-
src/WinRestoreKit/Orchestration/IRunUi.cs | 7 +
src/WinRestoreKit/Orchestration/RunControl.cs | 106 +++
.../Orchestration/RunCoordinator.cs | 46 +
src/WinRestoreKit/Program.cs | 1 +
src/WinRestoreKit/Results/RunSummary.cs | 16 +
.../Views/AboutPageView.Designer.cs | 242 +----
src/WinRestoreKit/Views/AboutPageView.cs | 477 +++++++++-
src/WinRestoreKit/Views/BackupFolders.cs | 9 +
.../Views/BackupPageView.Designer.cs | 437 ---------
src/WinRestoreKit/Views/BackupPageView.cs | 885 +++++++++---------
src/WinRestoreKit/Views/HistoryPageView.cs | 806 +++++++++++-----
src/WinRestoreKit/Views/HomePageView.cs | 657 +++++++------
src/WinRestoreKit/Views/ProgressPageView.cs | 777 +++++++++++++++
.../Views/RestoreWizardStep1View.cs | 428 ++++++---
.../Views/RestoreWizardStep2View.cs | 602 ++++++------
src/WinRestoreKit/Views/RunResultsPanel.cs | 360 -------
src/WinRestoreKit/Views/ScopeGroups.cs | 160 ++++
src/WinRestoreKit/Views/WatchedGroups.cs | 32 +
src/WinRestoreKit/WinRestoreKit.csproj | 5 +
71 files changed, 7483 insertions(+), 3130 deletions(-)
create mode 100644 src/WinRestoreKit.Core/CompressionLevel.cs
create mode 100644 src/WinRestoreKit.Core/Results/BackupNaming.cs
create mode 100644 src/WinRestoreKit.Core/Results/BackupPayload.cs
create mode 100644 src/WinRestoreKit.Core/Results/DriftDetector.cs
create mode 100644 src/WinRestoreKit.Tests/BackupPageViewTests.cs
create mode 100644 src/WinRestoreKit.Tests/BackupPayloadTests.cs
create mode 100644 src/WinRestoreKit.Tests/HomePageViewDriftTests.cs
create mode 100644 src/WinRestoreKit.Tests/MainFormRunAdmissionTests.cs
create mode 100644 src/WinRestoreKit.Tests/NavigationServiceTests.cs
create mode 100644 src/WinRestoreKit.Tests/ProgressMetricsTests.cs
create mode 100644 src/WinRestoreKit.Tests/ProgressPageViewTests.cs
create mode 100644 src/WinRestoreKit.Tests/RegistryDriftTests.cs
create mode 100644 src/WinRestoreKit.Tests/RunControlTests.cs
create mode 100644 src/WinRestoreKit.Tests/RunCoordinatorTests.cs
create mode 100644 src/WinRestoreKit.Tests/ShellLayoutTests.cs
create mode 100644 src/WinRestoreKit.Tests/SnapshotCompressionTests.cs
create mode 100644 src/WinRestoreKit.Tests/SnapshotFolderPathTests.cs
create mode 100644 src/WinRestoreKit.Tests/SnapshotNameTests.cs
create mode 100644 src/WinRestoreKit.Tests/ViewDataHelperTests.cs
create mode 100644 src/WinRestoreKit/Controls/BlueprintFrame.cs
create mode 100644 src/WinRestoreKit/Controls/CustomCheckbox.cs
create mode 100644 src/WinRestoreKit/Controls/KeyedMark.cs
create mode 100644 src/WinRestoreKit/Controls/NavButton.cs
create mode 100644 src/WinRestoreKit/Controls/SegmentedControl.cs
create mode 100644 src/WinRestoreKit/Controls/TagChip.cs
create mode 100644 src/WinRestoreKit/Fonts/Barlow-Medium.otf
create mode 100644 src/WinRestoreKit/Fonts/Barlow-Regular.otf
create mode 100644 src/WinRestoreKit/Fonts/Barlow-SemiBold.otf
create mode 100644 src/WinRestoreKit/Fonts/BarlowCondensed-Bold.otf
create mode 100644 src/WinRestoreKit/Fonts/BarlowCondensed-Regular.otf
create mode 100644 src/WinRestoreKit/Fonts/BarlowCondensed-SemiBold.otf
create mode 100644 src/WinRestoreKit/Fonts/IBMPlexMono-Medium.ttf
create mode 100644 src/WinRestoreKit/Fonts/IBMPlexMono-Regular.ttf
create mode 100644 src/WinRestoreKit/Helpers/FontLoader.cs
create mode 100644 src/WinRestoreKit/Helpers/ProgressLogSink.cs
create mode 100644 src/WinRestoreKit/Orchestration/RunControl.cs
create mode 100644 src/WinRestoreKit/Orchestration/RunCoordinator.cs
delete mode 100644 src/WinRestoreKit/Views/BackupPageView.Designer.cs
create mode 100644 src/WinRestoreKit/Views/ProgressPageView.cs
delete mode 100644 src/WinRestoreKit/Views/RunResultsPanel.cs
create mode 100644 src/WinRestoreKit/Views/ScopeGroups.cs
create mode 100644 src/WinRestoreKit/Views/WatchedGroups.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6eff854..59d870e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ version numbers, because that is what those releases were called.
## [Unreleased]
## [0.0.1] - 2026-08-02
+### Changed
+
+- Rebuilt the app shell and all primary views with the Industry design system: bundled Barlow, Barlow Condensed, and IBM Plex Mono typography; Voltage, Flux, and Follow system palettes; blueprint frames; icon rail navigation; and a dedicated progress view.
+- Added snapshot display names, selectable destination folders, Fast and Max archive compression, archive-backed restore discovery, live registry drift detection, rich backup progress metrics, and safe pause or cancel controls.
+- Reworked backup, restore, History, Home, and About around real manifest and module data. Existing backup folders and frozen manifest keys remain compatible.
+
First WinRestoreKit version. The application was renamed from Appcopier and moved to a standalone
repository; the version series restarts here rather than continuing Appcopier's, because this is a
diff --git a/src/WinRestoreKit.Core/BackupBase.cs b/src/WinRestoreKit.Core/BackupBase.cs
index 6bb0121..2b5831b 100644
--- a/src/WinRestoreKit.Core/BackupBase.cs
+++ b/src/WinRestoreKit.Core/BackupBase.cs
@@ -62,6 +62,76 @@ public abstract class BackupBase
///
public virtual bool? HasArtifactIn(string backupPath) => null;
+ ///
+ /// Whether this module's live state differs from the state captured in
+ /// .
+ ///
+ ///
+ /// A module that cannot compare its live state with the backup returns null rather than
+ /// guessing. Callers render only confirmed drift.
+ ///
+ public virtual bool? HasDriftedFrom(string backupPath) => null;
+
+ ///
+ /// Compares a recorded registry export with a fresh, temporary export of the live key.
+ ///
+ ///
+ /// The fresh export is deliberately outside the backup directory. Drift reads must never
+ /// clear, replace, or add to an existing snapshot. A missing or unreadable recorded artifact,
+ /// an indeterminate probe, and a failed fresh export all return null because none establishes
+ /// a comparison.
+ ///
+ protected static bool? HasDriftedFromRegistryArtifact(string artifactPath, string registryPath)
+ {
+ if (RegFile.Validate(artifactPath) != RegFileCheck.Valid)
+ return null;
+
+ KeyProbe probe = Utils.ProbeKey(registryPath);
+
+ if (probe == KeyProbe.Indeterminate)
+ return null;
+
+ // A valid export records a present key. The live key's confirmed absence is therefore
+ // real drift, without relying on a file timestamp.
+ if (probe == KeyProbe.Absent)
+ return true;
+
+ string currentArtifact = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".reg");
+
+ if (IsInSameDirectory(currentArtifact, artifactPath))
+ return null;
+
+ try
+ {
+ StepResult export = Utils.ExportRegistryKey(currentArtifact, registryPath, absenceIsNormal: false);
+
+ if (export.State != ResultState.Succeeded)
+ return null;
+
+ bool? same = RegFile.HasSameCanonicalContent(artifactPath, currentArtifact);
+ return same.HasValue ? !same.Value : null;
+ }
+ finally
+ {
+ try { File.Delete(currentArtifact); } catch { }
+ }
+ }
+
+ private static bool IsInSameDirectory(string firstPath, string secondPath)
+ {
+ try
+ {
+ string firstDirectory = Path.GetDirectoryName(Path.GetFullPath(firstPath));
+ string secondDirectory = Path.GetDirectoryName(Path.GetFullPath(secondPath));
+
+ return string.Equals(firstDirectory, secondDirectory, StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
///
/// The backup file this module writes for one registry key.
///
diff --git a/src/WinRestoreKit.Core/CompressionLevel.cs b/src/WinRestoreKit.Core/CompressionLevel.cs
new file mode 100644
index 0000000..0fcc4de
--- /dev/null
+++ b/src/WinRestoreKit.Core/CompressionLevel.cs
@@ -0,0 +1,9 @@
+namespace WinRestoreKit
+{
+ public enum SnapshotCompression
+ {
+ None,
+ Fast,
+ Max
+ }
+}
diff --git a/src/WinRestoreKit.Core/Conf/EEnvironmentFiltered.cs b/src/WinRestoreKit.Core/Conf/EEnvironmentFiltered.cs
index 80901e9..94593a3 100644
--- a/src/WinRestoreKit.Core/Conf/EEnvironmentFiltered.cs
+++ b/src/WinRestoreKit.Core/Conf/EEnvironmentFiltered.cs
@@ -81,6 +81,10 @@ public override ModuleResult Backup(string path)
return ModuleResult.Aggregate(new[] { export, Describe(outcome) });
}
+ // The recorded export deliberately omits values selected by RegSecretFilter. Comparing it
+ // with the unfiltered live key would manufacture drift, so this module remains unknown.
+ public override bool? HasDriftedFrom(string backupPath) => null;
+
///
/// Deletes the export the filter could not process, and fails the step.
///
diff --git a/src/WinRestoreKit.Core/Conf/MultiKeyRegistryModule.cs b/src/WinRestoreKit.Core/Conf/MultiKeyRegistryModule.cs
index 16b0403..5118655 100644
--- a/src/WinRestoreKit.Core/Conf/MultiKeyRegistryModule.cs
+++ b/src/WinRestoreKit.Core/Conf/MultiKeyRegistryModule.cs
@@ -111,5 +111,37 @@ public override ModuleResult Restore(string path)
return false;
}
+
+ ///
+ /// Reports confirmed drift from any recorded key, while keeping incomplete comparisons unknown.
+ ///
+ public override bool? HasDriftedFrom(string backupPath)
+ {
+ if (string.IsNullOrWhiteSpace(backupPath))
+ return null;
+
+ bool comparedAny = false;
+ bool hasUnknown = false;
+
+ foreach (string key in Keys)
+ {
+ bool? drift = HasDriftedFromRegistryArtifact(
+ Path.Combine(backupPath, RegFileNameFor(key)),
+ key);
+
+ if (!drift.HasValue)
+ {
+ hasUnknown = true;
+ continue;
+ }
+
+ comparedAny = true;
+
+ if (drift.Value)
+ return true;
+ }
+
+ return comparedAny && !hasUnknown ? false : null;
+ }
}
}
diff --git a/src/WinRestoreKit.Core/Conf/RegistryModule.cs b/src/WinRestoreKit.Core/Conf/RegistryModule.cs
index 51cbbd0..c614299 100644
--- a/src/WinRestoreKit.Core/Conf/RegistryModule.cs
+++ b/src/WinRestoreKit.Core/Conf/RegistryModule.cs
@@ -52,6 +52,17 @@ public override ModuleResult Restore(string path)
public override bool? HasArtifactIn(string backupPath)
=> !string.IsNullOrWhiteSpace(backupPath) && File.Exists(FileFor(backupPath));
+ ///
+ /// Compares the one exported key this module records with its current live representation.
+ ///
+ public override bool? HasDriftedFrom(string backupPath)
+ {
+ if (string.IsNullOrWhiteSpace(backupPath))
+ return null;
+
+ return HasDriftedFromRegistryArtifact(FileFor(backupPath), Key);
+ }
+
// One key, so one file, and the name does not need to encode which key it holds. Overriding
// rather than inheriting the key-derived default keeps the filenames these ten modules have
// always written, so existing backups stay restorable.
diff --git a/src/WinRestoreKit.Core/Results/BackupManifest.cs b/src/WinRestoreKit.Core/Results/BackupManifest.cs
index 53bc621..82a2b69 100644
--- a/src/WinRestoreKit.Core/Results/BackupManifest.cs
+++ b/src/WinRestoreKit.Core/Results/BackupManifest.cs
@@ -57,7 +57,10 @@ internal static string Compose(IReadOnlyList modules,
string machineName,
string userName,
string osBuild,
- string appVersion)
+ string appVersion,
+ string snapshotName = null,
+ SnapshotCompression compression = SnapshotCompression.None,
+ string payloadFile = null)
{
JArray moduleRows = new JArray();
@@ -96,6 +99,15 @@ internal static string Compose(IReadOnlyList modules,
["modules"] = moduleRows
};
+ if (!string.IsNullOrEmpty(snapshotName))
+ root["snapshot_name"] = snapshotName;
+
+ if (!string.IsNullOrEmpty(payloadFile))
+ {
+ root["compression"] = compression.ToString().ToLowerInvariant();
+ root["payload_file"] = payloadFile;
+ }
+
return root.ToString(Formatting.Indented);
}
@@ -179,6 +191,9 @@ internal static ManifestData TryParse(string json)
Text(root["machine_name"]),
Text(root["user_name"]),
Text(root["os_build"]),
+ Text(root["snapshot_name"]),
+ Text(root["compression"]),
+ Text(root["payload_file"]),
modules);
}
@@ -262,6 +277,21 @@ internal sealed class ManifestData
{
internal ManifestData(int manifestVersion, string appVersion, string created, string machineName,
string userName, string osBuild, IReadOnlyList modules)
+ : this(manifestVersion, appVersion, created, machineName, userName, osBuild, null, null, null, modules)
+ {
+ }
+
+ internal ManifestData(int manifestVersion, string appVersion, string created, string machineName,
+ string userName, string osBuild, string snapshotName,
+ IReadOnlyList modules)
+ : this(manifestVersion, appVersion, created, machineName, userName, osBuild,
+ snapshotName, null, null, modules)
+ {
+ }
+
+ internal ManifestData(int manifestVersion, string appVersion, string created, string machineName,
+ string userName, string osBuild, string snapshotName,
+ string compression, string payloadFile, IReadOnlyList modules)
{
ManifestVersion = manifestVersion;
AppVersion = appVersion;
@@ -269,6 +299,9 @@ internal ManifestData(int manifestVersion, string appVersion, string created, st
MachineName = machineName;
UserName = userName;
OsBuild = osBuild;
+ SnapshotName = snapshotName;
+ Compression = compression;
+ PayloadFile = payloadFile;
Modules = modules;
}
@@ -285,6 +318,18 @@ internal ManifestData(int manifestVersion, string appVersion, string created, st
internal string OsBuild { get; }
+ ///
+ /// Optional user-supplied name for the backup folder. A missing value identifies a legacy
+ /// timestamp-named folder and callers must display the folder name instead.
+ ///
+ internal string SnapshotName { get; }
+
+
+ /// Optional archive compression used for the payload, when one exists.
+ internal string Compression { get; }
+
+ /// Optional archive file that holds this backup's module artifacts.
+ internal string PayloadFile { get; }
internal IReadOnlyList Modules { get; }
}
diff --git a/src/WinRestoreKit.Core/Results/BackupNaming.cs b/src/WinRestoreKit.Core/Results/BackupNaming.cs
new file mode 100644
index 0000000..0da2ac0
--- /dev/null
+++ b/src/WinRestoreKit.Core/Results/BackupNaming.cs
@@ -0,0 +1,63 @@
+using System;
+using System.IO;
+
+namespace WinRestoreKit
+{
+ ///
+ /// Validates an optional name for a user-created backup folder.
+ ///
+ ///
+ /// Custom names are stored and used verbatim. Validation deliberately rejects rather than changes
+ /// input, because silently changing a name would make the destination shown to the user differ
+ /// from the folder written to disk. Empty input means the caller keeps the legacy timestamp name.
+ ///
+ internal static class BackupNaming
+ {
+ private const int MaxSegmentLength = 120;
+
+ internal static bool TryValidateCustomName(string value, out string name)
+ {
+ name = null;
+
+ if (string.IsNullOrEmpty(value))
+ return true;
+
+ if (string.IsNullOrWhiteSpace(value)
+ || value.Length > MaxSegmentLength
+ || value == "."
+ || value == ".."
+ || value.EndsWith(" ", StringComparison.Ordinal)
+ || value.EndsWith(".", StringComparison.Ordinal)
+ || value.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
+ || value.IndexOf('/') >= 0
+ || value.IndexOf('\\') >= 0
+ || IsReservedDeviceName(value))
+ {
+ return false;
+ }
+
+ name = value;
+ return true;
+ }
+
+ private static bool IsReservedDeviceName(string value)
+ {
+ int extension = value.IndexOf('.');
+ string baseName = extension < 0 ? value : value.Substring(0, extension);
+
+ if (baseName.Equals("CON", StringComparison.OrdinalIgnoreCase)
+ || baseName.Equals("PRN", StringComparison.OrdinalIgnoreCase)
+ || baseName.Equals("AUX", StringComparison.OrdinalIgnoreCase)
+ || baseName.Equals("NUL", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return baseName.Length == 4
+ && (baseName.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
+ || baseName.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
+ && baseName[3] >= '1'
+ && baseName[3] <= '9';
+ }
+ }
+}
diff --git a/src/WinRestoreKit.Core/Results/BackupPayload.cs b/src/WinRestoreKit.Core/Results/BackupPayload.cs
new file mode 100644
index 0000000..8dca7ed
--- /dev/null
+++ b/src/WinRestoreKit.Core/Results/BackupPayload.cs
@@ -0,0 +1,286 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+
+namespace WinRestoreKit
+{
+ ///
+ /// Creates and opens the optional archive payload for a backup folder.
+ ///
+ ///
+ /// The manifest and human-readable log remain at the backup root so existing readers retain
+ /// their metadata path. Module artifacts move into the archive only after its entries have been
+ /// reopened and checked against the source file list. Legacy folders without this file are read
+ /// directly and never require extraction.
+ ///
+ internal static class BackupPayload
+ {
+ internal const string FileName = "payload.zip";
+
+ private static readonly HashSet RootMetadata = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ BackupManifest.FileName,
+ "backup_log.txt",
+ FileName
+ };
+
+ internal static bool TryArchive(string backupPath, SnapshotCompression compression, out string error)
+ {
+ error = null;
+
+ if (compression == SnapshotCompression.None)
+ return false;
+
+ if (compression != SnapshotCompression.Fast && compression != SnapshotCompression.Max)
+ {
+ error = "The requested compression mode is not supported.";
+ return false;
+ }
+
+ try
+ {
+ List sourceFiles = ListPayloadFiles(backupPath);
+ List emptyDirectories = ListEmptyPayloadDirectories(backupPath);
+ string payloadPath = Path.Combine(backupPath, FileName);
+ string temporaryPath = Path.Combine(backupPath, ".payload-"
+ + Guid.NewGuid().ToString("N") + ".tmp");
+
+ using (FileStream stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
+ using (ZipArchive archive = new ZipArchive(stream, ZipArchiveMode.Create, false))
+ {
+ CompressionLevel level = compression == SnapshotCompression.Fast
+ ? CompressionLevel.Fastest
+ : CompressionLevel.SmallestSize;
+
+ foreach (SourceFile source in sourceFiles)
+ archive.CreateEntryFromFile(source.FullPath, source.EntryName, level);
+
+ foreach (string directory in emptyDirectories)
+ archive.CreateEntry(directory + "/", CompressionLevel.NoCompression);
+ }
+
+ if (!ArchiveMatches(temporaryPath, sourceFiles, emptyDirectories))
+ {
+ File.Delete(temporaryPath);
+ error = "The compressed payload could not be verified.";
+ return false;
+ }
+
+ File.Move(temporaryPath, payloadPath, true);
+
+ foreach (SourceFile source in sourceFiles)
+ File.Delete(source.FullPath);
+
+ RemoveEmptyDirectories(backupPath);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ return false;
+ }
+ }
+
+ internal static bool TryPrepareForRead(string backupPath, out ReadScope payload, out string error)
+ {
+ payload = null;
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(backupPath))
+ {
+ error = "The backup folder is empty.";
+ return false;
+ }
+
+ string payloadPath = Path.Combine(backupPath, FileName);
+
+ if (!File.Exists(payloadPath))
+ {
+ payload = new ReadScope(backupPath, null);
+ return true;
+ }
+
+ string extractionPath = Path.Combine(Path.GetTempPath(), "WinRestoreKit", "payload-"
+ + Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ Directory.CreateDirectory(extractionPath);
+ string extractionRoot = Path.GetFullPath(extractionPath)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ + Path.DirectorySeparatorChar;
+
+ using (ZipArchive archive = ZipFile.OpenRead(payloadPath))
+ {
+ foreach (ZipArchiveEntry entry in archive.Entries)
+ {
+ string destination = Path.GetFullPath(Path.Combine(extractionPath, entry.FullName));
+
+ if (!destination.StartsWith(extractionRoot, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException("The payload contains an unsafe entry path.");
+
+ if (string.IsNullOrEmpty(entry.Name))
+ {
+ Directory.CreateDirectory(destination);
+ continue;
+ }
+
+ string directory = Path.GetDirectoryName(destination);
+
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ using (Stream input = entry.Open())
+ using (FileStream output = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None))
+ input.CopyTo(output);
+ }
+ }
+
+ payload = new ReadScope(extractionPath, extractionPath);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+
+ try
+ {
+ if (Directory.Exists(extractionPath))
+ Directory.Delete(extractionPath, true);
+ }
+ catch (Exception)
+ {
+ }
+
+ return false;
+ }
+ }
+
+ private static List ListPayloadFiles(string backupPath)
+ {
+ List files = new List();
+ string root = Path.GetFullPath(backupPath)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ + Path.DirectorySeparatorChar;
+
+ foreach (string path in Directory.EnumerateFiles(backupPath, "*", SearchOption.AllDirectories))
+ {
+ string fullPath = Path.GetFullPath(path);
+ string relative = fullPath.Substring(root.Length);
+
+ if (relative.IndexOf(Path.DirectorySeparatorChar) < 0
+ && (RootMetadata.Contains(relative)
+ || relative.StartsWith(".payload-", StringComparison.OrdinalIgnoreCase)))
+ {
+ continue;
+ }
+
+ files.Add(new SourceFile(fullPath, relative.Replace(Path.DirectorySeparatorChar, '/')));
+ }
+
+ return files;
+ }
+
+ private static List ListEmptyPayloadDirectories(string backupPath)
+ {
+ List directories = new List();
+ string root = Path.GetFullPath(backupPath)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ + Path.DirectorySeparatorChar;
+
+ foreach (string path in Directory.EnumerateDirectories(backupPath, "*", SearchOption.AllDirectories))
+ {
+ if (!Directory.EnumerateFileSystemEntries(path).GetEnumerator().MoveNext())
+ {
+ string relative = Path.GetFullPath(path).Substring(root.Length)
+ .Replace(Path.DirectorySeparatorChar, '/');
+ directories.Add(relative);
+ }
+ }
+
+ return directories;
+ }
+
+ private static bool ArchiveMatches(string archivePath, IReadOnlyList files,
+ IReadOnlyList emptyDirectories)
+ {
+ using (ZipArchive archive = ZipFile.OpenRead(archivePath))
+ {
+ if (archive.Entries.Count != files.Count + emptyDirectories.Count)
+ return false;
+
+ foreach (SourceFile source in files)
+ {
+ ZipArchiveEntry entry = archive.GetEntry(source.EntryName);
+
+ if (entry == null || entry.Length != new FileInfo(source.FullPath).Length)
+ return false;
+ }
+
+ foreach (string directory in emptyDirectories)
+ {
+ if (archive.GetEntry(directory + "/") == null)
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static void RemoveEmptyDirectories(string root)
+ {
+ List directories = new List(
+ Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories));
+
+ directories.Sort((left, right) => right.Length.CompareTo(left.Length));
+
+ foreach (string directory in directories)
+ {
+ if (!Directory.EnumerateFileSystemEntries(directory).GetEnumerator().MoveNext())
+ Directory.Delete(directory);
+ }
+ }
+
+ private sealed class SourceFile
+ {
+ internal SourceFile(string fullPath, string entryName)
+ {
+ FullPath = fullPath;
+ EntryName = entryName;
+ }
+
+ internal string FullPath { get; }
+
+ internal string EntryName { get; }
+ }
+
+ internal sealed class ReadScope : IDisposable
+ {
+ private readonly string ownedPath;
+
+ internal ReadScope(string path, string ownedPath)
+ {
+ Path = path;
+ this.ownedPath = ownedPath;
+ }
+
+ internal string Path { get; }
+
+ public void Dispose()
+ {
+ if (ownedPath == null)
+ return;
+
+ try
+ {
+ if (Directory.Exists(ownedPath))
+ Directory.Delete(ownedPath, true);
+ }
+ catch (Exception)
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/src/WinRestoreKit.Core/Results/DriftDetector.cs b/src/WinRestoreKit.Core/Results/DriftDetector.cs
new file mode 100644
index 0000000..928165f
--- /dev/null
+++ b/src/WinRestoreKit.Core/Results/DriftDetector.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+
+namespace WinRestoreKit
+{
+ internal sealed class DriftItem
+ {
+ internal DriftItem(string name, string path, DateTime? changedAt)
+ {
+ Name = name;
+ Path = path;
+ ChangedAt = changedAt;
+ }
+
+ internal string Name { get; }
+
+ internal string Path { get; }
+
+ internal DateTime? ChangedAt { get; }
+ }
+
+ internal static class DriftDetector
+ {
+ internal static IReadOnlyList Detect(string backupPath, IReadOnlyList modules)
+ {
+ List drifted = new List();
+
+ foreach (BackupBase module in modules)
+ {
+ if (module.HasDriftedFrom(backupPath) == true)
+ drifted.Add(new DriftItem(module.Title, backupPath, null));
+ }
+
+ return drifted;
+ }
+ }
+}
diff --git a/src/WinRestoreKit.Core/Results/RegFile.cs b/src/WinRestoreKit.Core/Results/RegFile.cs
index f6af1a1..3a58f65 100644
--- a/src/WinRestoreKit.Core/Results/RegFile.cs
+++ b/src/WinRestoreKit.Core/Results/RegFile.cs
@@ -65,5 +65,44 @@ internal static RegFileCheck Validate(string path, out string error)
? RegFileCheck.Valid
: RegFileCheck.BadHeader;
}
+
+ ///
+ /// Compares two valid registry exports after normalizing their text encoding and line endings.
+ ///
+ ///
+ /// Returns null when either artifact is absent, unreadable, or not a valid registry export.
+ /// That is deliberately not drift evidence: a comparison is only meaningful when both sides
+ /// are known representations of registry state.
+ ///
+ internal static bool? HasSameCanonicalContent(string capturedPath, string currentPath)
+ {
+ string captured = ReadCanonicalContent(capturedPath);
+ string current = ReadCanonicalContent(currentPath);
+
+ if (captured == null || current == null)
+ return null;
+
+ return string.Equals(captured, current, StringComparison.Ordinal);
+ }
+
+ private static string ReadCanonicalContent(string path)
+ {
+ if (Validate(path) != RegFileCheck.Valid)
+ return null;
+
+ try
+ {
+ // File.ReadAllText handles the UTF-16LE BOM written by regedit. The content, rather
+ // than its encoding or line-ending convention, is what captures registry state.
+ return File.ReadAllText(path)
+ .Replace("\r\n", "\n")
+ .Replace("\r", "\n")
+ .TrimEnd('\n');
+ }
+ catch
+ {
+ return null;
+ }
+ }
}
}
diff --git a/src/WinRestoreKit.Core/Results/RestoreContents.cs b/src/WinRestoreKit.Core/Results/RestoreContents.cs
index 4060c27..0406a38 100644
--- a/src/WinRestoreKit.Core/Results/RestoreContents.cs
+++ b/src/WinRestoreKit.Core/Results/RestoreContents.cs
@@ -45,18 +45,32 @@ internal static IReadOnlyList For(IReadOnlyList
if (modules == null)
return rows;
- foreach (BackupBase module in modules)
- {
- if (module == null)
- continue;
+ string probePath = restoreSourcePath;
+ BackupPayload.ReadScope payload = null;
- string state = ManifestStateFor(manifest, module.GetType().Name);
+ if (BackupPayload.TryPrepareForRead(restoreSourcePath, out payload, out string ignoredError))
+ probePath = payload.Path;
- rows.Add(new RestoreContentsRow(
- module,
- HoldsSomethingFor(module, restoreSourcePath, manifest, state),
- state,
- module.WarningMessage ?? ""));
+ try
+ {
+ foreach (BackupBase module in modules)
+ {
+ if (module == null)
+ continue;
+
+ string state = ManifestStateFor(manifest, module.GetType().Name);
+
+ rows.Add(new RestoreContentsRow(
+ module,
+ HoldsSomethingFor(module, probePath, manifest, state),
+ state,
+ module.WarningMessage ?? ""));
+ }
+ }
+ finally
+ {
+ if (payload != null)
+ payload.Dispose();
}
return rows;
diff --git a/src/WinRestoreKit.Tests/BackupManifestTests.cs b/src/WinRestoreKit.Tests/BackupManifestTests.cs
index 3450ad4..724e874 100644
--- a/src/WinRestoreKit.Tests/BackupManifestTests.cs
+++ b/src/WinRestoreKit.Tests/BackupManifestTests.cs
@@ -159,6 +159,45 @@ public void TryParse_ReadsBackWhatComposeWrote()
CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind).ToUniversalTime());
}
+ [Fact]
+ public void TryParse_ExistingManifestWithoutSnapshotName_LeavesItNull()
+ {
+ ManifestData data = BackupManifest.TryParse(
+ @"{ ""manifest_version"": 1, ""modules"": [] }");
+
+ Assert.NotNull(data);
+ Assert.Null(data.SnapshotName);
+ }
+
+ [Fact]
+ public void ComposeAndTryParse_RoundTripOptionalSnapshotName()
+ {
+ string json = BackupManifest.Compose(
+ Modules(), new List { Ok(), Skip() }, When,
+ "DESKTOP-NB01", "nicol", "Build 26100.4652", "0.0.1",
+ snapshotName: "before-driver-update");
+
+ JObject root = JObject.Parse(json);
+ ManifestData data = BackupManifest.TryParse(json);
+
+ Assert.Equal("before-driver-update", root["snapshot_name"].Value());
+ Assert.Equal("before-driver-update", data.SnapshotName);
+ }
+
+ [Fact]
+ public void ComposeAndTryParse_RoundTripArchiveMetadata()
+ {
+ string json = BackupManifest.Compose(
+ Modules(), new List { Ok(), Skip() }, When,
+ "DESKTOP-NB01", "nicol", "Build 26100.4652", "0.0.1",
+ compression: SnapshotCompression.Fast, payloadFile: BackupPayload.FileName);
+
+ ManifestData data = BackupManifest.TryParse(json);
+
+ Assert.Equal("fast", data.Compression);
+ Assert.Equal(BackupPayload.FileName, data.PayloadFile);
+ }
+
[Fact]
public void TryParse_ReturnsTheTimestampExactlyAsWritten()
{
diff --git a/src/WinRestoreKit.Tests/BackupPageViewTests.cs b/src/WinRestoreKit.Tests/BackupPageViewTests.cs
new file mode 100644
index 0000000..221adb9
--- /dev/null
+++ b/src/WinRestoreKit.Tests/BackupPageViewTests.cs
@@ -0,0 +1,61 @@
+using System;
+using Conf;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows.Forms;
+using Views;
+using WinRestoreKit;
+using Xunit;
+
+namespace WinRestoreKit.Tests
+{
+ public class BackupPageViewTests
+ {
+ [Fact]
+ public void Capture_RequestsSelectedConcreteScopeModulesWithoutOwningRunUi()
+ {
+ using (BackupPageView view = new BackupPageView())
+ {
+ Assert.False(view is IRunUi);
+
+ foreach (CustomCheckbox scope in Descendants(view).OfType())
+ scope.Checked = false;
+
+ CustomCheckbox explorerScope = view.Controls
+ .Find("scopeToggle1", true)
+ .OfType()
+ .Single();
+ explorerScope.Checked = true;
+
+ IReadOnlyList requestedModules = null;
+ view.StartBackupRequested = (modules, snapshotName, compression, destination) =>
+ requestedModules = modules;
+
+ view.Controls.Find("captureButton", true).OfType