AppStateModel.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.Diagnostics.CodeAnalysis; 7 using System.Text.Json; 8 using System.Text.Json.Nodes; 9 using System.Text.Json.Serialization; 10 using CommunityToolkit.Mvvm.ComponentModel; 11 using ManagedCommon; 12 using Microsoft.CommandPalette.Extensions.Toolkit; 13 using Windows.Foundation; 14 15 namespace Microsoft.CmdPal.UI.ViewModels; 16 17 public partial class AppStateModel : ObservableObject 18 { 19 [JsonIgnore] 20 public static readonly string FilePath; 21 22 public event TypedEventHandler<AppStateModel, object?>? StateChanged; 23 24 /////////////////////////////////////////////////////////////////////////// 25 // STATE HERE 26 // Make sure that you make the setters public (JsonSerializer.Deserialize will fail silently otherwise)! 27 // Make sure that any new types you add are added to JsonSerializationContext! 28 public RecentCommandsManager RecentCommands { get; set; } = new(); 29 30 public List<string> RunHistory { get; set; } = []; 31 32 // END SETTINGS 33 /////////////////////////////////////////////////////////////////////////// 34 35 static AppStateModel() 36 { 37 FilePath = StateJsonPath(); 38 } 39 40 public static AppStateModel LoadState() 41 { 42 if (string.IsNullOrEmpty(FilePath)) 43 { 44 throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(LoadState)}"); 45 } 46 47 if (!File.Exists(FilePath)) 48 { 49 Debug.WriteLine("The provided settings file does not exist"); 50 return new(); 51 } 52 53 try 54 { 55 // Read the JSON content from the file 56 var jsonContent = File.ReadAllText(FilePath); 57 58 var loaded = JsonSerializer.Deserialize<AppStateModel>(jsonContent, JsonSerializationContext.Default.AppStateModel); 59 60 Debug.WriteLine(loaded is not null ? "Loaded settings file" : "Failed to parse"); 61 62 return loaded ?? new(); 63 } 64 catch (Exception ex) 65 { 66 Debug.WriteLine(ex.ToString()); 67 } 68 69 return new(); 70 } 71 72 public static void SaveState(AppStateModel model) 73 { 74 if (string.IsNullOrEmpty(FilePath)) 75 { 76 throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(SaveState)}"); 77 } 78 79 try 80 { 81 // Serialize the main dictionary to JSON and save it to the file 82 var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.AppStateModel!); 83 84 // validate JSON 85 if (JsonNode.Parse(settingsJson) is not JsonObject newSettings) 86 { 87 Logger.LogError("Failed to parse app state as a JsonObject."); 88 return; 89 } 90 91 // read previous settings 92 if (!TryReadSavedState(out var savedSettings)) 93 { 94 savedSettings = new JsonObject(); 95 } 96 97 // merge new settings into old ones 98 foreach (var item in newSettings) 99 { 100 savedSettings[item.Key] = item.Value?.DeepClone(); 101 } 102 103 var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.AppStateModel!.Options); 104 File.WriteAllText(FilePath, serialized); 105 106 // TODO: Instead of just raising the event here, we should 107 // have a file change watcher on the settings file, and 108 // reload the settings then 109 model.StateChanged?.Invoke(model, null); 110 } 111 catch (Exception ex) 112 { 113 Logger.LogError($"Failed to save application state to {FilePath}:", ex); 114 } 115 } 116 117 private static bool TryReadSavedState([NotNullWhen(true)] out JsonObject? savedSettings) 118 { 119 savedSettings = null; 120 121 // read existing content from the file 122 string oldContent; 123 try 124 { 125 if (File.Exists(FilePath)) 126 { 127 oldContent = File.ReadAllText(FilePath); 128 } 129 else 130 { 131 // file doesn't exist (might not have been created yet), so consider this a success 132 // and return empty settings 133 savedSettings = new JsonObject(); 134 return true; 135 } 136 } 137 catch (Exception ex) 138 { 139 Logger.LogWarning($"Failed to read app state file {FilePath}:\n{ex}"); 140 return false; 141 } 142 143 // detect empty file, just for sake of logging 144 if (string.IsNullOrWhiteSpace(oldContent)) 145 { 146 Logger.LogInfo($"App state file is empty: {FilePath}"); 147 return false; 148 } 149 150 // is it valid JSON? 151 try 152 { 153 savedSettings = JsonNode.Parse(oldContent) as JsonObject; 154 return savedSettings != null; 155 } 156 catch (Exception ex) 157 { 158 Logger.LogWarning($"Failed to parse app state from {FilePath}:\n{ex}"); 159 return false; 160 } 161 } 162 163 internal static string StateJsonPath() 164 { 165 var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); 166 Directory.CreateDirectory(directory); 167 168 // now, the settings is just next to the exe 169 return Path.Combine(directory, "state.json"); 170 } 171 }