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(); + services.AddSingleton(_ => new SettingsManager("CoderDesktop")); + // SettingsWindow views and view models + services.AddTransient(); + // SettingsMainPage is created by SettingsWindow. + services.AddTransient(); + // 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; +/// +/// Generic persistence contract for simple key/value settings. +/// +public interface ISettingsManager +{ + /// + /// Saves under and returns the value. + /// + T Save(string name, T value); + + /// + /// Reads the setting or returns when the key is missing. + /// + T Read(string name, T defaultValue); +} +/// +/// JSON‑file implementation that works in unpackaged Win32/WinUI 3 apps. +/// +public sealed class SettingsManager : ISettingsManager +{ + private readonly string _settingsFilePath; + private readonly string _fileName = "app-settings.json"; + private readonly object _lock = new(); + private Dictionary _cache; + + /// + /// Sub‑folder under %LOCALAPPDATA% (e.g. "coder-desktop"). + /// If null the folder name defaults to the executable name. + /// For unit‑tests you can pass an absolute path that already exists. + /// + 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(string name, T value) + { + lock (_lock) + { + _cache[name] = JsonSerializer.SerializeToElement(value); + Persist(); + return value; + } + } + + public T Read(string name, T defaultValue) + { + lock (_lock) + { + if (_cache.TryGetValue(name, out var element)) + { + try + { + return element.Deserialize() ?? defaultValue; + } + catch + { + // Malformed value – fall back. + return defaultValue; + } + } + return defaultValue; // key not found – return caller‑supplied default (false etc.) + } + } + + private Dictionary Load() + { + if (!File.Exists(_settingsFilePath)) + return new(); + + try + { + using var fs = File.OpenRead(_settingsFilePath); + return JsonSerializer.Deserialize>(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.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 @@ + + + + + + + + + + + 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"> @@ -331,9 +331,18 @@ + + + + + @@ -342,7 +351,7 @@ 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 @@ + + + + + + + + + + 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 @@ + all 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 _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)_services.GetService(typeof(ILogger))!; _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(); + _ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t => { - var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var syncSessionController = _services.GetRequiredService(); - 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(); 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 _cache; + public static readonly string ConnectOnLaunchKey = "ConnectOnLaunch"; + public static readonly string StartOnLoginKey = "StartOnLogin"; + /// - /// Sub‑folder under %LOCALAPPDATA% (e.g. "coder-desktop"). + /// Sub‑folder under %LOCALAPPDATA% (e.g. "CoderDesktop"). /// If null the folder name defaults to the executable name. /// For unit‑tests you can pass an absolute path that already exists. /// 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 _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 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}"> - - + + + + + 4 + + + + + + + + + + + + + + + + + + + + + - - - 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"> 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(); + 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(); 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(); - _ = 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(); 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; /// 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"; + + /// + /// Adds the current executable to the per‑user Run key. Returns true if successful. + /// Fails (returns false) when blocked by policy or lack of permissions. + /// + 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; } + } + + /// Removes the value from the Run key (no-op if missing). + public static void Disable() + { + using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true); + key?.DeleteValue(_defaultValueName, throwOnMissingValue: false); + } + + /// Checks whether the value exists in the Run key. + public static bool IsEnabled() + { + using var key = Registry.CurrentUser.OpenSubKey(RunKey); + return key?.GetValue(_defaultValueName) != null; + } + + /// + /// Detects whether group policy disables per‑user startup programs. + /// Mirrors . + /// + 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 _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 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 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 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 @@ + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.StartOnLoginDisabled, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}"> - + HeaderIcon="{ui:FontIcon Glyph=}" + > 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(); - services.AddSingleton(_ => new SettingsManager("CoderDesktop")); + services.AddSingleton(); + services.AddSingleton(); // SettingsWindow views and view models services.AddTransient(); // 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(); - 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; + /// -/// Generic persistence contract for simple key/value settings. +/// Settings contract exposing properties for app settings. /// public interface ISettingsManager { /// - /// Saves under and returns the value. + /// Returns the value of the StartOnLogin setting. Returns false if the key is not found. /// - T Save(string name, T value); + bool StartOnLogin { get; set; } /// - /// Reads the setting or returns when the key is missing. + /// Returns the value of the ConnectOnLaunch setting. Returns false if the key is not found. /// - T Read(string name, T defaultValue); + bool ConnectOnLaunch { get; set; } } + /// -/// JSON‑file implementation that works in unpackaged Win32/WinUI 3 apps. +/// Implemention of that persists settings to a JSON file +/// located in the user's local application data folder. /// 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 _cache; - public static readonly string ConnectOnLaunchKey = "ConnectOnLaunch"; - public static readonly string StartOnLoginKey = "StartOnLogin"; + public const string ConnectOnLaunchKey = "ConnectOnLaunch"; + public const string StartOnLoginKey = "StartOnLogin"; - /// - /// Sub‑folder under %LOCALAPPDATA% (e.g. "CoderDesktop"). - /// If null 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); + } + } + + /// /// 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. /// - 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(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>(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(string name, T defaultValue) + private bool Read(string name, bool defaultValue) { lock (_lock) { @@ -75,39 +126,28 @@ public T Read(string name, T defaultValue) { try { - return element.Deserialize() ?? defaultValue; + return element.Deserialize() ?? 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 Load() { - if (!File.Exists(_settingsFilePath)) - return new(); - try { using var fs = File.OpenRead(_settingsFilePath); return JsonSerializer.Deserialize>(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 +{ + /// + /// Adds the current executable to the per‑user Run key. Returns true if successful. + /// Fails (returns false) when blocked by policy or lack of permissions. + /// + bool Enable(); + /// + /// Removes the value from the Run key (no-op if missing). + /// + void Disable(); + /// + /// Checks whether the value exists in the Run key. + /// + bool IsEnabled(); + /// + /// Detects whether group policy disables per‑user startup programs. + /// Mirrors . + /// + 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"; - /// - /// Adds the current executable to the per‑user Run key. Returns true if successful. - /// Fails (returns false) when blocked by policy or lack of permissions. - /// - public static bool Enable() + public bool Enable() { if (IsDisabledByPolicy()) return false; @@ -35,25 +54,19 @@ public static bool Enable() catch (SecurityException) { return false; } } - /// Removes the value from the Run key (no-op if missing). - public static void Disable() + public void Disable() { using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true); key?.DeleteValue(_defaultValueName, throwOnMissingValue: false); } - /// Checks whether the value exists in the Run key. - public static bool IsEnabled() + public bool IsEnabled() { using var key = Registry.CurrentUser.OpenSubKey(RunKey); return key?.GetValue(_defaultValueName) != null; } - /// - /// Detects whether group policy disables per‑user startup programs. - /// Mirrors . - /// - 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 _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 logger, ISettingsManager settingsManager) + public SettingsViewModel(ILogger 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 logger, ISettingsManager set { try { - _settingsManager.Save(SettingsManager.ConnectOnLaunchKey, ConnectOnLaunch); + _settingsManager.ConnectOnLaunch = ConnectOnLaunch; } catch (Exception ex) { @@ -50,14 +52,14 @@ public SettingsViewModel(ILogger 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 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>(fs) ?? new(); + + // Ensure cache is loaded before saving + var currentCache = JsonSerializer.Deserialize>(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 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 _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>(fs) ?? []; - _cache = currentCache; - _cache[name] = JsonSerializer.SerializeToElement(value); + var freshCache = JsonSerializer.Deserialize(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 Load() + private Settings Load() { try { using var fs = File.OpenRead(_settingsFilePath); - return JsonSerializer.Deserialize>(fs) ?? new(); + return JsonSerializer.Deserialize(fs) ?? new(null, new Dictionary()); } 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 + { + /// + /// 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. + /// + public int Version { get; set; } = 1; + public Dictionary Options { get; set; } + public Settings() + { + Options = new Dictionary(); + } + + public Settings(int? version, Dictionary 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 @@ > + 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)_services.GetService(typeof(ILogger))!; _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!; _settingsManager = (ISettingsManager)_services.GetService(typeof(ISettingsManager))!; + _appLifetime = (IHostApplicationLifetime)_services.GetRequiredService(); 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.Closed += (_, closedArgs) => + { + if (!_handleWindowClosed) return; + closedArgs.Handled = true; + trayWindow.AppWindow.Hide(); + }; + } + + /// + /// Loads stored VPN credentials, reconnects the RPC controller, + /// and (optionally) starts the VPN tunnel on application launch. + /// + private async Task InitializeServicesAsync(CancellationToken cancellationToken = default) + { var credentialManager = _services.GetRequiredService(); - credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t => + var rpcController = _services.GetRequiredService(); + + 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(); - _ = 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(); - _ = 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.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(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(fs) ?? new(null, new Dictionary()); + 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 +{ + /// + /// 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. + /// + public int Version { get; set; } + public Dictionary Options { get; set; } - [JsonSerializable(typeof(Settings))] - private class Settings + private const int VERSION = 1; // Default version for backward compatibility + public Settings() { - /// - /// 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. - /// - public int Version { get; set; } = 1; - public Dictionary Options { get; set; } - public Settings() - { - Options = new Dictionary(); - } + Version = VERSION; + Options = []; + } - public Settings(int? version, Dictionary options) - { - Version = version ?? Version; - Options = options; - } + public Settings(int? version, Dictionary 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 _logger; private readonly IUriHandler _uriHandler; - private readonly ISettingsManager _settingsManager; + private readonly ISettingsManager _settingsManager; private readonly IHostApplicationLifetime _appLifetime; @@ -94,7 +94,7 @@ public App() // FileSyncListMainPage is created by FileSyncListWindow. services.AddTransient(); - services.AddSingleton(); + services.AddSingleton, SettingsManager>(); services.AddSingleton(); // SettingsWindow views and view models services.AddTransient(); @@ -118,10 +118,10 @@ public App() services.AddTransient(); _services = services.BuildServiceProvider(); - _logger = (ILogger)_services.GetService(typeof(ILogger))!; - _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!; - _settingsManager = (ISettingsManager)_services.GetService(typeof(ISettingsManager))!; - _appLifetime = (IHostApplicationLifetime)_services.GetRequiredService(); + _logger = _services.GetRequiredService>(); + _uriHandler = _services.GetRequiredService(); + _settingsManager = _services.GetRequiredService>(); + _appLifetime = _services.GetRequiredService(); 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; /// /// Settings contract exposing properties for app settings. /// -public interface ISettingsManager +public interface ISettingsManager where T : ISettings, new() { /// - /// Returns the value of the StartOnLogin setting. Returns false 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. /// - bool StartOnLogin { get; set; } - + /// + /// + public Task Read(CancellationToken ct = default); + /// + /// Writes the settings to the file system. + /// + /// Object containing the settings. + /// + /// + public Task Write(T settings, CancellationToken ct = default); /// - /// Returns the value of the ConnectOnLaunch setting. Returns false if the key is not found. + /// Returns null if the settings are not cached or not available. /// - bool ConnectOnLaunch { get; set; } + /// + public T? GetFromCache(); } /// /// Implemention of that persists settings to a JSON file /// located in the user's local application data folder. /// -public sealed class SettingsManager : ISettingsManager +public sealed class SettingsManager : ISettingsManager 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); /// /// 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 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(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() ?? 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 { /// - /// User settings version. Increment this when the settings schema changes. + /// Gets the version of the settings schema. + /// + int Version { get; } + + /// + /// FileName where the settings are stored. + /// + static abstract string SettingsFileName { get; } +} + +/// +/// CoderConnect settings class that holds the settings for the CoderConnect feature. +/// +public class CoderConnectSettings : ISettings +{ + /// + /// 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. /// public int Version { get; set; } - public Dictionary 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 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 _connectSettingsManager; + private CoderConnectSettings _connectSettings = new CoderConnectSettings(); private IStartupManager _startupManager; - public SettingsViewModel(ILogger logger, ISettingsManager settingsManager, IStartupManager startupManager) + public SettingsViewModel(ILogger logger, ISettingsManager 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 _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(_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}"> - - - 4 -