/ src / settings-ui / Settings.UI.Library / SettingsUtils.cs
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  }