Skip to content

Commit fa4fbd8

Browse files
committed
implemented a generic settings manager
1 parent fc426a8 commit fa4fbd8

File tree

4 files changed

+164
-143
lines changed

4 files changed

+164
-143
lines changed

App/App.xaml.cs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public partial class App : Application
4444
private readonly ILogger<App> _logger;
4545
private readonly IUriHandler _uriHandler;
4646

47-
private readonly ISettingsManager _settingsManager;
47+
private readonly ISettingsManager<CoderConnectSettings> _settingsManager;
4848

4949
private readonly IHostApplicationLifetime _appLifetime;
5050

@@ -94,7 +94,7 @@ public App()
9494
// FileSyncListMainPage is created by FileSyncListWindow.
9595
services.AddTransient<FileSyncListWindow>();
9696

97-
services.AddSingleton<ISettingsManager, SettingsManager>();
97+
services.AddSingleton<ISettingsManager<CoderConnectSettings>, SettingsManager<CoderConnectSettings>>();
9898
services.AddSingleton<IStartupManager, StartupManager>();
9999
// SettingsWindow views and view models
100100
services.AddTransient<SettingsViewModel>();
@@ -118,10 +118,10 @@ public App()
118118
services.AddTransient<TrayWindow>();
119119

120120
_services = services.BuildServiceProvider();
121-
_logger = (ILogger<App>)_services.GetService(typeof(ILogger<App>))!;
122-
_uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!;
123-
_settingsManager = (ISettingsManager)_services.GetService(typeof(ISettingsManager))!;
124-
_appLifetime = (IHostApplicationLifetime)_services.GetRequiredService<IHostApplicationLifetime>();
121+
_logger = _services.GetRequiredService<ILogger<App>>();
122+
_uriHandler = _services.GetRequiredService<IUriHandler>();
123+
_settingsManager = _services.GetRequiredService<ISettingsManager<CoderConnectSettings>>();
124+
_appLifetime = _services.GetRequiredService<IHostApplicationLifetime>();
125125

126126
InitializeComponent();
127127
}
@@ -167,12 +167,15 @@ private async Task InitializeServicesAsync(CancellationToken cancellationToken =
167167
using var credsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
168168
credsCts.CancelAfter(TimeSpan.FromSeconds(15));
169169

170-
Task loadCredsTask = credentialManager.LoadCredentials(credsCts.Token);
171-
Task reconnectTask = rpcController.Reconnect(cancellationToken);
170+
var loadCredsTask = credentialManager.LoadCredentials(credsCts.Token);
171+
var reconnectTask = rpcController.Reconnect(cancellationToken);
172+
var settingsTask = _settingsManager.Read(cancellationToken);
173+
174+
var dependenciesLoaded = true;
172175

173176
try
174177
{
175-
await Task.WhenAll(loadCredsTask, reconnectTask);
178+
await Task.WhenAll(loadCredsTask, reconnectTask, settingsTask);
176179
}
177180
catch (Exception)
178181
{
@@ -184,10 +187,17 @@ private async Task InitializeServicesAsync(CancellationToken cancellationToken =
184187
_logger.LogError(reconnectTask.Exception!.GetBaseException(),
185188
"Failed to connect to VPN service");
186189

187-
return;
190+
if (settingsTask.IsFaulted)
191+
_logger.LogError(settingsTask.Exception!.GetBaseException(),
192+
"Failed to fetch Coder Connect settings");
193+
194+
// Don't attempt to connect if we failed to load credentials or reconnect.
195+
// This will prevent the app from trying to connect to the VPN service.
196+
dependenciesLoaded = false;
188197
}
189198

190-
if (_settingsManager.ConnectOnLaunch)
199+
var attemptCoderConnection = settingsTask.Result?.ConnectOnLaunch ?? false;
200+
if (dependenciesLoaded && attemptCoderConnection)
191201
{
192202
try
193203
{

App/Services/SettingsManager.cs

Lines changed: 114 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,57 @@
1+
using Google.Protobuf.WellKnownTypes;
12
using System;
23
using System.Collections.Generic;
34
using System.IO;
45
using System.Text.Json;
56
using System.Text.Json.Serialization;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using System.Xml.Linq;
610

711
namespace Coder.Desktop.App.Services;
812

913
/// <summary>
1014
/// Settings contract exposing properties for app settings.
1115
/// </summary>
12-
public interface ISettingsManager
16+
public interface ISettingsManager<T> where T : ISettings, new()
1317
{
1418
/// <summary>
15-
/// Returns the value of the StartOnLogin setting. Returns <c>false</c> if the key is not found.
19+
/// Reads the settings from the file system.
20+
/// Always returns the latest settings, even if they were modified by another instance of the app.
21+
/// Returned object is always a fresh instance, so it can be modified without affecting the stored settings.
1622
/// </summary>
17-
bool StartOnLogin { get; set; }
18-
23+
/// <param name="ct"></param>
24+
/// <returns></returns>
25+
public Task<T> Read(CancellationToken ct = default);
26+
/// <summary>
27+
/// Writes the settings to the file system.
28+
/// </summary>
29+
/// <param name="settings">Object containing the settings.</param>
30+
/// <param name="ct"></param>
31+
/// <returns></returns>
32+
public Task Write(T settings, CancellationToken ct = default);
1933
/// <summary>
20-
/// Returns the value of the ConnectOnLaunch setting. Returns <c>false</c> if the key is not found.
34+
/// Returns null if the settings are not cached or not available.
2135
/// </summary>
22-
bool ConnectOnLaunch { get; set; }
36+
/// <returns></returns>
37+
public T? GetFromCache();
2338
}
2439

2540
/// <summary>
2641
/// Implemention of <see cref="ISettingsManager"/> that persists settings to a JSON file
2742
/// located in the user's local application data folder.
2843
/// </summary>
29-
public sealed class SettingsManager : ISettingsManager
44+
public sealed class SettingsManager<T> : ISettingsManager<T> where T : ISettings, new()
3045
{
3146
private readonly string _settingsFilePath;
32-
private Settings _settings;
33-
private readonly string _fileName = "app-settings.json";
3447
private readonly string _appName = "CoderDesktop";
48+
private string _fileName;
3549
private readonly object _lock = new();
3650

37-
public const string ConnectOnLaunchKey = "ConnectOnLaunch";
38-
public const string StartOnLoginKey = "StartOnLogin";
51+
private T? _cachedSettings;
3952

40-
public bool StartOnLogin
41-
{
42-
get
43-
{
44-
return Read(StartOnLoginKey, false);
45-
}
46-
set
47-
{
48-
Save(StartOnLoginKey, value);
49-
}
50-
}
51-
52-
public bool ConnectOnLaunch
53-
{
54-
get
55-
{
56-
return Read(ConnectOnLaunchKey, false);
57-
}
58-
set
59-
{
60-
Save(ConnectOnLaunchKey, value);
61-
}
62-
}
53+
private readonly SemaphoreSlim _gate = new(1, 1);
54+
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(3);
6355

6456
/// <param name="settingsFilePath">
6557
/// For unit‑tests you can pass an absolute path that already exists.
@@ -81,109 +73,129 @@ public SettingsManager(string? settingsFilePath = null)
8173
_appName);
8274

8375
Directory.CreateDirectory(folder);
76+
77+
_fileName = T.SettingsFileName;
8478
_settingsFilePath = Path.Combine(folder, _fileName);
79+
}
8580

86-
if (!File.Exists(_settingsFilePath))
81+
public async Task<T> Read(CancellationToken ct = default)
82+
{
83+
// try to get the lock with short timeout
84+
if (!await _gate.WaitAsync(LockTimeout, ct).ConfigureAwait(false))
85+
throw new InvalidOperationException(
86+
$"Could not acquire the settings lock within {LockTimeout.TotalSeconds} s.");
87+
88+
try
8789
{
88-
// Create the settings file if it doesn't exist
89-
_settings = new();
90-
File.WriteAllText(_settingsFilePath, JsonSerializer.Serialize(_settings, SettingsJsonContext.Default.Settings));
90+
if (!File.Exists(_settingsFilePath))
91+
return new();
92+
93+
var json = await File.ReadAllTextAsync(_settingsFilePath, ct)
94+
.ConfigureAwait(false);
95+
96+
// deserialize; fall back to default(T) if empty or malformed
97+
var result = JsonSerializer.Deserialize<T>(json)!;
98+
_cachedSettings = result;
99+
return result;
91100
}
92-
else
101+
catch (OperationCanceledException)
93102
{
94-
_settings = Load();
103+
throw; // propagate caller-requested cancellation
95104
}
96-
}
97-
98-
private void Save(string name, bool value)
99-
{
100-
lock (_lock)
105+
catch (Exception ex)
101106
{
102-
try
103-
{
104-
// We lock the file for the entire operation to prevent concurrent writes
105-
using var fs = new FileStream(_settingsFilePath,
106-
FileMode.OpenOrCreate,
107-
FileAccess.ReadWrite,
108-
FileShare.None);
109-
110-
// Ensure cache is loaded before saving
111-
var freshCache = JsonSerializer.Deserialize(fs, SettingsJsonContext.Default.Settings) ?? new();
112-
_settings = freshCache;
113-
_settings.Options[name] = JsonSerializer.SerializeToElement(value);
114-
fs.Position = 0; // Reset stream position to the beginning before writing
115-
116-
JsonSerializer.Serialize(fs, _settings, SettingsJsonContext.Default.Settings);
117-
118-
// This ensures the file is truncated to the new length
119-
// if the new content is shorter than the old content
120-
fs.SetLength(fs.Position);
121-
}
122-
catch
123-
{
124-
throw new InvalidOperationException($"Failed to persist settings to {_settingsFilePath}. The file may be corrupted, malformed or locked.");
125-
}
107+
throw new InvalidOperationException(
108+
$"Failed to read settings from {_settingsFilePath}. " +
109+
"The file may be corrupted, malformed or locked.", ex);
126110
}
127-
}
128-
129-
private bool Read(string name, bool defaultValue)
130-
{
131-
lock (_lock)
111+
finally
132112
{
133-
if (_settings.Options.TryGetValue(name, out var element))
134-
{
135-
try
136-
{
137-
return element.Deserialize<bool?>() ?? defaultValue;
138-
}
139-
catch
140-
{
141-
// malformed value – return default value
142-
return defaultValue;
143-
}
144-
}
145-
return defaultValue; // key not found – return default value
113+
_gate.Release();
146114
}
147115
}
148116

149-
private Settings Load()
117+
public async Task Write(T settings, CancellationToken ct = default)
150118
{
119+
// try to get the lock with short timeout
120+
if (!await _gate.WaitAsync(LockTimeout, ct).ConfigureAwait(false))
121+
throw new InvalidOperationException(
122+
$"Could not acquire the settings lock within {LockTimeout.TotalSeconds} s.");
123+
151124
try
152125
{
153-
using var fs = File.OpenRead(_settingsFilePath);
154-
return JsonSerializer.Deserialize(fs, SettingsJsonContext.Default.Settings) ?? new();
126+
// overwrite the settings file with the new settings
127+
var json = JsonSerializer.Serialize(
128+
settings, new JsonSerializerOptions() { WriteIndented = true });
129+
_cachedSettings = settings; // cache the settings
130+
await File.WriteAllTextAsync(_settingsFilePath, json, ct)
131+
.ConfigureAwait(false);
132+
}
133+
catch (OperationCanceledException)
134+
{
135+
throw; // let callers observe cancellation
155136
}
156137
catch (Exception ex)
157138
{
158-
throw new InvalidOperationException($"Failed to load settings from {_settingsFilePath}. The file may be corrupted or malformed. Exception: {ex.Message}");
139+
throw new InvalidOperationException(
140+
$"Failed to persist settings to {_settingsFilePath}. " +
141+
"The file may be corrupted, malformed or locked.", ex);
142+
}
143+
finally
144+
{
145+
_gate.Release();
159146
}
160147
}
148+
149+
public T? GetFromCache()
150+
{
151+
return _cachedSettings;
152+
}
161153
}
162154

163-
public class Settings
155+
public interface ISettings
164156
{
165157
/// <summary>
166-
/// User settings version. Increment this when the settings schema changes.
158+
/// Gets the version of the settings schema.
159+
/// </summary>
160+
int Version { get; }
161+
162+
/// <summary>
163+
/// FileName where the settings are stored.
164+
/// </summary>
165+
static abstract string SettingsFileName { get; }
166+
}
167+
168+
/// <summary>
169+
/// CoderConnect settings class that holds the settings for the CoderConnect feature.
170+
/// </summary>
171+
public class CoderConnectSettings : ISettings
172+
{
173+
/// <summary>
174+
/// CoderConnect settings version. Increment this when the settings schema changes.
167175
/// In future iterations we will be able to handle migrations when the user has
168176
/// an older version.
169177
/// </summary>
170178
public int Version { get; set; }
171-
public Dictionary<string, JsonElement> Options { get; set; }
179+
public bool ConnectOnLaunch { get; set; }
180+
public static string SettingsFileName { get; } = "coder-connect-settings.json";
172181

173182
private const int VERSION = 1; // Default version for backward compatibility
174-
public Settings()
183+
public CoderConnectSettings()
175184
{
176185
Version = VERSION;
177-
Options = [];
186+
ConnectOnLaunch = false;
178187
}
179188

180-
public Settings(int? version, Dictionary<string, JsonElement> options)
189+
public CoderConnectSettings(int? version, bool connectOnLogin)
181190
{
182191
Version = version ?? VERSION;
183-
Options = options;
192+
ConnectOnLaunch = connectOnLogin;
184193
}
185-
}
186194

187-
[JsonSerializable(typeof(Settings))]
188-
[JsonSourceGenerationOptions(WriteIndented = true)]
189-
public partial class SettingsJsonContext : JsonSerializerContext;
195+
public CoderConnectSettings Clone()
196+
{
197+
return new CoderConnectSettings(Version, ConnectOnLaunch);
198+
}
199+
200+
201+
}

0 commit comments

Comments
 (0)