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