From 7465bb59f4d30d9c3dc8b088893d8132c4164c48 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 29 May 2025 19:09:13 +0200
Subject: [PATCH 01/18] added new settings dialog + settings manager

---
 App/App.xaml.cs                           |   6 ++
 App/Services/SettingsManager.cs           | 113 ++++++++++++++++++++++
 App/ViewModels/SettingsViewModel.cs       |  54 +++++++++++
 App/ViewModels/TrayWindowViewModel.cs     |  18 ++++
 App/Views/Pages/SettingsMainPage.xaml     |  23 +++++
 App/Views/Pages/SettingsMainPage.xaml.cs  |  15 +++
 App/Views/Pages/TrayWindowMainPage.xaml   |  15 ++-
 App/Views/SettingsWindow.xaml             |  20 ++++
 App/Views/SettingsWindow.xaml.cs          |  26 +++++
 Tests.App/Services/SettingsManagerTest.cs |  62 ++++++++++++
 10 files changed, 349 insertions(+), 3 deletions(-)
 create mode 100644 App/Services/SettingsManager.cs
 create mode 100644 App/ViewModels/SettingsViewModel.cs
 create mode 100644 App/Views/Pages/SettingsMainPage.xaml
 create mode 100644 App/Views/Pages/SettingsMainPage.xaml.cs
 create mode 100644 App/Views/SettingsWindow.xaml
 create mode 100644 App/Views/SettingsWindow.xaml.cs
 create mode 100644 Tests.App/Services/SettingsManagerTest.cs

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 06ab676..952661d 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -90,6 +90,12 @@ public App()
         // FileSyncListMainPage is created by FileSyncListWindow.
         services.AddTransient<FileSyncListWindow>();
 
+        services.AddSingleton<ISettingsManager>(_ => new SettingsManager("CoderDesktop"));
+        // SettingsWindow views and view models
+        services.AddTransient<SettingsViewModel>();
+        // SettingsMainPage is created by SettingsWindow.
+        services.AddTransient<SettingsWindow>();
+
         // DirectoryPickerWindow views and view models are created by FileSyncListViewModel.
 
         // TrayWindow views and view models
diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
new file mode 100644
index 0000000..d792233
--- /dev/null
+++ b/App/Services/SettingsManager.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace Coder.Desktop.App.Services;
+/// <summary>
+/// Generic persistence contract for simple key/value settings.
+/// </summary>
+public interface ISettingsManager
+{
+    /// <summary>
+    /// Saves <paramref name="value"/> under <paramref name="name"/> and returns the value.
+    /// </summary>
+    T Save<T>(string name, T value);
+
+    /// <summary>
+    /// Reads the setting or returns <paramref name="defaultValue"/> when the key is missing.
+    /// </summary>
+    T Read<T>(string name, T defaultValue);
+}
+/// <summary>
+/// JSON‑file implementation that works in unpackaged Win32/WinUI 3 apps.
+/// </summary>
+public sealed class SettingsManager : ISettingsManager
+{
+    private readonly string _settingsFilePath;
+    private readonly string _fileName = "app-settings.json";
+    private readonly object _lock = new();
+    private Dictionary<string, JsonElement> _cache;
+
+    /// <param name="appName">
+    /// Sub‑folder under %LOCALAPPDATA% (e.g. "coder-desktop").
+    /// If <c>null</c> the folder name defaults to the executable name.
+    /// For unit‑tests you can pass an absolute path that already exists.
+    /// </param>
+    public SettingsManager(string? appName = null)
+    {
+        // Allow unit‑tests to inject a fully‑qualified path.
+        if (appName is not null && Path.IsPathRooted(appName))
+        {
+            _settingsFilePath = Path.Combine(appName, _fileName);
+            Directory.CreateDirectory(Path.GetDirectoryName(_settingsFilePath)!);
+        }
+        else
+        {
+            string folder = Path.Combine(
+                Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+                appName ?? AppDomain.CurrentDomain.FriendlyName.ToLowerInvariant());
+            Directory.CreateDirectory(folder);
+            _settingsFilePath = Path.Combine(folder, _fileName);
+        }
+
+        _cache = Load();
+    }
+
+    public T Save<T>(string name, T value)
+    {
+        lock (_lock)
+        {
+            _cache[name] = JsonSerializer.SerializeToElement(value);
+            Persist();
+            return value;
+        }
+    }
+
+    public T Read<T>(string name, T defaultValue)
+    {
+        lock (_lock)
+        {
+            if (_cache.TryGetValue(name, out var element))
+            {
+                try
+                {
+                    return element.Deserialize<T>() ?? defaultValue;
+                }
+                catch
+                {
+                    // Malformed value – fall back.
+                    return defaultValue;
+                }
+            }
+            return defaultValue; // key not found – return caller‑supplied default (false etc.)
+        }
+    }
+
+    private Dictionary<string, JsonElement> Load()
+    {
+        if (!File.Exists(_settingsFilePath))
+            return new();
+
+        try
+        {
+            using var fs = File.OpenRead(_settingsFilePath);
+            return JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(fs) ?? new();
+        }
+        catch
+        {
+            // Corrupted file – start fresh.
+            return new();
+        }
+    }
+
+    private void Persist()
+    {
+        using var fs = File.Create(_settingsFilePath);
+        var options = new JsonSerializerOptions { WriteIndented = true };
+        JsonSerializer.Serialize(fs, _cache, options);
+    }
+}
diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..8028e6f
--- /dev/null
+++ b/App/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,54 @@
+using System;
+using Coder.Desktop.App.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.ViewModels;
+
+public partial class SettingsViewModel : ObservableObject
+{
+    private Window? _window;
+    private DispatcherQueue? _dispatcherQueue;
+
+    private ISettingsManager _settingsManager;
+
+    public SettingsViewModel(ISettingsManager settingsManager)
+    {
+        _settingsManager = settingsManager;
+    }
+
+    public void Initialize(Window window, DispatcherQueue dispatcherQueue)
+    {
+        _window = window;
+        _dispatcherQueue = dispatcherQueue;
+        if (!_dispatcherQueue.HasThreadAccess)
+            throw new InvalidOperationException("Initialize must be called from the UI thread");
+    }
+
+    [RelayCommand]
+    private void SaveSetting()
+    {
+        //_settingsManager.Save();
+    }
+
+    [RelayCommand]
+    private void ShowSettingsDialog()
+    {
+        if (_window is null || _dispatcherQueue is null)
+            throw new InvalidOperationException("Initialize must be called before showing the settings dialog.");
+        // Here you would typically open a settings dialog or page.
+        // For example, you could navigate to a SettingsPage in your app.
+        // This is just a placeholder for demonstration purposes.
+        // Display MessageBox and show a message
+        var message = $"Settings dialog opened. Current setting: {_settingsManager.Read("SomeSetting", false)}\n" +
+                      "You can implement your settings dialog here.";
+        var dialog = new ContentDialog();
+        dialog.Title = "Settings";
+        dialog.Content = message;
+        dialog.XamlRoot = _window.Content.XamlRoot;
+        _ = dialog.ShowAsync();
+    }
+}
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
index d8b3182..c49fef7 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -39,6 +39,8 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
 
     private FileSyncListWindow? _fileSyncListWindow;
 
+    private SettingsWindow? _settingsWindow;
+
     private DispatcherQueue? _dispatcherQueue;
 
     // When we transition from 0 online workspaces to >0 online workspaces, the
@@ -359,6 +361,22 @@ private void ShowFileSyncListWindow()
         _fileSyncListWindow.Activate();
     }
 
+    [RelayCommand]
+    private void ShowSettingsWindow()
+    {
+        // This is safe against concurrent access since it all happens in the
+        // UI thread.
+        if (_settingsWindow != null)
+        {
+            _settingsWindow.Activate();
+            return;
+        }
+
+        _settingsWindow = _services.GetRequiredService<SettingsWindow>();
+        _settingsWindow.Closed += (_, _) => _settingsWindow = null;
+        _settingsWindow.Activate();
+    }
+
     [RelayCommand]
     private async Task SignOut()
     {
diff --git a/App/Views/Pages/SettingsMainPage.xaml b/App/Views/Pages/SettingsMainPage.xaml
new file mode 100644
index 0000000..b2a025f
--- /dev/null
+++ b/App/Views/Pages/SettingsMainPage.xaml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Page
+    x:Class="Coder.Desktop.App.Views.Pages.SettingsMainPage"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    xmlns:viewmodels="using:Coder.Desktop.App.ViewModels"
+    xmlns:converters="using:Coder.Desktop.App.Converters"
+    mc:Ignorable="d"
+    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
+
+    <Grid>
+        <HyperlinkButton
+    Command="{x:Bind ViewModel.ShowSettingsDialogCommand, Mode=OneWay}"
+    HorizontalAlignment="Stretch"
+    HorizontalContentAlignment="Left">
+
+            <TextBlock Text="Show settings" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+        </HyperlinkButton>
+    </Grid>
+</Page>
diff --git a/App/Views/Pages/SettingsMainPage.xaml.cs b/App/Views/Pages/SettingsMainPage.xaml.cs
new file mode 100644
index 0000000..5fd9c3c
--- /dev/null
+++ b/App/Views/Pages/SettingsMainPage.xaml.cs
@@ -0,0 +1,15 @@
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class SettingsMainPage : Page
+{
+    public SettingsViewModel ViewModel;
+
+    public SettingsMainPage(SettingsViewModel viewModel)
+    {
+        ViewModel = viewModel; // already initialized
+        InitializeComponent();
+    }
+}
diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml
index 283867d..83ba29f 100644
--- a/App/Views/Pages/TrayWindowMainPage.xaml
+++ b/App/Views/Pages/TrayWindowMainPage.xaml
@@ -25,7 +25,7 @@
         Orientation="Vertical"
         HorizontalAlignment="Stretch"
         VerticalAlignment="Top"
-        Padding="20,20,20,30"
+        Padding="20,20,20,20"
         Spacing="10">
 
         <Grid>
@@ -331,9 +331,18 @@
 
         <controls:HorizontalRule />
 
+        <HyperlinkButton
+            Command="{x:Bind ViewModel.ShowSettingsWindowCommand, Mode=OneWay}"
+            Margin="-12,-4,-12,-4"
+            HorizontalAlignment="Stretch"
+            HorizontalContentAlignment="Left">
+
+            <TextBlock Text="Settings" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+        </HyperlinkButton>
+
         <HyperlinkButton
             Command="{x:Bind ViewModel.SignOutCommand, Mode=OneWay}"
-            Margin="-12,0"
+            Margin="-12,-4,-12,-4"
             HorizontalAlignment="Stretch"
             HorizontalContentAlignment="Left">
 
@@ -342,7 +351,7 @@
 
         <HyperlinkButton
             Command="{x:Bind ViewModel.ExitCommand, Mode=OneWay}"
-            Margin="-12,-8,-12,-5"
+            Margin="-12,-4,-12,-4"
             HorizontalAlignment="Stretch"
             HorizontalContentAlignment="Left">          
 
diff --git a/App/Views/SettingsWindow.xaml b/App/Views/SettingsWindow.xaml
new file mode 100644
index 0000000..02055ff
--- /dev/null
+++ b/App/Views/SettingsWindow.xaml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<winuiex:WindowEx
+    x:Class="Coder.Desktop.App.Views.SettingsWindow"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    xmlns:winuiex="using:WinUIEx"
+    mc:Ignorable="d"
+    Title="Coder Settings"
+    Width="1000" Height="300"
+    MinWidth="1000" MinHeight="300">
+
+    <Window.SystemBackdrop>
+        <DesktopAcrylicBackdrop />
+    </Window.SystemBackdrop>
+
+    <Frame x:Name="RootFrame" />
+</winuiex:WindowEx>
diff --git a/App/Views/SettingsWindow.xaml.cs b/App/Views/SettingsWindow.xaml.cs
new file mode 100644
index 0000000..3fb71d3
--- /dev/null
+++ b/App/Views/SettingsWindow.xaml.cs
@@ -0,0 +1,26 @@
+using Coder.Desktop.App.Utils;
+using Coder.Desktop.App.ViewModels;
+using Coder.Desktop.App.Views.Pages;
+using Microsoft.UI.Xaml.Media;
+using WinUIEx;
+
+namespace Coder.Desktop.App.Views;
+
+public sealed partial class SettingsWindow : WindowEx
+{
+    public readonly SettingsViewModel ViewModel;
+
+    public SettingsWindow(SettingsViewModel viewModel)
+    {
+        ViewModel = viewModel;
+        InitializeComponent();
+        TitleBarIcon.SetTitlebarIcon(this);
+
+        SystemBackdrop = new DesktopAcrylicBackdrop();
+
+        ViewModel.Initialize(this, DispatcherQueue);
+        RootFrame.Content = new SettingsMainPage(ViewModel);
+
+        this.CenterOnScreen();
+    }
+}
diff --git a/Tests.App/Services/SettingsManagerTest.cs b/Tests.App/Services/SettingsManagerTest.cs
new file mode 100644
index 0000000..a8c3351
--- /dev/null
+++ b/Tests.App/Services/SettingsManagerTest.cs
@@ -0,0 +1,62 @@
+using Coder.Desktop.App.Services;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Coder.Desktop.Tests.App.Services
+{
+    [TestFixture]
+    public sealed class SettingsManagerTests
+    {
+        private string _tempDir = string.Empty;
+        private SettingsManager _sut = null!;
+
+        [SetUp]
+        public void SetUp()
+        {
+            _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+            Directory.CreateDirectory(_tempDir);
+            _sut = new SettingsManager(_tempDir); // inject isolated path
+        }
+
+        [TearDown]
+        public void TearDown()
+        {
+            try { Directory.Delete(_tempDir, true); } catch { /* ignore */ }
+        }
+
+        [Test]
+        public void Save_ReturnsValue_AndPersists()
+        {
+            int expected = 42;
+            int actual = _sut.Save("Answer", expected);
+
+            Assert.That(actual, Is.EqualTo(expected));
+            Assert.That(_sut.Read("Answer", -1), Is.EqualTo(expected));
+        }
+
+        [Test]
+        public void Read_MissingKey_ReturnsDefault()
+        {
+            bool result = _sut.Read("DoesNotExist", defaultValue: false);
+            Assert.That(result, Is.False);
+        }
+
+        [Test]
+        public void Read_AfterReload_ReturnsPreviouslySavedValue()
+        {
+            const string key = "Greeting";
+            const string value = "Hello";
+
+            _sut.Save(key, value);
+
+            // Create new instance to force file reload.
+            var newManager = new SettingsManager(_tempDir);
+            string persisted = newManager.Read(key, string.Empty);
+
+            Assert.That(persisted, Is.EqualTo(value));
+        }
+    }
+}

From cd99645571a56183d4adb6690ae8bc454758c4ae Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 2 Jun 2025 11:24:02 +0200
Subject: [PATCH 02/18] WIP

---
 App/App.csproj                        |  1 +
 App/App.xaml.cs                       | 44 ++++++++++++------
 App/Services/SettingsManager.cs       |  5 +-
 App/ViewModels/SettingsViewModel.cs   | 66 ++++++++++++++++-----------
 App/Views/Pages/SettingsMainPage.xaml | 49 ++++++++++++++++----
 App/Views/SettingsWindow.xaml         |  4 +-
 App/packages.lock.json                | 28 ++++++++++++
 7 files changed, 145 insertions(+), 52 deletions(-)

diff --git a/App/App.csproj b/App/App.csproj
index fcfb92f..68cef65 100644
--- a/App/App.csproj
+++ b/App/App.csproj
@@ -57,6 +57,7 @@
     <ItemGroup>
         <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
         <PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250402" />
+        <PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.250402" />
         <PackageReference Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
         <PackageReference Include="DependencyPropertyGenerator" Version="1.5.0">
             <PrivateAssets>all</PrivateAssets>
diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 952661d..0876849 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -44,6 +44,8 @@ public partial class App : Application
     private readonly ILogger<App> _logger;
     private readonly IUriHandler _uriHandler;
 
+    private readonly ISettingsManager _settingsManager;
+
     public App()
     {
         var builder = Host.CreateApplicationBuilder();
@@ -115,6 +117,7 @@ public App()
         _services = services.BuildServiceProvider();
         _logger = (ILogger<App>)_services.GetService(typeof(ILogger<App>))!;
         _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!;
+        _settingsManager = (ISettingsManager)_services.GetService(typeof(ISettingsManager))!;
 
         InitializeComponent();
     }
@@ -150,6 +153,22 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
                 Debug.WriteLine(t.Exception);
                 Debugger.Break();
 #endif
+            } else
+            {
+                if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
+                {
+                    if (_settingsManager.Read(SettingsManager.ConnectOnLaunchKey, false))
+                    {
+                        _logger.LogInformation("RPC lifecycle is disconnected, but ConnectOnLaunch is enabled; attempting to connect");
+                        _ = rpcController.StartVpn(CancellationToken.None).ContinueWith(connectTask =>
+                        {
+                            if (connectTask.Exception != null)
+                            {
+                                _logger.LogError(connectTask.Exception, "failed to connect on launch");
+                            }
+                        });
+                    }
+                }
             }
         });
 
@@ -171,22 +190,17 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
         }, CancellationToken.None);
 
         // Initialize file sync.
-        // We're adding a 5s delay here to avoid race conditions when loading the mutagen binary.
-
-        _ = Task.Delay(5000).ContinueWith((_) =>
+        var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+        var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
+        _ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t =>
         {
-            var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-            var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
-            syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(
-                t =>
-                {
-                    if (t.IsCanceled || t.Exception != null)
-                    {
-                        _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled);
-                    }
-                    syncSessionCts.Dispose();
-                }, CancellationToken.None);
-        });
+            if (t.IsCanceled || t.Exception != null)
+            {
+                _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled);
+            }
+
+            syncSessionCts.Dispose();
+        }, CancellationToken.None);
 
         // Prevent the TrayWindow from closing, just hide it.
         var trayWindow = _services.GetRequiredService<TrayWindow>();
diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index d792233..972f34a 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -32,8 +32,11 @@ public sealed class SettingsManager : ISettingsManager
     private readonly object _lock = new();
     private Dictionary<string, JsonElement> _cache;
 
+    public static readonly string ConnectOnLaunchKey = "ConnectOnLaunch";
+    public static readonly string StartOnLoginKey = "StartOnLogin";
+
     /// <param name="appName">
-    /// Sub‑folder under %LOCALAPPDATA% (e.g. "coder-desktop").
+    /// Sub‑folder under %LOCALAPPDATA% (e.g. "CoderDesktop").
     /// If <c>null</c> the folder name defaults to the executable name.
     /// For unit‑tests you can pass an absolute path that already exists.
     /// </param>
diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs
index 8028e6f..90a6ef4 100644
--- a/App/ViewModels/SettingsViewModel.cs
+++ b/App/ViewModels/SettingsViewModel.cs
@@ -1,10 +1,11 @@
-using System;
 using Coder.Desktop.App.Services;
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
 using Microsoft.UI.Dispatching;
 using Microsoft.UI.Xaml;
 using Microsoft.UI.Xaml.Controls;
+using System;
 
 namespace Coder.Desktop.App.ViewModels;
 
@@ -13,11 +14,48 @@ public partial class SettingsViewModel : ObservableObject
     private Window? _window;
     private DispatcherQueue? _dispatcherQueue;
 
+    private readonly ILogger<SettingsViewModel> _logger;
+
+    [ObservableProperty]
+    public partial bool ConnectOnLaunch { get; set; } = false;
+
+    [ObservableProperty]
+    public partial bool StartOnLogin { get; set; } = false;
+
     private ISettingsManager _settingsManager;
 
-    public SettingsViewModel(ISettingsManager settingsManager)
+    public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager settingsManager)
     {
         _settingsManager = settingsManager;
+        _logger = logger;
+        ConnectOnLaunch = _settingsManager.Read(SettingsManager.ConnectOnLaunchKey, false);
+        StartOnLogin = _settingsManager.Read(SettingsManager.StartOnLoginKey, false);
+
+        this.PropertyChanged += (_, args) =>
+        {
+            if (args.PropertyName == nameof(ConnectOnLaunch))
+            {
+                try
+                {
+                    _settingsManager.Save(SettingsManager.ConnectOnLaunchKey, ConnectOnLaunch);
+                }
+                catch (Exception ex)
+                {
+                    Console.WriteLine($"Error saving {SettingsManager.ConnectOnLaunchKey} setting: {ex.Message}");
+                }
+            }
+            else if (args.PropertyName == nameof(StartOnLogin))
+            {
+                try
+                {
+                    _settingsManager.Save(SettingsManager.StartOnLoginKey, StartOnLogin);
+                }
+                catch (Exception ex)
+                {
+                    Console.WriteLine($"Error saving {SettingsManager.StartOnLoginKey} setting: {ex.Message}");
+                }
+            }
+        };
     }
 
     public void Initialize(Window window, DispatcherQueue dispatcherQueue)
@@ -27,28 +65,4 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue)
         if (!_dispatcherQueue.HasThreadAccess)
             throw new InvalidOperationException("Initialize must be called from the UI thread");
     }
-
-    [RelayCommand]
-    private void SaveSetting()
-    {
-        //_settingsManager.Save();
-    }
-
-    [RelayCommand]
-    private void ShowSettingsDialog()
-    {
-        if (_window is null || _dispatcherQueue is null)
-            throw new InvalidOperationException("Initialize must be called before showing the settings dialog.");
-        // Here you would typically open a settings dialog or page.
-        // For example, you could navigate to a SettingsPage in your app.
-        // This is just a placeholder for demonstration purposes.
-        // Display MessageBox and show a message
-        var message = $"Settings dialog opened. Current setting: {_settingsManager.Read("SomeSetting", false)}\n" +
-                      "You can implement your settings dialog here.";
-        var dialog = new ContentDialog();
-        dialog.Title = "Settings";
-        dialog.Content = message;
-        dialog.XamlRoot = _window.Content.XamlRoot;
-        _ = dialog.ShowAsync();
-    }
 }
diff --git a/App/Views/Pages/SettingsMainPage.xaml b/App/Views/Pages/SettingsMainPage.xaml
index b2a025f..3efaefb 100644
--- a/App/Views/Pages/SettingsMainPage.xaml
+++ b/App/Views/Pages/SettingsMainPage.xaml
@@ -8,16 +8,49 @@
     xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
     xmlns:viewmodels="using:Coder.Desktop.App.ViewModels"
     xmlns:converters="using:Coder.Desktop.App.Converters"
+    xmlns:ui="using:CommunityToolkit.WinUI"
+    xmlns:win="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:controls="using:CommunityToolkit.WinUI.Controls"
     mc:Ignorable="d"
     Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
 
-    <Grid>
-        <HyperlinkButton
-    Command="{x:Bind ViewModel.ShowSettingsDialogCommand, Mode=OneWay}"
-    HorizontalAlignment="Stretch"
-    HorizontalContentAlignment="Left">
+    <Page.Resources>
+        <!--  These styles can be referenced to create a consistent SettingsPage layout  -->
+
+        <!--  Spacing between cards  -->
+        <x:Double x:Key="SettingsCardSpacing">4</x:Double>
+
+        <!--  Style (inc. the correct spacing) of a section header  -->
+        <Style x:Key="SettingsSectionHeaderTextBlockStyle"
+               BasedOn="{StaticResource BodyStrongTextBlockStyle}"
+               TargetType="TextBlock">
+            <Style.Setters>
+                <Setter Property="Margin" Value="1,30,0,6" />
+            </Style.Setters>
+        </Style>
+    </Page.Resources>
+    <ScrollViewer>
+        <Grid Padding="20, 0, 20, 0">
+            <StackPanel MaxWidth="1000"
+                        HorizontalAlignment="Stretch"
+                        Spacing="{StaticResource SettingsCardSpacing}">
+
+                <TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Coder Desktop" />
+
+                <controls:SettingsCard Description="This setting controls whether the Coder Desktop app starts on Windows startup."
+                                       Header="Start on login"
+                                       HeaderIcon="{ui:FontIcon Glyph=&#xE819;}">
+                    <ToggleSwitch IsOn="{x:Bind ViewModel.StartOnLogin, Mode=TwoWay}" />
+                </controls:SettingsCard>
+
+                <TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Coder Connect" />
+                <controls:SettingsCard Description="This setting controls whether Coder Connect automatically starts with Coder Desktop  "
+                                       Header="Connect on launch"
+                                       HeaderIcon="{ui:FontIcon Glyph=&#xE8AF;}">
+                    <ToggleSwitch IsOn="{x:Bind ViewModel.ConnectOnLaunch, Mode=TwoWay}" />
+                </controls:SettingsCard>
+            </StackPanel>
+        </Grid>
+    </ScrollViewer>
 
-            <TextBlock Text="Show settings" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
-        </HyperlinkButton>
-    </Grid>
 </Page>
diff --git a/App/Views/SettingsWindow.xaml b/App/Views/SettingsWindow.xaml
index 02055ff..a84bbc4 100644
--- a/App/Views/SettingsWindow.xaml
+++ b/App/Views/SettingsWindow.xaml
@@ -9,8 +9,8 @@
     xmlns:winuiex="using:WinUIEx"
     mc:Ignorable="d"
     Title="Coder Settings"
-    Width="1000" Height="300"
-    MinWidth="1000" MinHeight="300">
+    Width="600" Height="350"
+    MinWidth="600" MinHeight="350">
 
     <Window.SystemBackdrop>
         <DesktopAcrylicBackdrop />
diff --git a/App/packages.lock.json b/App/packages.lock.json
index a47908a..e442998 100644
--- a/App/packages.lock.json
+++ b/App/packages.lock.json
@@ -18,6 +18,16 @@
           "Microsoft.WindowsAppSDK": "1.6.250108002"
         }
       },
+      "CommunityToolkit.WinUI.Controls.SettingsControls": {
+        "type": "Direct",
+        "requested": "[8.2.250402, )",
+        "resolved": "8.2.250402",
+        "contentHash": "whJNIyxVwwLmmCS63m91r0aL13EYgdKDE0ERh2e0G2U5TUeYQQe2XRODGGr/ceBRvqu6SIvq1sXxVwglSMiJMg==",
+        "dependencies": {
+          "CommunityToolkit.WinUI.Triggers": "8.2.250402",
+          "Microsoft.WindowsAppSDK": "1.6.250108002"
+        }
+      },
       "CommunityToolkit.WinUI.Extensions": {
         "type": "Direct",
         "requested": "[8.2.250402, )",
@@ -152,6 +162,24 @@
         "resolved": "8.2.1",
         "contentHash": "LWuhy8cQKJ/MYcy3XafJ916U3gPH/YDvYoNGWyQWN11aiEKCZszzPOTJAOvBjP9yG8vHmIcCyPUt4L82OK47Iw=="
       },
+      "CommunityToolkit.WinUI.Helpers": {
+        "type": "Transitive",
+        "resolved": "8.2.250402",
+        "contentHash": "DThBXB4hT3/aJ7xFKQJw/C0ZEs1QhZL7QG6AFOYcpnGWNlv3tkF761PFtTyhpNQrR1AFfzml5zG+zWfFbKs6Mw==",
+        "dependencies": {
+          "CommunityToolkit.WinUI.Extensions": "8.2.250402",
+          "Microsoft.WindowsAppSDK": "1.6.250108002"
+        }
+      },
+      "CommunityToolkit.WinUI.Triggers": {
+        "type": "Transitive",
+        "resolved": "8.2.250402",
+        "contentHash": "laHIrBQkwQCurTNSQdGdEXUyoCpqY8QFXSybDM/Q1Ti/23xL+sRX/gHe3pP8uMM59bcwYbYlMRCbIaLnxnrdjw==",
+        "dependencies": {
+          "CommunityToolkit.WinUI.Helpers": "8.2.250402",
+          "Microsoft.WindowsAppSDK": "1.6.250108002"
+        }
+      },
       "Google.Protobuf": {
         "type": "Transitive",
         "resolved": "3.29.3",

From 779c11b822456c0309fd776e5778f3d69873c574 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 2 Jun 2025 13:13:32 +0200
Subject: [PATCH 03/18] added StartupManager to handle auto-start changed order
 of CredentialManager loading

---
 App/App.xaml.cs                           | 38 ++++++------
 App/Services/SettingsManager.cs           |  3 -
 App/Services/StartupManager.cs            | 73 +++++++++++++++++++++++
 App/ViewModels/SettingsViewModel.cs       | 36 ++++++-----
 App/Views/Pages/SettingsMainPage.xaml     |  8 ++-
 App/Views/SettingsWindow.xaml.cs          |  1 -
 Tests.App/Services/SettingsManagerTest.cs |  5 --
 7 files changed, 120 insertions(+), 44 deletions(-)
 create mode 100644 App/Services/StartupManager.cs

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 0876849..5ebe227 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -138,6 +138,25 @@ public async Task ExitApplication()
     protected override void OnLaunched(LaunchActivatedEventArgs args)
     {
         _logger.LogInformation("new instance launched");
+
+        // Load the credentials in the background.
+        var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+        var credentialManager = _services.GetRequiredService<ICredentialManager>();
+        credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t =>
+        {
+            if (t.Exception != null)
+            {
+                _logger.LogError(t.Exception, "failed to load credentials");
+#if DEBUG
+                Debug.WriteLine(t.Exception);
+                Debugger.Break();
+#endif
+            }
+
+            credentialManagerCts.Dispose();
+        });
+
+
         // Start connecting to the manager in the background.
         var rpcController = _services.GetRequiredService<IRpcController>();
         if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
@@ -155,7 +174,7 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
 #endif
             } else
             {
-                if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
+                if (rpcController.GetState().VpnLifecycle == VpnLifecycle.Stopped)
                 {
                     if (_settingsManager.Read(SettingsManager.ConnectOnLaunchKey, false))
                     {
@@ -172,23 +191,6 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
             }
         });
 
-        // Load the credentials in the background.
-        var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
-        var credentialManager = _services.GetRequiredService<ICredentialManager>();
-        _ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t =>
-        {
-            if (t.Exception != null)
-            {
-                _logger.LogError(t.Exception, "failed to load credentials");
-#if DEBUG
-                Debug.WriteLine(t.Exception);
-                Debugger.Break();
-#endif
-            }
-
-            credentialManagerCts.Dispose();
-        }, CancellationToken.None);
-
         // Initialize file sync.
         var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
         var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index 972f34a..83ace1d 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -1,10 +1,7 @@
 using System;
 using System.Collections.Generic;
 using System.IO;
-using System.Linq;
-using System.Text;
 using System.Text.Json;
-using System.Threading.Tasks;
 
 namespace Coder.Desktop.App.Services;
 /// <summary>
diff --git a/App/Services/StartupManager.cs b/App/Services/StartupManager.cs
new file mode 100644
index 0000000..b38c17d
--- /dev/null
+++ b/App/Services/StartupManager.cs
@@ -0,0 +1,73 @@
+using Microsoft.Win32;
+using System;
+using System.Diagnostics;
+using System.Security;
+
+namespace Coder.Desktop.App.Services;
+public static class StartupManager
+{
+    private const string RunKey = @"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
+    private const string PoliciesExplorerUser = @"Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer";
+    private const string PoliciesExplorerMachine = @"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer";
+    private const string DisableCurrentUserRun = "DisableCurrentUserRun";
+    private const string DisableLocalMachineRun = "DisableLocalMachineRun";
+
+    private const string _defaultValueName = "CoderDesktopApp";
+
+    /// <summary>
+    /// Adds the current executable to the per‑user Run key. Returns <c>true</c> if successful.
+    /// Fails (returns <c>false</c>) when blocked by policy or lack of permissions.
+    /// </summary>
+    public static bool Enable()
+    {
+        if (IsDisabledByPolicy())
+            return false;
+
+        string exe = Process.GetCurrentProcess().MainModule!.FileName;
+        try
+        {
+            using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true)
+                            ?? Registry.CurrentUser.CreateSubKey(RunKey)!;
+            key.SetValue(_defaultValueName, $"\"{exe}\"");
+            return true;
+        }
+        catch (UnauthorizedAccessException) { return false; }
+        catch (SecurityException) { return false; }
+    }
+
+    /// <summary>Removes the value from the Run key (no-op if missing).</summary>
+    public static void Disable()
+    {
+        using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true);
+        key?.DeleteValue(_defaultValueName, throwOnMissingValue: false);
+    }
+
+    /// <summary>Checks whether the value exists in the Run key.</summary>
+    public static bool IsEnabled()
+    {
+        using var key = Registry.CurrentUser.OpenSubKey(RunKey);
+        return key?.GetValue(_defaultValueName) != null;
+    }
+
+    /// <summary>
+    /// Detects whether group policy disables per‑user startup programs.
+    /// Mirrors <see cref="Windows.ApplicationModel.StartupTaskState.DisabledByPolicy"/>.
+    /// </summary>
+    public static bool IsDisabledByPolicy()
+    {
+        // User policy – HKCU
+        using (var keyUser = Registry.CurrentUser.OpenSubKey(PoliciesExplorerUser))
+        {
+            if ((int?)keyUser?.GetValue(DisableCurrentUserRun) == 1) return true;
+        }
+        // Machine policy – HKLM
+        using (var keyMachine = Registry.LocalMachine.OpenSubKey(PoliciesExplorerMachine))
+        {
+            if ((int?)keyMachine?.GetValue(DisableLocalMachineRun) == 1) return true;
+        }
+
+        // Some non‑desktop SKUs report DisabledByPolicy implicitly
+        return false;
+    }
+}
+
diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs
index 90a6ef4..c8efa2c 100644
--- a/App/ViewModels/SettingsViewModel.cs
+++ b/App/ViewModels/SettingsViewModel.cs
@@ -1,24 +1,22 @@
 using Coder.Desktop.App.Services;
 using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
 using Microsoft.Extensions.Logging;
 using Microsoft.UI.Dispatching;
 using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Controls;
 using System;
 
 namespace Coder.Desktop.App.ViewModels;
 
 public partial class SettingsViewModel : ObservableObject
 {
-    private Window? _window;
-    private DispatcherQueue? _dispatcherQueue;
-
     private readonly ILogger<SettingsViewModel> _logger;
 
     [ObservableProperty]
     public partial bool ConnectOnLaunch { get; set; } = false;
 
+    [ObservableProperty]
+    public partial bool StartOnLoginDisabled { get; set; } = false;
+
     [ObservableProperty]
     public partial bool StartOnLogin { get; set; } = false;
 
@@ -31,6 +29,10 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager set
         ConnectOnLaunch = _settingsManager.Read(SettingsManager.ConnectOnLaunchKey, false);
         StartOnLogin = _settingsManager.Read(SettingsManager.StartOnLoginKey, false);
 
+        // Various policies can disable the "Start on login" option.
+        // We disable the option in the UI if the policy is set.
+        StartOnLoginDisabled = StartupManager.IsDisabledByPolicy();
+
         this.PropertyChanged += (_, args) =>
         {
             if (args.PropertyName == nameof(ConnectOnLaunch))
@@ -41,7 +43,7 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager set
                 }
                 catch (Exception ex)
                 {
-                    Console.WriteLine($"Error saving {SettingsManager.ConnectOnLaunchKey} setting: {ex.Message}");
+                    _logger.LogError($"Error saving {SettingsManager.ConnectOnLaunchKey} setting: {ex.Message}");
                 }
             }
             else if (args.PropertyName == nameof(StartOnLogin))
@@ -49,20 +51,26 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager set
                 try
                 {
                     _settingsManager.Save(SettingsManager.StartOnLoginKey, StartOnLogin);
+                    if (StartOnLogin)
+                    {
+                        StartupManager.Enable();
+                    }
+                    else
+                    {
+                        StartupManager.Disable();
+                    }
                 }
                 catch (Exception ex)
                 {
-                    Console.WriteLine($"Error saving {SettingsManager.StartOnLoginKey} setting: {ex.Message}");
+                    _logger.LogError($"Error saving {SettingsManager.StartOnLoginKey} setting: {ex.Message}");
                 }
             }
         };
-    }
 
-    public void Initialize(Window window, DispatcherQueue dispatcherQueue)
-    {
-        _window = window;
-        _dispatcherQueue = dispatcherQueue;
-        if (!_dispatcherQueue.HasThreadAccess)
-            throw new InvalidOperationException("Initialize must be called from the UI thread");
+        // Ensure the StartOnLogin property matches the current startup state.
+        if (StartOnLogin != StartupManager.IsEnabled())
+        {
+            StartOnLogin = StartupManager.IsEnabled();
+        }
     }
 }
diff --git a/App/Views/Pages/SettingsMainPage.xaml b/App/Views/Pages/SettingsMainPage.xaml
index 3efaefb..610df15 100644
--- a/App/Views/Pages/SettingsMainPage.xaml
+++ b/App/Views/Pages/SettingsMainPage.xaml
@@ -39,14 +39,16 @@
 
                 <controls:SettingsCard Description="This setting controls whether the Coder Desktop app starts on Windows startup."
                                        Header="Start on login"
-                                       HeaderIcon="{ui:FontIcon Glyph=&#xE819;}">
+                                       HeaderIcon="{ui:FontIcon Glyph=&#xE819;}"
+                                       IsEnabled="{x:Bind ViewModel.StartOnLoginDisabled, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}">
                     <ToggleSwitch IsOn="{x:Bind ViewModel.StartOnLogin, Mode=TwoWay}" />
                 </controls:SettingsCard>
 
                 <TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Coder Connect" />
-                <controls:SettingsCard Description="This setting controls whether Coder Connect automatically starts with Coder Desktop  "
+                <controls:SettingsCard Description="This setting controls whether Coder Connect automatically starts with Coder Desktop. "
                                        Header="Connect on launch"
-                                       HeaderIcon="{ui:FontIcon Glyph=&#xE8AF;}">
+                                       HeaderIcon="{ui:FontIcon Glyph=&#xE8AF;}"
+                                       >
                     <ToggleSwitch IsOn="{x:Bind ViewModel.ConnectOnLaunch, Mode=TwoWay}" />
                 </controls:SettingsCard>
             </StackPanel>
diff --git a/App/Views/SettingsWindow.xaml.cs b/App/Views/SettingsWindow.xaml.cs
index 3fb71d3..7cc9661 100644
--- a/App/Views/SettingsWindow.xaml.cs
+++ b/App/Views/SettingsWindow.xaml.cs
@@ -18,7 +18,6 @@ public SettingsWindow(SettingsViewModel viewModel)
 
         SystemBackdrop = new DesktopAcrylicBackdrop();
 
-        ViewModel.Initialize(this, DispatcherQueue);
         RootFrame.Content = new SettingsMainPage(ViewModel);
 
         this.CenterOnScreen();
diff --git a/Tests.App/Services/SettingsManagerTest.cs b/Tests.App/Services/SettingsManagerTest.cs
index a8c3351..4de8d16 100644
--- a/Tests.App/Services/SettingsManagerTest.cs
+++ b/Tests.App/Services/SettingsManagerTest.cs
@@ -1,9 +1,4 @@
 using Coder.Desktop.App.Services;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
 
 namespace Coder.Desktop.Tests.App.Services
 {

From fcefec4d47c882cd475708dcc2d38838c6bdbd45 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 2 Jun 2025 18:30:32 +0200
Subject: [PATCH 04/18] settings manager moved from generic to explicit
 settings

---
 App/App.xaml.cs                           |  27 ++---
 App/Services/SettingsManager.cs           | 126 ++++++++++++++--------
 App/Services/StartupManager.cs            |  45 +++++---
 App/ViewModels/SettingsViewModel.cs       |  28 ++---
 Tests.App/Services/SettingsManagerTest.cs |  98 ++++++++---------
 5 files changed, 183 insertions(+), 141 deletions(-)

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 5ebe227..7b0d78e 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -92,7 +92,8 @@ public App()
         // FileSyncListMainPage is created by FileSyncListWindow.
         services.AddTransient<FileSyncListWindow>();
 
-        services.AddSingleton<ISettingsManager>(_ => new SettingsManager("CoderDesktop"));
+        services.AddSingleton<ISettingsManager, SettingsManager>();
+        services.AddSingleton<IStartupManager, StartupManager>();
         // SettingsWindow views and view models
         services.AddTransient<SettingsViewModel>();
         // SettingsMainPage is created by SettingsWindow.
@@ -159,10 +160,6 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
 
         // Start connecting to the manager in the background.
         var rpcController = _services.GetRequiredService<IRpcController>();
-        if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
-            // Passing in a CT with no cancellation is desired here, because
-            // the named pipe open will block until the pipe comes up.
-            _logger.LogDebug("reconnecting with VPN service");
         _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
         {
             if (t.Exception != null)
@@ -172,22 +169,18 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
                 Debug.WriteLine(t.Exception);
                 Debugger.Break();
 #endif
-            } else
+                return;
+            }
+            if (_settingsManager.ConnectOnLaunch)
             {
-                if (rpcController.GetState().VpnLifecycle == VpnLifecycle.Stopped)
+                _logger.LogInformation("RPC lifecycle is disconnected, but ConnectOnLaunch is enabled; attempting to connect");
+                _ = rpcController.StartVpn(CancellationToken.None).ContinueWith(connectTask =>
                 {
-                    if (_settingsManager.Read(SettingsManager.ConnectOnLaunchKey, false))
+                    if (connectTask.Exception != null)
                     {
-                        _logger.LogInformation("RPC lifecycle is disconnected, but ConnectOnLaunch is enabled; attempting to connect");
-                        _ = rpcController.StartVpn(CancellationToken.None).ContinueWith(connectTask =>
-                        {
-                            if (connectTask.Exception != null)
-                            {
-                                _logger.LogError(connectTask.Exception, "failed to connect on launch");
-                            }
-                        });
+                        _logger.LogError(connectTask.Exception, "failed to connect on launch");
                     }
-                }
+                });
             }
         });
 
diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index 83ace1d..2ff3955 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -4,70 +4,121 @@
 using System.Text.Json;
 
 namespace Coder.Desktop.App.Services;
+
 /// <summary>
-/// Generic persistence contract for simple key/value settings.
+/// Settings contract exposing properties for app settings.
 /// </summary>
 public interface ISettingsManager
 {
     /// <summary>
-    /// Saves <paramref name="value"/> under <paramref name="name"/> and returns the value.
+    /// Returns the value of the StartOnLogin setting. Returns <c>false</c> if the key is not found.
     /// </summary>
-    T Save<T>(string name, T value);
+    bool StartOnLogin { get; set; }
 
     /// <summary>
-    /// Reads the setting or returns <paramref name="defaultValue"/> when the key is missing.
+    /// Returns the value of the ConnectOnLaunch setting. Returns <c>false</c> if the key is not found.
     /// </summary>
-    T Read<T>(string name, T defaultValue);
+    bool ConnectOnLaunch { get; set; }
 }
+
 /// <summary>
-/// JSON‑file implementation that works in unpackaged Win32/WinUI 3 apps.
+/// Implemention of <see cref="ISettingsManager"/> that persists settings to a JSON file
+/// located in the user's local application data folder.
 /// </summary>
 public sealed class SettingsManager : ISettingsManager
 {
     private readonly string _settingsFilePath;
     private readonly string _fileName = "app-settings.json";
+    private readonly string _appName = "CoderDesktop";
     private readonly object _lock = new();
     private Dictionary<string, JsonElement> _cache;
 
-    public static readonly string ConnectOnLaunchKey = "ConnectOnLaunch";
-    public static readonly string StartOnLoginKey = "StartOnLogin";
+    public const string ConnectOnLaunchKey = "ConnectOnLaunch";
+    public const string StartOnLoginKey = "StartOnLogin";
 
-    /// <param name="appName">
-    /// Sub‑folder under %LOCALAPPDATA% (e.g. "CoderDesktop").
-    /// If <c>null</c> the folder name defaults to the executable name.
+    public bool StartOnLogin
+    {
+        get
+        {
+            return Read(StartOnLoginKey, false);
+        }
+        set
+        {
+            Save(StartOnLoginKey, value);
+        }
+    }
+
+    public bool ConnectOnLaunch
+    {
+        get
+        {
+            return Read(ConnectOnLaunchKey, false);
+        }
+        set
+        {
+            Save(ConnectOnLaunchKey, value);
+        }
+    }
+
+    /// <param name="settingsFilePath">
     /// For unit‑tests you can pass an absolute path that already exists.
+    /// Otherwise the settings file will be created in the user's local application data folder.
     /// </param>
-    public SettingsManager(string? appName = null)
+    public SettingsManager(string? settingsFilePath = null)
     {
-        // Allow unit‑tests to inject a fully‑qualified path.
-        if (appName is not null && Path.IsPathRooted(appName))
+        if (settingsFilePath is null)
         {
-            _settingsFilePath = Path.Combine(appName, _fileName);
-            Directory.CreateDirectory(Path.GetDirectoryName(_settingsFilePath)!);
+            settingsFilePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
         }
-        else
+        else if (!Path.IsPathRooted(settingsFilePath))
         {
-            string folder = Path.Combine(
-                Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
-                appName ?? AppDomain.CurrentDomain.FriendlyName.ToLowerInvariant());
-            Directory.CreateDirectory(folder);
-            _settingsFilePath = Path.Combine(folder, _fileName);
+            throw new ArgumentException("settingsFilePath must be an absolute path if provided", nameof(settingsFilePath));
+        }
+
+        string folder = Path.Combine(
+                settingsFilePath,
+                _appName);
+
+        Directory.CreateDirectory(folder);
+        _settingsFilePath = Path.Combine(folder, _fileName);
+
+        if(!File.Exists(_settingsFilePath))
+        {
+            // Create the settings file if it doesn't exist
+            string emptyJson = JsonSerializer.Serialize(new { });
+            File.WriteAllText(_settingsFilePath, emptyJson);
         }
 
         _cache = Load();
     }
 
-    public T Save<T>(string name, T value)
+    private void Save(string name, bool value)
     {
         lock (_lock)
         {
-            _cache[name] = JsonSerializer.SerializeToElement(value);
-            Persist();
-            return value;
+            try
+            {
+                // Ensure cache is loaded before saving
+                using var fs = new FileStream(_settingsFilePath,
+                    FileMode.OpenOrCreate,
+                    FileAccess.ReadWrite,
+                    FileShare.None);
+                
+                var currentCache = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(fs) ?? new();
+                _cache = currentCache;
+                _cache[name] = JsonSerializer.SerializeToElement(value);
+                fs.Position = 0; // Reset stream position to the beginning before writing to override the file
+                var options = new JsonSerializerOptions { WriteIndented = true};
+                JsonSerializer.Serialize(fs, _cache, options);
+            }
+            catch
+            {
+                throw new InvalidOperationException($"Failed to persist settings to {_settingsFilePath}. The file may be corrupted, malformed or locked.");
+            }
         }
     }
 
-    public T Read<T>(string name, T defaultValue)
+    private bool Read(string name, bool defaultValue)
     {
         lock (_lock)
         {
@@ -75,39 +126,28 @@ public T Read<T>(string name, T defaultValue)
             {
                 try
                 {
-                    return element.Deserialize<T>() ?? defaultValue;
+                    return element.Deserialize<bool?>() ?? defaultValue;
                 }
                 catch
                 {
-                    // Malformed value – fall back.
+                    // malformed value – return default value
                     return defaultValue;
                 }
             }
-            return defaultValue; // key not found – return caller‑supplied default (false etc.)
+            return defaultValue; // key not found – return default value
         }
     }
 
     private Dictionary<string, JsonElement> Load()
     {
-        if (!File.Exists(_settingsFilePath))
-            return new();
-
         try
         {
             using var fs = File.OpenRead(_settingsFilePath);
             return JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(fs) ?? new();
         }
-        catch
+        catch (Exception ex)
         {
-            // Corrupted file – start fresh.
-            return new();
+            throw new InvalidOperationException($"Failed to load settings from {_settingsFilePath}. The file may be corrupted or malformed. Exception: {ex.Message}");
         }
     }
-
-    private void Persist()
-    {
-        using var fs = File.Create(_settingsFilePath);
-        var options = new JsonSerializerOptions { WriteIndented = true };
-        JsonSerializer.Serialize(fs, _cache, options);
-    }
 }
diff --git a/App/Services/StartupManager.cs b/App/Services/StartupManager.cs
index b38c17d..2ab7631 100644
--- a/App/Services/StartupManager.cs
+++ b/App/Services/StartupManager.cs
@@ -4,7 +4,30 @@
 using System.Security;
 
 namespace Coder.Desktop.App.Services;
-public static class StartupManager
+
+public interface IStartupManager
+{
+    /// <summary>
+    /// Adds the current executable to the per‑user Run key. Returns <c>true</c> if successful.
+    /// Fails (returns <c>false</c>) when blocked by policy or lack of permissions.
+    /// </summary>
+    bool Enable();
+    /// <summary>
+    /// Removes the value from the Run key (no-op if missing).
+    /// </summary>
+    void Disable();
+    /// <summary>
+    /// Checks whether the value exists in the Run key.
+    /// </summary>
+    bool IsEnabled();
+    /// <summary>
+    /// Detects whether group policy disables per‑user startup programs.
+    /// Mirrors <see cref="Windows.ApplicationModel.StartupTaskState.DisabledByPolicy"/>.
+    /// </summary>
+    bool IsDisabledByPolicy();
+}
+
+public class StartupManager : IStartupManager
 {
     private const string RunKey = @"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
     private const string PoliciesExplorerUser = @"Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer";
@@ -14,11 +37,7 @@ public static class StartupManager
 
     private const string _defaultValueName = "CoderDesktopApp";
 
-    /// <summary>
-    /// Adds the current executable to the per‑user Run key. Returns <c>true</c> if successful.
-    /// Fails (returns <c>false</c>) when blocked by policy or lack of permissions.
-    /// </summary>
-    public static bool Enable()
+    public bool Enable()
     {
         if (IsDisabledByPolicy())
             return false;
@@ -35,25 +54,19 @@ public static bool Enable()
         catch (SecurityException) { return false; }
     }
 
-    /// <summary>Removes the value from the Run key (no-op if missing).</summary>
-    public static void Disable()
+    public void Disable()
     {
         using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true);
         key?.DeleteValue(_defaultValueName, throwOnMissingValue: false);
     }
 
-    /// <summary>Checks whether the value exists in the Run key.</summary>
-    public static bool IsEnabled()
+    public bool IsEnabled()
     {
         using var key = Registry.CurrentUser.OpenSubKey(RunKey);
         return key?.GetValue(_defaultValueName) != null;
     }
 
-    /// <summary>
-    /// Detects whether group policy disables per‑user startup programs.
-    /// Mirrors <see cref="Windows.ApplicationModel.StartupTaskState.DisabledByPolicy"/>.
-    /// </summary>
-    public static bool IsDisabledByPolicy()
+    public bool IsDisabledByPolicy()
     {
         // User policy – HKCU
         using (var keyUser = Registry.CurrentUser.OpenSubKey(PoliciesExplorerUser))
@@ -65,8 +78,6 @@ public static bool IsDisabledByPolicy()
         {
             if ((int?)keyMachine?.GetValue(DisableLocalMachineRun) == 1) return true;
         }
-
-        // Some non‑desktop SKUs report DisabledByPolicy implicitly
         return false;
     }
 }
diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs
index c8efa2c..f49b302 100644
--- a/App/ViewModels/SettingsViewModel.cs
+++ b/App/ViewModels/SettingsViewModel.cs
@@ -12,26 +12,28 @@ public partial class SettingsViewModel : ObservableObject
     private readonly ILogger<SettingsViewModel> _logger;
 
     [ObservableProperty]
-    public partial bool ConnectOnLaunch { get; set; } = false;
+    public partial bool ConnectOnLaunch { get; set; }
 
     [ObservableProperty]
-    public partial bool StartOnLoginDisabled { get; set; } = false;
+    public partial bool StartOnLoginDisabled { get; set; }
 
     [ObservableProperty]
-    public partial bool StartOnLogin { get; set; } = false;
+    public partial bool StartOnLogin { get; set; }
 
     private ISettingsManager _settingsManager;
+    private IStartupManager _startupManager;
 
-    public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager settingsManager)
+    public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager settingsManager, IStartupManager startupManager)
     {
         _settingsManager = settingsManager;
+        _startupManager = startupManager;
         _logger = logger;
-        ConnectOnLaunch = _settingsManager.Read(SettingsManager.ConnectOnLaunchKey, false);
-        StartOnLogin = _settingsManager.Read(SettingsManager.StartOnLoginKey, false);
+        ConnectOnLaunch = _settingsManager.ConnectOnLaunch;
+        StartOnLogin = _settingsManager.StartOnLogin;
 
         // Various policies can disable the "Start on login" option.
         // We disable the option in the UI if the policy is set.
-        StartOnLoginDisabled = StartupManager.IsDisabledByPolicy();
+        StartOnLoginDisabled = _startupManager.IsDisabledByPolicy();
 
         this.PropertyChanged += (_, args) =>
         {
@@ -39,7 +41,7 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager set
             {
                 try
                 {
-                    _settingsManager.Save(SettingsManager.ConnectOnLaunchKey, ConnectOnLaunch);
+                    _settingsManager.ConnectOnLaunch = ConnectOnLaunch;
                 }
                 catch (Exception ex)
                 {
@@ -50,14 +52,14 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager set
             {
                 try
                 {
-                    _settingsManager.Save(SettingsManager.StartOnLoginKey, StartOnLogin);
+                    _settingsManager.StartOnLogin = StartOnLogin;
                     if (StartOnLogin)
                     {
-                        StartupManager.Enable();
+                        _startupManager.Enable();
                     }
                     else
                     {
-                        StartupManager.Disable();
+                        _startupManager.Disable();
                     }
                 }
                 catch (Exception ex)
@@ -68,9 +70,9 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager set
         };
 
         // Ensure the StartOnLogin property matches the current startup state.
-        if (StartOnLogin != StartupManager.IsEnabled())
+        if (StartOnLogin != _startupManager.IsEnabled())
         {
-            StartOnLogin = StartupManager.IsEnabled();
+            StartOnLogin = _startupManager.IsEnabled();
         }
     }
 }
diff --git a/Tests.App/Services/SettingsManagerTest.cs b/Tests.App/Services/SettingsManagerTest.cs
index 4de8d16..0804c0b 100644
--- a/Tests.App/Services/SettingsManagerTest.cs
+++ b/Tests.App/Services/SettingsManagerTest.cs
@@ -1,57 +1,53 @@
 using Coder.Desktop.App.Services;
 
-namespace Coder.Desktop.Tests.App.Services
+namespace Coder.Desktop.Tests.App.Services;
+[TestFixture]
+public sealed class SettingsManagerTests
 {
-    [TestFixture]
-    public sealed class SettingsManagerTests
+    private string _tempDir = string.Empty;
+    private SettingsManager _sut = null!;
+
+    [SetUp]
+    public void SetUp()
     {
-        private string _tempDir = string.Empty;
-        private SettingsManager _sut = null!;
-
-        [SetUp]
-        public void SetUp()
-        {
-            _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
-            Directory.CreateDirectory(_tempDir);
-            _sut = new SettingsManager(_tempDir); // inject isolated path
-        }
-
-        [TearDown]
-        public void TearDown()
-        {
-            try { Directory.Delete(_tempDir, true); } catch { /* ignore */ }
-        }
-
-        [Test]
-        public void Save_ReturnsValue_AndPersists()
-        {
-            int expected = 42;
-            int actual = _sut.Save("Answer", expected);
-
-            Assert.That(actual, Is.EqualTo(expected));
-            Assert.That(_sut.Read("Answer", -1), Is.EqualTo(expected));
-        }
-
-        [Test]
-        public void Read_MissingKey_ReturnsDefault()
-        {
-            bool result = _sut.Read("DoesNotExist", defaultValue: false);
-            Assert.That(result, Is.False);
-        }
-
-        [Test]
-        public void Read_AfterReload_ReturnsPreviouslySavedValue()
-        {
-            const string key = "Greeting";
-            const string value = "Hello";
-
-            _sut.Save(key, value);
-
-            // Create new instance to force file reload.
-            var newManager = new SettingsManager(_tempDir);
-            string persisted = newManager.Read(key, string.Empty);
-
-            Assert.That(persisted, Is.EqualTo(value));
-        }
+        _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+        Directory.CreateDirectory(_tempDir);
+        _sut = new SettingsManager(_tempDir); // inject isolated path
+    }
+
+    [TearDown]
+    public void TearDown()
+    {
+        try { Directory.Delete(_tempDir, true); } catch { /* ignore */ }
+    }
+
+    [Test]
+    public void Save_Persists()
+    {
+        bool expected = true;
+        _sut.StartOnLogin = expected;
+
+        Assert.That(_sut.StartOnLogin, Is.EqualTo(expected));
+    }
+
+    [Test]
+    public void Read_MissingKey_ReturnsDefault()
+    {
+        bool result = _sut.ConnectOnLaunch; // default is false
+        Assert.That(result, Is.False);
+    }
+
+    [Test]
+    public void Read_AfterReload_ReturnsPreviouslySavedValue()
+    {
+        const bool value = true;
+
+        _sut.ConnectOnLaunch = value;
+
+        // Create new instance to force file reload.
+        var newManager = new SettingsManager(_tempDir);
+        bool persisted = newManager.ConnectOnLaunch;
+
+        Assert.That(persisted, Is.EqualTo(value));
     }
 }

From 39ff83c93b546484f17fe70fcb32f5c52bb0753c Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 2 Jun 2025 18:37:03 +0200
Subject: [PATCH 05/18] added comments

---
 App/Services/SettingsManager.cs | 17 +++++++++++------
 1 file changed, 11 insertions(+), 6 deletions(-)

diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index 2ff3955..b29d427 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -98,18 +98,23 @@ private void Save(string name, bool value)
         {
             try
             {
-                // Ensure cache is loaded before saving
+                // We lock the file for the entire operation to prevent concurrent writes   
                 using var fs = new FileStream(_settingsFilePath,
                     FileMode.OpenOrCreate,
                     FileAccess.ReadWrite,
                     FileShare.None);
-                
-                var currentCache = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(fs) ?? new();
+
+                // Ensure cache is loaded before saving 
+                var currentCache = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(fs) ?? [];
                 _cache = currentCache;
                 _cache[name] = JsonSerializer.SerializeToElement(value);
-                fs.Position = 0; // Reset stream position to the beginning before writing to override the file
-                var options = new JsonSerializerOptions { WriteIndented = true};
-                JsonSerializer.Serialize(fs, _cache, options);
+                fs.Position = 0; // Reset stream position to the beginning before writing
+
+                JsonSerializer.Serialize(fs, _cache, new JsonSerializerOptions { WriteIndented = true });
+
+                // This ensures the file is truncated to the new length
+                // if the new content is shorter than the old content
+                fs.SetLength(fs.Position);
             }
             catch
             {

From 07ec7257849f28d97e4e151730837bae577b0808 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 2 Jun 2025 18:57:39 +0200
Subject: [PATCH 06/18] PR review + fmt

---
 App/Services/SettingsManager.cs          |  2 +-
 App/ViewModels/SettingsViewModel.cs      | 66 ++++++++++++------------
 App/Views/Pages/SettingsMainPage.xaml.cs |  2 +-
 3 files changed, 36 insertions(+), 34 deletions(-)

diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index b29d427..9de0d4f 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -82,7 +82,7 @@ public SettingsManager(string? settingsFilePath = null)
         Directory.CreateDirectory(folder);
         _settingsFilePath = Path.Combine(folder, _fileName);
 
-        if(!File.Exists(_settingsFilePath))
+        if (!File.Exists(_settingsFilePath))
         {
             // Create the settings file if it doesn't exist
             string emptyJson = JsonSerializer.Serialize(new { });
diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs
index f49b302..f49d159 100644
--- a/App/ViewModels/SettingsViewModel.cs
+++ b/App/ViewModels/SettingsViewModel.cs
@@ -35,44 +35,46 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager set
         // We disable the option in the UI if the policy is set.
         StartOnLoginDisabled = _startupManager.IsDisabledByPolicy();
 
-        this.PropertyChanged += (_, args) =>
+        // Ensure the StartOnLogin property matches the current startup state.
+        if (StartOnLogin != _startupManager.IsEnabled())
+        {
+            StartOnLogin = _startupManager.IsEnabled();
+        }
+    }
+
+    partial void OnConnectOnLaunchChanged(bool oldValue, bool newValue)
+    {
+        if (oldValue == newValue)
+            return;
+        try
+        {
+            _settingsManager.ConnectOnLaunch = ConnectOnLaunch;
+        }
+        catch (Exception ex)
         {
-            if (args.PropertyName == nameof(ConnectOnLaunch))
+            _logger.LogError($"Error saving {SettingsManager.ConnectOnLaunchKey} setting: {ex.Message}");
+        }
+    }
+
+    partial void OnStartOnLoginChanged(bool oldValue, bool newValue)
+    {
+        if (oldValue == newValue)
+            return;
+        try
+        {
+            _settingsManager.StartOnLogin = StartOnLogin;
+            if (StartOnLogin)
             {
-                try
-                {
-                    _settingsManager.ConnectOnLaunch = ConnectOnLaunch;
-                }
-                catch (Exception ex)
-                {
-                    _logger.LogError($"Error saving {SettingsManager.ConnectOnLaunchKey} setting: {ex.Message}");
-                }
+                _startupManager.Enable();
             }
-            else if (args.PropertyName == nameof(StartOnLogin))
+            else
             {
-                try
-                {
-                    _settingsManager.StartOnLogin = StartOnLogin;
-                    if (StartOnLogin)
-                    {
-                        _startupManager.Enable();
-                    }
-                    else
-                    {
-                        _startupManager.Disable();
-                    }
-                }
-                catch (Exception ex)
-                {
-                    _logger.LogError($"Error saving {SettingsManager.StartOnLoginKey} setting: {ex.Message}");
-                }
+                _startupManager.Disable();
             }
-        };
-
-        // Ensure the StartOnLogin property matches the current startup state.
-        if (StartOnLogin != _startupManager.IsEnabled())
+        }
+        catch (Exception ex)
         {
-            StartOnLogin = _startupManager.IsEnabled();
+            _logger.LogError($"Error saving {SettingsManager.StartOnLoginKey} setting: {ex.Message}");
         }
     }
 }
diff --git a/App/Views/Pages/SettingsMainPage.xaml.cs b/App/Views/Pages/SettingsMainPage.xaml.cs
index 5fd9c3c..f2494b1 100644
--- a/App/Views/Pages/SettingsMainPage.xaml.cs
+++ b/App/Views/Pages/SettingsMainPage.xaml.cs
@@ -9,7 +9,7 @@ public sealed partial class SettingsMainPage : Page
 
     public SettingsMainPage(SettingsViewModel viewModel)
     {
-        ViewModel = viewModel; // already initialized
+        ViewModel = viewModel;
         InitializeComponent();
     }
 }

From c21072fa19e531c878fd37b60b395ef2349f6fce Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Tue, 3 Jun 2025 13:27:49 +0200
Subject: [PATCH 07/18] created Settings class to handle versioning

---
 App/Services/SettingsManager.cs       | 46 +++++++++++++++++++++------
 App/Views/Pages/SettingsMainPage.xaml |  1 +
 2 files changed, 37 insertions(+), 10 deletions(-)

diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index 9de0d4f..805fb6d 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -2,6 +2,7 @@
 using System.Collections.Generic;
 using System.IO;
 using System.Text.Json;
+using System.Text.Json.Serialization;
 
 namespace Coder.Desktop.App.Services;
 
@@ -28,10 +29,10 @@ public interface ISettingsManager
 public sealed class SettingsManager : ISettingsManager
 {
     private readonly string _settingsFilePath;
+    private Settings _settings;
     private readonly string _fileName = "app-settings.json";
     private readonly string _appName = "CoderDesktop";
     private readonly object _lock = new();
-    private Dictionary<string, JsonElement> _cache;
 
     public const string ConnectOnLaunchKey = "ConnectOnLaunch";
     public const string StartOnLoginKey = "StartOnLogin";
@@ -87,9 +88,12 @@ public SettingsManager(string? settingsFilePath = null)
             // Create the settings file if it doesn't exist
             string emptyJson = JsonSerializer.Serialize(new { });
             File.WriteAllText(_settingsFilePath, emptyJson);
+            _settings = new();
+        }
+        else
+        {
+            _settings = Load();
         }
-
-        _cache = Load();
     }
 
     private void Save(string name, bool value)
@@ -105,12 +109,12 @@ private void Save(string name, bool value)
                     FileShare.None);
 
                 // Ensure cache is loaded before saving 
-                var currentCache = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(fs) ?? [];
-                _cache = currentCache;
-                _cache[name] = JsonSerializer.SerializeToElement(value);
+                var freshCache = JsonSerializer.Deserialize<Settings>(fs) ?? new();
+                _settings = freshCache;
+                _settings.Options[name] = JsonSerializer.SerializeToElement(value);
                 fs.Position = 0; // Reset stream position to the beginning before writing
 
-                JsonSerializer.Serialize(fs, _cache, new JsonSerializerOptions { WriteIndented = true });
+                JsonSerializer.Serialize(fs, _settings, new JsonSerializerOptions { WriteIndented = true });
 
                 // This ensures the file is truncated to the new length
                 // if the new content is shorter than the old content
@@ -127,7 +131,7 @@ private bool Read(string name, bool defaultValue)
     {
         lock (_lock)
         {
-            if (_cache.TryGetValue(name, out var element))
+            if (_settings.Options.TryGetValue(name, out var element))
             {
                 try
                 {
@@ -143,16 +147,38 @@ private bool Read(string name, bool defaultValue)
         }
     }
 
-    private Dictionary<string, JsonElement> Load()
+    private Settings Load()
     {
         try
         {
             using var fs = File.OpenRead(_settingsFilePath);
-            return JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(fs) ?? new();
+            return JsonSerializer.Deserialize<Settings>(fs) ?? new(null, new Dictionary<string, JsonElement>());
         }
         catch (Exception ex)
         {
             throw new InvalidOperationException($"Failed to load settings from {_settingsFilePath}. The file may be corrupted or malformed. Exception: {ex.Message}");
         }
     }
+
+    [JsonSerializable(typeof(Settings))]
+    private class Settings
+    {
+        /// <summary>
+        /// User settings version. Increment this when the settings schema changes.
+        /// In future iterations we will be able to handle migrations when the user has
+        /// an older version.
+        /// </summary>
+        public int Version { get; set; } = 1;
+        public Dictionary<string, JsonElement> Options { get; set; }
+        public Settings()
+        {
+            Options = new Dictionary<string, JsonElement>();
+        }
+
+        public Settings(int? version, Dictionary<string, JsonElement> options)
+        {
+            Version = version ?? Version;
+            Options = options;
+        }
+    }
 }
diff --git a/App/Views/Pages/SettingsMainPage.xaml b/App/Views/Pages/SettingsMainPage.xaml
index 610df15..a8a9f66 100644
--- a/App/Views/Pages/SettingsMainPage.xaml
+++ b/App/Views/Pages/SettingsMainPage.xaml
@@ -51,6 +51,7 @@
                                        >
                     <ToggleSwitch IsOn="{x:Bind ViewModel.ConnectOnLaunch, Mode=TwoWay}" />
                 </controls:SettingsCard>
+
             </StackPanel>
         </Grid>
     </ScrollViewer>

From bad53201174090dc88abad23aeece86a277d6208 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Tue, 3 Jun 2025 15:47:24 +0200
Subject: [PATCH 08/18] async handling of dependency load in app

---
 App/App.xaml.cs | 103 +++++++++++++++++++++++++-----------------------
 1 file changed, 54 insertions(+), 49 deletions(-)

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 7b0d78e..2fdf431 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -46,6 +46,8 @@ public partial class App : Application
 
     private readonly ISettingsManager _settingsManager;
 
+    private readonly IHostApplicationLifetime _appLifetime;
+
     public App()
     {
         var builder = Host.CreateApplicationBuilder();
@@ -119,6 +121,7 @@ public App()
         _logger = (ILogger<App>)_services.GetService(typeof(ILogger<App>))!;
         _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!;
         _settingsManager = (ISettingsManager)_services.GetService(typeof(ISettingsManager))!;
+        _appLifetime = (IHostApplicationLifetime)_services.GetRequiredService<IHostApplicationLifetime>();
 
         InitializeComponent();
     }
@@ -140,71 +143,73 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
     {
         _logger.LogInformation("new instance launched");
 
-        // Load the credentials in the background.
-        var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+        _ = InitializeServicesAsync(_appLifetime.ApplicationStopping);
+
+        // Prevent the TrayWindow from closing, just hide it.
+        var trayWindow = _services.GetRequiredService<TrayWindow>();
+        trayWindow.Closed += (_, closedArgs) =>
+        {
+            if (!_handleWindowClosed) return;
+            closedArgs.Handled = true;
+            trayWindow.AppWindow.Hide();
+        };
+    }
+
+    /// <summary>
+    /// Loads stored VPN credentials, reconnects the RPC controller,
+    /// and (optionally) starts the VPN tunnel on application launch.
+    /// </summary>
+    private async Task InitializeServicesAsync(CancellationToken cancellationToken = default)
+    {
         var credentialManager = _services.GetRequiredService<ICredentialManager>();
-        credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t =>
+        var rpcController = _services.GetRequiredService<IRpcController>();
+
+        using var credsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+        credsCts.CancelAfter(TimeSpan.FromSeconds(15));
+
+        Task loadCredsTask = credentialManager.LoadCredentials(credsCts.Token);
+        Task reconnectTask = rpcController.Reconnect(cancellationToken);
+
+        try
         {
-            if (t.Exception != null)
-            {
-                _logger.LogError(t.Exception, "failed to load credentials");
-#if DEBUG
-                Debug.WriteLine(t.Exception);
-                Debugger.Break();
-#endif
-            }
+            await Task.WhenAll(loadCredsTask, reconnectTask);
+        }
+        catch (Exception)
+        {
+            if (loadCredsTask.IsFaulted)
+                _logger.LogError(loadCredsTask.Exception!.GetBaseException(),
+                                 "Failed to load credentials");
 
-            credentialManagerCts.Dispose();
-        });
+            if (reconnectTask.IsFaulted)
+                _logger.LogError(reconnectTask.Exception!.GetBaseException(),
+                                 "Failed to connect to VPN service");
 
+            return;
+        }
 
-        // Start connecting to the manager in the background.
-        var rpcController = _services.GetRequiredService<IRpcController>();
-        _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
+        if (_settingsManager.ConnectOnLaunch)
         {
-            if (t.Exception != null)
+            try
             {
-                _logger.LogError(t.Exception, "failed to connect to VPN service");
-#if DEBUG
-                Debug.WriteLine(t.Exception);
-                Debugger.Break();
-#endif
-                return;
+                await rpcController.StartVpn(cancellationToken);
             }
-            if (_settingsManager.ConnectOnLaunch)
+            catch (Exception ex)
             {
-                _logger.LogInformation("RPC lifecycle is disconnected, but ConnectOnLaunch is enabled; attempting to connect");
-                _ = rpcController.StartVpn(CancellationToken.None).ContinueWith(connectTask =>
-                {
-                    if (connectTask.Exception != null)
-                    {
-                        _logger.LogError(connectTask.Exception, "failed to connect on launch");
-                    }
-                });
+                _logger.LogError(ex, "Failed to connect on launch");
             }
-        });
+        }
 
         // Initialize file sync.
         var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
         var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
-        _ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t =>
+        try
         {
-            if (t.IsCanceled || t.Exception != null)
-            {
-                _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled);
-            }
-
-            syncSessionCts.Dispose();
-        }, CancellationToken.None);
-
-        // Prevent the TrayWindow from closing, just hide it.
-        var trayWindow = _services.GetRequiredService<TrayWindow>();
-        trayWindow.Closed += (_, closedArgs) =>
+            await syncSessionController.RefreshState(syncSessionCts.Token);
+        }
+        catch(Exception ex)
         {
-            if (!_handleWindowClosed) return;
-            closedArgs.Handled = true;
-            trayWindow.AppWindow.Hide();
-        };
+            _logger.LogError($"Failed to refresh sync session state {ex.Message}", ex);
+        }
     }
 
     public void OnActivated(object? sender, AppActivationArguments args)

From 065eda18e3eaf3769a35170ffe43068b173b77e5 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Tue, 3 Jun 2025 17:02:05 +0200
Subject: [PATCH 09/18] fmt fix

---
 App/App.xaml.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 2fdf431..db224b7 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -206,7 +206,7 @@ private async Task InitializeServicesAsync(CancellationToken cancellationToken =
         {
             await syncSessionController.RefreshState(syncSessionCts.Token);
         }
-        catch(Exception ex)
+        catch (Exception ex)
         {
             _logger.LogError($"Failed to refresh sync session state {ex.Message}", ex);
         }

From fc426a85f4b6924151c8d14f0356e9a7e305f9bf Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Wed, 4 Jun 2025 14:01:06 +0200
Subject: [PATCH 10/18] JsonContext improvements and usage within Settings

---
 App/Services/SettingsManager.cs | 53 ++++++++++++++++++---------------
 1 file changed, 29 insertions(+), 24 deletions(-)

diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index 805fb6d..13e7db4 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -76,7 +76,7 @@ public SettingsManager(string? settingsFilePath = null)
             throw new ArgumentException("settingsFilePath must be an absolute path if provided", nameof(settingsFilePath));
         }
 
-        string folder = Path.Combine(
+        var folder = Path.Combine(
                 settingsFilePath,
                 _appName);
 
@@ -86,9 +86,8 @@ public SettingsManager(string? settingsFilePath = null)
         if (!File.Exists(_settingsFilePath))
         {
             // Create the settings file if it doesn't exist
-            string emptyJson = JsonSerializer.Serialize(new { });
-            File.WriteAllText(_settingsFilePath, emptyJson);
             _settings = new();
+            File.WriteAllText(_settingsFilePath, JsonSerializer.Serialize(_settings, SettingsJsonContext.Default.Settings));
         }
         else
         {
@@ -109,12 +108,12 @@ private void Save(string name, bool value)
                     FileShare.None);
 
                 // Ensure cache is loaded before saving 
-                var freshCache = JsonSerializer.Deserialize<Settings>(fs) ?? new();
+                var freshCache = JsonSerializer.Deserialize(fs, SettingsJsonContext.Default.Settings) ?? new();
                 _settings = freshCache;
                 _settings.Options[name] = JsonSerializer.SerializeToElement(value);
                 fs.Position = 0; // Reset stream position to the beginning before writing
 
-                JsonSerializer.Serialize(fs, _settings, new JsonSerializerOptions { WriteIndented = true });
+                JsonSerializer.Serialize(fs, _settings, SettingsJsonContext.Default.Settings);
 
                 // This ensures the file is truncated to the new length
                 // if the new content is shorter than the old content
@@ -152,33 +151,39 @@ private Settings Load()
         try
         {
             using var fs = File.OpenRead(_settingsFilePath);
-            return JsonSerializer.Deserialize<Settings>(fs) ?? new(null, new Dictionary<string, JsonElement>());
+            return JsonSerializer.Deserialize(fs, SettingsJsonContext.Default.Settings) ?? new();
         }
         catch (Exception ex)
         {
             throw new InvalidOperationException($"Failed to load settings from {_settingsFilePath}. The file may be corrupted or malformed. Exception: {ex.Message}");
         }
     }
+}
+
+public class Settings
+{
+    /// <summary>
+    /// User settings version. Increment this when the settings schema changes.
+    /// In future iterations we will be able to handle migrations when the user has
+    /// an older version.
+    /// </summary>
+    public int Version { get; set; }
+    public Dictionary<string, JsonElement> Options { get; set; }
 
-    [JsonSerializable(typeof(Settings))]
-    private class Settings
+    private const int VERSION = 1; // Default version for backward compatibility
+    public Settings()
     {
-        /// <summary>
-        /// User settings version. Increment this when the settings schema changes.
-        /// In future iterations we will be able to handle migrations when the user has
-        /// an older version.
-        /// </summary>
-        public int Version { get; set; } = 1;
-        public Dictionary<string, JsonElement> Options { get; set; }
-        public Settings()
-        {
-            Options = new Dictionary<string, JsonElement>();
-        }
+        Version = VERSION;
+        Options = [];
+    }
 
-        public Settings(int? version, Dictionary<string, JsonElement> options)
-        {
-            Version = version ?? Version;
-            Options = options;
-        }
+    public Settings(int? version, Dictionary<string, JsonElement> options)
+    {
+        Version = version ?? VERSION;
+        Options = options;
     }
 }
+
+[JsonSerializable(typeof(Settings))]
+[JsonSourceGenerationOptions(WriteIndented = true)]
+public partial class SettingsJsonContext : JsonSerializerContext;

From fa4fbd8dbea7494434f9a68a9fa01565ba86225a Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 6 Jun 2025 16:25:40 +0200
Subject: [PATCH 11/18] implemented a generic settings manager

---
 App/App.xaml.cs                           |  32 ++--
 App/Services/SettingsManager.cs           | 216 ++++++++++++----------
 App/ViewModels/SettingsViewModel.cs       |  26 ++-
 Tests.App/Services/SettingsManagerTest.cs |  33 ++--
 4 files changed, 164 insertions(+), 143 deletions(-)

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index db224b7..68d1208 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -44,7 +44,7 @@ public partial class App : Application
     private readonly ILogger<App> _logger;
     private readonly IUriHandler _uriHandler;
 
-    private readonly ISettingsManager _settingsManager;
+    private readonly ISettingsManager<CoderConnectSettings> _settingsManager;
 
     private readonly IHostApplicationLifetime _appLifetime;
 
@@ -94,7 +94,7 @@ public App()
         // FileSyncListMainPage is created by FileSyncListWindow.
         services.AddTransient<FileSyncListWindow>();
 
-        services.AddSingleton<ISettingsManager, SettingsManager>();
+        services.AddSingleton<ISettingsManager<CoderConnectSettings>, SettingsManager<CoderConnectSettings>>();
         services.AddSingleton<IStartupManager, StartupManager>();
         // SettingsWindow views and view models
         services.AddTransient<SettingsViewModel>();
@@ -118,10 +118,10 @@ public App()
         services.AddTransient<TrayWindow>();
 
         _services = services.BuildServiceProvider();
-        _logger = (ILogger<App>)_services.GetService(typeof(ILogger<App>))!;
-        _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!;
-        _settingsManager = (ISettingsManager)_services.GetService(typeof(ISettingsManager))!;
-        _appLifetime = (IHostApplicationLifetime)_services.GetRequiredService<IHostApplicationLifetime>();
+        _logger = _services.GetRequiredService<ILogger<App>>();
+        _uriHandler = _services.GetRequiredService<IUriHandler>();
+        _settingsManager = _services.GetRequiredService<ISettingsManager<CoderConnectSettings>>();
+        _appLifetime = _services.GetRequiredService<IHostApplicationLifetime>();
 
         InitializeComponent();
     }
@@ -167,12 +167,15 @@ private async Task InitializeServicesAsync(CancellationToken cancellationToken =
         using var credsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
         credsCts.CancelAfter(TimeSpan.FromSeconds(15));
 
-        Task loadCredsTask = credentialManager.LoadCredentials(credsCts.Token);
-        Task reconnectTask = rpcController.Reconnect(cancellationToken);
+        var loadCredsTask = credentialManager.LoadCredentials(credsCts.Token);
+        var reconnectTask = rpcController.Reconnect(cancellationToken);
+        var settingsTask = _settingsManager.Read(cancellationToken);
+
+        var dependenciesLoaded = true;
 
         try
         {
-            await Task.WhenAll(loadCredsTask, reconnectTask);
+            await Task.WhenAll(loadCredsTask, reconnectTask, settingsTask);
         }
         catch (Exception)
         {
@@ -184,10 +187,17 @@ private async Task InitializeServicesAsync(CancellationToken cancellationToken =
                 _logger.LogError(reconnectTask.Exception!.GetBaseException(),
                                  "Failed to connect to VPN service");
 
-            return;
+            if (settingsTask.IsFaulted)
+                _logger.LogError(settingsTask.Exception!.GetBaseException(),
+                                 "Failed to fetch Coder Connect settings");
+
+            // Don't attempt to connect if we failed to load credentials or reconnect.
+            // This will prevent the app from trying to connect to the VPN service.
+            dependenciesLoaded = false; 
         }
 
-        if (_settingsManager.ConnectOnLaunch)
+        var attemptCoderConnection = settingsTask.Result?.ConnectOnLaunch ?? false;
+        if (dependenciesLoaded && attemptCoderConnection)
         {
             try
             {
diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index 13e7db4..da062ef 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -1,65 +1,57 @@
+using Google.Protobuf.WellKnownTypes;
 using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Text.Json;
 using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
 
 namespace Coder.Desktop.App.Services;
 
 /// <summary>
 /// Settings contract exposing properties for app settings.
 /// </summary>
-public interface ISettingsManager
+public interface ISettingsManager<T> where T : ISettings, new()
 {
     /// <summary>
-    /// Returns the value of the StartOnLogin setting. Returns <c>false</c> if the key is not found.
+    /// Reads the settings from the file system.
+    /// Always returns the latest settings, even if they were modified by another instance of the app.
+    /// Returned object is always a fresh instance, so it can be modified without affecting the stored settings.
     /// </summary>
-    bool StartOnLogin { get; set; }
-
+    /// <param name="ct"></param>
+    /// <returns></returns>
+    public Task<T> Read(CancellationToken ct = default);
+    /// <summary>
+    /// Writes the settings to the file system.
+    /// </summary>
+    /// <param name="settings">Object containing the settings.</param>
+    /// <param name="ct"></param>
+    /// <returns></returns>
+    public Task Write(T settings, CancellationToken ct = default);
     /// <summary>
-    /// Returns the value of the ConnectOnLaunch setting. Returns <c>false</c> if the key is not found.
+    /// Returns null if the settings are not cached or not available.
     /// </summary>
-    bool ConnectOnLaunch { get; set; }
+    /// <returns></returns>
+    public T? GetFromCache();
 }
 
 /// <summary>
 /// Implemention of <see cref="ISettingsManager"/> that persists settings to a JSON file
 /// located in the user's local application data folder.
 /// </summary>
-public sealed class SettingsManager : ISettingsManager
+public sealed class SettingsManager<T> : ISettingsManager<T> where T : ISettings, new()
 {
     private readonly string _settingsFilePath;
-    private Settings _settings;
-    private readonly string _fileName = "app-settings.json";
     private readonly string _appName = "CoderDesktop";
+    private string _fileName;
     private readonly object _lock = new();
 
-    public const string ConnectOnLaunchKey = "ConnectOnLaunch";
-    public const string StartOnLoginKey = "StartOnLogin";
+    private T? _cachedSettings;
 
-    public bool StartOnLogin
-    {
-        get
-        {
-            return Read(StartOnLoginKey, false);
-        }
-        set
-        {
-            Save(StartOnLoginKey, value);
-        }
-    }
-
-    public bool ConnectOnLaunch
-    {
-        get
-        {
-            return Read(ConnectOnLaunchKey, false);
-        }
-        set
-        {
-            Save(ConnectOnLaunchKey, value);
-        }
-    }
+    private readonly SemaphoreSlim _gate = new(1, 1);
+    private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(3);
 
     /// <param name="settingsFilePath">
     /// For unit‑tests you can pass an absolute path that already exists.
@@ -81,109 +73,129 @@ public SettingsManager(string? settingsFilePath = null)
                 _appName);
 
         Directory.CreateDirectory(folder);
+
+        _fileName = T.SettingsFileName;
         _settingsFilePath = Path.Combine(folder, _fileName);
+    }
 
-        if (!File.Exists(_settingsFilePath))
+    public async Task<T> Read(CancellationToken ct = default)
+    {
+        // try to get the lock with short timeout
+        if (!await _gate.WaitAsync(LockTimeout, ct).ConfigureAwait(false))
+            throw new InvalidOperationException(
+                $"Could not acquire the settings lock within {LockTimeout.TotalSeconds} s.");
+
+        try
         {
-            // Create the settings file if it doesn't exist
-            _settings = new();
-            File.WriteAllText(_settingsFilePath, JsonSerializer.Serialize(_settings, SettingsJsonContext.Default.Settings));
+            if (!File.Exists(_settingsFilePath))
+                return new();
+
+            var json = await File.ReadAllTextAsync(_settingsFilePath, ct)
+                                 .ConfigureAwait(false);
+
+            // deserialize; fall back to default(T) if empty or malformed
+            var result = JsonSerializer.Deserialize<T>(json)!;
+            _cachedSettings = result;
+            return result;
         }
-        else
+        catch (OperationCanceledException)
         {
-            _settings = Load();
+            throw; // propagate caller-requested cancellation
         }
-    }
-
-    private void Save(string name, bool value)
-    {
-        lock (_lock)
+        catch (Exception ex)
         {
-            try
-            {
-                // We lock the file for the entire operation to prevent concurrent writes   
-                using var fs = new FileStream(_settingsFilePath,
-                    FileMode.OpenOrCreate,
-                    FileAccess.ReadWrite,
-                    FileShare.None);
-
-                // Ensure cache is loaded before saving 
-                var freshCache = JsonSerializer.Deserialize(fs, SettingsJsonContext.Default.Settings) ?? new();
-                _settings = freshCache;
-                _settings.Options[name] = JsonSerializer.SerializeToElement(value);
-                fs.Position = 0; // Reset stream position to the beginning before writing
-
-                JsonSerializer.Serialize(fs, _settings, SettingsJsonContext.Default.Settings);
-
-                // This ensures the file is truncated to the new length
-                // if the new content is shorter than the old content
-                fs.SetLength(fs.Position);
-            }
-            catch
-            {
-                throw new InvalidOperationException($"Failed to persist settings to {_settingsFilePath}. The file may be corrupted, malformed or locked.");
-            }
+            throw new InvalidOperationException(
+                $"Failed to read settings from {_settingsFilePath}. " +
+                "The file may be corrupted, malformed or locked.", ex);
         }
-    }
-
-    private bool Read(string name, bool defaultValue)
-    {
-        lock (_lock)
+        finally
         {
-            if (_settings.Options.TryGetValue(name, out var element))
-            {
-                try
-                {
-                    return element.Deserialize<bool?>() ?? defaultValue;
-                }
-                catch
-                {
-                    // malformed value – return default value
-                    return defaultValue;
-                }
-            }
-            return defaultValue; // key not found – return default value
+            _gate.Release();
         }
     }
 
-    private Settings Load()
+    public async Task Write(T settings, CancellationToken ct = default)
     {
+        // try to get the lock with short timeout
+        if (!await _gate.WaitAsync(LockTimeout, ct).ConfigureAwait(false))
+            throw new InvalidOperationException(
+                $"Could not acquire the settings lock within {LockTimeout.TotalSeconds} s.");
+
         try
         {
-            using var fs = File.OpenRead(_settingsFilePath);
-            return JsonSerializer.Deserialize(fs, SettingsJsonContext.Default.Settings) ?? new();
+            // overwrite the settings file with the new settings
+            var json = JsonSerializer.Serialize(
+                settings, new JsonSerializerOptions() { WriteIndented = true });
+            _cachedSettings = settings; // cache the settings
+            await File.WriteAllTextAsync(_settingsFilePath, json, ct)
+                      .ConfigureAwait(false);
+        }
+        catch (OperationCanceledException)
+        {
+            throw;  // let callers observe cancellation
         }
         catch (Exception ex)
         {
-            throw new InvalidOperationException($"Failed to load settings from {_settingsFilePath}. The file may be corrupted or malformed. Exception: {ex.Message}");
+            throw new InvalidOperationException(
+                $"Failed to persist settings to {_settingsFilePath}. " +
+                "The file may be corrupted, malformed or locked.", ex);
+        }
+        finally
+        {
+            _gate.Release();
         }
     }
+
+    public T? GetFromCache()
+    {
+        return _cachedSettings;
+    }
 }
 
-public class Settings
+public interface ISettings
 {
     /// <summary>
-    /// User settings version. Increment this when the settings schema changes.
+    /// Gets the version of the settings schema.
+    /// </summary>
+    int Version { get; }
+
+    /// <summary>
+    /// FileName where the settings are stored.
+    /// </summary>
+    static abstract string SettingsFileName { get; }
+}
+
+/// <summary>
+/// CoderConnect settings class that holds the settings for the CoderConnect feature.
+/// </summary>
+public class CoderConnectSettings : ISettings
+{
+    /// <summary>
+    /// CoderConnect settings version. Increment this when the settings schema changes.
     /// In future iterations we will be able to handle migrations when the user has
     /// an older version.
     /// </summary>
     public int Version { get; set; }
-    public Dictionary<string, JsonElement> Options { get; set; }
+    public bool ConnectOnLaunch { get; set; }
+    public static string SettingsFileName { get; } = "coder-connect-settings.json";
 
     private const int VERSION = 1; // Default version for backward compatibility
-    public Settings()
+    public CoderConnectSettings()
     {
         Version = VERSION;
-        Options = [];
+        ConnectOnLaunch = false;
     }
 
-    public Settings(int? version, Dictionary<string, JsonElement> options)
+    public CoderConnectSettings(int? version, bool connectOnLogin)
     {
         Version = version ?? VERSION;
-        Options = options;
+        ConnectOnLaunch = connectOnLogin;
     }
-}
 
-[JsonSerializable(typeof(Settings))]
-[JsonSourceGenerationOptions(WriteIndented = true)]
-public partial class SettingsJsonContext : JsonSerializerContext;
+    public CoderConnectSettings Clone()
+    {
+        return new CoderConnectSettings(Version, ConnectOnLaunch);
+    }
+
+
+}
diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs
index f49d159..75ba57b 100644
--- a/App/ViewModels/SettingsViewModel.cs
+++ b/App/ViewModels/SettingsViewModel.cs
@@ -20,16 +20,24 @@ public partial class SettingsViewModel : ObservableObject
     [ObservableProperty]
     public partial bool StartOnLogin { get; set; }
 
-    private ISettingsManager _settingsManager;
+    private ISettingsManager<CoderConnectSettings> _connectSettingsManager;
+    private CoderConnectSettings _connectSettings = new CoderConnectSettings();
     private IStartupManager _startupManager;
 
-    public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager settingsManager, IStartupManager startupManager)
+    public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager<CoderConnectSettings> settingsManager, IStartupManager startupManager)
     {
-        _settingsManager = settingsManager;
+        _connectSettingsManager = settingsManager;
         _startupManager = startupManager;
         _logger = logger;
-        ConnectOnLaunch = _settingsManager.ConnectOnLaunch;
-        StartOnLogin = _settingsManager.StartOnLogin;
+        // Application settings are loaded on application startup,
+        // so we expect the settings to be available immediately.
+        var settingsCache = settingsManager.GetFromCache();
+        if (settingsCache is not null)
+        {
+            _connectSettings = settingsCache.Clone();
+        }
+        StartOnLogin = startupManager.IsEnabled();
+        ConnectOnLaunch = _connectSettings.ConnectOnLaunch;
 
         // Various policies can disable the "Start on login" option.
         // We disable the option in the UI if the policy is set.
@@ -48,11 +56,12 @@ partial void OnConnectOnLaunchChanged(bool oldValue, bool newValue)
             return;
         try
         {
-            _settingsManager.ConnectOnLaunch = ConnectOnLaunch;
+            _connectSettings.ConnectOnLaunch = ConnectOnLaunch;
+            _connectSettingsManager.Write(_connectSettings);
         }
         catch (Exception ex)
         {
-            _logger.LogError($"Error saving {SettingsManager.ConnectOnLaunchKey} setting: {ex.Message}");
+            _logger.LogError($"Error saving Coder Connect settings: {ex.Message}");
         }
     }
 
@@ -62,7 +71,6 @@ partial void OnStartOnLoginChanged(bool oldValue, bool newValue)
             return;
         try
         {
-            _settingsManager.StartOnLogin = StartOnLogin;
             if (StartOnLogin)
             {
                 _startupManager.Enable();
@@ -74,7 +82,7 @@ partial void OnStartOnLoginChanged(bool oldValue, bool newValue)
         }
         catch (Exception ex)
         {
-            _logger.LogError($"Error saving {SettingsManager.StartOnLoginKey} setting: {ex.Message}");
+            _logger.LogError($"Error setting StartOnLogin in registry: {ex.Message}");
         }
     }
 }
diff --git a/Tests.App/Services/SettingsManagerTest.cs b/Tests.App/Services/SettingsManagerTest.cs
index 0804c0b..4b8b6a0 100644
--- a/Tests.App/Services/SettingsManagerTest.cs
+++ b/Tests.App/Services/SettingsManagerTest.cs
@@ -5,14 +5,14 @@ namespace Coder.Desktop.Tests.App.Services;
 public sealed class SettingsManagerTests
 {
     private string _tempDir = string.Empty;
-    private SettingsManager _sut = null!;
+    private SettingsManager<CoderConnectSettings> _sut = null!;
 
     [SetUp]
     public void SetUp()
     {
         _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
         Directory.CreateDirectory(_tempDir);
-        _sut = new SettingsManager(_tempDir); // inject isolated path
+        _sut = new SettingsManager<CoderConnectSettings>(_tempDir); // inject isolated path
     }
 
     [TearDown]
@@ -25,29 +25,20 @@ public void TearDown()
     public void Save_Persists()
     {
         bool expected = true;
-        _sut.StartOnLogin = expected;
-
-        Assert.That(_sut.StartOnLogin, Is.EqualTo(expected));
+        var settings = new CoderConnectSettings
+        {
+            Version = 1,
+            ConnectOnLaunch = expected
+        };
+        _sut.Write(settings).GetAwaiter().GetResult();
+        var actual = _sut.Read().GetAwaiter().GetResult();
+        Assert.That(actual.ConnectOnLaunch, Is.EqualTo(expected));
     }
 
     [Test]
     public void Read_MissingKey_ReturnsDefault()
     {
-        bool result = _sut.ConnectOnLaunch; // default is false
-        Assert.That(result, Is.False);
-    }
-
-    [Test]
-    public void Read_AfterReload_ReturnsPreviouslySavedValue()
-    {
-        const bool value = true;
-
-        _sut.ConnectOnLaunch = value;
-
-        // Create new instance to force file reload.
-        var newManager = new SettingsManager(_tempDir);
-        bool persisted = newManager.ConnectOnLaunch;
-
-        Assert.That(persisted, Is.EqualTo(value));
+        var actual = _sut.Read().GetAwaiter().GetResult();
+        Assert.That(actual.ConnectOnLaunch, Is.False);
     }
 }

From e7b2491ac577938ea8b0b808e6071faa0c43d091 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 6 Jun 2025 16:31:10 +0200
Subject: [PATCH 12/18] formatting

---
 App/App.xaml.cs                       | 2 +-
 App/Views/Pages/SettingsMainPage.xaml | 9 ---------
 2 files changed, 1 insertion(+), 10 deletions(-)

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 68d1208..d047f6b 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -193,7 +193,7 @@ private async Task InitializeServicesAsync(CancellationToken cancellationToken =
 
             // Don't attempt to connect if we failed to load credentials or reconnect.
             // This will prevent the app from trying to connect to the VPN service.
-            dependenciesLoaded = false; 
+            dependenciesLoaded = false;
         }
 
         var attemptCoderConnection = settingsTask.Result?.ConnectOnLaunch ?? false;
diff --git a/App/Views/Pages/SettingsMainPage.xaml b/App/Views/Pages/SettingsMainPage.xaml
index a8a9f66..5ae7230 100644
--- a/App/Views/Pages/SettingsMainPage.xaml
+++ b/App/Views/Pages/SettingsMainPage.xaml
@@ -13,13 +13,9 @@
     xmlns:controls="using:CommunityToolkit.WinUI.Controls"
     mc:Ignorable="d"
     Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
-
     <Page.Resources>
-        <!--  These styles can be referenced to create a consistent SettingsPage layout  -->
-
         <!--  Spacing between cards  -->
         <x:Double x:Key="SettingsCardSpacing">4</x:Double>
-
         <!--  Style (inc. the correct spacing) of a section header  -->
         <Style x:Key="SettingsSectionHeaderTextBlockStyle"
                BasedOn="{StaticResource BodyStrongTextBlockStyle}"
@@ -34,16 +30,13 @@
             <StackPanel MaxWidth="1000"
                         HorizontalAlignment="Stretch"
                         Spacing="{StaticResource SettingsCardSpacing}">
-
                 <TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Coder Desktop" />
-
                 <controls:SettingsCard Description="This setting controls whether the Coder Desktop app starts on Windows startup."
                                        Header="Start on login"
                                        HeaderIcon="{ui:FontIcon Glyph=&#xE819;}"
                                        IsEnabled="{x:Bind ViewModel.StartOnLoginDisabled, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}">
                     <ToggleSwitch IsOn="{x:Bind ViewModel.StartOnLogin, Mode=TwoWay}" />
                 </controls:SettingsCard>
-
                 <TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Coder Connect" />
                 <controls:SettingsCard Description="This setting controls whether Coder Connect automatically starts with Coder Desktop. "
                                        Header="Connect on launch"
@@ -51,9 +44,7 @@
                                        >
                     <ToggleSwitch IsOn="{x:Bind ViewModel.ConnectOnLaunch, Mode=TwoWay}" />
                 </controls:SettingsCard>
-
             </StackPanel>
         </Grid>
     </ScrollViewer>
-
 </Page>

From ced517e1cf2619f0bf00cc6d66c95e50ce295016 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 6 Jun 2025 17:07:01 +0200
Subject: [PATCH 13/18] PR adjustments

---
 App/Services/SettingsManager.cs     | 45 +++++++++++++++--------------
 App/ViewModels/SettingsViewModel.cs |  6 +---
 2 files changed, 25 insertions(+), 26 deletions(-)

diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index da062ef..6280ac5 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -1,4 +1,5 @@
 using Google.Protobuf.WellKnownTypes;
+using Serilog;
 using System;
 using System.Collections.Generic;
 using System.IO;
@@ -30,11 +31,6 @@ namespace Coder.Desktop.App.Services;
     /// <param name="ct"></param>
     /// <returns></returns>
     public Task Write(T settings, CancellationToken ct = default);
-    /// <summary>
-    /// Returns null if the settings are not cached or not available.
-    /// </summary>
-    /// <returns></returns>
-    public T? GetFromCache();
 }
 
 /// <summary>
@@ -80,6 +76,12 @@ public SettingsManager(string? settingsFilePath = null)
 
     public async Task<T> Read(CancellationToken ct = default)
     {
+        if (_cachedSettings is not null)
+        {
+            // return cached settings if available
+            return (T)_cachedSettings.Clone();
+        }
+
         // try to get the lock with short timeout
         if (!await _gate.WaitAsync(LockTimeout, ct).ConfigureAwait(false))
             throw new InvalidOperationException(
@@ -145,24 +147,21 @@ await File.WriteAllTextAsync(_settingsFilePath, json, ct)
             _gate.Release();
         }
     }
-
-    public T? GetFromCache()
-    {
-        return _cachedSettings;
-    }
 }
 
 public interface ISettings
 {
     /// <summary>
-    /// Gets the version of the settings schema.
+    /// FileName where the settings are stored.
     /// </summary>
-    int Version { get; }
+    static abstract string SettingsFileName { get; }
 
     /// <summary>
-    /// FileName where the settings are stored.
+    /// Gets the version of the settings schema.
     /// </summary>
-    static abstract string SettingsFileName { get; }
+    int Version { get; }
+
+    ISettings Clone();
 }
 
 /// <summary>
@@ -170,16 +169,17 @@ public interface ISettings
 /// </summary>
 public class CoderConnectSettings : ISettings
 {
+    public static string SettingsFileName { get; } = "coder-connect-settings.json";
+    public int Version { get; set; }
+    public bool ConnectOnLaunch { get; set; }
+
     /// <summary>
-    /// CoderConnect settings version. Increment this when the settings schema changes.
+    /// CoderConnect current settings version. Increment this when the settings schema changes.
     /// In future iterations we will be able to handle migrations when the user has
     /// an older version.
     /// </summary>
-    public int Version { get; set; }
-    public bool ConnectOnLaunch { get; set; }
-    public static string SettingsFileName { get; } = "coder-connect-settings.json";
+    private const int VERSION = 1;
 
-    private const int VERSION = 1; // Default version for backward compatibility
     public CoderConnectSettings()
     {
         Version = VERSION;
@@ -192,10 +192,13 @@ public CoderConnectSettings(int? version, bool connectOnLogin)
         ConnectOnLaunch = connectOnLogin;
     }
 
+    ISettings ISettings.Clone()
+    {
+        return Clone();
+    }
+
     public CoderConnectSettings Clone()
     {
         return new CoderConnectSettings(Version, ConnectOnLaunch);
     }
-
-
 }
diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs
index 75ba57b..aa6f0f9 100644
--- a/App/ViewModels/SettingsViewModel.cs
+++ b/App/ViewModels/SettingsViewModel.cs
@@ -31,11 +31,7 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager<Cod
         _logger = logger;
         // Application settings are loaded on application startup,
         // so we expect the settings to be available immediately.
-        var settingsCache = settingsManager.GetFromCache();
-        if (settingsCache is not null)
-        {
-            _connectSettings = settingsCache.Clone();
-        }
+        var settingsCache = settingsManager.Read();
         StartOnLogin = startupManager.IsEnabled();
         ConnectOnLaunch = _connectSettings.ConnectOnLaunch;
 

From c4c52e2bd83c3451ca8d1d5c823ea701a5ed1b93 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 9 Jun 2025 12:17:55 +0200
Subject: [PATCH 14/18] PR review fixes

---
 App/App.xaml.cs                               |  1 -
 App/Models/SettingsGroup.cs                   | 62 ++++++++++++++++
 App/Services/SettingsManager.cs               | 73 ++-----------------
 App/ViewModels/SettingsViewModel.cs           |  7 +-
 .../Pages/TrayWindowLoginRequiredPage.xaml    |  2 +-
 Tests.App/Services/SettingsManagerTest.cs     |  3 +-
 6 files changed, 74 insertions(+), 74 deletions(-)
 create mode 100644 App/Models/SettingsGroup.cs

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index d047f6b..1918765 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -1,6 +1,5 @@
 using System;
 using System.Collections.Generic;
-using System.Diagnostics;
 using System.IO;
 using System.Threading;
 using System.Threading.Tasks;
diff --git a/App/Models/SettingsGroup.cs b/App/Models/SettingsGroup.cs
new file mode 100644
index 0000000..ee2f149
--- /dev/null
+++ b/App/Models/SettingsGroup.cs
@@ -0,0 +1,62 @@
+using System;
+
+namespace Coder.Desktop.App.Models;
+
+
+public interface ISettings<T> : ICloneable<T>
+{
+    /// <summary>
+    /// FileName where the settings are stored.
+    /// </summary>
+    static abstract string SettingsFileName { get; }
+
+    /// <summary>
+    /// Gets the version of the settings schema.
+    /// </summary>
+    int Version { get; }
+}
+
+public interface ICloneable<T>
+{
+    /// <summary>
+    /// Creates a deep copy of the settings object.
+    /// </summary>
+    /// <returns>A new instance of the settings object with the same values.</returns>
+    T Clone();
+}
+
+/// <summary>
+/// CoderConnect settings class that holds the settings for the CoderConnect feature.
+/// </summary>
+public class CoderConnectSettings : ISettings<CoderConnectSettings>
+{
+    public static string SettingsFileName { get; } = "coder-connect-settings.json";
+    public int Version { get; set; }
+    public bool ConnectOnLaunch { get; set; }
+
+    /// <summary>
+    /// CoderConnect current settings version. Increment this when the settings schema changes.
+    /// In future iterations we will be able to handle migrations when the user has
+    /// an older version.
+    /// </summary>
+    private const int VERSION = 1;
+
+    public CoderConnectSettings()
+    {
+        Version = VERSION;
+
+        ConnectOnLaunch = false;
+    }
+
+    public CoderConnectSettings(int? version, bool connectOnLaunch)
+    {
+        Version = version ?? VERSION;
+
+        ConnectOnLaunch = connectOnLaunch;
+    }
+
+    public CoderConnectSettings Clone()
+    {
+        return new CoderConnectSettings(Version, ConnectOnLaunch);
+    }
+}
diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index 6280ac5..3774611 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -1,20 +1,16 @@
-using Google.Protobuf.WellKnownTypes;
-using Serilog;
 using System;
-using System.Collections.Generic;
 using System.IO;
 using System.Text.Json;
-using System.Text.Json.Serialization;
 using System.Threading;
 using System.Threading.Tasks;
-using System.Xml.Linq;
+using Coder.Desktop.App.Models;
 
 namespace Coder.Desktop.App.Services;
 
 /// <summary>
 /// Settings contract exposing properties for app settings.
 /// </summary>
-public interface ISettingsManager<T> where T : ISettings, new()
+public interface ISettingsManager<T> where T : ISettings<T>, new()
 {
     /// <summary>
     /// Reads the settings from the file system.
@@ -23,26 +19,25 @@ namespace Coder.Desktop.App.Services;
     /// </summary>
     /// <param name="ct"></param>
     /// <returns></returns>
-    public Task<T> Read(CancellationToken ct = default);
+    Task<T> Read(CancellationToken ct = default);
     /// <summary>
     /// Writes the settings to the file system.
     /// </summary>
     /// <param name="settings">Object containing the settings.</param>
     /// <param name="ct"></param>
     /// <returns></returns>
-    public Task Write(T settings, CancellationToken ct = default);
+    Task Write(T settings, CancellationToken ct = default);
 }
 
 /// <summary>
 /// Implemention of <see cref="ISettingsManager"/> that persists settings to a JSON file
 /// located in the user's local application data folder.
 /// </summary>
-public sealed class SettingsManager<T> : ISettingsManager<T> where T : ISettings, new()
+public sealed class SettingsManager<T> : ISettingsManager<T> where T : ISettings<T>, new()
 {
     private readonly string _settingsFilePath;
     private readonly string _appName = "CoderDesktop";
     private string _fileName;
-    private readonly object _lock = new();
 
     private T? _cachedSettings;
 
@@ -79,7 +74,7 @@ public async Task<T> Read(CancellationToken ct = default)
         if (_cachedSettings is not null)
         {
             // return cached settings if available
-            return (T)_cachedSettings.Clone();
+            return _cachedSettings.Clone();
         }
 
         // try to get the lock with short timeout
@@ -98,7 +93,7 @@ public async Task<T> Read(CancellationToken ct = default)
             // deserialize; fall back to default(T) if empty or malformed
             var result = JsonSerializer.Deserialize<T>(json)!;
             _cachedSettings = result;
-            return result;
+            return _cachedSettings.Clone(); // return a fresh instance of the settings
         }
         catch (OperationCanceledException)
         {
@@ -148,57 +143,3 @@ await File.WriteAllTextAsync(_settingsFilePath, json, ct)
         }
     }
 }
-
-public interface ISettings
-{
-    /// <summary>
-    /// FileName where the settings are stored.
-    /// </summary>
-    static abstract string SettingsFileName { get; }
-
-    /// <summary>
-    /// Gets the version of the settings schema.
-    /// </summary>
-    int Version { get; }
-
-    ISettings Clone();
-}
-
-/// <summary>
-/// CoderConnect settings class that holds the settings for the CoderConnect feature.
-/// </summary>
-public class CoderConnectSettings : ISettings
-{
-    public static string SettingsFileName { get; } = "coder-connect-settings.json";
-    public int Version { get; set; }
-    public bool ConnectOnLaunch { get; set; }
-
-    /// <summary>
-    /// CoderConnect current settings version. Increment this when the settings schema changes.
-    /// In future iterations we will be able to handle migrations when the user has
-    /// an older version.
-    /// </summary>
-    private const int VERSION = 1;
-
-    public CoderConnectSettings()
-    {
-        Version = VERSION;
-        ConnectOnLaunch = false;
-    }
-
-    public CoderConnectSettings(int? version, bool connectOnLogin)
-    {
-        Version = version ?? VERSION;
-        ConnectOnLaunch = connectOnLogin;
-    }
-
-    ISettings ISettings.Clone()
-    {
-        return Clone();
-    }
-
-    public CoderConnectSettings Clone()
-    {
-        return new CoderConnectSettings(Version, ConnectOnLaunch);
-    }
-}
diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs
index aa6f0f9..721ea95 100644
--- a/App/ViewModels/SettingsViewModel.cs
+++ b/App/ViewModels/SettingsViewModel.cs
@@ -1,8 +1,7 @@
+using Coder.Desktop.App.Models;
 using Coder.Desktop.App.Services;
 using CommunityToolkit.Mvvm.ComponentModel;
 using Microsoft.Extensions.Logging;
-using Microsoft.UI.Dispatching;
-using Microsoft.UI.Xaml;
 using System;
 
 namespace Coder.Desktop.App.ViewModels;
@@ -29,9 +28,7 @@ public SettingsViewModel(ILogger<SettingsViewModel> logger, ISettingsManager<Cod
         _connectSettingsManager = settingsManager;
         _startupManager = startupManager;
         _logger = logger;
-        // Application settings are loaded on application startup,
-        // so we expect the settings to be available immediately.
-        var settingsCache = settingsManager.Read();
+        _connectSettings = settingsManager.Read().GetAwaiter().GetResult();
         StartOnLogin = startupManager.IsEnabled();
         ConnectOnLaunch = _connectSettings.ConnectOnLaunch;
 
diff --git a/App/Views/Pages/TrayWindowLoginRequiredPage.xaml b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
index c1d69aa..171e292 100644
--- a/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
+++ b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
@@ -36,7 +36,7 @@
         </HyperlinkButton>
 
         <HyperlinkButton
-            Command="{x:Bind ViewModel.ExitCommand, Mode=OneWay}"
+            Command="{x:Bind ViewModel.ExitCommand}"
             Margin="-12,-8,-12,-5"
             HorizontalAlignment="Stretch"
             HorizontalContentAlignment="Left"> 
diff --git a/Tests.App/Services/SettingsManagerTest.cs b/Tests.App/Services/SettingsManagerTest.cs
index 4b8b6a0..44f5c06 100644
--- a/Tests.App/Services/SettingsManagerTest.cs
+++ b/Tests.App/Services/SettingsManagerTest.cs
@@ -1,3 +1,4 @@
+using Coder.Desktop.App.Models;
 using Coder.Desktop.App.Services;
 
 namespace Coder.Desktop.Tests.App.Services;
@@ -24,7 +25,7 @@ public void TearDown()
     [Test]
     public void Save_Persists()
     {
-        bool expected = true;
+        var expected = true;
         var settings = new CoderConnectSettings
         {
             Version = 1,

From 0c7567b647b648509484d3755c71893caf598597 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 9 Jun 2025 12:19:14 +0200
Subject: [PATCH 15/18] renamed Settings models

---
 App/Models/{SettingsGroup.cs => Settings.cs} | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename App/Models/{SettingsGroup.cs => Settings.cs} (100%)

diff --git a/App/Models/SettingsGroup.cs b/App/Models/Settings.cs
similarity index 100%
rename from App/Models/SettingsGroup.cs
rename to App/Models/Settings.cs

From 2824bd896fd87b71527c33566eb279e06ee434cc Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 9 Jun 2025 12:19:43 +0200
Subject: [PATCH 16/18] comment added to ConnectOnLaunch setting

---
 App/Models/Settings.cs | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/App/Models/Settings.cs b/App/Models/Settings.cs
index ee2f149..a98b244 100644
--- a/App/Models/Settings.cs
+++ b/App/Models/Settings.cs
@@ -32,6 +32,9 @@ public class CoderConnectSettings : ISettings<CoderConnectSettings>
 {
     public static string SettingsFileName { get; } = "coder-connect-settings.json";
     public int Version { get; set; }
+    /// <summary>
+    /// When this is true, CoderConnect will automatically connect to the Coder VPN when the application starts.
+    /// </summary>
     public bool ConnectOnLaunch { get; set; }
 
     /// <summary>

From c11f6db7b4859f34b82efe526ec964bdbdab18a1 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Mon, 9 Jun 2025 12:20:02 +0200
Subject: [PATCH 17/18] removed unecessary using

---
 App/Models/Settings.cs | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/App/Models/Settings.cs b/App/Models/Settings.cs
index a98b244..ec4c61b 100644
--- a/App/Models/Settings.cs
+++ b/App/Models/Settings.cs
@@ -1,8 +1,5 @@
-using System;
-
 namespace Coder.Desktop.App.Models;
 
-
 public interface ISettings<T> : ICloneable<T>
 {
     /// <summary>

From 043c9bb7ac4be4e98e35264ab63977613d3fe890 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Tue, 10 Jun 2025 15:45:13 +0200
Subject: [PATCH 18/18] adjusted comments, linked cancellation token for file
 sync

---
 App/App.xaml.cs                 | 3 ++-
 App/Services/SettingsManager.cs | 5 ++---
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 1918765..87afcb3 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -209,7 +209,8 @@ private async Task InitializeServicesAsync(CancellationToken cancellationToken =
         }
 
         // Initialize file sync.
-        var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+        using var syncSessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+        syncSessionCts.CancelAfter(TimeSpan.FromSeconds(10));
         var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
         try
         {
diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs
index 3774611..886d5d2 100644
--- a/App/Services/SettingsManager.cs
+++ b/App/Services/SettingsManager.cs
@@ -13,9 +13,8 @@ namespace Coder.Desktop.App.Services;
 public interface ISettingsManager<T> where T : ISettings<T>, new()
 {
     /// <summary>
-    /// Reads the settings from the file system.
-    /// Always returns the latest settings, even if they were modified by another instance of the app.
-    /// Returned object is always a fresh instance, so it can be modified without affecting the stored settings.
+    /// Reads the settings from the file system or returns from cache if available.
+    /// Returned object is always a cloned instance, so it can be modified without affecting the stored settings.
     /// </summary>
     /// <param name="ct"></param>
     /// <returns></returns>