SettingsModel.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.Diagnostics;
  6  using System.Text.Json;
  7  using System.Text.Json.Nodes;
  8  using System.Text.Json.Serialization;
  9  using System.Text.Json.Serialization.Metadata;
 10  using CommunityToolkit.Mvvm.ComponentModel;
 11  using ManagedCommon;
 12  using Microsoft.CmdPal.UI.ViewModels.Settings;
 13  using Microsoft.CommandPalette.Extensions.Toolkit;
 14  using Microsoft.UI;
 15  using Windows.Foundation;
 16  using Windows.UI;
 17  
 18  namespace Microsoft.CmdPal.UI.ViewModels;
 19  
 20  public partial class SettingsModel : ObservableObject
 21  {
 22      private const string DeprecatedHotkeyGoesHomeKey = "HotkeyGoesHome";
 23  
 24      [JsonIgnore]
 25      public static readonly string FilePath;
 26  
 27      public event TypedEventHandler<SettingsModel, object?>? SettingsChanged;
 28  
 29      ///////////////////////////////////////////////////////////////////////////
 30      // SETTINGS HERE
 31      public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space
 32  
 33      public HotkeySettings? Hotkey { get; set; } = DefaultActivationShortcut;
 34  
 35      public bool UseLowLevelGlobalHotkey { get; set; }
 36  
 37      public bool ShowAppDetails { get; set; }
 38  
 39      public bool BackspaceGoesBack { get; set; }
 40  
 41      public bool SingleClickActivates { get; set; }
 42  
 43      public bool HighlightSearchOnActivate { get; set; } = true;
 44  
 45      public bool ShowSystemTrayIcon { get; set; } = true;
 46  
 47      public bool IgnoreShortcutWhenFullscreen { get; set; }
 48  
 49      public bool AllowExternalReload { get; set; }
 50  
 51      public Dictionary<string, ProviderSettings> ProviderSettings { get; set; } = [];
 52  
 53      public string[] FallbackRanks { get; set; } = [];
 54  
 55      public Dictionary<string, CommandAlias> Aliases { get; set; } = [];
 56  
 57      public List<TopLevelHotkey> CommandHotkeys { get; set; } = [];
 58  
 59      public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse;
 60  
 61      public bool DisableAnimations { get; set; } = true;
 62  
 63      public WindowPosition? LastWindowPosition { get; set; }
 64  
 65      public TimeSpan AutoGoHomeInterval { get; set; } = Timeout.InfiniteTimeSpan;
 66  
 67      public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack;
 68  
 69      public UserTheme Theme { get; set; } = UserTheme.Default;
 70  
 71      public ColorizationMode ColorizationMode { get; set; }
 72  
 73      public Color CustomThemeColor { get; set; } = Colors.Transparent;
 74  
 75      public int CustomThemeColorIntensity { get; set; } = 100;
 76  
 77      public int BackgroundImageOpacity { get; set; } = 20;
 78  
 79      public int BackgroundImageBlurAmount { get; set; }
 80  
 81      public int BackgroundImageBrightness { get; set; }
 82  
 83      public BackgroundImageFit BackgroundImageFit { get; set; }
 84  
 85      public string? BackgroundImagePath { get; set; }
 86  
 87      // END SETTINGS
 88      ///////////////////////////////////////////////////////////////////////////
 89  
 90      static SettingsModel()
 91      {
 92          FilePath = SettingsJsonPath();
 93      }
 94  
 95      public ProviderSettings GetProviderSettings(CommandProviderWrapper provider)
 96      {
 97          ProviderSettings? settings;
 98          if (!ProviderSettings.TryGetValue(provider.ProviderId, out settings))
 99          {
100              settings = new ProviderSettings(provider);
101              settings.Connect(provider);
102              ProviderSettings[provider.ProviderId] = settings;
103          }
104          else
105          {
106              settings.Connect(provider);
107          }
108  
109          return settings;
110      }
111  
112      public string[] GetGlobalFallbacks()
113      {
114          var globalFallbacks = new HashSet<string>();
115  
116          foreach (var provider in ProviderSettings.Values)
117          {
118              foreach (var fallback in provider.FallbackCommands)
119              {
120                  var fallbackSetting = fallback.Value;
121                  if (fallbackSetting.IsEnabled && fallbackSetting.IncludeInGlobalResults)
122                  {
123                      globalFallbacks.Add(fallback.Key);
124                  }
125              }
126          }
127  
128          return globalFallbacks.ToArray();
129      }
130  
131      public static SettingsModel LoadSettings()
132      {
133          if (string.IsNullOrEmpty(FilePath))
134          {
135              throw new InvalidOperationException($"You must set a valid {nameof(SettingsModel.FilePath)} before calling {nameof(LoadSettings)}");
136          }
137  
138          if (!File.Exists(FilePath))
139          {
140              Debug.WriteLine("The provided settings file does not exist");
141              return new();
142          }
143  
144          try
145          {
146              // Read the JSON content from the file
147              var jsonContent = File.ReadAllText(FilePath);
148              var loaded = JsonSerializer.Deserialize<SettingsModel>(jsonContent, JsonSerializationContext.Default.SettingsModel) ?? new();
149  
150              var migratedAny = false;
151              try
152              {
153                  if (JsonNode.Parse(jsonContent) is JsonObject root)
154                  {
155                      migratedAny |= ApplyMigrations(root, loaded);
156                  }
157              }
158              catch (Exception ex)
159              {
160                  Debug.WriteLine($"Migration check failed: {ex}");
161              }
162  
163              Debug.WriteLine("Loaded settings file");
164  
165              if (migratedAny)
166              {
167                  SaveSettings(loaded);
168              }
169  
170              return loaded;
171          }
172          catch (Exception ex)
173          {
174              Debug.WriteLine(ex.ToString());
175          }
176  
177          return new();
178      }
179  
180      private static bool ApplyMigrations(JsonObject root, SettingsModel model)
181      {
182          var migrated = false;
183  
184          // Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)
185          // The old 'HotkeyGoesHome' boolean indicated whether the "go home" action should happen immediately (true) or never (false).
186          // The new 'AutoGoHomeInterval' uses a TimeSpan: 'TimeSpan.Zero' means immediate, 'Timeout.InfiniteTimeSpan' means never.
187          migrated |= TryMigrate(
188              "Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)",
189              root,
190              model,
191              nameof(AutoGoHomeInterval),
192              DeprecatedHotkeyGoesHomeKey,
193              (settingsModel, goesHome) => settingsModel.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan,
194              JsonSerializationContext.Default.Boolean);
195  
196          return migrated;
197      }
198  
199      private static bool TryMigrate<T>(string migrationName, JsonObject root, SettingsModel model, string newKey, string oldKey, Action<SettingsModel, T> apply, JsonTypeInfo<T> jsonTypeInfo)
200      {
201          try
202          {
203              // If new key already present, skip migration
204              if (root.ContainsKey(newKey) && root[newKey] is not null)
205              {
206                  return false;
207              }
208  
209              // If old key present, try to deserialize and apply
210              if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null)
211              {
212                  var value = oldNode.Deserialize<T>(jsonTypeInfo);
213                  apply(model, value!);
214                  return true;
215              }
216          }
217          catch (Exception ex)
218          {
219              Logger.LogError($"Error during migration {migrationName}.", ex);
220          }
221  
222          return false;
223      }
224  
225      public static void SaveSettings(SettingsModel model)
226      {
227          if (string.IsNullOrEmpty(FilePath))
228          {
229              throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(SaveSettings)}");
230          }
231  
232          try
233          {
234              // Serialize the main dictionary to JSON and save it to the file
235              var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.SettingsModel);
236  
237              // Is it valid JSON?
238              if (JsonNode.Parse(settingsJson) is JsonObject newSettings)
239              {
240                  // Now, read the existing content from the file
241                  var oldContent = File.Exists(FilePath) ? File.ReadAllText(FilePath) : "{}";
242  
243                  // Is it valid JSON?
244                  if (JsonNode.Parse(oldContent) is JsonObject savedSettings)
245                  {
246                      foreach (var item in newSettings)
247                      {
248                          savedSettings[item.Key] = item.Value?.DeepClone();
249                      }
250  
251                      // Remove deprecated keys
252                      savedSettings.Remove(DeprecatedHotkeyGoesHomeKey);
253  
254                      var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.Options);
255                      File.WriteAllText(FilePath, serialized);
256  
257                      // TODO: Instead of just raising the event here, we should
258                      // have a file change watcher on the settings file, and
259                      // reload the settings then
260                      model.SettingsChanged?.Invoke(model, null);
261                  }
262                  else
263                  {
264                      Debug.WriteLine("Failed to parse settings file as JsonObject.");
265                  }
266              }
267              else
268              {
269                  Debug.WriteLine("Failed to parse settings file as JsonObject.");
270              }
271          }
272          catch (Exception ex)
273          {
274              Debug.WriteLine(ex.ToString());
275          }
276      }
277  
278      internal static string SettingsJsonPath()
279      {
280          var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
281          Directory.CreateDirectory(directory);
282  
283          // now, the settings is just next to the exe
284          return Path.Combine(directory, "settings.json");
285      }
286  
287      // [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "<Pending>")]
288      // private static readonly JsonSerializerOptions _serializerOptions = new()
289      // {
290      //    WriteIndented = true,
291      //    Converters = { new JsonStringEnumConverter() },
292      // };
293      // private static readonly JsonSerializerOptions _deserializerOptions = new()
294      // {
295      //    PropertyNameCaseInsensitive = true,
296      //    IncludeFields = true,
297      //    Converters = { new JsonStringEnumConverter() },
298      //    AllowTrailingCommas = true,
299      // };
300  }
301  
302  [JsonSerializable(typeof(float))]
303  [JsonSerializable(typeof(int))]
304  [JsonSerializable(typeof(string))]
305  [JsonSerializable(typeof(bool))]
306  [JsonSerializable(typeof(HistoryItem))]
307  [JsonSerializable(typeof(SettingsModel))]
308  [JsonSerializable(typeof(WindowPosition))]
309  [JsonSerializable(typeof(AppStateModel))]
310  [JsonSerializable(typeof(RecentCommandsManager))]
311  [JsonSerializable(typeof(List<string>), TypeInfoPropertyName = "StringList")]
312  [JsonSerializable(typeof(List<HistoryItem>), TypeInfoPropertyName = "HistoryList")]
313  [JsonSerializable(typeof(Dictionary<string, object>), TypeInfoPropertyName = "Dictionary")]
314  [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
315  [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")]
316  internal sealed partial class JsonSerializationContext : JsonSerializerContext
317  {
318  }
319  
320  public enum MonitorBehavior
321  {
322      ToMouse = 0,
323      ToPrimary = 1,
324      ToFocusedWindow = 2,
325      InPlace = 3,
326      ToLast = 4,
327  }
328  
329  public enum EscapeKeyBehavior
330  {
331      ClearSearchFirstThenGoBack = 0,
332      AlwaysGoBack = 1,
333      AlwaysDismiss = 2,
334      AlwaysHide = 3,
335  }