SettingsUtils.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 #nullable enable 6 7 using System; 8 using System.IO; 9 using System.IO.Abstractions; 10 using System.Runtime.CompilerServices; 11 using System.Text.Json; 12 13 using ManagedCommon; 14 using Microsoft.PowerToys.Settings.UI.Library.Interfaces; 15 16 namespace Microsoft.PowerToys.Settings.UI.Library 17 { 18 // Some functions are marked as virtual to allow mocking in unit tests. 19 public class SettingsUtils 20 { 21 public const string DefaultFileName = "settings.json"; 22 private const string DefaultModuleName = ""; 23 private readonly IFile _file; 24 private readonly SettingPath _settingsPath; 25 private readonly JsonSerializerOptions _serializerOptions; 26 27 /// <summary> 28 /// Gets the default instance of the <see cref="SettingsUtils"/> class for general use. 29 /// Same as instantiating a new instance with the <see cref="SettingsUtils(IFileSystem?, JsonSerializerOptions?)"/> constructor with a new <see cref="FileSystem"/> object as the first argument and <c>null</c> as the second argument. 30 /// </summary> 31 /// <remarks>For using in tests, you should use one of the public constructors.</remarks> 32 public static SettingsUtils Default { get; } = new SettingsUtils(); 33 34 private SettingsUtils() 35 : this(new FileSystem()) 36 { 37 } 38 39 public SettingsUtils(IFileSystem? fileSystem, JsonSerializerOptions? serializerOptions = null) 40 : this(fileSystem?.File!, new SettingPath(fileSystem?.Directory, fileSystem?.Path), serializerOptions) 41 { 42 } 43 44 public SettingsUtils(IFile file, SettingPath settingPath, JsonSerializerOptions? serializerOptions = null) 45 { 46 _file = file ?? throw new ArgumentNullException(nameof(file)); 47 _settingsPath = settingPath; 48 _serializerOptions = serializerOptions ?? new JsonSerializerOptions 49 { 50 MaxDepth = 0, 51 IncludeFields = true, 52 TypeInfoResolver = SettingsSerializationContext.Default, 53 }; 54 } 55 56 public bool SettingsExists(string powertoy = DefaultModuleName, string fileName = DefaultFileName) 57 { 58 var settingsPath = _settingsPath.GetSettingsPath(powertoy, fileName); 59 return _file.Exists(settingsPath); 60 } 61 62 public void DeleteSettings(string powertoy = "") 63 { 64 _settingsPath.DeleteSettings(powertoy); 65 } 66 67 public virtual T GetSettings<T>(string powertoy = DefaultModuleName, string fileName = DefaultFileName) 68 where T : ISettingsConfig, new() 69 { 70 if (!SettingsExists(powertoy, fileName)) 71 { 72 throw new FileNotFoundException(); 73 } 74 75 // Given the file already exists, to deserialize the file and read its content. 76 T deserializedSettings = GetFile<T>(powertoy, fileName); 77 78 // If the file needs to be modified, to save the new configurations accordingly. 79 if (deserializedSettings.UpgradeSettingsConfiguration()) 80 { 81 SaveSettings(deserializedSettings.ToJsonString(), powertoy, fileName); 82 } 83 84 return deserializedSettings; 85 } 86 87 /// <summary> 88 /// Get a Deserialized object of the json settings string. 89 /// This function creates a file in the powertoy folder if it does not exist and returns an object with default properties. 90 /// </summary> 91 /// <returns>Deserialized json settings object.</returns> 92 public virtual T GetSettingsOrDefault<T>(string powertoy = DefaultModuleName, string fileName = DefaultFileName) 93 where T : ISettingsConfig, new() 94 { 95 try 96 { 97 return GetSettings<T>(powertoy, fileName); 98 } 99 100 // Catch json deserialization exceptions when the file is corrupt and has an invalid json. 101 // If there are any deserialization issues like in https://github.com/microsoft/PowerToys/issues/7500, log the error and create a new settings.json file. 102 // This is different from the case where we have trailing zeros following a valid json file, which we have handled by trimming the trailing zeros. 103 catch (JsonException ex) 104 { 105 Logger.LogError($"Exception encountered while loading {powertoy} settings.", ex); 106 } 107 catch (FileNotFoundException) 108 { 109 Logger.LogInfo($"Settings file {fileName} for {powertoy} was not found."); 110 } 111 112 // If the settings file does not exist or if the file is corrupt, to create a new object with default parameters and save it to a newly created settings file. 113 T newSettingsItem = new T(); 114 SaveSettings(newSettingsItem.ToJsonString(), powertoy, fileName); 115 return newSettingsItem; 116 } 117 118 /// <summary> 119 /// Get a Deserialized object of the json settings string. 120 /// This function creates a file in the powertoy folder if it does not exist and returns an object with default properties. 121 /// </summary> 122 /// <returns>Deserialized json settings object.</returns> 123 public virtual T GetSettingsOrDefault<T, T2>(string powertoy = DefaultModuleName, string fileName = DefaultFileName, Func<object, object>? settingsUpgrader = null) 124 where T : ISettingsConfig, new() 125 where T2 : ISettingsConfig, new() 126 { 127 try 128 { 129 return GetSettings<T>(powertoy, fileName); 130 } 131 132 // Catch json deserialization exceptions when the file is corrupt and has an invalid json. 133 // If there are any deserialization issues like in https://github.com/microsoft/PowerToys/issues/7500, log the error and create a new settings.json file. 134 // This is different from the case where we have trailing zeros following a valid json file, which we have handled by trimming the trailing zeros. 135 catch (JsonException ex) 136 { 137 Logger.LogInfo($"Settings file {fileName} for {powertoy} was unrecognized. Possibly containing an older version. Trying to read again."); 138 139 // try to deserialize to the old format, which is presented in T2 140 try 141 { 142 T2 oldSettings = GetSettings<T2>(powertoy, fileName); 143 T newSettings = (T)settingsUpgrader!(oldSettings); 144 Logger.LogInfo($"Settings file {fileName} for {powertoy} was read successfully in the old format."); 145 146 // If the file needs to be modified, to save the new configurations accordingly. 147 if (newSettings.UpgradeSettingsConfiguration()) 148 { 149 SaveSettings(newSettings.ToJsonString(), powertoy, fileName); 150 } 151 152 return newSettings; 153 } 154 catch (Exception) 155 { 156 // do nothing, the problem wasn't that the settings was stored in the previous format, continue with the default settings 157 Logger.LogError($"{powertoy} settings are corrupt or the format is not supported any longer. Using default settings instead.", ex); 158 } 159 } 160 catch (FileNotFoundException) 161 { 162 Logger.LogInfo($"Settings file {fileName} for {powertoy} was not found."); 163 } 164 165 // If the settings file does not exist or if the file is corrupt, to create a new object with default parameters and save it to a newly created settings file. 166 T newSettingsItem = new T(); 167 SaveSettings(newSettingsItem.ToJsonString(), powertoy, fileName); 168 return newSettingsItem; 169 } 170 171 /// <summary> 172 /// Deserializes settings from a JSON file. 173 /// </summary> 174 /// <typeparam name="T">The settings type to deserialize. Must be registered in <see cref="SettingsSerializationContext"/>.</typeparam> 175 /// <param name="powertoyFolderName">The PowerToy module folder name.</param> 176 /// <param name="fileName">The settings file name.</param> 177 /// <returns>Deserialized settings object of type T.</returns> 178 /// <exception cref="InvalidOperationException"> 179 /// Thrown when type T is not registered in <see cref="SettingsSerializationContext"/>. 180 /// All settings types must be registered with <c>[JsonSerializable(typeof(T))]</c> attribute 181 /// for Native AOT compatibility. 182 /// </exception> 183 /// <remarks> 184 /// This method uses Native AOT-compatible JSON deserialization. Type T must be registered 185 /// in <see cref="SettingsSerializationContext"/> before calling this method. 186 /// </remarks> 187 private T GetFile<T>(string powertoyFolderName = DefaultModuleName, string fileName = DefaultFileName) 188 { 189 // Adding Trim('\0') to overcome possible NTFS file corruption. 190 // Look at issue https://github.com/microsoft/PowerToys/issues/6413 you'll see the file has a large sum of \0 to fill up a 4096 byte buffer for writing to disk 191 // This, while not totally ideal, does work around the problem by trimming the end. 192 // The file itself did write the content correctly but something is off with the actual end of the file, hence the 0x00 bug 193 var jsonSettingsString = _file.ReadAllText(_settingsPath.GetSettingsPath(powertoyFolderName, fileName)).Trim('\0'); 194 195 // For Native AOT compatibility, get JsonTypeInfo from the TypeInfoResolver 196 var typeInfo = _serializerOptions.TypeInfoResolver?.GetTypeInfo(typeof(T), _serializerOptions); 197 198 if (typeInfo == null) 199 { 200 throw new InvalidOperationException($"Type {typeof(T).FullName} is not registered in SettingsSerializationContext. Please add it to the [JsonSerializable] attributes."); 201 } 202 203 // Use AOT-friendly deserialization 204 return (T)JsonSerializer.Deserialize(jsonSettingsString, typeInfo)!; 205 } 206 207 // Save settings to a json file. 208 public virtual void SaveSettings(string jsonSettings, string powertoy = DefaultModuleName, string fileName = DefaultFileName) 209 { 210 try 211 { 212 if (jsonSettings != null) 213 { 214 if (!_settingsPath.SettingsFolderExists(powertoy)) 215 { 216 _settingsPath.CreateSettingsFolder(powertoy); 217 } 218 219 _file.WriteAllText(_settingsPath.GetSettingsPath(powertoy, fileName), jsonSettings); 220 } 221 } 222 catch (Exception e) 223 { 224 Logger.LogError($"Exception encountered while saving {powertoy} settings.", e); 225 #if DEBUG 226 if (e is ArgumentException || e is ArgumentNullException || e is PathTooLongException) 227 { 228 throw; 229 } 230 #endif 231 } 232 } 233 234 // Returns the file path to the settings file, that is exposed from the local ISettingsPath instance. 235 public string GetSettingsFilePath(string powertoy = "", string fileName = "settings.json") 236 { 237 return _settingsPath.GetSettingsPath(powertoy, fileName); 238 } 239 240 /// <summary> 241 /// Method <c>BackupSettings</c> Mostly a wrapper for SettingsBackupAndRestoreUtils.BackupSettings 242 /// </summary> 243 public static (bool Success, string Message, string Severity, bool LastBackupExists, string OptionalMessage) BackupSettings() 244 { 245 var settingsBackupAndRestoreUtilsX = SettingsBackupAndRestoreUtils.Instance; 246 var settingsUtils = Default; 247 var appBasePath = Path.GetDirectoryName(settingsUtils._settingsPath.GetSettingsPath(string.Empty, string.Empty)); 248 string settingsBackupAndRestoreDir = settingsBackupAndRestoreUtilsX.GetSettingsBackupAndRestoreDir(); 249 250 return settingsBackupAndRestoreUtilsX.BackupSettings(appBasePath, settingsBackupAndRestoreDir, false); 251 } 252 253 /// <summary> 254 /// Method <c>RestoreSettings</c> Mostly a wrapper for SettingsBackupAndRestoreUtils.RestoreSettings 255 /// </summary> 256 public static (bool Success, string Message, string Severity) RestoreSettings() 257 { 258 var settingsBackupAndRestoreUtilsX = SettingsBackupAndRestoreUtils.Instance; 259 var settingsUtils = Default; 260 var appBasePath = Path.GetDirectoryName(settingsUtils._settingsPath.GetSettingsPath(string.Empty, string.Empty)); 261 string settingsBackupAndRestoreDir = settingsBackupAndRestoreUtilsX.GetSettingsBackupAndRestoreDir(); 262 return settingsBackupAndRestoreUtilsX.RestoreSettings(appBasePath, settingsBackupAndRestoreDir); 263 } 264 } 265 }