Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ publish/
# The rest of .claude/ - hooks, skills, agents - is shared project tooling and is tracked.
.claude/settings.local.json
WATCHDOG.yml
/.logs
/.superpowers
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,28 @@ version numbers, because that is what those releases were called.

## [Unreleased]

### Design

- Started a three-direction visual identity and WinUI 3 Home-screen exploration around the
positioning "Backup and retrieve the little details that make your system yours." The light Fluent
direction now pairs granular selection of Windows and app settings with a quiet solid-color system:
warm neutral surfaces, softened slate navigation, muted mineral-blue actions, gentler type, and
calmer spacing. Logo exploration remains unselected and no runtime or backup-format behavior has
changed.

### 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.

## [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
Expand Down
70 changes: 70 additions & 0 deletions src/WinRestoreKit.Core/BackupBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,76 @@ public abstract class BackupBase
/// </remarks>
public virtual bool? HasArtifactIn(string backupPath) => null;

/// <summary>
/// Whether this module's live state differs from the state captured in
/// <paramref name="backupPath"/>.
/// </summary>
/// <remarks>
/// A module that cannot compare its live state with the backup returns null rather than
/// guessing. Callers render only confirmed drift.
/// </remarks>
public virtual bool? HasDriftedFrom(string backupPath) => null;

/// <summary>
/// Compares a recorded registry export with a fresh, temporary export of the live key.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
}
}

/// <summary>
/// The backup file this module writes for one registry key.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions src/WinRestoreKit.Core/CompressionLevel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace WinRestoreKit
{
public enum SnapshotCompression
{
None,
Fast,
Max
}
}
31 changes: 15 additions & 16 deletions src/WinRestoreKit.Core/Conf/AppStoreApps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,22 @@ public AppStoreApps()

// HasBackupIn is deliberately NOT overridden to test for ExportPathIn(restorePath).
//
// It looks like the obvious use of the new seam, and it would be wrong twice over. This
// module's restore does not read the export at all - it opens RestAppsForm, which lets the
// user pick ANY backup folder from its own dropdown, not the one being restored. Answering
// "no" here would make RestoreScope drop the module, so the dialog would never open for a
// user whose selected folder happens to hold no export, while the dialog itself would have
// been perfectly able to offer every other backup. It would also break
// RestoreDeclarationTests.ModulesThatCloseNothing_AssumeTheBackupHasSomethingForThem.
// This module opens RestAppsForm, which starts with the selected restore source but also
// lets the user choose another backup folder from its own dropdown. Answering "no" here
// would make RestoreScope drop the module, so the dialog would never open for a user whose
// selected folder happens to hold no export, while the dialog could have offered another
// backup. It would also break RestoreDeclarationTests.ModulesThatCloseNothing_AssumeTheBackupHasSomethingForThem.
//
// The same reasoning binds HasArtifactIn, added later for the restore wizard, and binds it
// HARDER: where HasBackupIn made RestoreScope drop the module after the fact, the wizard
// greys the checkbox out in front of the user and labels it "(nothing in this backup)".
// That would be a false statement about a dialog that reads a folder of its own choosing.
// That would be a false statement about a dialog that can read a folder of the user's
// choosing.
//
// So it is overridden to a flat TRUE rather than left at the null default. Null would mean
// "cannot tell", and the wizard resolves that to false whenever a manifest exists without
// naming this module - which happens on any second backup within one app session. This is
// not uncertainty; it is a module for which folder contents are the wrong question.
// naming this module, which happens on any second backup within one app session. This is
// not uncertainty; it is a module for which folder contents are not the only question.
public override bool? HasArtifactIn(string backupPath) => true;

public override IReadOnlyList<RestoreTarget> RestoreTargets
Expand Down Expand Up @@ -199,19 +198,19 @@ public override Task<ModuleResult> RestoreAsync(string path)
=> Task.FromResult(Restore(path));

/// <summary>
/// Opens the app reinstall dialog. Registered by the app at startup; null in any process
/// that has no UI to open it with.
/// Opens the app reinstall dialog for <paramref name="path"/>. Registered by the app at
/// startup; null in any process that has no UI to open it with.
/// </summary>
/// <remarks>
/// A delegate rather than a constructor argument because this module is constructed by
/// Activator.CreateInstance(type) with no arguments in nine test sites, and every module in
/// the app is enumerated that way. A parameterless constructor is not negotiable here.
///
/// Registration happens in Program.Main before the message pump starts, so the unregistered
/// path below is not reachable from the running app - it exists for the test suite and for
/// path below is not reachable from the running app. It exists for the test suite and for
/// any future headless host, where failing closed is the point.
/// </remarks>
internal static Action RestoreDialog;
internal static Action<string> RestoreDialog;

/// <remarks>
/// This module restores nothing itself. It opens the app restore dialog, and the installs
Expand All @@ -231,7 +230,7 @@ public override ModuleResult Restore(string path)
{
// Read the delegate once: it is static and mutable, and a null check against one read
// followed by an invoke of another is a race with whatever cleared it.
Action dialog = RestoreDialog;
Action<string> dialog = RestoreDialog;

if (dialog == null)
{
Expand All @@ -243,7 +242,7 @@ public override ModuleResult Restore(string path)
});
}

dialog();
dialog(path);

return ModuleResult.Aggregate(new[]
{
Expand Down
4 changes: 4 additions & 0 deletions src/WinRestoreKit.Core/Conf/EEnvironmentFiltered.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Deletes the export the filter could not process, and fails the step.
/// </summary>
Expand Down
32 changes: 32 additions & 0 deletions src/WinRestoreKit.Core/Conf/MultiKeyRegistryModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,37 @@ public override ModuleResult Restore(string path)

return false;
}

/// <summary>
/// Reports confirmed drift from any recorded key, while keeping incomplete comparisons unknown.
/// </summary>
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;
}
}
}
11 changes: 11 additions & 0 deletions src/WinRestoreKit.Core/Conf/RegistryModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ public override ModuleResult Restore(string path)
public override bool? HasArtifactIn(string backupPath)
=> !string.IsNullOrWhiteSpace(backupPath) && File.Exists(FileFor(backupPath));

/// <summary>
/// Compares the one exported key this module records with its current live representation.
/// </summary>
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.
Expand Down
3 changes: 3 additions & 0 deletions src/WinRestoreKit.Core/Results/BackupLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ internal static class BackupLog
// Older backups on disk still carry the Appcopier header, which is fine because readers never parse it.
internal const string VersionHeader = "# WinRestoreKit backup log v2";

/// <summary>The human-readable backup log stored at the backup root.</summary>
internal const string FileName = "backup_log.txt";

/// <param name="extraHeaderLines">
/// Written verbatim between the version header and the timestamp, or null for none. Verbatim
/// because the caller - RestoreLog - owns how its lines read; prefixing them here would put
Expand Down
47 changes: 46 additions & 1 deletion src/WinRestoreKit.Core/Results/BackupManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ internal static string Compose(IReadOnlyList<BackupBase> 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();

Expand Down Expand Up @@ -96,6 +99,15 @@ internal static string Compose(IReadOnlyList<BackupBase> 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);
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -262,13 +277,31 @@ internal sealed class ManifestData
{
internal ManifestData(int manifestVersion, string appVersion, string created, string machineName,
string userName, string osBuild, IReadOnlyList<ManifestModule> 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<ManifestModule> 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<ManifestModule> modules)
{
ManifestVersion = manifestVersion;
AppVersion = appVersion;
Created = created;
MachineName = machineName;
UserName = userName;
OsBuild = osBuild;
SnapshotName = snapshotName;
Compression = compression;
PayloadFile = payloadFile;
Modules = modules;
}

Expand All @@ -285,6 +318,18 @@ internal ManifestData(int manifestVersion, string appVersion, string created, st

internal string OsBuild { get; }

/// <summary>
/// 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.
/// </summary>
internal string SnapshotName { get; }


/// <summary>Optional archive compression used for the payload, when one exists.</summary>
internal string Compression { get; }

/// <summary>Optional archive file that holds this backup's module artifacts.</summary>
internal string PayloadFile { get; }
internal IReadOnlyList<ManifestModule> Modules { get; }
}

Expand Down
Loading
Loading