// 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.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using CommunityToolkit.Mvvm.ComponentModel; using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.UI; using Windows.Foundation; using Windows.UI; namespace Microsoft.CmdPal.UI.ViewModels; public partial class SettingsModel : ObservableObject { private const string DeprecatedHotkeyGoesHomeKey = "HotkeyGoesHome"; [JsonIgnore] public static readonly string FilePath; public event TypedEventHandler? SettingsChanged; /////////////////////////////////////////////////////////////////////////// // SETTINGS HERE public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space public HotkeySettings? Hotkey { get; set; } = DefaultActivationShortcut; public bool UseLowLevelGlobalHotkey { get; set; } public bool ShowAppDetails { get; set; } public bool BackspaceGoesBack { get; set; } public bool SingleClickActivates { get; set; } public bool HighlightSearchOnActivate { get; set; } = true; public bool ShowSystemTrayIcon { get; set; } = true; public bool IgnoreShortcutWhenFullscreen { get; set; } public bool AllowExternalReload { get; set; } public Dictionary ProviderSettings { get; set; } = []; public Dictionary Aliases { get; set; } = []; public List CommandHotkeys { get; set; } = []; public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse; public bool DisableAnimations { get; set; } = true; public WindowPosition? LastWindowPosition { get; set; } public TimeSpan AutoGoHomeInterval { get; set; } = Timeout.InfiniteTimeSpan; public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack; public UserTheme Theme { get; set; } = UserTheme.Default; public ColorizationMode ColorizationMode { get; set; } public Color CustomThemeColor { get; set; } = Colors.Transparent; public int CustomThemeColorIntensity { get; set; } = 100; public int BackgroundImageOpacity { get; set; } = 20; public int BackgroundImageBlurAmount { get; set; } public int BackgroundImageBrightness { get; set; } public BackgroundImageFit BackgroundImageFit { get; set; } public string? BackgroundImagePath { get; set; } // END SETTINGS /////////////////////////////////////////////////////////////////////////// static SettingsModel() { FilePath = SettingsJsonPath(); } public ProviderSettings GetProviderSettings(CommandProviderWrapper provider) { ProviderSettings? settings; if (!ProviderSettings.TryGetValue(provider.ProviderId, out settings)) { settings = new ProviderSettings(provider); settings.Connect(provider); ProviderSettings[provider.ProviderId] = settings; } else { settings.Connect(provider); } return settings; } public static SettingsModel LoadSettings() { if (string.IsNullOrEmpty(FilePath)) { throw new InvalidOperationException($"You must set a valid {nameof(SettingsModel.FilePath)} before calling {nameof(LoadSettings)}"); } if (!File.Exists(FilePath)) { Debug.WriteLine("The provided settings file does not exist"); return new(); } try { // Read the JSON content from the file var jsonContent = File.ReadAllText(FilePath); var loaded = JsonSerializer.Deserialize(jsonContent, JsonSerializationContext.Default.SettingsModel) ?? new(); var migratedAny = false; try { if (JsonNode.Parse(jsonContent) is JsonObject root) { migratedAny |= ApplyMigrations(root, loaded); } } catch (Exception ex) { Debug.WriteLine($"Migration check failed: {ex}"); } Debug.WriteLine("Loaded settings file"); if (migratedAny) { SaveSettings(loaded); } return loaded; } catch (Exception ex) { Debug.WriteLine(ex.ToString()); } return new(); } private static bool ApplyMigrations(JsonObject root, SettingsModel model) { var migrated = false; // Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan) // The old 'HotkeyGoesHome' boolean indicated whether the "go home" action should happen immediately (true) or never (false). // The new 'AutoGoHomeInterval' uses a TimeSpan: 'TimeSpan.Zero' means immediate, 'Timeout.InfiniteTimeSpan' means never. migrated |= TryMigrate( "Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)", root, model, nameof(AutoGoHomeInterval), DeprecatedHotkeyGoesHomeKey, (settingsModel, goesHome) => settingsModel.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan, JsonSerializationContext.Default.Boolean); return migrated; } private static bool TryMigrate(string migrationName, JsonObject root, SettingsModel model, string newKey, string oldKey, Action apply, JsonTypeInfo jsonTypeInfo) { try { // If new key already present, skip migration if (root.ContainsKey(newKey) && root[newKey] is not null) { return false; } // If old key present, try to deserialize and apply if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null) { var value = oldNode.Deserialize(jsonTypeInfo); apply(model, value!); return true; } } catch (Exception ex) { Logger.LogError($"Error during migration {migrationName}.", ex); } return false; } public static void SaveSettings(SettingsModel model) { if (string.IsNullOrEmpty(FilePath)) { throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(SaveSettings)}"); } try { // Serialize the main dictionary to JSON and save it to the file var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.SettingsModel); // Is it valid JSON? if (JsonNode.Parse(settingsJson) is JsonObject newSettings) { // Now, read the existing content from the file var oldContent = File.Exists(FilePath) ? File.ReadAllText(FilePath) : "{}"; // Is it valid JSON? if (JsonNode.Parse(oldContent) is JsonObject savedSettings) { foreach (var item in newSettings) { savedSettings[item.Key] = item.Value?.DeepClone(); } // Remove deprecated keys savedSettings.Remove(DeprecatedHotkeyGoesHomeKey); var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.Options); File.WriteAllText(FilePath, serialized); // TODO: Instead of just raising the event here, we should // have a file change watcher on the settings file, and // reload the settings then model.SettingsChanged?.Invoke(model, null); } else { Debug.WriteLine("Failed to parse settings file as JsonObject."); } } else { Debug.WriteLine("Failed to parse settings file as JsonObject."); } } catch (Exception ex) { Debug.WriteLine(ex.ToString()); } } internal static string SettingsJsonPath() { var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); Directory.CreateDirectory(directory); // now, the settings is just next to the exe return Path.Combine(directory, "settings.json"); } // [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] // private static readonly JsonSerializerOptions _serializerOptions = new() // { // WriteIndented = true, // Converters = { new JsonStringEnumConverter() }, // }; // private static readonly JsonSerializerOptions _deserializerOptions = new() // { // PropertyNameCaseInsensitive = true, // IncludeFields = true, // Converters = { new JsonStringEnumConverter() }, // AllowTrailingCommas = true, // }; } [JsonSerializable(typeof(float))] [JsonSerializable(typeof(int))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(bool))] [JsonSerializable(typeof(HistoryItem))] [JsonSerializable(typeof(SettingsModel))] [JsonSerializable(typeof(WindowPosition))] [JsonSerializable(typeof(AppStateModel))] [JsonSerializable(typeof(RecentCommandsManager))] [JsonSerializable(typeof(List), TypeInfoPropertyName = "StringList")] [JsonSerializable(typeof(List), TypeInfoPropertyName = "HistoryList")] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "Dictionary")] [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")] internal sealed partial class JsonSerializationContext : JsonSerializerContext { } public enum MonitorBehavior { ToMouse = 0, ToPrimary = 1, ToFocusedWindow = 2, InPlace = 3, ToLast = 4, } public enum EscapeKeyBehavior { ClearSearchFirstThenGoBack = 0, AlwaysGoBack = 1, AlwaysDismiss = 2, AlwaysHide = 3, }