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 }