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 }