PowerToys/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs

1074 lines
46 KiB
C#
Raw Normal View History

Settings backup and restore (#20551) * Merge and conflict resolution * Messages good, backup/restore algo better. * Start of "GetExportVerion" * fixed spelling * New backup/restore mode working. * Rename a project * Removed test project * Switch to text.json * Renamed BackupAndSync to BackupAndRestore * Added IgnoredPTRunSettings and full merge * Restored "fixed" settings that change for no reason * Various UI updates. * speling * Some cleanup and zip support. * Merge and clean * code clean up * code clean up * code clean up * Smarter settings compare and merge. * config based file include/exclude * Removed some "words" * Code clean up * cleanup * cleanup * cleanup * cleanup * fixed spelling. * Fixed clean up 1 * more clean up * Trying to add ptb as an OK word * Some UI updates. * UI tweaks and PR review items. * UI tweaks * Merge conflicts resolved. * Added CurrentSettingMatchText * PR review updates. * Removed weird file. * Review updates and fixes * More UI tweaks. * UI tweaks * Set default backup location to "%USERPROFILE%\\Documents\\PowerToys\\Backup" * settings ui tweaks * Added ExpanderContentSettingStyle * fix missing config file * fix missing config file, part 2 * update ui, cleanup cope * update ui, cleanup code - Part2 * update method comments * code cleanup and adjust Backup message time * fix changing backup location on empty Regsitry * fix select location - part 2 * location input box min-width * remove lastRestoreDate from ViewModel * Code or backup timing, and error handling. * Should fix file/folder name crash. * Progress to instance class for backup/restore * Persist backup status state, added refresh button. * Better auto check for settings status * Some UI/text updates. * Clean up * Added prefix for "General_Settings" to resources * Code review updates. * Code review changes. * Changed to FolderPicker per review * Fixed issue with early delete of cleanup. * Testing issues with FolderPicker * Removed WinForm req and fixed win10 issue. * Review update. * Review changes. Co-authored-by: htcfreek <61519853+htcfreek@users.noreply.github.com>
2022-10-13 15:41:21 +08:00
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Threading;
using Microsoft.PowerToys.Settings.UI.Library.Utilities;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class SettingsBackupAndRestoreUtils
{
private static SettingsBackupAndRestoreUtils instance;
private (bool success, string severity, bool lastBackupExists, DateTime? lastRan) lastBackupSettingsResults;
private static object backupSettingsInternalLock = new object();
private static object removeOldBackupsLock = new object();
public DateTime LastBackupStartTime { get; set; }
private SettingsBackupAndRestoreUtils()
{
LastBackupStartTime = DateTime.MinValue;
}
public static SettingsBackupAndRestoreUtils Instance
{
get
{
if (instance == null)
{
instance = new SettingsBackupAndRestoreUtils();
}
return instance;
}
}
private class JsonMergeHelper
{
// mostly from https://stackoverflow.com/questions/58694837/system-text-json-merge-two-objects
// but with some update to prevent array item duplicates
public static string Merge(string originalJson, string newContent)
{
var outputBuffer = new ArrayBufferWriter<byte>();
using (JsonDocument jDoc1 = JsonDocument.Parse(originalJson))
using (JsonDocument jDoc2 = JsonDocument.Parse(newContent))
using (var jsonWriter = new Utf8JsonWriter(outputBuffer, new JsonWriterOptions { Indented = true }))
{
JsonElement root1 = jDoc1.RootElement;
JsonElement root2 = jDoc2.RootElement;
if (root1.ValueKind != JsonValueKind.Array && root1.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException($"The original JSON document to merge new content into must be a container type. Instead it is {root1.ValueKind}.");
}
if (root1.ValueKind != root2.ValueKind)
{
return originalJson;
}
if (root1.ValueKind == JsonValueKind.Array)
{
MergeArrays(jsonWriter, root1, root2, false);
}
else
{
MergeObjects(jsonWriter, root1, root2);
}
}
return Encoding.UTF8.GetString(outputBuffer.WrittenSpan);
}
private static void MergeObjects(Utf8JsonWriter jsonWriter, JsonElement root1, JsonElement root2)
{
jsonWriter.WriteStartObject();
// Write all the properties of the first document.
// If a property exists in both documents, either:
// * Merge them, if the value kinds match (e.g. both are objects or arrays),
// * Completely override the value of the first with the one from the second, if the value kind mismatches (e.g. one is object, while the other is an array or string),
// * Or favor the value of the first (regardless of what it may be), if the second one is null (i.e. don't override the first).
foreach (JsonProperty property in root1.EnumerateObject())
{
string propertyName = property.Name;
JsonValueKind newValueKind;
if (root2.TryGetProperty(propertyName, out JsonElement newValue) && (newValueKind = newValue.ValueKind) != JsonValueKind.Null)
{
jsonWriter.WritePropertyName(propertyName);
JsonElement originalValue = property.Value;
JsonValueKind originalValueKind = originalValue.ValueKind;
if (newValueKind == JsonValueKind.Object && originalValueKind == JsonValueKind.Object)
{
MergeObjects(jsonWriter, originalValue, newValue); // Recursive call
}
else if (newValueKind == JsonValueKind.Array && originalValueKind == JsonValueKind.Array)
{
MergeArrays(jsonWriter, originalValue, newValue, false);
}
else
{
newValue.WriteTo(jsonWriter);
}
}
else
{
property.WriteTo(jsonWriter);
}
}
// Write all the properties of the second document that are unique to it.
foreach (JsonProperty property in root2.EnumerateObject())
{
if (!root1.TryGetProperty(property.Name, out _))
{
property.WriteTo(jsonWriter);
}
}
jsonWriter.WriteEndObject();
}
private static void MergeArrays(Utf8JsonWriter jsonWriter, JsonElement root1, JsonElement root2, bool allowDupes)
{
// just does one level!!!
jsonWriter.WriteStartArray();
if (allowDupes)
{
// Write all the elements from both JSON arrays
foreach (JsonElement element in root1.EnumerateArray())
{
element.WriteTo(jsonWriter);
}
foreach (JsonElement element in root2.EnumerateArray())
{
element.WriteTo(jsonWriter);
}
}
else
{
var arrayItems = new HashSet<string>();
foreach (JsonElement element in root1.EnumerateArray())
{
element.WriteTo(jsonWriter);
arrayItems.Add(element.ToString());
}
foreach (JsonElement element in root2.EnumerateArray())
{
if (!arrayItems.Contains(element.ToString()))
{
element.WriteTo(jsonWriter);
}
}
}
jsonWriter.WriteEndArray();
}
}
private static bool TryCreateDirectory(string path)
{
try
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
return true;
}
}
catch (Exception ex3)
{
Logger.LogError($"There was an error in TryCreateDirectory {path}: {ex3.Message}", ex3);
return false;
}
return true;
}
private static bool TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, true);
return true;
}
}
catch (Exception ex3)
{
Logger.LogError($"There was an error in TryDeleteDirectory {path}: {ex3.Message}", ex3);
return false;
}
return true;
}
/// <summary>
/// Method <c>SetRegSettingsBackupAndRestoreItem</c> helper method to write to the registry.
/// </summary>
public static void SetRegSettingsBackupAndRestoreItem(string itemName, string itemValue)
{
using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey("Software\\Microsoft", true))
{
var ptKey = key.OpenSubKey("PowerToys", true);
if (ptKey != null)
{
ptKey.SetValue(itemName, itemValue);
}
else
{
var newPtKey = key.CreateSubKey("PowerToys");
newPtKey.SetValue(itemName, itemValue);
}
}
}
/// <summary>
/// Method <c>GetRegSettingsBackupAndRestoreRegItem</c> helper method to read from the registry.
/// </summary>
public static string GetRegSettingsBackupAndRestoreRegItem(string itemName)
{
using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\PowerToys"))
{
if (key != null)
{
var val = key.GetValue(itemName);
if (val != null)
{
return val.ToString();
}
}
}
return null;
}
/// <summary>
/// Method <c>RestoreSettings</c> returns a folder that has the latest backup in it.
/// </summary>
/// <returns>
/// A tuple that indicates if the backup was done or not, and a message.
/// The message usually is a localized reference key.
/// </returns>
public (bool success, string message, string severity) RestoreSettings(string appBasePath, string settingsBackupAndRestoreDir)
{
try
{
// verify inputs
if (!Directory.Exists(appBasePath))
{
return (false, $"Invalid appBasePath {appBasePath}", "Error");
}
if (string.IsNullOrEmpty(settingsBackupAndRestoreDir))
{
return (false, $"General_SettingsBackupAndRestore_NoBackupSyncPath", "Error");
}
if (!Directory.Exists(settingsBackupAndRestoreDir))
{
Logger.LogError($"Invalid settingsBackupAndRestoreDir {settingsBackupAndRestoreDir}");
return (false, "General_SettingsBackupAndRestore_InvalidBackupLocation", "Error");
}
var latestSettingsFolder = GetLatestSettingsFolder();
if (latestSettingsFolder == null)
{
return (false, $"General_SettingsBackupAndRestore_NoBackupsFound", "Warning");
}
// get data needed for process
var backupRetoreSettings = JsonNode.Parse(GetBackupRestoreSettingsJson());
var currentSettingsFiles = GetSettingsFiles(backupRetoreSettings, appBasePath).ToList().ToDictionary(x => x.Substring(appBasePath.Length));
var backupSettingsFiles = GetSettingsFiles(backupRetoreSettings, latestSettingsFolder).ToList().ToDictionary(x => x.Substring(latestSettingsFolder.Length));
if (backupSettingsFiles.Count == 0)
{
return (false, $"General_SettingsBackupAndRestore_NoBackupsFound", "Warning");
}
var anyFilesUpdated = false;
foreach (var currentFile in backupSettingsFiles)
{
var relativePath = currentFile.Value.Substring(latestSettingsFolder.Length + 1);
var retoreFullPath = Path.Combine(appBasePath, relativePath);
var settingsToRestoreJson = GetExportVersion(backupRetoreSettings, currentFile.Key, currentFile.Value);
if (currentSettingsFiles.ContainsKey(currentFile.Key))
{
// we have a setting file to restore to
var currentSettingsFileJson = GetExportVersion(backupRetoreSettings, currentFile.Key, currentSettingsFiles[currentFile.Key]);
if (JsonNormalizer.Normalize(settingsToRestoreJson) != JsonNormalizer.Normalize(currentSettingsFileJson))
{
// the settings file needs to be updated, update the real one with non-excluded stuff...
Logger.LogInfo($"Settings file {currentFile.Key} is different and is getting updated from backup");
var newCurrentSettingsFile = JsonMergeHelper.Merge(File.ReadAllText(currentSettingsFiles[currentFile.Key]), settingsToRestoreJson);
File.WriteAllText(currentSettingsFiles[currentFile.Key], newCurrentSettingsFile);
anyFilesUpdated = true;
}
}
else
{
// we don't have anything to merge this into, we need to use it as is
Logger.LogInfo($"Settings file {currentFile.Key} is in the backup but does not exist for restore");
var thisPathToRestore = Path.Combine(appBasePath, currentFile.Key.Substring(1));
TryCreateDirectory(Path.GetDirectoryName(thisPathToRestore));
File.WriteAllText(thisPathToRestore, settingsToRestoreJson);
anyFilesUpdated = true;
}
}
if (anyFilesUpdated)
{
// something was changed do we need to return true to indicate a restart is needed.
var restartAfterRestore = (bool?)backupRetoreSettings!["RestartAfterRestore"];
if (!restartAfterRestore.HasValue || restartAfterRestore.Value)
{
return (true, $"RESTART APP", "Success");
}
else
{
return (false, $"RESTART APP", "Success");
}
}
else
{
return (false, $"General_SettingsBackupAndRestore_NothingToRestore", "Informational");
}
}
catch (Exception ex2)
{
Logger.LogError("Error in RestoreSettings, " + ex2.ToString());
return (false, $"General_SettingsBackupAndRestore_BackupError", "Error");
}
}
/// <summary>
/// Method <c>GetSettingsBackupAndRestoreDir</c> returns the path the backup and restore location.
/// </summary>
/// <remarks>
/// This will return a default location based on user documents if non is set.
Settings backup and restore (#20551) * Merge and conflict resolution * Messages good, backup/restore algo better. * Start of "GetExportVerion" * fixed spelling * New backup/restore mode working. * Rename a project * Removed test project * Switch to text.json * Renamed BackupAndSync to BackupAndRestore * Added IgnoredPTRunSettings and full merge * Restored "fixed" settings that change for no reason * Various UI updates. * speling * Some cleanup and zip support. * Merge and clean * code clean up * code clean up * code clean up * Smarter settings compare and merge. * config based file include/exclude * Removed some "words" * Code clean up * cleanup * cleanup * cleanup * cleanup * fixed spelling. * Fixed clean up 1 * more clean up * Trying to add ptb as an OK word * Some UI updates. * UI tweaks and PR review items. * UI tweaks * Merge conflicts resolved. * Added CurrentSettingMatchText * PR review updates. * Removed weird file. * Review updates and fixes * More UI tweaks. * UI tweaks * Set default backup location to "%USERPROFILE%\\Documents\\PowerToys\\Backup" * settings ui tweaks * Added ExpanderContentSettingStyle * fix missing config file * fix missing config file, part 2 * update ui, cleanup cope * update ui, cleanup code - Part2 * update method comments * code cleanup and adjust Backup message time * fix changing backup location on empty Regsitry * fix select location - part 2 * location input box min-width * remove lastRestoreDate from ViewModel * Code or backup timing, and error handling. * Should fix file/folder name crash. * Progress to instance class for backup/restore * Persist backup status state, added refresh button. * Better auto check for settings status * Some UI/text updates. * Clean up * Added prefix for "General_Settings" to resources * Code review updates. * Code review changes. * Changed to FolderPicker per review * Fixed issue with early delete of cleanup. * Testing issues with FolderPicker * Removed WinForm req and fixed win10 issue. * Review update. * Review changes. Co-authored-by: htcfreek <61519853+htcfreek@users.noreply.github.com>
2022-10-13 15:41:21 +08:00
/// </remarks>
public string GetSettingsBackupAndRestoreDir()
{
string settingsBackupAndRestoreDir = GetRegSettingsBackupAndRestoreRegItem("SettingsBackupAndRestoreDir");
if (settingsBackupAndRestoreDir == null)
{
settingsBackupAndRestoreDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerToys\\Backup");
Settings backup and restore (#20551) * Merge and conflict resolution * Messages good, backup/restore algo better. * Start of "GetExportVerion" * fixed spelling * New backup/restore mode working. * Rename a project * Removed test project * Switch to text.json * Renamed BackupAndSync to BackupAndRestore * Added IgnoredPTRunSettings and full merge * Restored "fixed" settings that change for no reason * Various UI updates. * speling * Some cleanup and zip support. * Merge and clean * code clean up * code clean up * code clean up * Smarter settings compare and merge. * config based file include/exclude * Removed some "words" * Code clean up * cleanup * cleanup * cleanup * cleanup * fixed spelling. * Fixed clean up 1 * more clean up * Trying to add ptb as an OK word * Some UI updates. * UI tweaks and PR review items. * UI tweaks * Merge conflicts resolved. * Added CurrentSettingMatchText * PR review updates. * Removed weird file. * Review updates and fixes * More UI tweaks. * UI tweaks * Set default backup location to "%USERPROFILE%\\Documents\\PowerToys\\Backup" * settings ui tweaks * Added ExpanderContentSettingStyle * fix missing config file * fix missing config file, part 2 * update ui, cleanup cope * update ui, cleanup code - Part2 * update method comments * code cleanup and adjust Backup message time * fix changing backup location on empty Regsitry * fix select location - part 2 * location input box min-width * remove lastRestoreDate from ViewModel * Code or backup timing, and error handling. * Should fix file/folder name crash. * Progress to instance class for backup/restore * Persist backup status state, added refresh button. * Better auto check for settings status * Some UI/text updates. * Clean up * Added prefix for "General_Settings" to resources * Code review updates. * Code review changes. * Changed to FolderPicker per review * Fixed issue with early delete of cleanup. * Testing issues with FolderPicker * Removed WinForm req and fixed win10 issue. * Review update. * Review changes. Co-authored-by: htcfreek <61519853+htcfreek@users.noreply.github.com>
2022-10-13 15:41:21 +08:00
}
return settingsBackupAndRestoreDir;
}
private IList<string> GetBackupSettingsFiles(string settingsBackupAndRestoreDir)
{
return Directory.GetFiles(settingsBackupAndRestoreDir, "settings_*.ptb", SearchOption.TopDirectoryOnly).ToList().Where(f => Regex.IsMatch(f, "settings_(\\d{1,19}).ptb")).ToList();
}
/// <summary>
/// Method <c>GetLatestSettingsFolder</c> returns a folder that has the latest backup in it.
/// </summary>
/// <remarks>
/// The backup will usually be a backup file that has to be extracted to a temp folder. This will do that for us.
/// </remarks>
private string GetLatestSettingsFolder()
{
string settingsBackupAndRestoreDir = GetSettingsBackupAndRestoreDir();
if (settingsBackupAndRestoreDir == null)
{
return null;
}
if (!Directory.Exists(settingsBackupAndRestoreDir))
{
return null;
}
var settingsBackupFolders = new Dictionary<long, string>();
var settingsBackupFiles = GetBackupSettingsFiles(settingsBackupAndRestoreDir).ToDictionary(x => long.Parse(Path.GetFileName(x).Replace("settings_", string.Empty).Replace(".ptb", string.Empty), CultureInfo.InvariantCulture));
var latestFolder = 0L;
var latestFile = 0L;
if (settingsBackupFolders.Count > 0)
{
latestFolder = settingsBackupFolders.OrderByDescending(x => x.Key).FirstOrDefault().Key;
}
if (settingsBackupFiles.Count > 0)
{
latestFile = settingsBackupFiles.OrderByDescending(x => x.Key).FirstOrDefault().Key;
}
if (latestFile == 0 && latestFolder == 0)
{
return null;
}
else if (latestFolder >= latestFile)
{
return settingsBackupFolders[latestFolder];
}
else
{
var tempPath = Path.GetTempPath();
var fullBackupDir = Path.Combine(tempPath, "PowerToys_settings_" + latestFile.ToString(CultureInfo.InvariantCulture));
lock (backupSettingsInternalLock)
Settings backup and restore (#20551) * Merge and conflict resolution * Messages good, backup/restore algo better. * Start of "GetExportVerion" * fixed spelling * New backup/restore mode working. * Rename a project * Removed test project * Switch to text.json * Renamed BackupAndSync to BackupAndRestore * Added IgnoredPTRunSettings and full merge * Restored "fixed" settings that change for no reason * Various UI updates. * speling * Some cleanup and zip support. * Merge and clean * code clean up * code clean up * code clean up * Smarter settings compare and merge. * config based file include/exclude * Removed some "words" * Code clean up * cleanup * cleanup * cleanup * cleanup * fixed spelling. * Fixed clean up 1 * more clean up * Trying to add ptb as an OK word * Some UI updates. * UI tweaks and PR review items. * UI tweaks * Merge conflicts resolved. * Added CurrentSettingMatchText * PR review updates. * Removed weird file. * Review updates and fixes * More UI tweaks. * UI tweaks * Set default backup location to "%USERPROFILE%\\Documents\\PowerToys\\Backup" * settings ui tweaks * Added ExpanderContentSettingStyle * fix missing config file * fix missing config file, part 2 * update ui, cleanup cope * update ui, cleanup code - Part2 * update method comments * code cleanup and adjust Backup message time * fix changing backup location on empty Regsitry * fix select location - part 2 * location input box min-width * remove lastRestoreDate from ViewModel * Code or backup timing, and error handling. * Should fix file/folder name crash. * Progress to instance class for backup/restore * Persist backup status state, added refresh button. * Better auto check for settings status * Some UI/text updates. * Clean up * Added prefix for "General_Settings" to resources * Code review updates. * Code review changes. * Changed to FolderPicker per review * Fixed issue with early delete of cleanup. * Testing issues with FolderPicker * Removed WinForm req and fixed win10 issue. * Review update. * Review changes. Co-authored-by: htcfreek <61519853+htcfreek@users.noreply.github.com>
2022-10-13 15:41:21 +08:00
{
if (!Directory.Exists(fullBackupDir) || !File.Exists(Path.Combine(fullBackupDir, "manifest.json")))
{
TryDeleteDirectory(fullBackupDir);
ZipFile.ExtractToDirectory(settingsBackupFiles[latestFile], fullBackupDir);
}
Settings backup and restore (#20551) * Merge and conflict resolution * Messages good, backup/restore algo better. * Start of "GetExportVerion" * fixed spelling * New backup/restore mode working. * Rename a project * Removed test project * Switch to text.json * Renamed BackupAndSync to BackupAndRestore * Added IgnoredPTRunSettings and full merge * Restored "fixed" settings that change for no reason * Various UI updates. * speling * Some cleanup and zip support. * Merge and clean * code clean up * code clean up * code clean up * Smarter settings compare and merge. * config based file include/exclude * Removed some "words" * Code clean up * cleanup * cleanup * cleanup * cleanup * fixed spelling. * Fixed clean up 1 * more clean up * Trying to add ptb as an OK word * Some UI updates. * UI tweaks and PR review items. * UI tweaks * Merge conflicts resolved. * Added CurrentSettingMatchText * PR review updates. * Removed weird file. * Review updates and fixes * More UI tweaks. * UI tweaks * Set default backup location to "%USERPROFILE%\\Documents\\PowerToys\\Backup" * settings ui tweaks * Added ExpanderContentSettingStyle * fix missing config file * fix missing config file, part 2 * update ui, cleanup cope * update ui, cleanup code - Part2 * update method comments * code cleanup and adjust Backup message time * fix changing backup location on empty Regsitry * fix select location - part 2 * location input box min-width * remove lastRestoreDate from ViewModel * Code or backup timing, and error handling. * Should fix file/folder name crash. * Progress to instance class for backup/restore * Persist backup status state, added refresh button. * Better auto check for settings status * Some UI/text updates. * Clean up * Added prefix for "General_Settings" to resources * Code review updates. * Code review changes. * Changed to FolderPicker per review * Fixed issue with early delete of cleanup. * Testing issues with FolderPicker * Removed WinForm req and fixed win10 issue. * Review update. * Review changes. Co-authored-by: htcfreek <61519853+htcfreek@users.noreply.github.com>
2022-10-13 15:41:21 +08:00
}
ThreadPool.QueueUserWorkItem((x) =>
{
try
{
RemoveOldBackups(tempPath, 1, TimeSpan.FromDays(7));
}
catch
{
// hmm, ok
}
});
return fullBackupDir;
}
}
/// <summary>
/// Method <c>GetLatestBackupFileName</c> returns the name of the newest backup file.
/// </summary>
public string GetLatestBackupFileName()
{
string settingsBackupAndRestoreDir = GetSettingsBackupAndRestoreDir();
if (string.IsNullOrEmpty(settingsBackupAndRestoreDir) || !Directory.Exists(settingsBackupAndRestoreDir))
{
return string.Empty;
}
var settingsBackupFiles = GetBackupSettingsFiles(settingsBackupAndRestoreDir);
if (settingsBackupFiles.Count > 0)
{
return Path.GetFileName(settingsBackupFiles.OrderByDescending(x => x).First());
}
else
{
return string.Empty;
}
}
/// <summary>
/// Method <c>GetLatestSettingsBackupManifest</c> get's the meta data from a backup file.
/// </summary>
public JsonNode GetLatestSettingsBackupManifest()
{
var folder = GetLatestSettingsFolder();
if (folder == null)
{
return null;
}
return JsonNode.Parse(File.ReadAllText(Path.Combine(folder, "manifest.json")));
}
/// <summary>
/// Method <c>IsIncludeFile</c> check's to see if a settings file is to be included during backup and restore.
/// </summary>
private static bool IsIncludeFile(JsonNode settings, string name)
{
foreach (var test in (JsonArray)settings["IncludeFiles"])
{
if (Regex.IsMatch(name, WildCardToRegular(test.ToString())))
{
return true;
}
}
return false;
}
/// <summary>
/// Method <c>IsIgnoreFile</c> check's to see if a settings file is to be ignored during backup and restore.
/// </summary>
private static bool IsIgnoreFile(JsonNode settings, string name)
{
foreach (var test in (JsonArray)settings["IgnoreFiles"])
{
if (Regex.IsMatch(name, WildCardToRegular(test.ToString())))
{
return true;
}
}
return false;
}
/// <summary>
/// Class <c>GetSettingsFiles</c> returns the effective list of settings files.
/// </summary>
/// <remarks>
/// Handles all the included/exclude files.
/// </remarks>
private static string[] GetSettingsFiles(JsonNode settings, string path)
{
if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
{
return Array.Empty<string>();
}
return Directory.GetFiles(path, "*.json", SearchOption.AllDirectories).Where(s => IsIncludeFile(settings, s) && !IsIgnoreFile(settings, s)).ToArray();
}
/// <summary>
/// Method <c>BackupSettings</c> does the backup process.
/// </summary>
/// <returns>
/// A tuple that indicates if the backup was done or not, and a message.
/// The message usually is a localized reference key.
/// </returns>
/// <remarks>
/// This is a wrapper for BackupSettingsInternal, so we can check the time to run.
/// </remarks>
public (bool success, string message, string severity, bool lastBackupExists) BackupSettings(string appBasePath, string settingsBackupAndRestoreDir, bool dryRun)
{
var sw = Stopwatch.StartNew();
var results = BackupSettingsInternal(appBasePath, settingsBackupAndRestoreDir, dryRun);
sw.Stop();
Logger.LogInfo($"BackupSettings took {sw.ElapsedMilliseconds}");
lastBackupSettingsResults = (results.success, results.severity, results.lastBackupExists, DateTime.UtcNow);
return results;
}
/// <summary>
/// Method <c>DryRunBackup</c> wrapper function to do a dry-run backup
/// </summary>
public (bool success, string message, string severity, bool lastBackupExists) DryRunBackup()
{
var settingsUtils = new SettingsUtils();
var appBasePath = Path.GetDirectoryName(settingsUtils.GetSettingsFilePath());
string settingsBackupAndRestoreDir = GetSettingsBackupAndRestoreDir();
var results = BackupSettings(appBasePath, settingsBackupAndRestoreDir, true);
lastBackupSettingsResults = (results.success, results.severity, results.lastBackupExists, DateTime.UtcNow);
return results;
}
/// <summary>
/// Method <c>GetLastBackupSettingsResults</c> gets the results from the last backup process
/// </summary>
/// <returns>
/// A tuple that indicates if the backup was done or not, and other information
/// </returns>
public (bool success, bool hadError, bool lastBackupExists, DateTime? lastRan) GetLastBackupSettingsResults()
{
return (lastBackupSettingsResults.success, lastBackupSettingsResults.severity == "Error", lastBackupSettingsResults.lastBackupExists, lastBackupSettingsResults.lastRan);
}
/// <summary>
/// Method <c>BackupSettingsInternal</c> does the backup process.
/// </summary>
/// <returns>
/// A tuple that indicates if the backup was done or not, and a message.
/// The message usually is a localized reference key.
/// </returns>
private (bool success, string message, string severity, bool lastBackupExists) BackupSettingsInternal(string appBasePath, string settingsBackupAndRestoreDir, bool dryRun)
{
var lastBackupExists = false;
lock (backupSettingsInternalLock)
{
// simulated delay to validate behavior
// Thread.Sleep(1000);
try
{
// verify inputs
if (!Directory.Exists(appBasePath))
{
return (false, $"Invalid appBasePath {appBasePath}", "Error", lastBackupExists);
}
if (string.IsNullOrEmpty(settingsBackupAndRestoreDir))
{
return (false, $"General_SettingsBackupAndRestore_NoBackupSyncPath", "Error", lastBackupExists);
}
if (!Path.IsPathRooted(settingsBackupAndRestoreDir))
{
return (false, $"Invalid settingsBackupAndRestoreDir, not rooted", "Error", lastBackupExists);
}
if (settingsBackupAndRestoreDir.StartsWith(appBasePath, StringComparison.InvariantCultureIgnoreCase))
{
// backup cannot be under app
Logger.LogError($"BackupSettings, backup cannot be under app");
return (false, "General_SettingsBackupAndRestore_InvalidBackupLocation", "Error", lastBackupExists);
}
var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir);
if (!dirExists)
{
Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}");
return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists);
}
// get data needed for process
var backupRetoreSettings = JsonNode.Parse(GetBackupRestoreSettingsJson());
var currentSettingsFiles = GetSettingsFiles(backupRetoreSettings, appBasePath).ToList().ToDictionary(x => x.Substring(appBasePath.Length));
var fullBackupDir = Path.Combine(Path.GetTempPath(), $"settings_{DateTime.UtcNow.ToFileTimeUtc().ToString(CultureInfo.InvariantCulture)}");
var latestSettingsFolder = GetLatestSettingsFolder();
var lastBackupSettingsFiles = GetSettingsFiles(backupRetoreSettings, latestSettingsFolder).ToList().ToDictionary(x => x.Substring(latestSettingsFolder.Length));
lastBackupExists = lastBackupSettingsFiles.Count > 0;
if (currentSettingsFiles.Count == 0)
{
return (false, "General_SettingsBackupAndRestore_NoSettingsFilesFound", "Error", lastBackupExists);
}
var anyFileBackedUp = false;
var skippedSettingsFiles = new Dictionary<string, (string path, string settings)>();
var updatedSettingsFiles = new Dictionary<string, string>();
foreach (var currentFile in currentSettingsFiles)
{
// need to check and back this up;
var currentSettingsFileToBackup = GetExportVersion(backupRetoreSettings, currentFile.Key, currentFile.Value);
var doBackup = false;
if (lastBackupSettingsFiles.ContainsKey(currentFile.Key))
{
// there is a previous backup for this, get an export version of it.
var lastSettingsFileDoc = GetExportVersion(backupRetoreSettings, currentFile.Key, lastBackupSettingsFiles[currentFile.Key]);
// check to see if the new export version would be same as last export version.
if (JsonNormalizer.Normalize(currentSettingsFileToBackup) != JsonNormalizer.Normalize(lastSettingsFileDoc))
{
doBackup = true;
Logger.LogInfo($"BackupSettings, {currentFile.Value} content is different.");
}
}
else
{
// this has never been backed up, we need to do it now.
Logger.LogInfo($"BackupSettings, {currentFile.Value} does not exists.");
doBackup = true;
}
if (doBackup)
{
// add to list of files we noted as needing backup
updatedSettingsFiles.Add(currentFile.Key, currentFile.Value);
// mark overall flag that a backup will be made
anyFileBackedUp = true;
// write the export version of the settings file to backup location.
var relativePath = currentFile.Value.Substring(appBasePath.Length + 1);
var backupFullPath = Path.Combine(fullBackupDir, relativePath);
TryCreateDirectory(fullBackupDir);
TryCreateDirectory(Path.GetDirectoryName(backupFullPath));
Logger.LogInfo($"BackupSettings writing, {backupFullPath}, dryRun:{dryRun}.");
if (!dryRun)
{
File.WriteAllText(backupFullPath, currentSettingsFileToBackup);
}
}
else
{
// if we found no reason to backup this settings file, record that in this collection
skippedSettingsFiles.Add(currentFile.Key, (currentFile.Value, currentSettingsFileToBackup));
}
}
if (!anyFileBackedUp)
{
// nothing was done!
return (false, $"General_SettingsBackupAndRestore_NothingToBackup", "Informational", lastBackupExists);
}
// add skipped.
foreach (var currentFile in skippedSettingsFiles)
{
// if we did do a backup, we need to copy in all the settings files we skipped so the backup is complete.
// this is needed since we might use the backup on another machine/
var relativePath = currentFile.Value.path.Substring(appBasePath.Length + 1);
var backupFullPath = Path.Combine(fullBackupDir, relativePath);
Logger.LogInfo($"BackupSettings writing, {backupFullPath}, dryRun:{dryRun}");
if (!dryRun)
{
TryCreateDirectory(fullBackupDir);
TryCreateDirectory(Path.GetDirectoryName(backupFullPath));
File.WriteAllText(backupFullPath, currentFile.Value.settings);
}
}
// add manifest
var manifestData = new
{
CreateDateTime = DateTime.UtcNow.ToString("u", CultureInfo.InvariantCulture),
@Version = Helper.GetProductVersion(),
UpdatedFiles = updatedSettingsFiles.Keys.ToList(),
BackupSource = Environment.MachineName,
UnchangedFiles = skippedSettingsFiles.Keys.ToList(),
};
var manifest = JsonSerializer.Serialize(manifestData, new JsonSerializerOptions() { WriteIndented = true });
if (!dryRun)
{
File.WriteAllText(Path.Combine(fullBackupDir, "manifest.json"), manifest);
// clean up, to prevent runaway disk usage.
RemoveOldBackups(settingsBackupAndRestoreDir, 10, TimeSpan.FromDays(60));
// compress the backup
var zipName = Path.Combine(settingsBackupAndRestoreDir, Path.GetFileName(fullBackupDir) + ".ptb");
ZipFile.CreateFromDirectory(fullBackupDir, zipName);
TryDeleteDirectory(fullBackupDir);
}
return (true, $"General_SettingsBackupAndRestore_BackupComplete", "Success", lastBackupExists);
}
catch (Exception ex2)
{
Logger.LogError($"There was an error: {ex2.Message}", ex2);
return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists);
}
}
}
/// <summary>
/// Searches for the config file (Json) in two possible paths and returns its content.
/// </summary>
/// <returns>Returns the content of the config file (Json) as string.</returns>
/// <exception cref="FileNotFoundException">Thrown if file is not found.</exception>
/// <remarks>If the settings window is launched from an installed instance of PT we need the path "...\Settings\\backup_restore_settings.json" and if the settings window is launched from a local VS build of PT we need the path "...\backup_restore_settings.json".</remarks>
private static string GetBackupRestoreSettingsJson()
{
if (File.Exists("backup_restore_settings.json"))
{
return File.ReadAllText("backup_restore_settings.json");
}
else if (File.Exists("Settings\\backup_restore_settings.json"))
{
return File.ReadAllText("Settings\\backup_restore_settings.json");
}
else
{
throw new FileNotFoundException($"The backup_restore_settings.json could not be found at {Environment.CurrentDirectory}");
}
}
/// <summary>
/// Method <c>WildCardToRegular</c> is so we can use 'normal' wildcard syntax and instead of regex
/// </summary>
private static string WildCardToRegular(string value)
{
return "^" + Regex.Escape(value).Replace("\\*", ".*") + "$";
}
/// <summary>
/// Method <c>GetExportVersion</c> gets the version of the settings file that we want to backup.
/// It will be formatted and all problematic settings removed from it.
/// </summary>
public static string GetExportVersion(JsonNode backupRetoreSettings, string settingFileKey, string settingsFileName)
{
var ignoredSettings = GetIgnoredSettings(backupRetoreSettings, settingFileKey);
var settingsFile = JsonDocument.Parse(File.ReadAllText(settingsFileName));
var outputBuffer = new ArrayBufferWriter<byte>();
using (var jsonWriter = new Utf8JsonWriter(outputBuffer, new JsonWriterOptions { Indented = true }))
{
jsonWriter.WriteStartObject();
foreach (var property in settingsFile.RootElement.EnumerateObject().OrderBy(p => p.Name))
{
if (!ignoredSettings.Contains(property.Name))
{
property.WriteTo(jsonWriter);
}
}
jsonWriter.WriteEndObject();
}
if (settingFileKey.Equals("\\PowerToys Run\\settings.json", StringComparison.OrdinalIgnoreCase))
{
// PowerToys Run hack fix-up
var ptRunIgnoredSettings = GetPTRunIgnoredSettings(backupRetoreSettings);
var ptrSettings = JsonNode.Parse(Encoding.UTF8.GetString(outputBuffer.WrittenSpan));
foreach (JsonObject pluginToChange in ptRunIgnoredSettings)
{
foreach (JsonObject plugin in (JsonArray)ptrSettings["plugins"])
{
if (plugin["Id"].ToString() == pluginToChange["Id"].ToString())
{
foreach (var nameOfPropertyToRemove in (JsonArray)pluginToChange["Names"])
{
plugin.Remove(nameOfPropertyToRemove.ToString());
}
}
}
}
return ptrSettings.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}
else
{
return JsonNode.Parse(Encoding.UTF8.GetString(outputBuffer.WrittenSpan)).ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}
}
/// <summary>
/// Method <c>GetPTRunIgnoredSettings</c> gets the 'Run-Plugin-level' settings we should ignore because they are problematic to backup/restore.
/// </summary>
private static JsonArray GetPTRunIgnoredSettings(JsonNode backupRetoreSettings)
{
if (backupRetoreSettings == null)
{
throw new ArgumentNullException(nameof(backupRetoreSettings));
}
if (backupRetoreSettings["IgnoredPTRunSettings"] != null)
{
return (JsonArray)backupRetoreSettings["IgnoredPTRunSettings"];
}
return new JsonArray();
}
/// <summary>
/// Method <c>GetIgnoredSettings</c> gets the 'top-level' settings we should ignore because they are problematic to backup/restore.
/// </summary>
private static string[] GetIgnoredSettings(JsonNode backupRetoreSettings, string settingFileKey)
{
if (backupRetoreSettings == null)
{
throw new ArgumentNullException(nameof(backupRetoreSettings));
}
if (settingFileKey.StartsWith("\\", StringComparison.OrdinalIgnoreCase))
{
settingFileKey = settingFileKey.Substring(1);
}
if (backupRetoreSettings["IgnoredSettings"] != null)
{
if (backupRetoreSettings["IgnoredSettings"][settingFileKey] != null)
{
var settingsArray = (JsonArray)backupRetoreSettings["IgnoredSettings"][settingFileKey];
Console.WriteLine("settingsArray " + settingsArray.GetType().FullName);
var settingsList = new List<string>();
foreach (var setting in settingsArray)
{
settingsList.Add(setting.ToString());
}
return settingsList.ToArray();
}
else
{
return Array.Empty<string>();
}
}
return Array.Empty<string>();
}
/// <summary>
/// Method <c>RemoveOldBackups</c> is a helper that prevents is from having some runaway disk usages.
/// </summary>
private static void RemoveOldBackups(string location, int minNumberToKeep, TimeSpan deleteIfOlderThanTs)
{
if (!Monitor.TryEnter(removeOldBackupsLock, 1000))
{
return;
}
try
{
DateTime deleteIfOlder = DateTime.UtcNow.Subtract(deleteIfOlderThanTs);
var settingsBackupFolders = Directory.GetDirectories(location, "settings_*", SearchOption.TopDirectoryOnly).ToList().Where(f => Regex.IsMatch(f, "settings_(\\d{1,19})")).ToDictionary(x => long.Parse(Path.GetFileName(x).Replace("settings_", string.Empty), CultureInfo.InvariantCulture)).ToList();
settingsBackupFolders.AddRange(Directory.GetDirectories(location, "PowerToys_settings_*", SearchOption.TopDirectoryOnly).ToList().Where(f => Regex.IsMatch(f, "PowerToys_settings_(\\d{1,19})")).ToDictionary(x => long.Parse(Path.GetFileName(x).Replace("PowerToys_settings_", string.Empty), CultureInfo.InvariantCulture)));
var settingsBackupFiles = Directory.GetFiles(location, "settings_*.ptb", SearchOption.TopDirectoryOnly).ToList().Where(f => Regex.IsMatch(f, "settings_(\\d{1,19}).ptb")).ToDictionary(x => long.Parse(Path.GetFileName(x).Replace("settings_", string.Empty).Replace(".ptb", string.Empty), CultureInfo.InvariantCulture));
if (settingsBackupFolders.Count + settingsBackupFiles.Count <= minNumberToKeep)
{
return;
}
foreach (var item in settingsBackupFolders)
{
var backupTime = DateTime.FromFileTimeUtc(item.Key);
if (item.Value.Contains("PowerToys_settings_", StringComparison.OrdinalIgnoreCase))
{
// this is a temp backup and we want to clean based on the time it was created in the temp place, not the time the backup was made.
var folderCreatedTime = new DirectoryInfo(item.Value).CreationTimeUtc;
if (folderCreatedTime > backupTime)
{
backupTime = folderCreatedTime;
}
}
if (backupTime < deleteIfOlder)
{
try
{
Logger.LogInfo($"RemoveOldBackups killing {item.Value}");
Directory.Delete(item.Value, true);
}
catch (Exception ex2)
{
Logger.LogError($"Failed to remove a setting backup folder ({item.Value}), because: ({ex2.Message})");
}
}
}
foreach (var item in settingsBackupFiles)
{
var backupTime = DateTime.FromFileTimeUtc(item.Key);
if (backupTime < deleteIfOlder)
{
try
{
Logger.LogInfo($"RemoveOldBackups killing {item.Value}");
File.Delete(item.Value);
}
catch (Exception ex2)
{
Logger.LogError($"Failed to remove a setting backup folder ({item.Value}), because: ({ex2.Message})");
}
}
}
}
finally
{
Monitor.Exit(removeOldBackupsLock);
}
}
/// <summary>
/// Class <c>JsonNormalizer</c> is a utility class to 'normalize' a JSON file so that it can be compared to another JSON file.
/// This really just means to fully sort it. This does not work for any JSON file where the order of the node is relevant.
/// </summary>
private class JsonNormalizer
{
public static string Normalize(string json)
{
var doc1 = JsonNormalizer.Deserialize(json);
var newJson1 = JsonSerializer.Serialize(doc1, new JsonSerializerOptions { WriteIndented = true });
return newJson1;
}
private static List<object> DeserializeArray(string json)
{
var result = JsonSerializer.Deserialize<List<object>>(json);
var updates = new List<object>();
foreach (var item in result)
{
if (item != null)
{
var currentItem = (JsonElement)item;
if (currentItem.ValueKind == JsonValueKind.Object)
{
updates.Add(Deserialize(currentItem.ToString()));
}
else if (((JsonElement)item).ValueKind == JsonValueKind.Array)
{
updates.Add(DeserializeArray(currentItem.ToString()));
}
else
{
updates.Add(item);
}
}
else
{
updates.Add(item);
}
}
return updates.OrderBy(x => JsonSerializer.Serialize(x)).ToList();
}
private static Dictionary<string, object> Deserialize(string json)
{
var doc = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
var updates = new Dictionary<string, object>();
foreach (var item in doc)
{
if (item.Value != null)
{
if (((JsonElement)item.Value).ValueKind == JsonValueKind.Object)
{
updates.Add(item.Key, Deserialize(((JsonElement)item.Value).ToString()));
}
else if (((JsonElement)item.Value).ValueKind == JsonValueKind.Array)
{
updates.Add(item.Key, DeserializeArray(((JsonElement)item.Value).ToString()));
}
}
}
foreach (var item in updates)
{
doc.Remove(item.Key);
doc.Add(item.Key, item.Value);
}
var ordered = new Dictionary<string, object>();
foreach (var item in doc.Keys.OrderBy(x => x))
{
ordered.Add(item, doc[item]);
}
return ordered;
}
}
}
}