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  }