lightweight variant (#1)
Reviewed-on: #1 Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the settings in the settings.json file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The file is the whole of the connection between the two processes, and they use it
|
||||
/// from opposite ends. The settings window calls <see cref="TrackChanges"/> 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.
|
||||
/// </remarks>
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal SettingsService(string filePath, string inheritedFilePath, TimeSpan saveDelay)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_inheritedFilePath = inheritedFilePath;
|
||||
|
||||
_saveTimer = new MessageTimer { Interval = saveDelay };
|
||||
_saveTimer.Tick += OnSaveTimerTick;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the settings from disk or returns the default values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts saving every change, after a pause. For the settings window: it is the
|
||||
/// only process allowed to write.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public void TrackChanges()
|
||||
{
|
||||
if (_isTrackingChanges)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Load().PropertyChanged += OnSettingsChanged;
|
||||
_isTrackingChanges = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the file. For the agent, when the settings window says it has written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public void Reload()
|
||||
{
|
||||
if (_settings is not null && ReadFile(_filePath) is { } fresh)
|
||||
{
|
||||
_settings.CopyFrom(fresh);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the settings and tells the agent to pick them up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The folder the application writes its settings to.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The settings folder of a separately installed application — the same source
|
||||
/// the package inherits the settings from on the first launch.
|
||||
/// </summary>
|
||||
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<AppSettings>(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();
|
||||
}
|
||||
Reference in New Issue
Block a user