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