Skip to content

Commit 059179c

Browse files
authoredJun 10, 2025··
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)
·
v0.6.3v0.6.0
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+
}

‎App/Services/StartupManager.cs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using Microsoft.Win32;
2+
using System;
3+
using System.Diagnostics;
4+
using System.Security;
5+
6+
namespace Coder.Desktop.App.Services;
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
31+
{
32+
private const string RunKey = @"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
33+
private const string PoliciesExplorerUser = @"Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer";
34+
private const string PoliciesExplorerMachine = @"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer";
35+
private const string DisableCurrentUserRun = "DisableCurrentUserRun";
36+
private const string DisableLocalMachineRun = "DisableLocalMachineRun";
37+
38+
private const string _defaultValueName = "CoderDesktopApp";
39+
40+
public bool Enable()
41+
{
42+
if (IsDisabledByPolicy())
43+
return false;
44+
45+
string exe = Process.GetCurrentProcess().MainModule!.FileName;
46+
try
47+
{
48+
using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true)
49+
?? Registry.CurrentUser.CreateSubKey(RunKey)!;
50+
key.SetValue(_defaultValueName, $"\"{exe}\"");
51+
return true;
52+
}
53+
catch (UnauthorizedAccessException) { return false; }
54+
catch (SecurityException) { return false; }
55+
}
56+
57+
public void Disable()
58+
{
59+
using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true);
60+
key?.DeleteValue(_defaultValueName, throwOnMissingValue: false);
61+
}
62+
63+
public bool IsEnabled()
64+
{
65+
using var key = Registry.CurrentUser.OpenSubKey(RunKey);
66+
return key?.GetValue(_defaultValueName) != null;
67+
}
68+
69+
public bool IsDisabledByPolicy()
70+
{
71+
// User policy – HKCU
72+
using (var keyUser = Registry.CurrentUser.OpenSubKey(PoliciesExplorerUser))
73+
{
74+
if ((int?)keyUser?.GetValue(DisableCurrentUserRun) == 1) return true;
75+
}
76+
// Machine policy – HKLM
77+
using (var keyMachine = Registry.LocalMachine.OpenSubKey(PoliciesExplorerMachine))
78+
{
79+
if ((int?)keyMachine?.GetValue(DisableLocalMachineRun) == 1) return true;
80+
}
81+
return false;
82+
}
83+
}
84+

‎App/ViewModels/SettingsViewModel.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using Coder.Desktop.App.Models;
2+
using Coder.Desktop.App.Services;
3+
using CommunityToolkit.Mvvm.ComponentModel;
4+
using Microsoft.Extensions.Logging;
5+
using System;
6+
7+
namespace Coder.Desktop.App.ViewModels;
8+
9+
public partial class SettingsViewModel : ObservableObject
10+
{
11+
private readonly ILogger<SettingsViewModel> _logger;
12+
13+
[ObservableProperty]
14+
public partial bool ConnectOnLaunch { get; set; }
15+
16+
[ObservableProperty]
17+
public partial bool StartOnLoginDisabled { get; set; }
18+
19+
[ObservableProperty]
20+
public partial bool StartOnLogin { get; set; }
21+
22+
private ISettingsManager<CoderConnectSettings> _connectSettingsManager;
23+
private CoderConnectSettings _connectSettings = new CoderConnectSettings();
24+
private IStartupManager _startupManager;
25+
26+
public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager<CoderConnectSettings> settingsManager, IStartupManager startupManager)
27+
{
28+
_connectSettingsManager = settingsManager;
29+
_startupManager = startupManager;
30+
_logger = logger;
31+
_connectSettings = settingsManager.Read().GetAwaiter().GetResult();
32+
StartOnLogin = startupManager.IsEnabled();
33+
ConnectOnLaunch = _connectSettings.ConnectOnLaunch;
34+
35+
// Various policies can disable the "Start on login" option.
36+
// We disable the option in the UI if the policy is set.
37+
StartOnLoginDisabled = _startupManager.IsDisabledByPolicy();
38+
39+
// Ensure the StartOnLogin property matches the current startup state.
40+
if (StartOnLogin != _startupManager.IsEnabled())
41+
{
42+
StartOnLogin = _startupManager.IsEnabled();
43+
}
44+
}
45+
46+
partial void OnConnectOnLaunchChanged(bool oldValue, bool newValue)
47+
{
48+
if (oldValue == newValue)
49+
return;
50+
try
51+
{
52+
_connectSettings.ConnectOnLaunch = ConnectOnLaunch;
53+
_connectSettingsManager.Write(_connectSettings);
54+
}
55+
catch (Exception ex)
56+
{
57+
_logger.LogError($"Error saving Coder Connect settings: {ex.Message}");
58+
}
59+
}
60+
61+
partial void OnStartOnLoginChanged(bool oldValue, bool newValue)
62+
{
63+
if (oldValue == newValue)
64+
return;
65+
try
66+
{
67+
if (StartOnLogin)
68+
{
69+
_startupManager.Enable();
70+
}
71+
else
72+
{
73+
_startupManager.Disable();
74+
}
75+
}
76+
catch (Exception ex)
77+
{
78+
_logger.LogError($"Error setting StartOnLogin in registry: {ex.Message}");
79+
}
80+
}
81+
}

‎App/ViewModels/TrayWindowViewModel.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
3838

3939
private FileSyncListWindow? _fileSyncListWindow;
4040

41+
private SettingsWindow? _settingsWindow;
42+
4143
private DispatcherQueue? _dispatcherQueue;
4244

4345
// When we transition from 0 online workspaces to >0 online workspaces, the
@@ -392,6 +394,22 @@ private void ShowFileSyncListWindow()
392394
_fileSyncListWindow.Activate();
393395
}
394396

397+
[RelayCommand]
398+
private void ShowSettingsWindow()
399+
{
400+
// This is safe against concurrent access since it all happens in the
401+
// UI thread.
402+
if (_settingsWindow != null)
403+
{
404+
_settingsWindow.Activate();
405+
return;
406+
}
407+
408+
_settingsWindow = _services.GetRequiredService<SettingsWindow>();
409+
_settingsWindow.Closed += (_, _) => _settingsWindow = null;
410+
_settingsWindow.Activate();
411+
}
412+
395413
[RelayCommand]
396414
private async Task SignOut()
397415
{

‎App/Views/Pages/SettingsMainPage.xaml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
3+
<Page
4+
x:Class="Coder.Desktop.App.Views.Pages.SettingsMainPage"
5+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
6+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
7+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
8+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
9+
xmlns:viewmodels="using:Coder.Desktop.App.ViewModels"
10+
xmlns:converters="using:Coder.Desktop.App.Converters"
11+
xmlns:ui="using:CommunityToolkit.WinUI"
12+
xmlns:win="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
13+
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
14+
mc:Ignorable="d"
15+
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
16+
<Page.Resources>
17+
<!-- Spacing between cards -->
18+
<x:Double x:Key="SettingsCardSpacing">4</x:Double>
19+
<!-- Style (inc. the correct spacing) of a section header -->
20+
<Style x:Key="SettingsSectionHeaderTextBlockStyle"
21+
BasedOn="{StaticResource BodyStrongTextBlockStyle}"
22+
TargetType="TextBlock">
23+
<Style.Setters>
24+
<Setter Property="Margin" Value="1,30,0,6" />
25+
</Style.Setters>
26+
</Style>
27+
</Page.Resources>
28+
<ScrollViewer>
29+
<Grid Padding="20, 0, 20, 0">
30+
<StackPanel MaxWidth="1000"
31+
HorizontalAlignment="Stretch"
32+
Spacing="{StaticResource SettingsCardSpacing}">
33+
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Coder Desktop" />
34+
<controls:SettingsCard Description="This setting controls whether the Coder Desktop app starts on Windows startup."
35+
Header="Start on login"
36+
HeaderIcon="{ui:FontIcon Glyph=&#xE819;}"
37+
IsEnabled="{x:Bind ViewModel.StartOnLoginDisabled, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}">
38+
<ToggleSwitch IsOn="{x:Bind ViewModel.StartOnLogin, Mode=TwoWay}" />
39+
</controls:SettingsCard>
40+
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Coder Connect" />
41+
<controls:SettingsCard Description="This setting controls whether Coder Connect automatically starts with Coder Desktop. "
42+
Header="Connect on launch"
43+
HeaderIcon="{ui:FontIcon Glyph=&#xE8AF;}"
44+
>
45+
<ToggleSwitch IsOn="{x:Bind ViewModel.ConnectOnLaunch, Mode=TwoWay}" />
46+
</controls:SettingsCard>
47+
</StackPanel>
48+
</Grid>
49+
</ScrollViewer>
50+
</Page>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using Coder.Desktop.App.ViewModels;
2+
using Microsoft.UI.Xaml.Controls;
3+
4+
namespace Coder.Desktop.App.Views.Pages;
5+
6+
public sealed partial class SettingsMainPage : Page
7+
{
8+
public SettingsViewModel ViewModel;
9+
10+
public SettingsMainPage(SettingsViewModel viewModel)
11+
{
12+
ViewModel = viewModel;
13+
InitializeComponent();
14+
}
15+
}

‎App/Views/Pages/TrayWindowMainPage.xaml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
Orientation="Vertical"
2626
HorizontalAlignment="Stretch"
2727
VerticalAlignment="Top"
28-
Padding="20,20,20,30"
28+
Padding="20,20,20,20"
2929
Spacing="10">
3030

3131
<Grid>
@@ -340,9 +340,18 @@
340340

341341
<controls:HorizontalRule />
342342

343+
<HyperlinkButton
344+
Command="{x:Bind ViewModel.ShowSettingsWindowCommand, Mode=OneWay}"
345+
Margin="-12,-4,-12,-4"
346+
HorizontalAlignment="Stretch"
347+
HorizontalContentAlignment="Left">
348+
349+
<TextBlock Text="Settings" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
350+
</HyperlinkButton>
351+
343352
<HyperlinkButton
344353
Command="{x:Bind ViewModel.SignOutCommand, Mode=OneWay}"
345-
Margin="-12,0"
354+
Margin="-12,-4,-12,-4"
346355
HorizontalAlignment="Stretch"
347356
HorizontalContentAlignment="Left">
348357

@@ -351,7 +360,7 @@
351360

352361
<HyperlinkButton
353362
Command="{x:Bind ViewModel.ExitCommand, Mode=OneWay}"
354-
Margin="-12,-8,-12,-5"
363+
Margin="-12,-4,-12,-4"
355364
HorizontalAlignment="Stretch"
356365
HorizontalContentAlignment="Left">
357366

‎App/Views/SettingsWindow.xaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
3+
<winuiex:WindowEx
4+
x:Class="Coder.Desktop.App.Views.SettingsWindow"
5+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
6+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
7+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
8+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
9+
xmlns:winuiex="using:WinUIEx"
10+
mc:Ignorable="d"
11+
Title="Coder Settings"
12+
Width="600" Height="350"
13+
MinWidth="600" MinHeight="350">
14+
15+
<Window.SystemBackdrop>
16+
<DesktopAcrylicBackdrop />
17+
</Window.SystemBackdrop>
18+
19+
<Frame x:Name="RootFrame" />
20+
</winuiex:WindowEx>

‎App/Views/SettingsWindow.xaml.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Coder.Desktop.App.Utils;
2+
using Coder.Desktop.App.ViewModels;
3+
using Coder.Desktop.App.Views.Pages;
4+
using Microsoft.UI.Xaml.Media;
5+
using WinUIEx;
6+
7+
namespace Coder.Desktop.App.Views;
8+
9+
public sealed partial class SettingsWindow : WindowEx
10+
{
11+
public readonly SettingsViewModel ViewModel;
12+
13+
public SettingsWindow(SettingsViewModel viewModel)
14+
{
15+
ViewModel = viewModel;
16+
InitializeComponent();
17+
TitleBarIcon.SetTitlebarIcon(this);
18+
19+
SystemBackdrop = new DesktopAcrylicBackdrop();
20+
21+
RootFrame.Content = new SettingsMainPage(ViewModel);
22+
23+
this.CenterOnScreen();
24+
}
25+
}

‎App/packages.lock.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@
1818
"Microsoft.WindowsAppSDK": "1.6.250108002"
1919
}
2020
},
21+
"CommunityToolkit.WinUI.Controls.SettingsControls": {
22+
"type": "Direct",
23+
"requested": "[8.2.250402, )",
24+
"resolved": "8.2.250402",
25+
"contentHash": "whJNIyxVwwLmmCS63m91r0aL13EYgdKDE0ERh2e0G2U5TUeYQQe2XRODGGr/ceBRvqu6SIvq1sXxVwglSMiJMg==",
26+
"dependencies": {
27+
"CommunityToolkit.WinUI.Triggers": "8.2.250402",
28+
"Microsoft.WindowsAppSDK": "1.6.250108002"
29+
}
30+
},
2131
"CommunityToolkit.WinUI.Extensions": {
2232
"type": "Direct",
2333
"requested": "[8.2.250402, )",
@@ -152,6 +162,24 @@
152162
"resolved": "8.2.1",
153163
"contentHash": "LWuhy8cQKJ/MYcy3XafJ916U3gPH/YDvYoNGWyQWN11aiEKCZszzPOTJAOvBjP9yG8vHmIcCyPUt4L82OK47Iw=="
154164
},
165+
"CommunityToolkit.WinUI.Helpers": {
166+
"type": "Transitive",
167+
"resolved": "8.2.250402",
168+
"contentHash": "DThBXB4hT3/aJ7xFKQJw/C0ZEs1QhZL7QG6AFOYcpnGWNlv3tkF761PFtTyhpNQrR1AFfzml5zG+zWfFbKs6Mw==",
169+
"dependencies": {
170+
"CommunityToolkit.WinUI.Extensions": "8.2.250402",
171+
"Microsoft.WindowsAppSDK": "1.6.250108002"
172+
}
173+
},
174+
"CommunityToolkit.WinUI.Triggers": {
175+
"type": "Transitive",
176+
"resolved": "8.2.250402",
177+
"contentHash": "laHIrBQkwQCurTNSQdGdEXUyoCpqY8QFXSybDM/Q1Ti/23xL+sRX/gHe3pP8uMM59bcwYbYlMRCbIaLnxnrdjw==",
178+
"dependencies": {
179+
"CommunityToolkit.WinUI.Helpers": "8.2.250402",
180+
"Microsoft.WindowsAppSDK": "1.6.250108002"
181+
}
182+
},
155183
"Google.Protobuf": {
156184
"type": "Transitive",
157185
"resolved": "3.29.3",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using Coder.Desktop.App.Models;
2+
using Coder.Desktop.App.Services;
3+
4+
namespace Coder.Desktop.Tests.App.Services;
5+
[TestFixture]
6+
public sealed class SettingsManagerTests
7+
{
8+
private string _tempDir = string.Empty;
9+
private SettingsManager<CoderConnectSettings> _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<CoderConnectSettings>(_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_Persists()
27+
{
28+
var expected = true;
29+
var settings = new CoderConnectSettings
30+
{
31+
Version = 1,
32+
ConnectOnLaunch = expected
33+
};
34+
_sut.Write(settings).GetAwaiter().GetResult();
35+
var actual = _sut.Read().GetAwaiter().GetResult();
36+
Assert.That(actual.ConnectOnLaunch, Is.EqualTo(expected));
37+
}
38+
39+
[Test]
40+
public void Read_MissingKey_ReturnsDefault()
41+
{
42+
var actual = _sut.Read().GetAwaiter().GetResult();
43+
Assert.That(actual.ConnectOnLaunch, Is.False);
44+
}
45+
}

0 commit comments

Comments
 (0)
Please sign in to comment.