/ src / settings-ui / Settings.UI.Library / SettingsBackupAndRestoreUtils.cs
SettingsBackupAndRestoreUtils.cs
   1  // Copyright (c) Microsoft Corporation
   2  // The Microsoft Corporation licenses this file to you under the MIT license.
   3  // See the LICENSE file in the project root for more information.
   4  
   5  using System;
   6  using System.Buffers;
   7  using System.Collections.Generic;
   8  using System.Diagnostics;
   9  using System.Globalization;
  10  using System.IO;
  11  using System.IO.Compression;
  12  using System.Linq;
  13  using System.Text;
  14  using System.Text.Json;
  15  using System.Text.Json.Nodes;
  16  using System.Text.RegularExpressions;
  17  using System.Threading;
  18  
  19  using ManagedCommon;
  20  using Microsoft.PowerToys.Settings.UI.Library.Utilities;
  21  
  22  namespace Microsoft.PowerToys.Settings.UI.Library
  23  {
  24      public class SettingsBackupAndRestoreUtils
  25      {
  26          private static SettingsBackupAndRestoreUtils instance;
  27          private (bool Success, string Severity, bool LastBackupExists, DateTime? LastRan) lastBackupSettingsResults;
  28          private static Lock backupSettingsInternalLock = new Lock();
  29          private static Lock removeOldBackupsLock = new Lock();
  30  
  31          public DateTime LastBackupStartTime { get; set; }
  32  
  33          private static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions
  34          {
  35              WriteIndented = true,
  36          };
  37  
  38          private SettingsBackupAndRestoreUtils()
  39          {
  40              LastBackupStartTime = DateTime.MinValue;
  41          }
  42  
  43          public static SettingsBackupAndRestoreUtils Instance
  44          {
  45              get
  46              {
  47                  if (instance == null)
  48                  {
  49                      instance = new SettingsBackupAndRestoreUtils();
  50                  }
  51  
  52                  return instance;
  53              }
  54          }
  55  
  56          private sealed class JsonMergeHelper
  57          {
  58              // mostly from https://stackoverflow.com/questions/58694837/system-text-json-merge-two-objects
  59              // but with some update to prevent array item duplicates
  60              public static string Merge(string originalJson, string newContent)
  61              {
  62                  var outputBuffer = new ArrayBufferWriter<byte>();
  63  
  64                  using (JsonDocument jDoc1 = JsonDocument.Parse(originalJson))
  65                  using (JsonDocument jDoc2 = JsonDocument.Parse(newContent))
  66                  using (var jsonWriter = new Utf8JsonWriter(outputBuffer, new JsonWriterOptions { Indented = true }))
  67                  {
  68                      JsonElement root1 = jDoc1.RootElement;
  69                      JsonElement root2 = jDoc2.RootElement;
  70  
  71                      if (root1.ValueKind != JsonValueKind.Array && root1.ValueKind != JsonValueKind.Object)
  72                      {
  73                          throw new InvalidOperationException($"The original JSON document to merge new content into must be a container type. Instead it is {root1.ValueKind}.");
  74                      }
  75  
  76                      if (root1.ValueKind != root2.ValueKind)
  77                      {
  78                          return originalJson;
  79                      }
  80  
  81                      if (root1.ValueKind == JsonValueKind.Array)
  82                      {
  83                          MergeArrays(jsonWriter, root1, root2, false);
  84                      }
  85                      else
  86                      {
  87                          MergeObjects(jsonWriter, root1, root2);
  88                      }
  89                  }
  90  
  91                  return Encoding.UTF8.GetString(outputBuffer.WrittenSpan);
  92              }
  93  
  94              private static void MergeObjects(Utf8JsonWriter jsonWriter, JsonElement root1, JsonElement root2)
  95              {
  96                  jsonWriter.WriteStartObject();
  97  
  98                  // Write all the properties of the first document.
  99                  // If a property exists in both documents, either:
 100                  // * Merge them, if the value kinds match (e.g. both are objects or arrays),
 101                  // * 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),
 102                  // * 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).
 103                  foreach (JsonProperty property in root1.EnumerateObject())
 104                  {
 105                      string propertyName = property.Name;
 106  
 107                      JsonValueKind newValueKind;
 108  
 109                      if (root2.TryGetProperty(propertyName, out JsonElement newValue) && (newValueKind = newValue.ValueKind) != JsonValueKind.Null)
 110                      {
 111                          jsonWriter.WritePropertyName(propertyName);
 112  
 113                          JsonElement originalValue = property.Value;
 114                          JsonValueKind originalValueKind = originalValue.ValueKind;
 115  
 116                          if (newValueKind == JsonValueKind.Object && originalValueKind == JsonValueKind.Object)
 117                          {
 118                              MergeObjects(jsonWriter, originalValue, newValue); // Recursive call
 119                          }
 120                          else if (newValueKind == JsonValueKind.Array && originalValueKind == JsonValueKind.Array)
 121                          {
 122                              MergeArrays(jsonWriter, originalValue, newValue, false);
 123                          }
 124                          else
 125                          {
 126                              newValue.WriteTo(jsonWriter);
 127                          }
 128                      }
 129                      else
 130                      {
 131                          property.WriteTo(jsonWriter);
 132                      }
 133                  }
 134  
 135                  // Write all the properties of the second document that are unique to it.
 136                  foreach (JsonProperty property in root2.EnumerateObject())
 137                  {
 138                      if (!root1.TryGetProperty(property.Name, out _))
 139                      {
 140                          property.WriteTo(jsonWriter);
 141                      }
 142                  }
 143  
 144                  jsonWriter.WriteEndObject();
 145              }
 146  
 147              private static void MergeArrays(Utf8JsonWriter jsonWriter, JsonElement root1, JsonElement root2, bool allowDupes)
 148              {
 149                  // just does one level!!!
 150                  jsonWriter.WriteStartArray();
 151  
 152                  if (allowDupes)
 153                  {
 154                      // Write all the elements from both JSON arrays
 155                      foreach (JsonElement element in root1.EnumerateArray())
 156                      {
 157                          element.WriteTo(jsonWriter);
 158                      }
 159  
 160                      foreach (JsonElement element in root2.EnumerateArray())
 161                      {
 162                          element.WriteTo(jsonWriter);
 163                      }
 164                  }
 165                  else
 166                  {
 167                      var arrayItems = new HashSet<string>();
 168                      foreach (JsonElement element in root1.EnumerateArray())
 169                      {
 170                          element.WriteTo(jsonWriter);
 171                          arrayItems.Add(element.ToString());
 172                      }
 173  
 174                      foreach (JsonElement element in root2.EnumerateArray())
 175                      {
 176                          if (!arrayItems.Contains(element.ToString()))
 177                          {
 178                              element.WriteTo(jsonWriter);
 179                          }
 180                      }
 181                  }
 182  
 183                  jsonWriter.WriteEndArray();
 184              }
 185          }
 186  
 187          private static bool TryCreateDirectory(string path)
 188          {
 189              try
 190              {
 191                  if (!Directory.Exists(path))
 192                  {
 193                      Directory.CreateDirectory(path);
 194                      return true;
 195                  }
 196              }
 197              catch (Exception ex3)
 198              {
 199                  Logger.LogError($"There was an error in TryCreateDirectory {path}: {ex3.Message}", ex3);
 200                  return false;
 201              }
 202  
 203              return true;
 204          }
 205  
 206          private static bool TryDeleteDirectory(string path)
 207          {
 208              try
 209              {
 210                  if (Directory.Exists(path))
 211                  {
 212                      Directory.Delete(path, true);
 213                      return true;
 214                  }
 215              }
 216              catch (Exception ex3)
 217              {
 218                  Logger.LogError($"There was an error in TryDeleteDirectory {path}: {ex3.Message}", ex3);
 219                  return false;
 220              }
 221  
 222              return true;
 223          }
 224  
 225          /// <summary>
 226          /// Method <c>SetRegSettingsBackupAndRestoreItem</c> helper method to write to the registry.
 227          /// </summary>
 228          public static void SetRegSettingsBackupAndRestoreItem(string itemName, string itemValue)
 229          {
 230              using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey("Software\\Microsoft", true))
 231              {
 232                  var ptKey = key.OpenSubKey("PowerToys", true);
 233                  if (ptKey != null)
 234                  {
 235                      ptKey.SetValue(itemName, itemValue);
 236                  }
 237                  else
 238                  {
 239                      var newPtKey = key.CreateSubKey("PowerToys");
 240                      newPtKey.SetValue(itemName, itemValue);
 241                  }
 242              }
 243          }
 244  
 245          /// <summary>
 246          /// Method <c>GetRegSettingsBackupAndRestoreRegItem</c> helper method to read from the registry.
 247          /// </summary>
 248          public static string GetRegSettingsBackupAndRestoreRegItem(string itemName)
 249          {
 250              using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\PowerToys"))
 251              {
 252                  if (key != null)
 253                  {
 254                      var val = key.GetValue(itemName);
 255                      if (val != null)
 256                      {
 257                          return val.ToString();
 258                      }
 259                  }
 260              }
 261  
 262              return null;
 263          }
 264  
 265          /// <summary>
 266          /// Method <c>RestoreSettings</c> returns a folder that has the latest backup in it.
 267          /// </summary>
 268          /// <returns>
 269          /// A tuple that indicates if the backup was done or not, and a message.
 270          /// The message usually is a localized reference key.
 271          /// </returns>
 272          public (bool Success, string Message, string Severity) RestoreSettings(string appBasePath, string settingsBackupAndRestoreDir)
 273          {
 274              try
 275              {
 276                  // verify inputs
 277                  if (!Directory.Exists(appBasePath))
 278                  {
 279                      return (false, $"Invalid appBasePath {appBasePath}", "Error");
 280                  }
 281  
 282                  if (string.IsNullOrEmpty(settingsBackupAndRestoreDir))
 283                  {
 284                      return (false, $"General_SettingsBackupAndRestore_NoBackupSyncPath", "Error");
 285                  }
 286  
 287                  if (!Directory.Exists(settingsBackupAndRestoreDir))
 288                  {
 289                      Logger.LogError($"Invalid settingsBackupAndRestoreDir {settingsBackupAndRestoreDir}");
 290                      return (false, "General_SettingsBackupAndRestore_InvalidBackupLocation", "Error");
 291                  }
 292  
 293                  var latestSettingsFolder = GetLatestSettingsFolder();
 294  
 295                  if (latestSettingsFolder == null)
 296                  {
 297                      return (false, $"General_SettingsBackupAndRestore_NoBackupsFound", "Warning");
 298                  }
 299  
 300                  // get data needed for process
 301                  var backupRestoreSettings = JsonNode.Parse(GetBackupRestoreSettingsJson());
 302                  var currentSettingsFiles = GetSettingsFiles(backupRestoreSettings, appBasePath).ToList().ToDictionary(x => x.Substring(appBasePath.Length));
 303                  var backupSettingsFiles = GetSettingsFiles(backupRestoreSettings, latestSettingsFolder).ToList().ToDictionary(x => x.Substring(latestSettingsFolder.Length));
 304  
 305                  if (backupSettingsFiles.Count == 0)
 306                  {
 307                      return (false, $"General_SettingsBackupAndRestore_NoBackupsFound", "Warning");
 308                  }
 309  
 310                  var anyFilesUpdated = false;
 311  
 312                  foreach (var currentFile in backupSettingsFiles)
 313                  {
 314                      var relativePath = currentFile.Value.Substring(latestSettingsFolder.Length + 1);
 315                      var restoreFullPath = Path.Combine(appBasePath, relativePath);
 316                      var settingsToRestoreJson = GetExportVersion(backupRestoreSettings, currentFile.Key, currentFile.Value);
 317  
 318                      if (currentSettingsFiles.TryGetValue(currentFile.Key, out string value))
 319                      {
 320                          // we have a setting file to restore to
 321                          var currentSettingsFileJson = GetExportVersion(backupRestoreSettings, currentFile.Key, value);
 322  
 323                          if (JsonNormalizer.Normalize(settingsToRestoreJson) != JsonNormalizer.Normalize(currentSettingsFileJson))
 324                          {
 325                              // the settings file needs to be updated, update the real one with non-excluded stuff...
 326                              Logger.LogInfo($"Settings file {currentFile.Key} is different and is getting updated from backup");
 327  
 328                              // we needed a new "CustomRestoreSettings" for now, to overwrite because some settings don't merge well (like KBM shortcuts)
 329                              var overwrite = false;
 330                              if (backupRestoreSettings["CustomRestoreSettings"] != null && backupRestoreSettings["CustomRestoreSettings"][currentFile.Key] != null)
 331                              {
 332                                  var customRestoreSettings = backupRestoreSettings["CustomRestoreSettings"][currentFile.Key];
 333                                  overwrite = customRestoreSettings["overwrite"] != null && (bool)customRestoreSettings["overwrite"];
 334                              }
 335  
 336                              if (overwrite)
 337                              {
 338                                  File.WriteAllText(currentSettingsFiles[currentFile.Key], settingsToRestoreJson);
 339                              }
 340                              else
 341                              {
 342                                  var newCurrentSettingsFile = JsonMergeHelper.Merge(File.ReadAllText(currentSettingsFiles[currentFile.Key]), settingsToRestoreJson);
 343                                  File.WriteAllText(currentSettingsFiles[currentFile.Key], newCurrentSettingsFile);
 344                              }
 345  
 346                              anyFilesUpdated = true;
 347                          }
 348                      }
 349                      else
 350                      {
 351                          // we don't have anything to merge this into, we need to use it as is
 352                          Logger.LogInfo($"Settings file {currentFile.Key} is in the backup but does not exist for restore");
 353  
 354                          var thisPathToRestore = Path.Combine(appBasePath, currentFile.Key.Substring(1));
 355                          TryCreateDirectory(Path.GetDirectoryName(thisPathToRestore));
 356                          File.WriteAllText(thisPathToRestore, settingsToRestoreJson);
 357                          anyFilesUpdated = true;
 358                      }
 359                  }
 360  
 361                  if (anyFilesUpdated)
 362                  {
 363                      // something was changed do we need to return true to indicate a restart is needed.
 364                      var restartAfterRestore = (bool?)backupRestoreSettings!["RestartAfterRestore"];
 365                      if (!restartAfterRestore.HasValue || restartAfterRestore.Value)
 366                      {
 367                          return (true, $"RESTART APP", "Success");
 368                      }
 369                      else
 370                      {
 371                          return (false, $"RESTART APP", "Success");
 372                      }
 373                  }
 374                  else
 375                  {
 376                      return (false, $"General_SettingsBackupAndRestore_NothingToRestore", "Informational");
 377                  }
 378              }
 379              catch (Exception ex2)
 380              {
 381                  Logger.LogError("Error in RestoreSettings, " + ex2.ToString());
 382                  return (false, $"General_SettingsBackupAndRestore_BackupError", "Error");
 383              }
 384          }
 385  
 386          /// <summary>
 387          /// Method <c>GetSettingsBackupAndRestoreDir</c> returns the path of the backup and restore location.
 388          /// </summary>
 389          /// <remarks>
 390          /// This will return a default location based on user documents if non is set.
 391          /// </remarks>
 392          public string GetSettingsBackupAndRestoreDir()
 393          {
 394              string settingsBackupAndRestoreDir = GetRegSettingsBackupAndRestoreRegItem("SettingsBackupAndRestoreDir");
 395              if (settingsBackupAndRestoreDir == null)
 396              {
 397                  settingsBackupAndRestoreDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerToys\\Backup");
 398              }
 399  
 400              return settingsBackupAndRestoreDir;
 401          }
 402  
 403          private List<string> GetBackupSettingsFiles(string settingsBackupAndRestoreDir)
 404          {
 405              return Directory.GetFiles(settingsBackupAndRestoreDir, "settings_*.ptb", SearchOption.TopDirectoryOnly).ToList().Where(f => Regex.IsMatch(f, "settings_(\\d{1,19}).ptb")).ToList();
 406          }
 407  
 408          /// <summary>
 409          /// Method <c>GetLatestSettingsFolder</c> returns a folder that has the latest backup in it.
 410          /// </summary>
 411          /// <remarks>
 412          /// The backup will usually be a backup file that has to be extracted to a temp folder. This will do that for us.
 413          /// </remarks>
 414          private string GetLatestSettingsFolder()
 415          {
 416              string settingsBackupAndRestoreDir = GetSettingsBackupAndRestoreDir();
 417  
 418              if (settingsBackupAndRestoreDir == null)
 419              {
 420                  return null;
 421              }
 422  
 423              if (!Directory.Exists(settingsBackupAndRestoreDir))
 424              {
 425                  return null;
 426              }
 427  
 428              var settingsBackupFolders = new Dictionary<long, string>();
 429  
 430              var settingsBackupFiles = GetBackupSettingsFiles(settingsBackupAndRestoreDir).ToDictionary(x => long.Parse(Path.GetFileName(x).Replace("settings_", string.Empty).Replace(".ptb", string.Empty), CultureInfo.InvariantCulture));
 431  
 432              var latestFolder = 0L;
 433              var latestFile = 0L;
 434  
 435              if (settingsBackupFolders.Count > 0)
 436              {
 437                  latestFolder = settingsBackupFolders.OrderByDescending(x => x.Key).FirstOrDefault().Key;
 438              }
 439  
 440              if (settingsBackupFiles.Count > 0)
 441              {
 442                  latestFile = settingsBackupFiles.OrderByDescending(x => x.Key).FirstOrDefault().Key;
 443              }
 444  
 445              if (latestFile == 0 && latestFolder == 0)
 446              {
 447                  return null;
 448              }
 449              else if (latestFolder >= latestFile)
 450              {
 451                  return settingsBackupFolders[latestFolder];
 452              }
 453              else
 454              {
 455                  var tempPath = Path.GetTempPath();
 456  
 457                  var fullBackupDir = Path.Combine(tempPath, "PowerToys_settings_" + latestFile.ToString(CultureInfo.InvariantCulture));
 458  
 459                  lock (backupSettingsInternalLock)
 460                  {
 461                      if (!Directory.Exists(fullBackupDir) || !File.Exists(Path.Combine(fullBackupDir, "manifest.json")))
 462                      {
 463                          TryDeleteDirectory(fullBackupDir);
 464                          ZipFile.ExtractToDirectory(settingsBackupFiles[latestFile], fullBackupDir);
 465                      }
 466                  }
 467  
 468                  ThreadPool.QueueUserWorkItem((x) =>
 469                  {
 470                      try
 471                      {
 472                          RemoveOldBackups(tempPath, 1, TimeSpan.FromDays(7));
 473                      }
 474                      catch
 475                      {
 476                          // hmm, ok
 477                      }
 478                  });
 479  
 480                  return fullBackupDir;
 481              }
 482          }
 483  
 484          /// <summary>
 485          /// Method <c>GetLatestBackupFileName</c> returns the name of the newest backup file.
 486          /// </summary>
 487          public string GetLatestBackupFileName()
 488          {
 489              string settingsBackupAndRestoreDir = GetSettingsBackupAndRestoreDir();
 490  
 491              if (string.IsNullOrEmpty(settingsBackupAndRestoreDir) || !Directory.Exists(settingsBackupAndRestoreDir))
 492              {
 493                  return string.Empty;
 494              }
 495  
 496              var settingsBackupFiles = GetBackupSettingsFiles(settingsBackupAndRestoreDir);
 497  
 498              if (settingsBackupFiles.Count > 0)
 499              {
 500                  return Path.GetFileName(settingsBackupFiles.OrderByDescending(x => x).First());
 501              }
 502              else
 503              {
 504                  return string.Empty;
 505              }
 506          }
 507  
 508          /// <summary>
 509          /// Method <c>GetLatestSettingsBackupManifest</c> get's the meta data from a backup file.
 510          /// </summary>
 511          public JsonNode GetLatestSettingsBackupManifest()
 512          {
 513              var folder = GetLatestSettingsFolder();
 514              if (folder == null)
 515              {
 516                  return null;
 517              }
 518  
 519              return JsonNode.Parse(File.ReadAllText(Path.Combine(folder, "manifest.json")));
 520          }
 521  
 522          /// <summary>
 523          /// Method <c>IsIncludeFile</c> check's to see if a settings file is to be included during backup and restore.
 524          /// </summary>
 525          private static bool IsIncludeFile(JsonNode settings, string name)
 526          {
 527              foreach (var test in (JsonArray)settings["IncludeFiles"])
 528              {
 529                  if (Regex.IsMatch(name, WildCardToRegular(test.ToString())))
 530                  {
 531                      return true;
 532                  }
 533              }
 534  
 535              return false;
 536          }
 537  
 538          /// <summary>
 539          /// Method <c>IsIgnoreFile</c> check's to see if a settings file is to be ignored during backup and restore.
 540          /// </summary>
 541          private static bool IsIgnoreFile(JsonNode settings, string name)
 542          {
 543              foreach (var test in (JsonArray)settings["IgnoreFiles"])
 544              {
 545                  if (Regex.IsMatch(name, WildCardToRegular(test.ToString())))
 546                  {
 547                      return true;
 548                  }
 549              }
 550  
 551              return false;
 552          }
 553  
 554          /// <summary>
 555          /// Class <c>GetSettingsFiles</c> returns the effective list of settings files.
 556          /// </summary>
 557          /// <remarks>
 558          /// Handles all the included/exclude files.
 559          /// </remarks>
 560          private static string[] GetSettingsFiles(JsonNode settings, string path)
 561          {
 562              if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
 563              {
 564                  return Array.Empty<string>();
 565              }
 566  
 567              return Directory.GetFiles(path, "*.json", SearchOption.AllDirectories).Where(s => IsIncludeFile(settings, s) && !IsIgnoreFile(settings, s)).ToArray();
 568          }
 569  
 570          /// <summary>
 571          /// Method <c>BackupSettings</c> does the backup process.
 572          /// </summary>
 573          /// <returns>
 574          /// A tuple that indicates if the backup was done or not, and a message.
 575          /// The message usually is a localized reference key.
 576          /// </returns>
 577          /// <remarks>
 578          /// This is a wrapper for BackupSettingsInternal, so we can check the time to run.
 579          /// </remarks>
 580          public (bool Success, string Message, string Severity, bool LastBackupExists, string OptionalMessage) BackupSettings(string appBasePath, string settingsBackupAndRestoreDir, bool dryRun)
 581          {
 582              var sw = Stopwatch.StartNew();
 583              var results = BackupSettingsInternal(appBasePath, settingsBackupAndRestoreDir, dryRun);
 584              sw.Stop();
 585              Logger.LogInfo($"BackupSettings took {sw.ElapsedMilliseconds}");
 586              lastBackupSettingsResults = (results.Success, results.Severity, results.LastBackupExists, DateTime.UtcNow);
 587              return results;
 588          }
 589  
 590          /// <summary>
 591          /// Method <c>DryRunBackup</c> wrapper function to do a dry-run backup
 592          /// </summary>
 593          public (bool Success, string Message, string Severity, bool LastBackupExists, string OptionalMessage) DryRunBackup()
 594          {
 595              var settingsUtils = SettingsUtils.Default;
 596              var appBasePath = Path.GetDirectoryName(settingsUtils.GetSettingsFilePath());
 597              string settingsBackupAndRestoreDir = GetSettingsBackupAndRestoreDir();
 598              var results = BackupSettings(appBasePath, settingsBackupAndRestoreDir, true);
 599              lastBackupSettingsResults = (results.Success, results.Severity, results.LastBackupExists, DateTime.UtcNow);
 600              return results;
 601          }
 602  
 603          /// <summary>
 604          /// Method <c>GetLastBackupSettingsResults</c> gets the results from the last backup process
 605          /// </summary>
 606          /// <returns>
 607          /// A tuple that indicates if the backup was done or not, and other information
 608          /// </returns>
 609          public (bool Success, bool HadError, bool LastBackupExists, DateTime? LastRan) GetLastBackupSettingsResults()
 610          {
 611              return (lastBackupSettingsResults.Success, lastBackupSettingsResults.Severity == "Error", lastBackupSettingsResults.LastBackupExists, lastBackupSettingsResults.LastRan);
 612          }
 613  
 614          /// <summary>
 615          /// Method <c>BackupSettingsInternal</c> does the backup process.
 616          /// </summary>
 617          /// <returns>
 618          /// A tuple that indicates if the backup was done or not, and a message.
 619          /// The message usually is a localized reference key.
 620          /// </returns>
 621          private (bool Success, string Message, string Severity, bool LastBackupExists, string OptionalMessage) BackupSettingsInternal(string appBasePath, string settingsBackupAndRestoreDir, bool dryRun)
 622          {
 623              var lastBackupExists = false;
 624  
 625              lock (backupSettingsInternalLock)
 626              {
 627                  // simulated delay to validate behavior
 628                  // Thread.Sleep(1000);
 629                  KeyValuePair<string, string> tempFile = default(KeyValuePair<string, string>);
 630  
 631                  try
 632                  {
 633                      // verify inputs
 634                      if (!Directory.Exists(appBasePath))
 635                      {
 636                          return (false, $"Invalid appBasePath {appBasePath}", "Error", lastBackupExists, string.Empty);
 637                      }
 638  
 639                      if (string.IsNullOrEmpty(settingsBackupAndRestoreDir))
 640                      {
 641                          return (false, $"General_SettingsBackupAndRestore_NoBackupSyncPath", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir);
 642                      }
 643  
 644                      if (!Path.IsPathRooted(settingsBackupAndRestoreDir))
 645                      {
 646                          return (false, $"Invalid settingsBackupAndRestoreDir, not rooted", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir);
 647                      }
 648  
 649                      if (settingsBackupAndRestoreDir.StartsWith(appBasePath, StringComparison.InvariantCultureIgnoreCase))
 650                      {
 651                          // backup cannot be under app
 652                          Logger.LogError($"BackupSettings, backup cannot be under app");
 653                          return (false, "General_SettingsBackupAndRestore_InvalidBackupLocation", "Error", lastBackupExists, "\n" + appBasePath);
 654                      }
 655  
 656                      // Only create the backup directory if this is not a dry run
 657                      if (!dryRun)
 658                      {
 659                          var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir);
 660                          if (!dirExists)
 661                          {
 662                              Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}");
 663                              return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir);
 664                          }
 665                      }
 666  
 667                      // get data needed for process
 668                      var backupRestoreSettings = JsonNode.Parse(GetBackupRestoreSettingsJson());
 669                      var currentSettingsFiles = GetSettingsFiles(backupRestoreSettings, appBasePath).ToList().ToDictionary(x => x.Substring(appBasePath.Length));
 670                      var fullBackupDir = Path.Combine(Path.GetTempPath(), $"settings_{DateTime.UtcNow.ToFileTimeUtc().ToString(CultureInfo.InvariantCulture)}");
 671                      var latestSettingsFolder = GetLatestSettingsFolder();
 672                      var lastBackupSettingsFiles = GetSettingsFiles(backupRestoreSettings, latestSettingsFolder).ToList().ToDictionary(x => x.Substring(latestSettingsFolder.Length));
 673  
 674                      lastBackupExists = lastBackupSettingsFiles.Count > 0;
 675  
 676                      if (currentSettingsFiles.Count == 0)
 677                      {
 678                          return (false, "General_SettingsBackupAndRestore_NoSettingsFilesFound", "Error", lastBackupExists, string.Empty);
 679                      }
 680  
 681                      var anyFileBackedUp = false;
 682                      var skippedSettingsFiles = new Dictionary<string, (string Path, string Settings)>();
 683                      var updatedSettingsFiles = new Dictionary<string, string>();
 684  
 685                      foreach (var currentFile in currentSettingsFiles)
 686                      {
 687                          tempFile = currentFile;
 688  
 689                          // need to check and back this up;
 690                          var currentSettingsFileToBackup = GetExportVersion(backupRestoreSettings, currentFile.Key, currentFile.Value);
 691  
 692                          var doBackup = false;
 693                          if (lastBackupSettingsFiles.TryGetValue(currentFile.Key, out string value))
 694                          {
 695                              // there is a previous backup for this, get an export version of it.
 696                              var lastSettingsFileDoc = GetExportVersion(backupRestoreSettings, currentFile.Key, value);
 697  
 698                              // check to see if the new export version would be same as last export version.
 699                              if (JsonNormalizer.Normalize(currentSettingsFileToBackup) != JsonNormalizer.Normalize(lastSettingsFileDoc))
 700                              {
 701                                  doBackup = true;
 702                                  Logger.LogInfo($"BackupSettings, {currentFile.Value} content is different.");
 703                              }
 704                          }
 705                          else
 706                          {
 707                              // this has never been backed up, we need to do it now.
 708                              Logger.LogInfo($"BackupSettings, {currentFile.Value} does not exists.");
 709                              doBackup = true;
 710                          }
 711  
 712                          if (doBackup)
 713                          {
 714                              // add to list of files we noted as needing backup
 715                              updatedSettingsFiles.Add(currentFile.Key, currentFile.Value);
 716  
 717                              // mark overall flag that a backup will be made
 718                              anyFileBackedUp = true;
 719  
 720                              // write the export version of the settings file to backup location.
 721                              var relativePath = currentFile.Value.Substring(appBasePath.Length + 1);
 722                              var backupFullPath = Path.Combine(fullBackupDir, relativePath);
 723  
 724                              Logger.LogInfo($"BackupSettings writing, {backupFullPath}, dryRun:{dryRun}.");
 725                              if (!dryRun)
 726                              {
 727                                  TryCreateDirectory(fullBackupDir);
 728                                  TryCreateDirectory(Path.GetDirectoryName(backupFullPath));
 729                                  File.WriteAllText(backupFullPath, currentSettingsFileToBackup);
 730                              }
 731                          }
 732                          else
 733                          {
 734                              // if we found no reason to backup this settings file, record that in this collection
 735                              skippedSettingsFiles.Add(currentFile.Key, (currentFile.Value, currentSettingsFileToBackup));
 736                          }
 737                      }
 738  
 739                      if (!anyFileBackedUp)
 740                      {
 741                          // nothing was done!
 742                          return (false, $"General_SettingsBackupAndRestore_NothingToBackup", "Informational", lastBackupExists, "\n" + tempFile.Value);
 743                      }
 744  
 745                      // add skipped.
 746                      foreach (var currentFile in skippedSettingsFiles)
 747                      {
 748                          // if we did do a backup, we need to copy in all the settings files we skipped so the backup is complete.
 749                          // this is needed since we might use the backup on another machine/
 750                          var relativePath = currentFile.Value.Path.Substring(appBasePath.Length + 1);
 751                          var backupFullPath = Path.Combine(fullBackupDir, relativePath);
 752  
 753                          Logger.LogInfo($"BackupSettings writing, {backupFullPath}, dryRun:{dryRun}");
 754                          if (!dryRun)
 755                          {
 756                              TryCreateDirectory(fullBackupDir);
 757                              TryCreateDirectory(Path.GetDirectoryName(backupFullPath));
 758  
 759                              File.WriteAllText(backupFullPath, currentFile.Value.Settings);
 760                          }
 761                      }
 762  
 763                      // add manifest
 764                      var manifestData = new
 765                      {
 766                          CreateDateTime = DateTime.UtcNow.ToString("u", CultureInfo.InvariantCulture),
 767                          @Version = Helper.GetProductVersion(),
 768                          UpdatedFiles = updatedSettingsFiles.Keys.ToList(),
 769                          BackupSource = Environment.MachineName,
 770                          UnchangedFiles = skippedSettingsFiles.Keys.ToList(),
 771                      };
 772  
 773                      var manifest = JsonSerializer.Serialize(manifestData, _serializerOptions);
 774  
 775                      if (!dryRun)
 776                      {
 777                          File.WriteAllText(Path.Combine(fullBackupDir, "manifest.json"), manifest);
 778  
 779                          // clean up, to prevent runaway disk usage.
 780                          RemoveOldBackups(settingsBackupAndRestoreDir, 10, TimeSpan.FromDays(60));
 781  
 782                          // compress the backup
 783                          var zipName = Path.Combine(settingsBackupAndRestoreDir, Path.GetFileName(fullBackupDir) + ".ptb");
 784                          ZipFile.CreateFromDirectory(fullBackupDir, zipName);
 785                          TryDeleteDirectory(fullBackupDir);
 786                      }
 787  
 788                      return (true, $"General_SettingsBackupAndRestore_BackupComplete", "Success", lastBackupExists, string.Empty);
 789                  }
 790                  catch (Exception ex2)
 791                  {
 792                      Logger.LogError($"There was an error in {tempFile.Value} : {ex2.Message}", ex2);
 793                      return (false, $"General_SettingsBackupAndRestore_SettingsFormatError", "Error", lastBackupExists, "\n" + tempFile.Value);
 794                  }
 795              }
 796          }
 797  
 798          /// <summary>
 799          /// Searches for the config file (Json) in two possible paths and returns its content.
 800          /// </summary>
 801          /// <returns>Returns the content of the config file (Json) as string.</returns>
 802          /// <exception cref="FileNotFoundException">Thrown if file is not found.</exception>
 803          /// <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>
 804          private static string GetBackupRestoreSettingsJson()
 805          {
 806              if (File.Exists("backup_restore_settings.json"))
 807              {
 808                  return File.ReadAllText("backup_restore_settings.json");
 809              }
 810              else if (File.Exists("Settings\\backup_restore_settings.json"))
 811              {
 812                  return File.ReadAllText("Settings\\backup_restore_settings.json");
 813              }
 814              else
 815              {
 816                  throw new FileNotFoundException($"The backup_restore_settings.json could not be found at {Environment.CurrentDirectory}");
 817              }
 818          }
 819  
 820          /// <summary>
 821          /// Method <c>WildCardToRegular</c> is so we can use 'normal' wildcard syntax and instead of regex
 822          /// </summary>
 823          private static string WildCardToRegular(string value)
 824          {
 825              return "^" + Regex.Escape(value).Replace("\\*", ".*") + "$";
 826          }
 827  
 828          /// <summary>
 829          /// Method <c>GetExportVersion</c> gets the version of the settings file that we want to backup.
 830          /// It will be formatted and all problematic settings removed from it.
 831          /// </summary>
 832          public static string GetExportVersion(JsonNode backupRestoreSettings, string settingFileKey, string settingsFileName)
 833          {
 834              var ignoredSettings = GetIgnoredSettings(backupRestoreSettings, settingFileKey);
 835              var settingsFile = JsonDocument.Parse(File.ReadAllText(settingsFileName));
 836  
 837              var outputBuffer = new ArrayBufferWriter<byte>();
 838  
 839              using (var jsonWriter = new Utf8JsonWriter(outputBuffer, new JsonWriterOptions { Indented = true }))
 840              {
 841                  jsonWriter.WriteStartObject();
 842                  foreach (var property in settingsFile.RootElement.EnumerateObject().OrderBy(p => p.Name))
 843                  {
 844                      if (!ignoredSettings.Contains(property.Name))
 845                      {
 846                          property.WriteTo(jsonWriter);
 847                      }
 848                  }
 849  
 850                  jsonWriter.WriteEndObject();
 851              }
 852  
 853              if (settingFileKey.Equals("\\PowerToys Run\\settings.json", StringComparison.OrdinalIgnoreCase))
 854              {
 855                  // PowerToys Run hack fix-up
 856                  var ptRunIgnoredSettings = GetPTRunIgnoredSettings(backupRestoreSettings);
 857                  var ptrSettings = JsonNode.Parse(Encoding.UTF8.GetString(outputBuffer.WrittenSpan));
 858  
 859                  foreach (JsonObject pluginToChange in ptRunIgnoredSettings)
 860                  {
 861                      foreach (JsonObject plugin in (JsonArray)ptrSettings["plugins"])
 862                      {
 863                          if (plugin["Id"].ToString() == pluginToChange["Id"].ToString())
 864                          {
 865                              foreach (var nameOfPropertyToRemove in (JsonArray)pluginToChange["Names"])
 866                              {
 867                                  plugin.Remove(nameOfPropertyToRemove.ToString());
 868                              }
 869                          }
 870                      }
 871                  }
 872  
 873                  return ptrSettings.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
 874              }
 875              else
 876              {
 877                  return JsonNode.Parse(Encoding.UTF8.GetString(outputBuffer.WrittenSpan)).ToJsonString(new JsonSerializerOptions { WriteIndented = true });
 878              }
 879          }
 880  
 881          /// <summary>
 882          /// Method <c>GetPTRunIgnoredSettings</c> gets the 'Run-Plugin-level' settings we should ignore because they are problematic to backup/restore.
 883          /// </summary>
 884          private static JsonArray GetPTRunIgnoredSettings(JsonNode backupRestoreSettings)
 885          {
 886              ArgumentNullException.ThrowIfNull(backupRestoreSettings);
 887  
 888              if (backupRestoreSettings["IgnoredPTRunSettings"] != null)
 889              {
 890                  return (JsonArray)backupRestoreSettings["IgnoredPTRunSettings"];
 891              }
 892  
 893              return new JsonArray();
 894          }
 895  
 896          /// <summary>
 897          /// Method <c>GetIgnoredSettings</c> gets the 'top-level' settings we should ignore because they are problematic to backup/restore.
 898          /// </summary>
 899          private static string[] GetIgnoredSettings(JsonNode backupRestoreSettings, string settingFileKey)
 900          {
 901              ArgumentNullException.ThrowIfNull(backupRestoreSettings);
 902  
 903              if (settingFileKey.StartsWith("\\", StringComparison.OrdinalIgnoreCase))
 904              {
 905                  settingFileKey = settingFileKey.Substring(1);
 906              }
 907  
 908              if (backupRestoreSettings["IgnoredSettings"] != null)
 909              {
 910                  if (backupRestoreSettings["IgnoredSettings"][settingFileKey] != null)
 911                  {
 912                      var settingsArray = (JsonArray)backupRestoreSettings["IgnoredSettings"][settingFileKey];
 913  
 914                      Console.WriteLine("settingsArray " + settingsArray.GetType().FullName);
 915  
 916                      var settingsList = new List<string>();
 917  
 918                      foreach (var setting in settingsArray)
 919                      {
 920                          settingsList.Add(setting.ToString());
 921                      }
 922  
 923                      return settingsList.ToArray();
 924                  }
 925                  else
 926                  {
 927                      return Array.Empty<string>();
 928                  }
 929              }
 930  
 931              return Array.Empty<string>();
 932          }
 933  
 934          /// <summary>
 935          /// Method <c>RemoveOldBackups</c> is a helper that prevents is from having some runaway disk usages.
 936          /// </summary>
 937          private static void RemoveOldBackups(string location, int minNumberToKeep, TimeSpan deleteIfOlderThanTs)
 938          {
 939              if (!removeOldBackupsLock.TryEnter(1000))
 940              {
 941                  return;
 942              }
 943  
 944              try
 945              {
 946                  DateTime deleteIfOlder = DateTime.UtcNow.Subtract(deleteIfOlderThanTs);
 947  
 948                  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();
 949  
 950                  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)));
 951  
 952                  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));
 953  
 954                  if (settingsBackupFolders.Count + settingsBackupFiles.Count <= minNumberToKeep)
 955                  {
 956                      return;
 957                  }
 958  
 959                  foreach (var item in settingsBackupFolders)
 960                  {
 961                      var backupTime = DateTime.FromFileTimeUtc(item.Key);
 962  
 963                      if (item.Value.Contains("PowerToys_settings_", StringComparison.OrdinalIgnoreCase))
 964                      {
 965                          // this is a temp backup and we want to clean based on the time it was created in the temp place, not the time that the backup was made.
 966                          var folderCreatedTime = new DirectoryInfo(item.Value).CreationTimeUtc;
 967  
 968                          if (folderCreatedTime > backupTime)
 969                          {
 970                              backupTime = folderCreatedTime;
 971                          }
 972                      }
 973  
 974                      if (backupTime < deleteIfOlder)
 975                      {
 976                          try
 977                          {
 978                              Logger.LogInfo($"RemoveOldBackups killing {item.Value}");
 979                              Directory.Delete(item.Value, true);
 980                          }
 981                          catch (Exception ex2)
 982                          {
 983                              Logger.LogError($"Failed to remove a setting backup folder ({item.Value}), because: ({ex2.Message})");
 984                          }
 985                      }
 986                  }
 987  
 988                  foreach (var item in settingsBackupFiles)
 989                  {
 990                      var backupTime = DateTime.FromFileTimeUtc(item.Key);
 991  
 992                      if (backupTime < deleteIfOlder)
 993                      {
 994                          try
 995                          {
 996                              Logger.LogInfo($"RemoveOldBackups killing {item.Value}");
 997                              File.Delete(item.Value);
 998                          }
 999                          catch (Exception ex2)
1000                          {
1001                              Logger.LogError($"Failed to remove a setting backup folder ({item.Value}), because: ({ex2.Message})");
1002                          }
1003                      }
1004                  }
1005              }
1006              finally
1007              {
1008                  removeOldBackupsLock.Exit();
1009              }
1010          }
1011  
1012          /// <summary>
1013          /// Class <c>JsonNormalizer</c> is a utility class to 'normalize' a JSON file so that it can be compared to another JSON file.
1014          /// This really just means to fully sort it. This does not work for any JSON file where the order of the node is relevant.
1015          /// </summary>
1016          private sealed class JsonNormalizer
1017          {
1018              public static string Normalize(string json)
1019              {
1020                  var doc1 = JsonNormalizer.Deserialize(json);
1021                  var newJson1 = JsonSerializer.Serialize(doc1, _serializerOptions);
1022                  return newJson1;
1023              }
1024  
1025              private static List<object> DeserializeArray(string json)
1026              {
1027                  var result = JsonSerializer.Deserialize<List<object>>(json);
1028  
1029                  var updates = new List<object>();
1030  
1031                  foreach (var item in result)
1032                  {
1033                      if (item != null)
1034                      {
1035                          var currentItem = (JsonElement)item;
1036  
1037                          if (currentItem.ValueKind == JsonValueKind.Object)
1038                          {
1039                              updates.Add(Deserialize(currentItem.ToString()));
1040                          }
1041                          else if (((JsonElement)item).ValueKind == JsonValueKind.Array)
1042                          {
1043                              updates.Add(DeserializeArray(currentItem.ToString()));
1044                          }
1045                          else
1046                          {
1047                              updates.Add(item);
1048                          }
1049                      }
1050                      else
1051                      {
1052                          updates.Add(item);
1053                      }
1054                  }
1055  
1056                  return updates.OrderBy(x => JsonSerializer.Serialize(x)).ToList();
1057              }
1058  
1059              private static Dictionary<string, object> Deserialize(string json)
1060              {
1061                  var doc = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
1062  
1063                  var updates = new Dictionary<string, object>();
1064  
1065                  foreach (var item in doc)
1066                  {
1067                      if (item.Value != null)
1068                      {
1069                          if (((JsonElement)item.Value).ValueKind == JsonValueKind.Object)
1070                          {
1071                              updates.Add(item.Key, Deserialize(((JsonElement)item.Value).ToString()));
1072                          }
1073                          else if (((JsonElement)item.Value).ValueKind == JsonValueKind.Array)
1074                          {
1075                              updates.Add(item.Key, DeserializeArray(((JsonElement)item.Value).ToString()));
1076                          }
1077                      }
1078                  }
1079  
1080                  foreach (var item in updates)
1081                  {
1082                      doc.Remove(item.Key);
1083                      doc.Add(item.Key, item.Value);
1084                  }
1085  
1086                  var ordered = new Dictionary<string, object>();
1087  
1088                  foreach (var item in doc.Keys.OrderBy(x => x))
1089                  {
1090                      ordered.Add(item, doc[item]);
1091                  }
1092  
1093                  return ordered;
1094              }
1095          }
1096      }
1097  }