Skip to content

Commit 059179c

Browse files
authored
added new settings dialog + settings manager (#113)
Closes: #57 & #55 Adds: - **SettingsManager** that manages settings located in AppData - **Settings** views to manage the settings - **StartupManager** that allows to control registry access to enable load on startup ![image](https://github.com/user-attachments/assets/deb834cb-44fd-4282-8db8-918bd11b1ab8)
1 parent d49de5b commit 059179c

14 files changed

+669
-57
lines changed

App/App.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
<ItemGroup>
5858
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
5959
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250402" />
60+
<PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.250402" />
6061
<PackageReference Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
6162
<PackageReference Include="DependencyPropertyGenerator" Version="1.5.0">
6263
<PrivateAssets>all</PrivateAssets>

App/App.xaml.cs

Lines changed: 84 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.Diagnostics;
43
using System.IO;
54
using System.Threading;
65
using System.Threading.Tasks;
@@ -44,6 +43,10 @@ public partial class App : Application
4443
private readonly ILogger<App> _logger;
4544
private readonly IUriHandler _uriHandler;
4645

46+
private readonly ISettingsManager<CoderConnectSettings> _settingsManager;
47+
48+
private readonly IHostApplicationLifetime _appLifetime;
49+
4750
public App()
4851
{
4952
var builder = Host.CreateApplicationBuilder();
@@ -90,6 +93,13 @@ public App()
9093
// FileSyncListMainPage is created by FileSyncListWindow.
9194
services.AddTransient<FileSyncListWindow>();
9295

96+
services.AddSingleton<ISettingsManager<CoderConnectSettings>, SettingsManager<CoderConnectSettings>>();
97+
services.AddSingleton<IStartupManager, StartupManager>();
98+
// SettingsWindow views and view models
99+
services.AddTransient<SettingsViewModel>();
100+
// SettingsMainPage is created by SettingsWindow.
101+
services.AddTransient<SettingsWindow>();
102+
93103
// DirectoryPickerWindow views and view models are created by FileSyncListViewModel.
94104

95105
// TrayWindow views and view models
@@ -107,8 +117,10 @@ public App()
107117
services.AddTransient<TrayWindow>();
108118

109119
_services = services.BuildServiceProvider();
110-
_logger = (ILogger<App>)_services.GetService(typeof(ILogger<App>))!;
111-
_uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!;
120+
_logger = _services.GetRequiredService<ILogger<App>>();
121+
_uriHandler = _services.GetRequiredService<IUriHandler>();
122+
_settingsManager = _services.GetRequiredService<ISettingsManager<CoderConnectSettings>>();
123+
_appLifetime = _services.GetRequiredService<IHostApplicationLifetime>();
112124

113125
InitializeComponent();
114126
}
@@ -129,58 +141,8 @@ public async Task ExitApplication()
129141
protected override void OnLaunched(LaunchActivatedEventArgs args)
130142
{
131143
_logger.LogInformation("new instance launched");
132-
// Start connecting to the manager in the background.
133-
var rpcController = _services.GetRequiredService<IRpcController>();
134-
if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
135-
// Passing in a CT with no cancellation is desired here, because
136-
// the named pipe open will block until the pipe comes up.
137-
_logger.LogDebug("reconnecting with VPN service");
138-
_ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
139-
{
140-
if (t.Exception != null)
141-
{
142-
_logger.LogError(t.Exception, "failed to connect to VPN service");
143-
#if DEBUG
144-
Debug.WriteLine(t.Exception);
145-
Debugger.Break();
146-
#endif
147-
}
148-
});
149-
150-
// Load the credentials in the background.
151-
var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
152-
var credentialManager = _services.GetRequiredService<ICredentialManager>();
153-
_ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t =>
154-
{
155-
if (t.Exception != null)
156-
{
157-
_logger.LogError(t.Exception, "failed to load credentials");
158-
#if DEBUG
159-
Debug.WriteLine(t.Exception);
160-
Debugger.Break();
161-
#endif
162-
}
163144

164-
credentialManagerCts.Dispose();
165-
}, CancellationToken.None);
166-
167-
// Initialize file sync.
168-
// We're adding a 5s delay here to avoid race conditions when loading the mutagen binary.
169-
170-
_ = Task.Delay(5000).ContinueWith((_) =>
171-
{
172-
var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
173-
var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
174-
syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(
175-
t =>
176-
{
177-
if (t.IsCanceled || t.Exception != null)
178-
{
179-
_logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled);
180-
}
181-
syncSessionCts.Dispose();
182-
}, CancellationToken.None);
183-
});
145+
_ = InitializeServicesAsync(_appLifetime.ApplicationStopping);
184146

185147
// Prevent the TrayWindow from closing, just hide it.
186148
var trayWindow = _services.GetRequiredService<TrayWindow>();
@@ -192,6 +154,74 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
192154
};
193155
}
194156

157+
/// <summary>
158+
/// Loads stored VPN credentials, reconnects the RPC controller,
159+
/// and (optionally) starts the VPN tunnel on application launch.
160+
/// </summary>
161+
private async Task InitializeServicesAsync(CancellationToken cancellationToken = default)
162+
{
163+
var credentialManager = _services.GetRequiredService<ICredentialManager>();
164+
var rpcController = _services.GetRequiredService<IRpcController>();
165+
166+
using var credsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
167+
credsCts.CancelAfter(TimeSpan.FromSeconds(15));
168+
169+
var loadCredsTask = credentialManager.LoadCredentials(credsCts.Token);
170+
var reconnectTask = rpcController.Reconnect(cancellationToken);
171+
var settingsTask = _settingsManager.Read(cancellationToken);
172+
173+
var dependenciesLoaded = true;
174+
175+
try
176+
{
177+
await Task.WhenAll(loadCredsTask, reconnectTask, settingsTask);
178+
}
179+
catch (Exception)
180+
{
181+
if (loadCredsTask.IsFaulted)
182+
_logger.LogError(loadCredsTask.Exception!.GetBaseException(),
183+
"Failed to load credentials");
184+
185+
if (reconnectTask.IsFaulted)
186+
_logger.LogError(reconnectTask.Exception!.GetBaseException(),
187+
"Failed to connect to VPN service");
188+
189+
if (settingsTask.IsFaulted)
190+
_logger.LogError(settingsTask.Exception!.GetBaseException(),
191+
"Failed to fetch Coder Connect settings");
192+
193+
// Don't attempt to connect if we failed to load credentials or reconnect.
194+
// This will prevent the app from trying to connect to the VPN service.
195+
dependenciesLoaded = false;
196+
}
197+
198+
var attemptCoderConnection = settingsTask.Result?.ConnectOnLaunch ?? false;
199+
if (dependenciesLoaded && attemptCoderConnection)
200+
{
201+
try
202+
{
203+
await rpcController.StartVpn(cancellationToken);
204+
}
205+
catch (Exception ex)
206+
{
207+
_logger.LogError(ex, "Failed to connect on launch");
208+
}
209+
}
210+
211+
// Initialize file sync.
212+
using var syncSessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
213+
syncSessionCts.CancelAfter(TimeSpan.FromSeconds(10));
214+
var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
215+
try
216+
{
217+
await syncSessionController.RefreshState(syncSessionCts.Token);
218+
}
219+
catch (Exception ex)
220+
{
221+
_logger.LogError($"Failed to refresh sync session state {ex.Message}", ex);
222+
}
223+
}
224+
195225
public void OnActivated(object? sender, AppActivationArguments args)
196226
{
197227
switch (args.Kind)

App/Models/Settings.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
namespace Coder.Desktop.App.Models;
2+
3+
public interface ISettings<T> : ICloneable<T>
4+
{
5+
/// <summary>
6+
/// FileName where the settings are stored.
7+
/// </summary>
8+
static abstract string SettingsFileName { get; }
9+
10+
/// <summary>
11+
/// Gets the version of the settings schema.
12+
/// </summary>
13+
int Version { get; }
14+
}
15+
16+
public interface ICloneable<T>
17+
{
18+
/// <summary>
19+
/// Creates a deep copy of the settings object.
20+
/// </summary>
21+
/// <returns>A new instance of the settings object with the same values.</returns>
22+
T Clone();
23+
}
24+
25+
/// <summary>
26+
/// CoderConnect settings class that holds the settings for the CoderConnect feature.
27+
/// </summary>
28+
public class CoderConnectSettings : ISettings<CoderConnectSettings>
29+
{
30+
public static string SettingsFileName { get; } = "coder-connect-settings.json";
31+
public int Version { get; set; }
32+
/// <summary>
33+
/// When this is true, CoderConnect will automatically connect to the Coder VPN when the application starts.
34+
/// </summary>
35+
public bool ConnectOnLaunch { get; set; }
36+
37+
/// <summary>
38+
/// CoderConnect current settings version. Increment this when the settings schema changes.
39+
/// In future iterations we will be able to handle migrations when the user has
40+
/// an older version.
41+
/// </summary>
42+
private const int VERSION = 1;
43+
44+
public CoderConnectSettings()
45+
{
46+
Version = VERSION;
47+
48+
ConnectOnLaunch = false;
49+
}
50+
51+
public CoderConnectSettings(int? version, bool connectOnLaunch)
52+
{
53+
Version = version ?? VERSION;
54+
55+
ConnectOnLaunch = connectOnLaunch;
56+
}
57+
58+
public CoderConnectSettings Clone()
59+
{
60+
return new CoderConnectSettings(Version, ConnectOnLaunch);
61+
}
62+
}

App/Services/SettingsManager.cs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
using System;
2+
using System.IO;
3+
using System.Text.Json;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Coder.Desktop.App.Models;
7+
8+
namespace Coder.Desktop.App.Services;
9+
10+
/// <summary>
11+
/// Settings contract exposing properties for app settings.
12+
/// </summary>
13+
public interface ISettingsManager<T> where T : ISettings<T>, new()
14+
{
15+
/// <summary>
16+
/// Reads the settings from the file system or returns from cache if available.
17+
/// Returned object is always a cloned instance, so it can be modified without affecting the stored settings.
18+
/// </summary>
19+
/// <param name="ct"></param>
20+
/// <returns></returns>
21+
Task<T> Read(CancellationToken ct = default);
22+
/// <summary>
23+
/// Writes the settings to the file system.
24+
/// </summary>
25+
/// <param name="settings">Object containing the settings.</param>
26+
/// <param name="ct"></param>
27+
/// <returns></returns>
28+
Task Write(T settings, CancellationToken ct = default);
29+
}
30+
31+
/// <summary>
32+
/// Implemention of <see cref="ISettingsManager"/> that persists settings to a JSON file
33+
/// located in the user's local application data folder.
34+
/// </summary>
35+
public sealed class SettingsManager<T> : ISettingsManager<T> where T : ISettings<T>, new()
36+
{
37+
private readonly string _settingsFilePath;
38+
private readonly string _appName = "CoderDesktop";
39+
private string _fileName;
40+
41+
private T? _cachedSettings;
42+
43+
private readonly SemaphoreSlim _gate = new(1, 1);
44+
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(3);
45+
46+
/// <param name="settingsFilePath">
47+
/// For unit‑tests you can pass an absolute path that already exists.
48+
/// Otherwise the settings file will be created in the user's local application data folder.
49+
/// </param>
50+
public SettingsManager(string? settingsFilePath = null)
51+
{
52+
if (settingsFilePath is null)
53+
{
54+
settingsFilePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
55+
}
56+
else if (!Path.IsPathRooted(settingsFilePath))
57+
{
58+
throw new ArgumentException("settingsFilePath must be an absolute path if provided", nameof(settingsFilePath));
59+
}
60+
61+
var folder = Path.Combine(
62+
settingsFilePath,
63+
_appName);
64+
65+
Directory.CreateDirectory(folder);
66+
67+
_fileName = T.SettingsFileName;
68+
_settingsFilePath = Path.Combine(folder, _fileName);
69+
}
70+
71+
public async Task<T> Read(CancellationToken ct = default)
72+
{
73+
if (_cachedSettings is not null)
74+
{
75+
// return cached settings if available
76+
return _cachedSettings.Clone();
77+
}
78+
79+
// try to get the lock with short timeout
80+
if (!await _gate.WaitAsync(LockTimeout, ct).ConfigureAwait(false))
81+
throw new InvalidOperationException(
82+
$"Could not acquire the settings lock within {LockTimeout.TotalSeconds} s.");
83+
84+
try
85+
{
86+
if (!File.Exists(_settingsFilePath))
87+
return new();
88+
89+
var json = await File.ReadAllTextAsync(_settingsFilePath, ct)
90+
.ConfigureAwait(false);
91+
92+
// deserialize; fall back to default(T) if empty or malformed
93+
var result = JsonSerializer.Deserialize<T>(json)!;
94+
_cachedSettings = result;
95+
return _cachedSettings.Clone(); // return a fresh instance of the settings
96+
}
97+
catch (OperationCanceledException)
98+
{
99+
throw; // propagate caller-requested cancellation
100+
}
101+
catch (Exception ex)
102+
{
103+
throw new InvalidOperationException(
104+
$"Failed to read settings from {_settingsFilePath}. " +
105+
"The file may be corrupted, malformed or locked.", ex);
106+
}
107+
finally
108+
{
109+
_gate.Release();
110+
}
111+
}
112+
113+
public async Task Write(T settings, CancellationToken ct = default)
114+
{
115+
// try to get the lock with short timeout
116+
if (!await _gate.WaitAsync(LockTimeout, ct).ConfigureAwait(false))
117+
throw new InvalidOperationException(
118+
$"Could not acquire the settings lock within {LockTimeout.TotalSeconds} s.");
119+
120+
try
121+
{
122+
// overwrite the settings file with the new settings
123+
var json = JsonSerializer.Serialize(
124+
settings, new JsonSerializerOptions() { WriteIndented = true });
125+
_cachedSettings = settings; // cache the settings
126+
await File.WriteAllTextAsync(_settingsFilePath, json, ct)
127+
.ConfigureAwait(false);
128+
}
129+
catch (OperationCanceledException)
130+
{
131+
throw; // let callers observe cancellation
132+
}
133+
catch (Exception ex)
134+
{
135+
throw new InvalidOperationException(
136+
$"Failed to persist settings to {_settingsFilePath}. " +
137+
"The file may be corrupted, malformed or locked.", ex);
138+
}
139+
finally
140+
{
141+
_gate.Release();
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)