using System.ComponentModel; using System.Text.Json; using System.Text.Json.Serialization; using CursorLang.Core.Interop; using CursorLang.Core.Models; using CursorLang.Core.Threading; using Windows.Storage; namespace CursorLang.Core.Services; /// /// Keeps the settings in the settings.json file. /// /// /// The file is the whole of the connection between the two processes, and they use it /// from opposite ends. The settings window calls and is the /// only writer; the agent only ever reads, and re-reads when the window tells it to. /// A second writer would mean two processes racing for one file and an edit going missing. /// /// The location depends on how the application is installed. A package from the Store /// keeps its settings in a folder of its own: Windows removes it together with the /// application, and after the removal nothing superfluous is left in the system — that /// is what Store applications are expected to do. A separately installed application /// keeps its settings in %APPDATA%, as before. /// /// Settings left over from a separately installed application are picked up by the /// package on the first launch and moved over. The original file stays where it is: /// both versions can be installed side by side, and the application has no right to /// delete settings that are not its own. /// public sealed class SettingsService : IDisposable { private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true, Converters = { new ColorJsonConverter(), new JsonStringEnumConverter() }, }; private const string FileName = "settings.json"; // Sliders change their values continuously, so writing to disk // is postponed until there is a pause in the changes private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(500); private readonly string _filePath; private readonly string _inheritedFilePath; private readonly MessageTimer _saveTimer; private AppSettings? _settings; private bool _isTrackingChanges; public SettingsService() : this( Path.Combine(GetSettingsFolder(), FileName), Path.Combine(GetSeparateInstallFolder(), FileName), SaveDelay) { } /// /// Sets the storage locations and the save delay explicitly — thereby making it /// possible to check the work with the file without touching the settings of the /// user themselves. /// internal SettingsService(string filePath, string inheritedFilePath, TimeSpan saveDelay) { _filePath = filePath; _inheritedFilePath = inheritedFilePath; _saveTimer = new MessageTimer { Interval = saveDelay }; _saveTimer.Tick += OnSaveTimerTick; } /// /// Reads the settings from disk or returns the default values. /// /// /// Asking twice hands out the same instance rather than reading again. Everything /// binds to what this returns — the window, the popup, the hook — and a second /// instance would mean one of them editing settings nobody else can see. /// public AppSettings Load() => _settings ??= ReadOrInherit(); private AppSettings ReadOrInherit() { AppSettings? stored = ReadFile(_filePath); // There is no file of our own — the application may well have been configured // before the move to a package. Taking the settings from there beats starting // from a blank slate bool inherited = stored is null && _filePath != _inheritedFilePath; if (inherited) { stored = ReadFile(_inheritedFilePath); inherited = stored is not null; } _settings = stored ?? CreateDefault(); // Moved settings are fixed in the new place right away rather than on the // first edit: otherwise the application would read someone else's file every // time until then if (inherited) { Save(); } return _settings; } /// /// Starts saving every change, after a pause. For the settings window: it is the /// only process allowed to write. /// /// /// Reads the file if that has not happened yet. The settings window asks in exactly /// that order — its container hands out this service first and the settings only /// when something needs them — and a version of this that quietly did nothing /// before the first read left the window saving nothing at all. /// public void TrackChanges() { if (_isTrackingChanges) { return; } Load().PropertyChanged += OnSettingsChanged; _isTrackingChanges = true; } /// /// Re-reads the file. For the agent, when the settings window says it has written. /// /// /// There is nothing to wait for and nothing to debounce: the window writes the file /// whole and moves it into place in one step, and only then says so. Nobody else /// writes it — the agent does not watch the file, and an edit made behind the /// application's back is not a case it is built for. /// public void Reload() { if (_settings is not null && ReadFile(_filePath) is { } fresh) { _settings.CopyFrom(fresh); } } /// /// Writes the settings and tells the agent to pick them up. /// /// /// The file is written beside its destination and moved onto it, which on one /// volume is a single step. That way the agent, which is told to re-read the moment /// this returns, never meets a half-written file. /// public void Save() { if (_settings is null) { return; } _saveTimer.Stop(); try { Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); string temporary = _filePath + ".tmp"; File.WriteAllText(temporary, JsonSerializer.Serialize(_settings, SerializerOptions)); File.Move(temporary, _filePath, overwrite: true); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { // The settings are not the kind of thing worth bringing the application down for return; } SettingsSignal.NotifyAgent(); } public void Dispose() { _saveTimer.Tick -= OnSaveTimerTick; _saveTimer.Dispose(); if (_settings is not null && _isTrackingChanges) { _settings.PropertyChanged -= OnSettingsChanged; _isTrackingChanges = false; Save(); } } /// /// The folder the application writes its settings to. /// private static string GetSettingsFolder() { if (!PackageIdentityNative.IsPackaged) { return GetSeparateInstallFolder(); } // A package has a data folder of its own, which Windows creates and removes // itself. The application name is not appended to it: the folder belongs to it alone anyway return ApplicationData.Current.LocalFolder.Path; } /// /// The settings folder of a separately installed application — the same source /// the package inherits the settings from on the first launch. /// private static string GetSeparateInstallFolder() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "CursorLang"); private static AppSettings? ReadFile(string path) { try { if (!File.Exists(path)) { return null; } return JsonSerializer.Deserialize(File.ReadAllText(path), SerializerOptions); } catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException) { return null; } } private static AppSettings CreateDefault() { string uiLanguage = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; return new AppSettings { Language = uiLanguage == "ru" ? "ru" : "en" }; } private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e) { _saveTimer.Stop(); _saveTimer.Start(); } private void OnSaveTimerTick(object? sender, EventArgs e) => Save(); }