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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy