diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 7a51d145a6..233fe2900b 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1619,7 +1619,7 @@ pstr pstream pstrm psz -PTB +ptb ptc ptd PTOKEN diff --git a/installer/PowerToysSetup/Product.wxs b/installer/PowerToysSetup/Product.wxs index aa9c4a87e7..76d05703f8 100644 --- a/installer/PowerToysSetup/Product.wxs +++ b/installer/PowerToysSetup/Product.wxs @@ -49,7 +49,7 @@ - + diff --git a/src/runner/main.cpp b/src/runner/main.cpp index c7c3ae64c8..bc6b4484e5 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -149,7 +149,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"modules/ShortcutGuide/ShortcutGuideModuleInterface/PowerToys.ShortcutGuideModuleInterface.dll", L"modules/ColorPicker/PowerToys.ColorPicker.dll", L"modules/Awake/PowerToys.AwakeModuleInterface.dll", - L"modules/MouseUtils/PowerToys.FindMyMouse.dll" , + L"modules/MouseUtils/PowerToys.FindMyMouse.dll", L"modules/MouseUtils/PowerToys.MouseHighlighter.dll", L"modules/AlwaysOnTop/PowerToys.AlwaysOnTopModuleInterface.dll", L"modules/MouseUtils/PowerToys.MousePointerCrosshairs.dll", @@ -324,7 +324,7 @@ void cleanup_updates() } } } - + // Log files auto rootPath{ PTSettingsHelper::get_root_save_folder_location() }; auto currentVersion = left_trim(get_product_version(), L"v"); @@ -474,9 +474,12 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine { result = runner(elevated, open_settings, settings_window, openOobe, openScoobe); - // Save settings on closing - auto general_settings = get_general_settings(); - PTSettingsHelper::save_general_settings(general_settings.to_json()); + if (result == 0) + { + // Save settings on closing, if closed 'normal' + auto general_settings = get_general_settings(); + PTSettingsHelper::save_general_settings(general_settings.to_json()); + } } else { diff --git a/src/runner/restart_elevated.cpp b/src/runner/restart_elevated.cpp index 6a911c635c..13d4b47136 100644 --- a/src/runner/restart_elevated.cpp +++ b/src/runner/restart_elevated.cpp @@ -8,7 +8,8 @@ enum State None, RestartAsElevated, RestartAsElevatedOpenSettings, - RestartAsNonElevated + RestartAsNonElevated, + RestartAsNonElevatedOpenSettings }; static State state = None; @@ -17,12 +18,16 @@ void schedule_restart_as_elevated(bool openSettings) state = openSettings ? RestartAsElevatedOpenSettings : RestartAsElevated; } - void schedule_restart_as_non_elevated() { state = RestartAsNonElevated; } +void schedule_restart_as_non_elevated(bool openSettings) +{ + state = openSettings ? RestartAsNonElevatedOpenSettings : RestartAsNonElevated; +} + bool is_restart_scheduled() { return state != None; @@ -40,6 +45,8 @@ bool restart_if_scheduled() return run_elevated(exe_path.get(), {}); case RestartAsElevatedOpenSettings: return run_elevated(exe_path.get(), L"--open-settings"); + case RestartAsNonElevatedOpenSettings: + return run_non_elevated(exe_path.get(), L"--open-settings", NULL); case RestartAsNonElevated: return run_non_elevated(exe_path.get(), L"", NULL); default: diff --git a/src/runner/restart_elevated.h b/src/runner/restart_elevated.h index b341afa975..9ffb545c91 100644 --- a/src/runner/restart_elevated.h +++ b/src/runner/restart_elevated.h @@ -1,6 +1,7 @@ #pragma once void schedule_restart_as_elevated(bool openSettings); void schedule_restart_as_non_elevated(); +void schedule_restart_as_non_elevated(bool openSettings); bool is_restart_scheduled(); bool restart_if_scheduled(); bool restart_same_elevation(); diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index 5d84d6847d..656c4e0680 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -83,6 +83,24 @@ std::optional dispatch_json_action_to_module(const json::JsonObjec PostQuitMessage(0); } } + else if (action == L"restart_maintain_elevation") + { + // this was added to restart and maintain elevation, which is needed after settings are change from outside the normal process. + // since a normal PostQuitMessage(0) would usually cause this process to save it's in memory settings to disk, we need to + // send a PostQuitMessage(1) and check for that on exit, and skip the settings-flush. + auto loaded = PTSettingsHelper::load_general_settings(); + + if (is_process_elevated()) + { + schedule_restart_as_elevated(true); + PostQuitMessage(1); + } + else + { + schedule_restart_as_non_elevated(true); + PostQuitMessage(1); + } + } else if (action == L"check_for_updates") { CheckForUpdatesCallback(); diff --git a/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj b/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj index 25ea43ce4f..c076e7f5d4 100644 --- a/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj +++ b/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj @@ -1,31 +1,38 @@  - + - - net6.0-windows - $(Version).0 - Microsoft Corporation - PowerToys - PowerToys Settings UI Library - PowerToys.Settings.UI.Lib - + + net6.0-windows + $(Version).0 + Microsoft Corporation + PowerToys + PowerToys Settings UI Library + PowerToys.Settings.UI.Lib + false + - - DEBUG;TRACE - full - true - + + DEBUG;TRACE + full + true + - - - - + + + PreserveNewest + + - - - - - + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs b/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs new file mode 100644 index 0000000000..5318fce551 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs @@ -0,0 +1,1068 @@ +// 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(); + + 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(); + 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; + } + + /// + /// Method SetRegSettingsBackupAndRestoreItem helper method to write to the registry. + /// + 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); + } + } + } + + /// + /// Method GetRegSettingsBackupAndRestoreRegItem helper method to read from the registry. + /// + 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; + } + + /// + /// Method RestoreSettings returns a folder that has the latest backup in it. + /// + /// + /// A tuple that indicates if the backup was done or not, and a message. + /// The message usually is a localized reference key. + /// + 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"); + } + } + + /// + /// Method GetSettingsBackupAndRestoreDir returns the path the backup and restore location. + /// + /// + /// This will return a default location based on Environment Variables if non is set. + /// + public string GetSettingsBackupAndRestoreDir() + { + string settingsBackupAndRestoreDir = GetRegSettingsBackupAndRestoreRegItem("SettingsBackupAndRestoreDir"); + if (settingsBackupAndRestoreDir == null) + { + settingsBackupAndRestoreDir = Environment.ExpandEnvironmentVariables("%USERPROFILE%\\Documents\\PowerToys\\Backup"); + } + + return settingsBackupAndRestoreDir; + } + + private IList GetBackupSettingsFiles(string settingsBackupAndRestoreDir) + { + return Directory.GetFiles(settingsBackupAndRestoreDir, "settings_*.ptb", SearchOption.TopDirectoryOnly).ToList().Where(f => Regex.IsMatch(f, "settings_(\\d{1,19}).ptb")).ToList(); + } + + /// + /// Method GetLatestSettingsFolder returns a folder that has the latest backup in it. + /// + /// + /// The backup will usually be a backup file that has to be extracted to a temp folder. This will do that for us. + /// + private string GetLatestSettingsFolder() + { + string settingsBackupAndRestoreDir = GetSettingsBackupAndRestoreDir(); + + if (settingsBackupAndRestoreDir == null) + { + return null; + } + + if (!Directory.Exists(settingsBackupAndRestoreDir)) + { + return null; + } + + var settingsBackupFolders = new Dictionary(); + + 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)); + if (!Directory.Exists(fullBackupDir)) + { + ZipFile.ExtractToDirectory(settingsBackupFiles[latestFile], fullBackupDir); + } + + ThreadPool.QueueUserWorkItem((x) => + { + try + { + RemoveOldBackups(tempPath, 1, TimeSpan.FromDays(7)); + } + catch + { + // hmm, ok + } + }); + + return fullBackupDir; + } + } + + /// + /// Method GetLatestBackupFileName returns the name of the newest backup file. + /// + 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; + } + } + + /// + /// Method GetLatestSettingsBackupManifest get's the meta data from a backup file. + /// + public JsonNode GetLatestSettingsBackupManifest() + { + var folder = GetLatestSettingsFolder(); + if (folder == null) + { + return null; + } + + return JsonNode.Parse(File.ReadAllText(Path.Combine(folder, "manifest.json"))); + } + + /// + /// Method IsIncludeFile check's to see if a settings file is to be included during backup and restore. + /// + 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; + } + + /// + /// Method IsIgnoreFile check's to see if a settings file is to be ignored during backup and restore. + /// + 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; + } + + /// + /// Class GetSettingsFiles returns the effective list of settings files. + /// + /// + /// Handles all the included/exclude files. + /// + private static string[] GetSettingsFiles(JsonNode settings, string path) + { + if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) + { + return Array.Empty(); + } + + return Directory.GetFiles(path, "*.json", SearchOption.AllDirectories).Where(s => IsIncludeFile(settings, s) && !IsIgnoreFile(settings, s)).ToArray(); + } + + /// + /// Method BackupSettings does the backup process. + /// + /// + /// A tuple that indicates if the backup was done or not, and a message. + /// The message usually is a localized reference key. + /// + /// + /// This is a wrapper for BackupSettingsInternal, so we can check the time to run. + /// + 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; + } + + /// + /// Method DryRunBackup wrapper function to do a dry-run backup + /// + 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; + } + + /// + /// Method GetLastBackupSettingsResults gets the results from the last backup process + /// + /// + /// A tuple that indicates if the backup was done or not, and other information + /// + public (bool success, bool hadError, bool lastBackupExists, DateTime? lastRan) GetLastBackupSettingsResults() + { + return (lastBackupSettingsResults.success, lastBackupSettingsResults.severity == "Error", lastBackupSettingsResults.lastBackupExists, lastBackupSettingsResults.lastRan); + } + + /// + /// Method BackupSettingsInternal does the backup process. + /// + /// + /// A tuple that indicates if the backup was done or not, and a message. + /// The message usually is a localized reference key. + /// + 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(); + var updatedSettingsFiles = new Dictionary(); + + 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); + } + } + } + + /// + /// Searches for the config file (Json) in two possible paths and returns its content. + /// + /// Returns the content of the config file (Json) as string. + /// Thrown if file is not found. + /// 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". + 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}"); + } + } + + /// + /// Method WildCardToRegular is so we can use 'normal' wildcard syntax and instead of regex + /// + private static string WildCardToRegular(string value) + { + return "^" + Regex.Escape(value).Replace("\\*", ".*") + "$"; + } + + /// + /// Method GetExportVersion gets the version of the settings file that we want to backup. + /// It will be formatted and all problematic settings removed from it. + /// + 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(); + + 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 }); + } + } + + /// + /// Method GetPTRunIgnoredSettings gets the 'Run-Plugin-level' settings we should ignore because they are problematic to backup/restore. + /// + 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(); + } + + /// + /// Method GetIgnoredSettings gets the 'top-level' settings we should ignore because they are problematic to backup/restore. + /// + 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(); + + foreach (var setting in settingsArray) + { + settingsList.Add(setting.ToString()); + } + + return settingsList.ToArray(); + } + else + { + return Array.Empty(); + } + } + + return Array.Empty(); + } + + /// + /// Method RemoveOldBackups is a helper that prevents is from having some runaway disk usages. + /// + 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); + } + } + + /// + /// Class JsonNormalizer 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. + /// + 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 DeserializeArray(string json) + { + var result = JsonSerializer.Deserialize>(json); + + var updates = new List(); + + 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 Deserialize(string json) + { + var doc = JsonSerializer.Deserialize>(json); + + var updates = new Dictionary(); + + 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(); + + foreach (var item in doc.Keys.OrderBy(x => x)) + { + ordered.Add(item, doc[item]); + } + + return ordered; + } + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/SettingsUtils.cs b/src/settings-ui/Settings.UI.Library/SettingsUtils.cs index e68e75581a..8c8cba0d90 100644 --- a/src/settings-ui/Settings.UI.Library/SettingsUtils.cs +++ b/src/settings-ui/Settings.UI.Library/SettingsUtils.cs @@ -139,5 +139,30 @@ namespace Microsoft.PowerToys.Settings.UI.Library { return _settingsPath.GetSettingsPath(powertoy, fileName); } + + /// + /// Method BackupSettings Mostly a wrapper for SettingsBackupAndRestoreUtils.BackupSettings + /// + public static (bool success, string message, string severity, bool lastBackupExists) BackupSettings() + { + var settingsBackupAndRestoreUtilsX = SettingsBackupAndRestoreUtils.Instance; + var settingsUtils = new SettingsUtils(); + var appBasePath = Path.GetDirectoryName(settingsUtils._settingsPath.GetSettingsPath(string.Empty, string.Empty)); + string settingsBackupAndRestoreDir = settingsBackupAndRestoreUtilsX.GetSettingsBackupAndRestoreDir(); + + return settingsBackupAndRestoreUtilsX.BackupSettings(appBasePath, settingsBackupAndRestoreDir, false); + } + + /// + /// Method RestoreSettings Mostly a wrapper for SettingsBackupAndRestoreUtils.RestoreSettings + /// + public static (bool success, string message, string severity) RestoreSettings() + { + var settingsBackupAndRestoreUtilsX = SettingsBackupAndRestoreUtils.Instance; + var settingsUtils = new SettingsUtils(); + var appBasePath = Path.GetDirectoryName(settingsUtils._settingsPath.GetSettingsPath(string.Empty, string.Empty)); + string settingsBackupAndRestoreDir = settingsBackupAndRestoreUtilsX.GetSettingsBackupAndRestoreDir(); + return settingsBackupAndRestoreUtilsX.RestoreSettings(appBasePath, settingsBackupAndRestoreDir); + } } } diff --git a/src/settings-ui/Settings.UI.Library/UpdatingSettings.cs b/src/settings-ui/Settings.UI.Library/UpdatingSettings.cs index 662b6fb7a4..e3ae5706ff 100644 --- a/src/settings-ui/Settings.UI.Library/UpdatingSettings.cs +++ b/src/settings-ui/Settings.UI.Library/UpdatingSettings.cs @@ -69,6 +69,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library { try { + if (LastCheckedDate == null) + { + return string.Empty; + } + long seconds = long.Parse(LastCheckedDate, CultureInfo.CurrentCulture); var date = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime; return date.ToLocalTime().ToString(CultureInfo.CurrentCulture); diff --git a/src/settings-ui/Settings.UI.Library/ViewModels/GeneralViewModel.cs b/src/settings-ui/Settings.UI.Library/ViewModels/GeneralViewModel.cs index f365524b7c..a1eac0be9a 100644 --- a/src/settings-ui/Settings.UI.Library/ViewModels/GeneralViewModel.cs +++ b/src/settings-ui/Settings.UI.Library/ViewModels/GeneralViewModel.cs @@ -4,8 +4,13 @@ using System; using System.Diagnostics; +using System.Globalization; +using System.IO; using System.IO.Abstractions; +using System.Reflection; using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading.Tasks; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.Library.Utilities; @@ -21,6 +26,20 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels public ButtonClickCommand CheckForUpdatesEventHandler { get; set; } + public object ResourceLoader { get; set; } + + private Action HideBackupAndRestoreMessageAreaAction { get; set; } + + private Action DoBackupAndRestoreDryRun { get; set; } + + public ButtonClickCommand BackupConfigsEventHandler { get; set; } + + public ButtonClickCommand RestoreConfigsEventHandler { get; set; } + + public ButtonClickCommand RefreshBackupStatusEventHandler { get; set; } + + public ButtonClickCommand SelectSettingBackupDirEventHandler { get; set; } + public ButtonClickCommand RestartElevatedButtonEventHandler { get; set; } public ButtonClickCommand UpdateNowButtonEventHandler { get; set; } @@ -41,11 +60,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels private IFileSystemWatcher _fileWatcher; - public GeneralViewModel(ISettingsRepository settingsRepository, string runAsAdminText, string runAsUserText, bool isElevated, bool isAdmin, Func updateTheme, Func ipcMSGCallBackFunc, Func ipcMSGRestartAsAdminMSGCallBackFunc, Func ipcMSGCheckForUpdatesCallBackFunc, string configFileSubfolder = "", Action dispatcherAction = null) + private Func> PickSingleFolderDialog { get; } + + private SettingsBackupAndRestoreUtils settingsBackupAndRestoreUtils = SettingsBackupAndRestoreUtils.Instance; + + public GeneralViewModel(ISettingsRepository settingsRepository, string runAsAdminText, string runAsUserText, bool isElevated, bool isAdmin, Func updateTheme, Func ipcMSGCallBackFunc, Func ipcMSGRestartAsAdminMSGCallBackFunc, Func ipcMSGCheckForUpdatesCallBackFunc, string configFileSubfolder = "", Action dispatcherAction = null, Action hideBackupAndRestoreMessageAreaAction = null, Action doBackupAndRestoreDryRun = null, Func> pickSingleFolderDialog = null, object resourceLoader = null) { CheckForUpdatesEventHandler = new ButtonClickCommand(CheckForUpdatesClick); RestartElevatedButtonEventHandler = new ButtonClickCommand(RestartElevated); UpdateNowButtonEventHandler = new ButtonClickCommand(UpdateNowClick); + BackupConfigsEventHandler = new ButtonClickCommand(BackupConfigsClick); + SelectSettingBackupDirEventHandler = new ButtonClickCommand(SelectSettingBackupDir); + RestoreConfigsEventHandler = new ButtonClickCommand(RestoreConfigsClick); + RefreshBackupStatusEventHandler = new ButtonClickCommand(RefreshBackupStatusEventHandlerClick); + HideBackupAndRestoreMessageAreaAction = hideBackupAndRestoreMessageAreaAction; + DoBackupAndRestoreDryRun = doBackupAndRestoreDryRun; + PickSingleFolderDialog = pickSingleFolderDialog; + ResourceLoader = resourceLoader; // To obtain the general settings configuration of PowerToys if it exists, else to create a new file and return the default configurations. if (settingsRepository == null) @@ -92,6 +123,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels _startup = GeneralSettingsConfig.Startup; _autoDownloadUpdates = GeneralSettingsConfig.AutoDownloadUpdates; + _isElevated = isElevated; _runElevated = GeneralSettingsConfig.RunElevated; @@ -127,6 +159,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels private bool _isNewVersionDownloading; private bool _isNewVersionChecked; + private bool _settingsBackupRestoreMessageVisible; + private string _settingsBackupMessage; + private string _backupRestoreMessageSeverity; + // Gets or sets a value indicating whether run powertoys on start-up. public bool Startup { @@ -253,6 +289,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels } } + public string SettingsBackupAndRestoreDir + { + get + { + return settingsBackupAndRestoreUtils.GetSettingsBackupAndRestoreDir(); + } + + set + { + if (settingsBackupAndRestoreUtils.GetSettingsBackupAndRestoreDir() != value) + { + SettingsBackupAndRestoreUtils.SetRegSettingsBackupAndRestoreItem("SettingsBackupAndRestoreDir", value); + NotifyPropertyChanged(); + } + } + } + public int ThemeIndex { get @@ -313,6 +366,155 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels } } + public string LastSettingsBackupDate + { + get + { + try + { + var manifest = settingsBackupAndRestoreUtils.GetLatestSettingsBackupManifest(); + if (manifest != null) + { + if (manifest["CreateDateTime"] != null) + { + if (DateTime.TryParse(manifest["CreateDateTime"].ToString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var theDateTime)) + { + return theDateTime.ToString("G", CultureInfo.CurrentCulture); + } + else + { + Logger.LogError("Failed to parse time from backup"); + return GetResourceString("General_SettingsBackupAndRestore_FailedToParseTime"); + } + } + else + { + return GetResourceString("General_SettingsBackupAndRestore_UnknownBackupTime"); + } + } + else + { + return GetResourceString("General_SettingsBackupAndRestore_NoBackupFound"); + } + } + catch (Exception e) + { + Logger.LogError("Error getting LastSettingsBackupDate", e); + return GetResourceString("General_SettingsBackupAndRestore_UnknownBackupTime"); + } + } + } + + public string CurrentSettingMatchText + { + get + { + try + { + var results = settingsBackupAndRestoreUtils.GetLastBackupSettingsResults(); + + var resultText = string.Empty; + + if (!results.lastRan.HasValue) + { + // not ran since started. + return GetResourceString("General_SettingsBackupAndRestore_CurrentSettingsNoChecked"); // "Current Settings Unknown"; + } + else + { + if (results.success) + { + if (results.lastBackupExists) + { + // if true, it means a backup would have been made + resultText = GetResourceString("General_SettingsBackupAndRestore_CurrentSettingsDiffer"); // "Current Settings Differ"; + } + else + { + // would have done the backup, but there also was not an existing one there. + resultText = GetResourceString("General_SettingsBackupAndRestore_NoBackupFound"); + } + } + else + { + if (results.hadError) + { + // if false and error we don't really know + resultText = GetResourceString("General_SettingsBackupAndRestore_CurrentSettingsUnknown"); // "Current Settings Unknown"; + } + else + { + // if false, it means a backup would not have been needed/made + resultText = GetResourceString("General_SettingsBackupAndRestore_CurrentSettingsMatch"); // "Current Settings Match"; + } + } + + return $"{resultText} {GetResourceString("General_SettingsBackupAndRestore_CurrentSettingsStatusAt")} {results.lastRan.Value.ToLocalTime().ToString("G", CultureInfo.CurrentCulture)}"; + } + } + catch (Exception e) + { + Logger.LogError("Error getting CurrentSettingMatchText", e); + return string.Empty; + } + } + } + + public string LastSettingsBackupSource + { + get + { + try + { + var manifest = settingsBackupAndRestoreUtils.GetLatestSettingsBackupManifest(); + if (manifest != null) + { + if (manifest["BackupSource"] != null) + { + if (manifest["BackupSource"].ToString().Equals(Environment.MachineName, StringComparison.OrdinalIgnoreCase)) + { + return GetResourceString("General_SettingsBackupAndRestore_ThisMachine"); + } + else + { + return manifest["BackupSource"].ToString(); + } + } + else + { + return GetResourceString("General_SettingsBackupAndRestore_UnknownBackupSource"); + } + } + else + { + return GetResourceString("General_SettingsBackupAndRestore_NoBackupFound"); + } + } + catch (Exception e) + { + Logger.LogError("Error getting LastSettingsBackupSource", e); + return GetResourceString("General_SettingsBackupAndRestore_UnknownBackupSource"); + } + } + } + + public string LastSettingsBackupFileName + { + get + { + try + { + var fileName = settingsBackupAndRestoreUtils.GetLatestBackupFileName(); + return !string.IsNullOrEmpty(fileName) ? fileName : GetResourceString("General_SettingsBackupAndRestore_NoBackupFound"); + } + catch (Exception e) + { + Logger.LogError("Error getting LastSettingsBackupFileName", e); + return string.Empty; + } + } + } + public UpdatingSettings.UpdatingState PowerToysUpdatingState { get @@ -389,6 +591,30 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels } } + public bool SettingsBackupRestoreMessageVisible + { + get + { + return _settingsBackupRestoreMessageVisible; + } + } + + public string BackupRestoreMessageSeverity + { + get + { + return _backupRestoreMessageSeverity; + } + } + + public string SettingsBackupMessage + { + get + { + return _settingsBackupMessage; + } + } + public bool IsDownloadAllowed { get @@ -397,13 +623,114 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels } } - public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null, bool reDoBackupDryRun = true) { // Notify UI of property change OnPropertyChanged(propertyName); + OutGoingGeneralSettings outsettings = new OutGoingGeneralSettings(GeneralSettingsConfig); SendConfigMSG(outsettings.ToString()); + + if (reDoBackupDryRun && DoBackupAndRestoreDryRun != null) + { + DoBackupAndRestoreDryRun(500); + } + } + + /// + /// Method SelectSettingBackupDir opens folder browser to select a backup and retore location. + /// + private async void SelectSettingBackupDir() + { + var currentDir = settingsBackupAndRestoreUtils.GetSettingsBackupAndRestoreDir(); + + var newPath = await PickSingleFolderDialog(); + + if (!string.IsNullOrEmpty(newPath)) + { + SettingsBackupAndRestoreDir = newPath; + NotifyAllBackupAndRestoreProperties(); + } + } + + private void RefreshBackupStatusEventHandlerClick() + { + DoBackupAndRestoreDryRun(0); + } + + /// + /// Method RestoreConfigsClick starts the restore. + /// + private void RestoreConfigsClick() + { + string settingsBackupAndRestoreDir = settingsBackupAndRestoreUtils.GetSettingsBackupAndRestoreDir(); + + if (string.IsNullOrEmpty(settingsBackupAndRestoreDir)) + { + SelectSettingBackupDir(); + } + + var results = SettingsUtils.RestoreSettings(); + _backupRestoreMessageSeverity = results.severity; + + if (!results.success) + { + _settingsBackupRestoreMessageVisible = true; + + _settingsBackupMessage = GetResourceString(results.message); + + NotifyAllBackupAndRestoreProperties(); + + HideBackupAndRestoreMessageAreaAction(); + } + else + { + // make sure not to do NotifyPropertyChanged here, else it will persist the configs from memory and + // undo the settings restore. + SettingsBackupAndRestoreUtils.SetRegSettingsBackupAndRestoreItem("LastSettingsRestoreDate", DateTime.UtcNow.ToString("u", CultureInfo.InvariantCulture)); + + Restart(); + } + } + + /// + /// Method BackupConfigsClick starts the backup. + /// + private void BackupConfigsClick() + { + string settingsBackupAndRestoreDir = settingsBackupAndRestoreUtils.GetSettingsBackupAndRestoreDir(); + + if (string.IsNullOrEmpty(settingsBackupAndRestoreDir)) + { + SelectSettingBackupDir(); + } + + var results = SettingsUtils.BackupSettings(); + + _settingsBackupRestoreMessageVisible = true; + _backupRestoreMessageSeverity = results.severity; + _settingsBackupMessage = GetResourceString(results.message); + + // now we do a dry run to get the results for "setting match" + var settingsUtils = new SettingsUtils(); + var appBasePath = Path.GetDirectoryName(settingsUtils.GetSettingsFilePath()); + settingsBackupAndRestoreUtils.BackupSettings(appBasePath, settingsBackupAndRestoreDir, true); + + NotifyAllBackupAndRestoreProperties(); + + HideBackupAndRestoreMessageAreaAction(); + } + + public void NotifyAllBackupAndRestoreProperties() + { + NotifyPropertyChanged(nameof(LastSettingsBackupDate), false); + NotifyPropertyChanged(nameof(LastSettingsBackupSource), false); + NotifyPropertyChanged(nameof(LastSettingsBackupFileName), false); + NotifyPropertyChanged(nameof(CurrentSettingMatchText), false); + NotifyPropertyChanged(nameof(SettingsBackupMessage), false); + NotifyPropertyChanged(nameof(BackupRestoreMessageSeverity), false); + NotifyPropertyChanged(nameof(SettingsBackupRestoreMessageVisible), false); } // callback function to launch the URL to check for updates. @@ -435,6 +762,36 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels Process.Start(new ProcessStartInfo(Helper.GetPowerToysInstallationFolder() + "\\PowerToys.exe") { Arguments = "powertoys://update_now/" }); } + /// + /// Class GetResourceString gets a localized text. + /// + /// + /// To do: see if there is a betting way to do this, there should be. It does allow us to return missing localization in a way that makes it obvious they were missed. + /// + public string GetResourceString(string resource) + { + if (ResourceLoader != null) + { + var type = ResourceLoader.GetType(); + MethodInfo methodInfo = type.GetMethod("GetString"); + object classInstance = Activator.CreateInstance(type, null); + object[] parametersArray = new object[] { resource }; + var result = (string)methodInfo.Invoke(ResourceLoader, parametersArray); + if (string.IsNullOrEmpty(result)) + { + return resource.ToUpperInvariant() + "!!!"; + } + else + { + return result; + } + } + else + { + return resource; + } + } + public void RequestUpdateCheckedDate() { GeneralSettingsConfig.CustomActionName = "request_update_state_date"; @@ -455,6 +812,36 @@ namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels SendRestartAsAdminConfigMSG(customaction.ToString()); } + /// + /// Class Restart begin a restart and signal we want to maintain elevation + /// + /// + /// Other restarts either raised or lowered elevation + /// + public void Restart() + { + GeneralSettingsConfig.CustomActionName = "restart_maintain_elevation"; + + OutGoingGeneralSettings outsettings = new OutGoingGeneralSettings(GeneralSettingsConfig); + GeneralSettingsCustomAction customaction = new GeneralSettingsCustomAction(outsettings); + + var dataToSend = customaction.ToString(); + dataToSend = JsonSerializer.Serialize(new { action = new { general = new { action_name = "restart_maintain_elevation" } } }); + SendRestartAsAdminConfigMSG(dataToSend); + } + + /// + /// Class HideBackupAndRestoreMessageArea hides the backup/restore message area + /// + /// + /// We want to have it go away after a short period. + /// + public void HideBackupAndRestoreMessageArea() + { + _settingsBackupRestoreMessageVisible = false; + NotifyAllBackupAndRestoreProperties(); + } + public void RefreshUpdatingState() { object oLock = new object(); diff --git a/src/settings-ui/Settings.UI.Library/backup_restore_settings.json b/src/settings-ui/Settings.UI.Library/backup_restore_settings.json new file mode 100644 index 0000000000..535a8b47b7 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/backup_restore_settings.json @@ -0,0 +1,46 @@ +{ + "RestartAfterRestore": true, + "IncludeFiles": [ + "*Keyboard Manager\\default.json", + "*settings.json", + "*FancyZones\\layout-hotkeys.json", + "*FancyZones\\layout-templates.json" + ], + "IgnoreFiles": [ + "*PowerToys\\log_settings.json", + "*PowerToys\\oobe_settings.json" + ], + "IgnoredPTRunSettings": [ + { + "Id": "525995402BEF4A8CA860D92F6D108092", + "Names": [ + "IconPathDark", + "IconPathLight" + ] + }, + { + "Id": "5D69806A5A474115821C3E4C56B9C793", + "Names": [ + "Description" + ] + } + ], + "IgnoredSettings": { + "backup-restore_settings.json": [ + "RestartAfterRestore" + ], + "settings.json": [ + "powertoys_version" + ], + "PowerToys Run\\Settings\\PowerToysRunSettings.json": [ + "WindowLeft", + "WindowTop", + "ActivateTimes" + ], + "PowerToys Run\\Settings\\Plugins\\Microsoft.Plugin.Program\\ProgramPluginSettings.json": [ + "LastIndexTime", + "WindowTop", + "ActivateTimes" + ] + } +} \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Converters/StringToInfoBarSeverityConverter.cs b/src/settings-ui/Settings.UI/Converters/StringToInfoBarSeverityConverter.cs new file mode 100644 index 0000000000..6b3f6ed3eb --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/StringToInfoBarSeverityConverter.cs @@ -0,0 +1,38 @@ +// 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 Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public sealed class StringToInfoBarSeverityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + try + { + if (value == null) + { + return Microsoft.UI.Xaml.Controls.InfoBarSeverity.Informational; + } + else + { + return (Microsoft.UI.Xaml.Controls.InfoBarSeverity)Enum.Parse(typeof(Microsoft.UI.Xaml.Controls.InfoBarSeverity), (string)value, true); + } + } + catch + { + return Microsoft.UI.Xaml.Controls.InfoBarSeverity.Informational; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index 4cbecd667f..20736c1d74 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -648,6 +648,24 @@ Check for updates + + Location + + + Backup + + + File name: + + + Refresh + + + Restore + + + Browse + Update now @@ -812,6 +830,12 @@ Appearance & behavior + + Backup & restore + + + Backup and restore your settings at any time + Enable Shortcut Guide do not loc the Product name. Do you want this feature on / off @@ -1045,6 +1069,21 @@ Last checked: + + Created at: + + + PowerToys will restart automatically if needed. + + + Backup information + + + Source machine: + + + Status: + Version @@ -2354,6 +2393,64 @@ Activate by holding the key for the character you want to add an accent to, then Learn more about Text Extractor + + A new backup was not created because no settings have been changed since last backup. + + + No backup found + + + Failed to parse time + + + Unknown + + + Unknown + + + This computer + + + Current settings match + + + at + E.g., Food was served 'at' noon. + + + Current settings differ + + + Checking... + + + Unknown + + + Never restored + + + Nothing to restore. + + + No settings files found. + + + There was an error. Try another backup location. + + + Backup completed. + + + No backup location selected. + + + No backups found to restore. + + + Invalid backup location. + Text Extractor is a convenient way to copy text from anywhere on screen diff --git a/src/settings-ui/Settings.UI/Styles/TextBlock.xaml b/src/settings-ui/Settings.UI/Styles/TextBlock.xaml index 6f091a32d6..731b635388 100644 --- a/src/settings-ui/Settings.UI/Styles/TextBlock.xaml +++ b/src/settings-ui/Settings.UI/Styles/TextBlock.xaml @@ -20,4 +20,11 @@ + + 12 + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Views/GeneralPage.xaml b/src/settings-ui/Settings.UI/Views/GeneralPage.xaml index 46db317031..f7af3990e5 100644 --- a/src/settings-ui/Settings.UI/Views/GeneralPage.xaml +++ b/src/settings-ui/Settings.UI/Views/GeneralPage.xaml @@ -14,6 +14,7 @@ + - - + + - + @@ -76,7 +77,7 @@ Command="{Binding UpdateNowButtonEventHandler}" IsEnabled="{Binding IsDownloadAllowed, Mode=OneWay}" Visibility="{Binding Mode=OneWay, Path=IsNewVersionDownloading, Converter={StaticResource NegationConverter}}"/> - + - + - + @@ -207,26 +208,179 @@ - - + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + +