Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit fcefec4

Browse files
committedJun 3, 2025
settings manager moved from generic to explicit settings
1 parent 779c11b commit fcefec4

File tree

5 files changed

+183
-141
lines changed

5 files changed

+183
-141
lines changed
 

‎App/App.xaml.cs

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ public App()
9292
// FileSyncListMainPage is created by FileSyncListWindow.
9393
services.AddTransient<FileSyncListWindow>();
9494

95-
services.AddSingleton<ISettingsManager>(_ => new SettingsManager("CoderDesktop"));
95+
services.AddSingleton<ISettingsManager, SettingsManager>();
96+
services.AddSingleton<IStartupManager, StartupManager>();
9697
// SettingsWindow views and view models
9798
services.AddTransient<SettingsViewModel>();
9899
// SettingsMainPage is created by SettingsWindow.
@@ -159,10 +160,6 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
159160

160161
// Start connecting to the manager in the background.
161162
var rpcController = _services.GetRequiredService<IRpcController>();
162-
if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
163-
// Passing in a CT with no cancellation is desired here, because
164-
// the named pipe open will block until the pipe comes up.
165-
_logger.LogDebug("reconnecting with VPN service");
166163
_ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
167164
{
168165
if (t.Exception != null)
@@ -172,22 +169,18 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
172169
Debug.WriteLine(t.Exception);
173170
Debugger.Break();
174171
#endif
175-
} else
172+
return;
173+
}
174+
if (_settingsManager.ConnectOnLaunch)
176175
{
177-
if (rpcController.GetState().VpnLifecycle == VpnLifecycle.Stopped)
176+
_logger.LogInformation("RPC lifecycle is disconnected, but ConnectOnLaunch is enabled; attempting to connect");
177+
_ = rpcController.StartVpn(CancellationToken.None).ContinueWith(connectTask =>
178178
{
179-
if (_settingsManager.Read(SettingsManager.ConnectOnLaunchKey, false))
179+
if (connectTask.Exception != null)
180180
{
181-
_logger.LogInformation("RPC lifecycle is disconnected, but ConnectOnLaunch is enabled; attempting to connect");
182-
_ = rpcController.StartVpn(CancellationToken.None).ContinueWith(connectTask =>
183-
{
184-
if (connectTask.Exception != null)
185-
{
186-
_logger.LogError(connectTask.Exception, "failed to connect on launch");
187-
}
188-
});
181+
_logger.LogError(connectTask.Exception, "failed to connect on launch");
189182
}
190-
}
183+
});
191184
}
192185
});
193186

‎App/Services/SettingsManager.cs

Lines changed: 83 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,110 +4,150 @@
44
using System.Text.Json;
55

66
namespace Coder.Desktop.App.Services;
7+
78
/// <summary>
8-
/// Generic persistence contract for simple key/value settings.
9+
/// Settings contract exposing properties for app settings.
910
/// </summary>
1011
public interface ISettingsManager
1112
{
1213
/// <summary>
13-
/// Saves <paramref name="value"/> under <paramref name="name"/> and returns the value.
14+
/// Returns the value of the StartOnLogin setting. Returns <c>false</c> if the key is not found.
1415
/// </summary>
15-
T Save<T>(string name, T value);
16+
bool StartOnLogin { get; set; }
1617

1718
/// <summary>
18-
/// Reads the setting or returns <paramref name="defaultValue"/> when the key is missing.
19+
/// Returns the value of the ConnectOnLaunch setting. Returns <c>false</c> if the key is not found.
1920
/// </summary>
20-
T Read<T>(string name, T defaultValue);
21+
bool ConnectOnLaunch { get; set; }
2122
}
23+
2224
/// <summary>
23-
/// JSON‑file implementation that works in unpackaged Win32/WinUI 3 apps.
25+
/// Implemention of <see cref="ISettingsManager"/> that persists settings to a JSON file
26+
/// located in the user's local application data folder.
2427
/// </summary>
2528
public sealed class SettingsManager : ISettingsManager
2629
{
2730
private readonly string _settingsFilePath;
2831
private readonly string _fileName = "app-settings.json";
32+
private readonly string _appName = "CoderDesktop";
2933
private readonly object _lock = new();
3034
private Dictionary<string, JsonElement> _cache;
3135

32-
public static readonly string ConnectOnLaunchKey = "ConnectOnLaunch";
33-
public static readonly string StartOnLoginKey = "StartOnLogin";
36+
public const string ConnectOnLaunchKey = "ConnectOnLaunch";
37+
public const string StartOnLoginKey = "StartOnLogin";
3438

35-
/// <param name="appName">
36-
/// Sub‑folder under %LOCALAPPDATA% (e.g. "CoderDesktop").
37-
/// If <c>null</c> the folder name defaults to the executable name.
39+
public bool StartOnLogin
40+
{
41+
get
42+
{
43+
return Read(StartOnLoginKey, false);
44+
}
45+
set
46+
{
47+
Save(StartOnLoginKey, value);
48+
}
49+
}
50+
51+
public bool ConnectOnLaunch
52+
{
53+
get
54+
{
55+
return Read(ConnectOnLaunchKey, false);
56+
}
57+
set
58+
{
59+
Save(ConnectOnLaunchKey, value);
60+
}
61+
}
62+
63+
/// <param name="settingsFilePath">
3864
/// For unit‑tests you can pass an absolute path that already exists.
65+
/// Otherwise the settings file will be created in the user's local application data folder.
3966
/// </param>
40-
public SettingsManager(string? appName = null)
67+
public SettingsManager(string? settingsFilePath = null)
4168
{
42-
// Allow unit‑tests to inject a fully‑qualified path.
43-
if (appName is not null && Path.IsPathRooted(appName))
69+
if (settingsFilePath is null)
4470
{
45-
_settingsFilePath = Path.Combine(appName, _fileName);
46-
Directory.CreateDirectory(Path.GetDirectoryName(_settingsFilePath)!);
71+
settingsFilePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
4772
}
48-
else
73+
else if (!Path.IsPathRooted(settingsFilePath))
4974
{
50-
string folder = Path.Combine(
51-
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
52-
appName ?? AppDomain.CurrentDomain.FriendlyName.ToLowerInvariant());
53-
Directory.CreateDirectory(folder);
54-
_settingsFilePath = Path.Combine(folder, _fileName);
75+
throw new ArgumentException("settingsFilePath must be an absolute path if provided", nameof(settingsFilePath));
76+
}
77+
78+
string folder = Path.Combine(
79+
settingsFilePath,
80+
_appName);
81+
82+
Directory.CreateDirectory(folder);
83+
_settingsFilePath = Path.Combine(folder, _fileName);
84+
85+
if(!File.Exists(_settingsFilePath))
86+
{
87+
// Create the settings file if it doesn't exist
88+
string emptyJson = JsonSerializer.Serialize(new { });
89+
File.WriteAllText(_settingsFilePath, emptyJson);
5590
}
5691

5792
_cache = Load();
5893
}
5994

60-
public T Save<T>(string name, T value)
95+
private void Save(string name, bool value)
6196
{
6297
lock (_lock)
6398
{
64-
_cache[name] = JsonSerializer.SerializeToElement(value);
65-
Persist();
66-
return value;
99+
try
100+
{
101+
// Ensure cache is loaded before saving
102+
using var fs = new FileStream(_settingsFilePath,
103+
FileMode.OpenOrCreate,
104+
FileAccess.ReadWrite,
105+
FileShare.None);
106+
107+
var currentCache = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(fs) ?? new();
108+
_cache = currentCache;
109+
_cache[name] = JsonSerializer.SerializeToElement(value);
110+
fs.Position = 0; // Reset stream position to the beginning before writing to override the file
111+
var options = new JsonSerializerOptions { WriteIndented = true};
112+
JsonSerializer.Serialize(fs, _cache, options);
113+
}
114+
catch
115+
{
116+
throw new InvalidOperationException($"Failed to persist settings to {_settingsFilePath}. The file may be corrupted, malformed or locked.");
117+
}
67118
}
68119
}
69120

70-
public T Read<T>(string name, T defaultValue)
121+
private bool Read(string name, bool defaultValue)
71122
{
72123
lock (_lock)
73124
{
74125
if (_cache.TryGetValue(name, out var element))
75126
{
76127
try
77128
{
78-
return element.Deserialize<T>() ?? defaultValue;
129+
return element.Deserialize<bool?>() ?? defaultValue;
79130
}
80131
catch
81132
{
82-
// Malformed value – fall back.
133+
// malformed value – return default value
83134
return defaultValue;
84135
}
85136
}
86-
return defaultValue; // key not found – return caller‑supplied default (false etc.)
137+
return defaultValue; // key not found – return default value
87138
}
88139
}
89140

90141
private Dictionary<string, JsonElement> Load()
91142
{
92-
if (!File.Exists(_settingsFilePath))
93-
return new();
94-
95143
try
96144
{
97145
using var fs = File.OpenRead(_settingsFilePath);
98146
return JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(fs) ?? new();
99147
}
100-
catch
148+
catch (Exception ex)
101149
{
102-
// Corrupted file – start fresh.
103-
return new();
150+
throw new InvalidOperationException($"Failed to load settings from {_settingsFilePath}. The file may be corrupted or malformed. Exception: {ex.Message}");
104151
}
105152
}
106-
107-
private void Persist()
108-
{
109-
using var fs = File.Create(_settingsFilePath);
110-
var options = new JsonSerializerOptions { WriteIndented = true };
111-
JsonSerializer.Serialize(fs, _cache, options);
112-
}
113153
}

‎App/Services/StartupManager.cs

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,30 @@
44
using System.Security;
55

66
namespace Coder.Desktop.App.Services;
7-
public static class StartupManager
7+
8+
public interface IStartupManager
9+
{
10+
/// <summary>
11+
/// Adds the current executable to the per‑user Run key. Returns <c>true</c> if successful.
12+
/// Fails (returns <c>false</c>) when blocked by policy or lack of permissions.
13+
/// </summary>
14+
bool Enable();
15+
/// <summary>
16+
/// Removes the value from the Run key (no-op if missing).
17+
/// </summary>
18+
void Disable();
19+
/// <summary>
20+
/// Checks whether the value exists in the Run key.
21+
/// </summary>
22+
bool IsEnabled();
23+
/// <summary>
24+
/// Detects whether group policy disables per‑user startup programs.
25+
/// Mirrors <see cref="Windows.ApplicationModel.StartupTaskState.DisabledByPolicy"/>.
26+
/// </summary>
27+
bool IsDisabledByPolicy();
28+
}
29+
30+
public class StartupManager : IStartupManager
831
{
932
private const string RunKey = @"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
1033
private const string PoliciesExplorerUser = @"Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer";
@@ -14,11 +37,7 @@ public static class StartupManager
1437

1538
private const string _defaultValueName = "CoderDesktopApp";
1639

17-
/// <summary>
18-
/// Adds the current executable to the per‑user Run key. Returns <c>true</c> if successful.
19-
/// Fails (returns <c>false</c>) when blocked by policy or lack of permissions.
20-
/// </summary>
21-
public static bool Enable()
40+
public bool Enable()
2241
{
2342
if (IsDisabledByPolicy())
2443
return false;
@@ -35,25 +54,19 @@ public static bool Enable()
3554
catch (SecurityException) { return false; }
3655
}
3756

38-
/// <summary>Removes the value from the Run key (no-op if missing).</summary>
39-
public static void Disable()
57+
public void Disable()
4058
{
4159
using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true);
4260
key?.DeleteValue(_defaultValueName, throwOnMissingValue: false);
4361
}
4462

45-
/// <summary>Checks whether the value exists in the Run key.</summary>
46-
public static bool IsEnabled()
63+
public bool IsEnabled()
4764
{
4865
using var key = Registry.CurrentUser.OpenSubKey(RunKey);
4966
return key?.GetValue(_defaultValueName) != null;
5067
}
5168

52-
/// <summary>
53-
/// Detects whether group policy disables per‑user startup programs.
54-
/// Mirrors <see cref="Windows.ApplicationModel.StartupTaskState.DisabledByPolicy"/>.
55-
/// </summary>
56-
public static bool IsDisabledByPolicy()
69+
public bool IsDisabledByPolicy()
5770
{
5871
// User policy – HKCU
5972
using (var keyUser = Registry.CurrentUser.OpenSubKey(PoliciesExplorerUser))
@@ -65,8 +78,6 @@ public static bool IsDisabledByPolicy()
6578
{
6679
if ((int?)keyMachine?.GetValue(DisableLocalMachineRun) == 1) return true;
6780
}
68-
69-
// Some non‑desktop SKUs report DisabledByPolicy implicitly
7081
return false;
7182
}
7283
}

‎App/ViewModels/SettingsViewModel.cs

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,36 @@ public partial class SettingsViewModel : ObservableObject
1212
private readonly ILogger<SettingsViewModel> _logger;
1313

1414
[ObservableProperty]
15-
public partial bool ConnectOnLaunch { get; set; } = false;
15+
public partial bool ConnectOnLaunch { get; set; }
1616

1717
[ObservableProperty]
18-
public partial bool StartOnLoginDisabled { get; set; } = false;
18+
public partial bool StartOnLoginDisabled { get; set; }
1919

2020
[ObservableProperty]
21-
public partial bool StartOnLogin { get; set; } = false;
21+
public partial bool StartOnLogin { get; set; }
2222

2323
private ISettingsManager _settingsManager;
24+
private IStartupManager _startupManager;
2425

25-
public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager settingsManager)
26+
public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager settingsManager, IStartupManager startupManager)
2627
{
2728
_settingsManager = settingsManager;
29+
_startupManager = startupManager;
2830
_logger = logger;
29-
ConnectOnLaunch = _settingsManager.Read(SettingsManager.ConnectOnLaunchKey, false);
30-
StartOnLogin = _settingsManager.Read(SettingsManager.StartOnLoginKey, false);
31+
ConnectOnLaunch = _settingsManager.ConnectOnLaunch;
32+
StartOnLogin = _settingsManager.StartOnLogin;
3133

3234
// Various policies can disable the "Start on login" option.
3335
// We disable the option in the UI if the policy is set.
34-
StartOnLoginDisabled = StartupManager.IsDisabledByPolicy();
36+
StartOnLoginDisabled = _startupManager.IsDisabledByPolicy();
3537

3638
this.PropertyChanged += (_, args) =>
3739
{
3840
if (args.PropertyName == nameof(ConnectOnLaunch))
3941
{
4042
try
4143
{
42-
_settingsManager.Save(SettingsManager.ConnectOnLaunchKey, ConnectOnLaunch);
44+
_settingsManager.ConnectOnLaunch = ConnectOnLaunch;
4345
}
4446
catch (Exception ex)
4547
{
@@ -50,14 +52,14 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager set
5052
{
5153
try
5254
{
53-
_settingsManager.Save(SettingsManager.StartOnLoginKey, StartOnLogin);
55+
_settingsManager.StartOnLogin = StartOnLogin;
5456
if (StartOnLogin)
5557
{
56-
StartupManager.Enable();
58+
_startupManager.Enable();
5759
}
5860
else
5961
{
60-
StartupManager.Disable();
62+
_startupManager.Disable();
6163
}
6264
}
6365
catch (Exception ex)
@@ -68,9 +70,9 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager set
6870
};
6971

7072
// Ensure the StartOnLogin property matches the current startup state.
71-
if (StartOnLogin != StartupManager.IsEnabled())
73+
if (StartOnLogin != _startupManager.IsEnabled())
7274
{
73-
StartOnLogin = StartupManager.IsEnabled();
75+
StartOnLogin = _startupManager.IsEnabled();
7476
}
7577
}
7678
}
Lines changed: 47 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,53 @@
11
using Coder.Desktop.App.Services;
22

3-
namespace Coder.Desktop.Tests.App.Services
3+
namespace Coder.Desktop.Tests.App.Services;
4+
[TestFixture]
5+
public sealed class SettingsManagerTests
46
{
5-
[TestFixture]
6-
public sealed class SettingsManagerTests
7+
private string _tempDir = string.Empty;
8+
private SettingsManager _sut = null!;
9+
10+
[SetUp]
11+
public void SetUp()
712
{
8-
private string _tempDir = string.Empty;
9-
private SettingsManager _sut = null!;
10-
11-
[SetUp]
12-
public void SetUp()
13-
{
14-
_tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
15-
Directory.CreateDirectory(_tempDir);
16-
_sut = new SettingsManager(_tempDir); // inject isolated path
17-
}
18-
19-
[TearDown]
20-
public void TearDown()
21-
{
22-
try { Directory.Delete(_tempDir, true); } catch { /* ignore */ }
23-
}
24-
25-
[Test]
26-
public void Save_ReturnsValue_AndPersists()
27-
{
28-
int expected = 42;
29-
int actual = _sut.Save("Answer", expected);
30-
31-
Assert.That(actual, Is.EqualTo(expected));
32-
Assert.That(_sut.Read("Answer", -1), Is.EqualTo(expected));
33-
}
34-
35-
[Test]
36-
public void Read_MissingKey_ReturnsDefault()
37-
{
38-
bool result = _sut.Read("DoesNotExist", defaultValue: false);
39-
Assert.That(result, Is.False);
40-
}
41-
42-
[Test]
43-
public void Read_AfterReload_ReturnsPreviouslySavedValue()
44-
{
45-
const string key = "Greeting";
46-
const string value = "Hello";
47-
48-
_sut.Save(key, value);
49-
50-
// Create new instance to force file reload.
51-
var newManager = new SettingsManager(_tempDir);
52-
string persisted = newManager.Read(key, string.Empty);
53-
54-
Assert.That(persisted, Is.EqualTo(value));
55-
}
13+
_tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
14+
Directory.CreateDirectory(_tempDir);
15+
_sut = new SettingsManager(_tempDir); // inject isolated path
16+
}
17+
18+
[TearDown]
19+
public void TearDown()
20+
{
21+
try { Directory.Delete(_tempDir, true); } catch { /* ignore */ }
22+
}
23+
24+
[Test]
25+
public void Save_Persists()
26+
{
27+
bool expected = true;
28+
_sut.StartOnLogin = expected;
29+
30+
Assert.That(_sut.StartOnLogin, Is.EqualTo(expected));
31+
}
32+
33+
[Test]
34+
public void Read_MissingKey_ReturnsDefault()
35+
{
36+
bool result = _sut.ConnectOnLaunch; // default is false
37+
Assert.That(result, Is.False);
38+
}
39+
40+
[Test]
41+
public void Read_AfterReload_ReturnsPreviouslySavedValue()
42+
{
43+
const bool value = true;
44+
45+
_sut.ConnectOnLaunch = value;
46+
47+
// Create new instance to force file reload.
48+
var newManager = new SettingsManager(_tempDir);
49+
bool persisted = newManager.ConnectOnLaunch;
50+
51+
Assert.That(persisted, Is.EqualTo(value));
5652
}
5753
}

0 commit comments

Comments
 (0)
Please sign in to comment.