+
+
+";
+
+ const string githubMarkdownCssToken = "{{GITHUB_MARKDOWN_CSS}}";
+ const string themeToken = "{{THEME}}";
+ const string contentToken = "{{CONTENT}}";
+
+ // We load the CSS from an embedded asset since it's large.
+ var css = "";
+ try
+ {
+ await using var stream = typeof(App).Assembly.GetManifestResourceStream(cssResourceName)
+ ?? throw new FileNotFoundException($"Embedded resource not found: {cssResourceName}");
+ using var reader = new StreamReader(stream);
+ css = await reader.ReadToEndAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "failed to load changelog CSS theme from embedded asset, ignoring");
+ }
+
+ // We store the changelog in the description field, rather than using
+ // the release notes URL to avoid extra requests.
+ var innerHtml = item.Description;
+ if (string.IsNullOrWhiteSpace(innerHtml))
+ {
+ innerHtml = "
No release notes available.
";
+ }
+
+ // The theme doesn't automatically update.
+ var currentTheme = Application.Current.RequestedTheme == ApplicationTheme.Dark ? "dark" : "light";
+ return htmlTemplate
+ .Replace(githubMarkdownCssToken, css)
+ .Replace(themeToken, currentTheme)
+ .Replace(contentToken, innerHtml);
+ }
+
+ public async Task Changelog_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not WebView2 webView)
+ return;
+
+ // Start the engine.
+ await webView.EnsureCoreWebView2Async();
+
+ // Disable unwanted features.
+ var settings = webView.CoreWebView2.Settings;
+ settings.IsScriptEnabled = false; // disables JS
+ settings.AreHostObjectsAllowed = false; // disables interaction with app code
+#if !DEBUG
+ settings.AreDefaultContextMenusEnabled = false; // disables right-click
+ settings.AreDevToolsEnabled = false;
+#endif
+ settings.IsZoomControlEnabled = false;
+ settings.IsStatusBarEnabled = false;
+
+ // Hijack navigation to prevent links opening in the web view.
+ webView.CoreWebView2.NavigationStarting += (_, e) =>
+ {
+ // webView.NavigateToString uses data URIs, so allow those to work.
+ if (e.Uri.StartsWith("data:text/html", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ // Prevent the web view from trying to navigate to it.
+ e.Cancel = true;
+
+ // Launch HTTP or HTTPS URLs in the default browser.
+ if (Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri) && uri is { Scheme: "http" or "https" })
+ Process.Start(new ProcessStartInfo(e.Uri) { UseShellExecute = true });
+ };
+ webView.CoreWebView2.NewWindowRequested += (_, e) =>
+ {
+ // Prevent new windows from being launched (e.g. target="_blank").
+ e.Handled = true;
+ // Launch HTTP or HTTPS URLs in the default browser.
+ if (Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri) && uri is { Scheme: "http" or "https" })
+ Process.Start(new ProcessStartInfo(e.Uri) { UseShellExecute = true });
+ };
+
+ var html = await ChangelogHtml(CurrentItem);
+ webView.NavigateToString(html);
+ }
+
+ private void SendResponse(UpdateAvailableResult result)
+ {
+ Result = result;
+ UserResponded?.Invoke(this, new UpdateResponseEventArgs(result, CurrentItem));
+ }
+
+ public void SkipButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (!SkipButtonVisible || MissingCriticalUpdate)
+ return;
+ SendResponse(UpdateAvailableResult.SkipUpdate);
+ }
+
+ public void RemindMeLaterButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (!RemindMeLaterButtonVisible || MissingCriticalUpdate)
+ return;
+ SendResponse(UpdateAvailableResult.RemindMeLater);
+ }
+
+ public void InstallButton_Click(object sender, RoutedEventArgs e)
+ {
+ SendResponse(UpdateAvailableResult.InstallUpdate);
+ }
+}
diff --git a/App/Views/MessageWindow.xaml b/App/Views/MessageWindow.xaml
new file mode 100644
index 0000000..e38ee4f
--- /dev/null
+++ b/App/Views/MessageWindow.xaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/MessageWindow.xaml.cs b/App/Views/MessageWindow.xaml.cs
new file mode 100644
index 0000000..00ed204
--- /dev/null
+++ b/App/Views/MessageWindow.xaml.cs
@@ -0,0 +1,32 @@
+using Coder.Desktop.App.Utils;
+using Microsoft.UI.Xaml;
+using WinUIEx;
+
+namespace Coder.Desktop.App.Views;
+
+public sealed partial class MessageWindow : WindowEx
+{
+ public string MessageTitle;
+ public string MessageContent;
+
+ public MessageWindow(string title, string content, string windowTitle = "Coder Desktop")
+ {
+ Title = windowTitle;
+ MessageTitle = title;
+ MessageContent = content;
+
+ InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+ this.CenterOnScreen();
+ AppWindow.Show();
+
+ // TODO: the window should resize to fit content and not be resizable
+ // by the user, probably possible with SizedFrame and a Page, but
+ // I didn't want to add a Page for this
+ }
+
+ public void CloseClicked(object? sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+}
diff --git a/App/Views/Pages/SettingsMainPage.xaml b/App/Views/Pages/SettingsMainPage.xaml
new file mode 100644
index 0000000..5ae7230
--- /dev/null
+++ b/App/Views/Pages/SettingsMainPage.xaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/SettingsMainPage.xaml.cs b/App/Views/Pages/SettingsMainPage.xaml.cs
new file mode 100644
index 0000000..f2494b1
--- /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;
+ InitializeComponent();
+ }
+}
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 @@
diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml
index 283867d..9f27fb1 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">
@@ -43,6 +43,8 @@
+
+
+
+
+
+
+
@@ -342,9 +360,9 @@
+ HorizontalContentAlignment="Left">
diff --git a/App/Views/Pages/UpdaterDownloadProgressMainPage.xaml b/App/Views/Pages/UpdaterDownloadProgressMainPage.xaml
new file mode 100644
index 0000000..ba54bea
--- /dev/null
+++ b/App/Views/Pages/UpdaterDownloadProgressMainPage.xaml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/UpdaterDownloadProgressMainPage.xaml.cs b/App/Views/Pages/UpdaterDownloadProgressMainPage.xaml.cs
new file mode 100644
index 0000000..3ca6cc2
--- /dev/null
+++ b/App/Views/Pages/UpdaterDownloadProgressMainPage.xaml.cs
@@ -0,0 +1,14 @@
+using Microsoft.UI.Xaml.Controls;
+using Coder.Desktop.App.ViewModels;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class UpdaterDownloadProgressMainPage : Page
+{
+ public readonly UpdaterDownloadProgressViewModel ViewModel;
+ public UpdaterDownloadProgressMainPage(UpdaterDownloadProgressViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ InitializeComponent();
+ }
+}
diff --git a/App/Views/Pages/UpdaterUpdateAvailableMainPage.xaml b/App/Views/Pages/UpdaterUpdateAvailableMainPage.xaml
new file mode 100644
index 0000000..68faee9
--- /dev/null
+++ b/App/Views/Pages/UpdaterUpdateAvailableMainPage.xaml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/UpdaterUpdateAvailableMainPage.xaml.cs b/App/Views/Pages/UpdaterUpdateAvailableMainPage.xaml.cs
new file mode 100644
index 0000000..cb2634c
--- /dev/null
+++ b/App/Views/Pages/UpdaterUpdateAvailableMainPage.xaml.cs
@@ -0,0 +1,15 @@
+using Microsoft.UI.Xaml.Controls;
+using Coder.Desktop.App.ViewModels;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class UpdaterUpdateAvailableMainPage : Page
+{
+ public readonly UpdaterUpdateAvailableViewModel ViewModel;
+
+ public UpdaterUpdateAvailableMainPage(UpdaterUpdateAvailableViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ InitializeComponent();
+ }
+}
diff --git a/App/Views/SettingsWindow.xaml b/App/Views/SettingsWindow.xaml
new file mode 100644
index 0000000..a84bbc4
--- /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..7cc9661
--- /dev/null
+++ b/App/Views/SettingsWindow.xaml.cs
@@ -0,0 +1,25 @@
+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();
+
+ RootFrame.Content = new SettingsMainPage(ViewModel);
+
+ this.CenterOnScreen();
+ }
+}
diff --git a/App/Views/TrayWindow.xaml b/App/Views/TrayWindow.xaml
index cfc4214..f5b4b01 100644
--- a/App/Views/TrayWindow.xaml
+++ b/App/Views/TrayWindow.xaml
@@ -15,7 +15,7 @@
-
+
diff --git a/App/Views/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs
index 7ecd75c..e505511 100644
--- a/App/Views/TrayWindow.xaml.cs
+++ b/App/Views/TrayWindow.xaml.cs
@@ -12,8 +12,8 @@
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Animation;
using System;
-using System.Diagnostics;
using System.Runtime.InteropServices;
+using System.Threading.Tasks;
using Windows.Graphics;
using Windows.System;
using Windows.UI.Core;
@@ -39,13 +39,14 @@ public sealed partial class TrayWindow : Window
private readonly IRpcController _rpcController;
private readonly ICredentialManager _credentialManager;
private readonly ISyncSessionController _syncSessionController;
+ private readonly IUpdateController _updateController;
private readonly TrayWindowLoadingPage _loadingPage;
private readonly TrayWindowDisconnectedPage _disconnectedPage;
private readonly TrayWindowLoginRequiredPage _loginRequiredPage;
private readonly TrayWindowMainPage _mainPage;
public TrayWindow(IRpcController rpcController, ICredentialManager credentialManager,
- ISyncSessionController syncSessionController,
+ ISyncSessionController syncSessionController, IUpdateController updateController,
TrayWindowLoadingPage loadingPage,
TrayWindowDisconnectedPage disconnectedPage, TrayWindowLoginRequiredPage loginRequiredPage,
TrayWindowMainPage mainPage)
@@ -53,6 +54,7 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
_rpcController = rpcController;
_credentialManager = credentialManager;
_syncSessionController = syncSessionController;
+ _updateController = updateController;
_loadingPage = loadingPage;
_disconnectedPage = disconnectedPage;
_loginRequiredPage = loginRequiredPage;
@@ -70,8 +72,9 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
SetPageByState(_rpcController.GetState(), _credentialManager.GetCachedCredentials(),
_syncSessionController.GetState());
- // Setting OpenCommand and ExitCommand directly in the .xaml doesn't seem to work for whatever reason.
+ // Setting these directly in the .xaml doesn't seem to work for whatever reason.
TrayIcon.OpenCommand = Tray_OpenCommand;
+ TrayIcon.CheckForUpdatesCommand = Tray_CheckForUpdatesCommand;
TrayIcon.ExitCommand = Tray_ExitCommand;
// Hide the title bar and buttons. WinUi 3 provides a method to do this with
@@ -118,7 +121,6 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
};
}
-
private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel,
SyncSessionControllerStateModel syncSessionModel)
{
@@ -231,7 +233,7 @@ private void MoveResizeAndActivate()
var rect = new RectInt32(pos.X, pos.Y, size.Width, size.Height);
AppWindow.MoveAndResize(rect);
AppWindow.Show();
- NativeApi.SetForegroundWindow(WindowNative.GetWindowHandle(this));
+ ForegroundWindow.MakeForeground(this);
}
private void SaveCursorPos()
@@ -314,6 +316,13 @@ private void Tray_Open()
MoveResizeAndActivate();
}
+ [RelayCommand]
+ private async Task Tray_CheckForUpdates()
+ {
+ // Handles errors itself for the most part.
+ await _updateController.CheckForUpdatesNow();
+ }
+
[RelayCommand]
private void Tray_Exit()
{
@@ -329,9 +338,6 @@ public static class NativeApi
[DllImport("user32.dll")]
public static extern bool GetCursorPos(out POINT lpPoint);
- [DllImport("user32.dll")]
- public static extern bool SetForegroundWindow(IntPtr hwnd);
-
public struct POINT
{
public int X;
diff --git a/App/Views/UpdaterCheckingForUpdatesWindow.xaml b/App/Views/UpdaterCheckingForUpdatesWindow.xaml
new file mode 100644
index 0000000..f8a02b4
--- /dev/null
+++ b/App/Views/UpdaterCheckingForUpdatesWindow.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/UpdaterCheckingForUpdatesWindow.xaml.cs b/App/Views/UpdaterCheckingForUpdatesWindow.xaml.cs
new file mode 100644
index 0000000..10a3ae2
--- /dev/null
+++ b/App/Views/UpdaterCheckingForUpdatesWindow.xaml.cs
@@ -0,0 +1,32 @@
+using System;
+using Coder.Desktop.App.Utils;
+using NetSparkleUpdater.Interfaces;
+using WinUIEx;
+
+namespace Coder.Desktop.App.Views;
+
+public sealed partial class UpdaterCheckingForUpdatesWindow : WindowEx, ICheckingForUpdates
+{
+ // Implements ICheckingForUpdates
+ public event EventHandler? UpdatesUIClosing;
+
+ public UpdaterCheckingForUpdatesWindow()
+ {
+ InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+ AppWindow.Hide();
+
+ Closed += (_, _) => UpdatesUIClosing?.Invoke(this, EventArgs.Empty);
+ }
+
+ void ICheckingForUpdates.Show()
+ {
+ AppWindow.Show();
+ this.CenterOnScreen();
+ }
+
+ void ICheckingForUpdates.Close()
+ {
+ Close();
+ }
+}
diff --git a/App/Views/UpdaterDownloadProgressWindow.xaml b/App/Views/UpdaterDownloadProgressWindow.xaml
new file mode 100644
index 0000000..8976766
--- /dev/null
+++ b/App/Views/UpdaterDownloadProgressWindow.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/UpdaterDownloadProgressWindow.xaml.cs b/App/Views/UpdaterDownloadProgressWindow.xaml.cs
new file mode 100644
index 0000000..a00123c
--- /dev/null
+++ b/App/Views/UpdaterDownloadProgressWindow.xaml.cs
@@ -0,0 +1,83 @@
+using Coder.Desktop.App.Utils;
+using Coder.Desktop.App.ViewModels;
+using Coder.Desktop.App.Views.Pages;
+using NetSparkleUpdater.Events;
+using NetSparkleUpdater.Interfaces;
+using WinUIEx;
+using WindowEventArgs = Microsoft.UI.Xaml.WindowEventArgs;
+
+namespace Coder.Desktop.App.Views;
+
+public sealed partial class UpdaterDownloadProgressWindow : WindowEx, IDownloadProgress
+{
+ // Implements IDownloadProgress
+ public event DownloadInstallEventHandler? DownloadProcessCompleted;
+
+ public UpdaterDownloadProgressViewModel ViewModel;
+
+ private bool _downloadProcessCompletedInvoked;
+
+ public UpdaterDownloadProgressWindow(UpdaterDownloadProgressViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ ViewModel.DownloadProcessCompleted += (_, args) => SendResponse(args);
+
+ InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+ AppWindow.Hide();
+
+ RootFrame.Content = new UpdaterDownloadProgressMainPage(ViewModel);
+
+ Closed += UpdaterDownloadProgressWindow_Closed;
+ }
+
+ public void SendResponse(DownloadInstallEventArgs args)
+ {
+ if (_downloadProcessCompletedInvoked)
+ return;
+ _downloadProcessCompletedInvoked = true;
+ DownloadProcessCompleted?.Invoke(this, args);
+ }
+
+ private void UpdaterDownloadProgressWindow_Closed(object sender, WindowEventArgs args)
+ {
+ SendResponse(new DownloadInstallEventArgs(false)); // Cancel
+ }
+
+ void IDownloadProgress.SetDownloadAndInstallButtonEnabled(bool shouldBeEnabled)
+ {
+ ViewModel.SetActionButtonEnabled(shouldBeEnabled);
+ }
+
+ void IDownloadProgress.Show()
+ {
+ AppWindow.Show();
+ this.CenterOnScreen();
+ }
+
+ void IDownloadProgress.Close()
+ {
+ Close();
+ }
+
+ void IDownloadProgress.OnDownloadProgressChanged(object sender, ItemDownloadProgressEventArgs args)
+ {
+ ViewModel.SetDownloadProgress((ulong)args.BytesReceived, (ulong)args.TotalBytesToReceive);
+ }
+
+ void IDownloadProgress.FinishedDownloadingFile(bool isDownloadedFileValid)
+ {
+ ViewModel.SetFinishedDownloading(isDownloadedFileValid);
+ }
+
+ bool IDownloadProgress.DisplayErrorMessage(string errorMessage)
+ {
+ // TODO: this is pretty lazy but works for now
+ _ = new MessageWindow(
+ "Download failed",
+ errorMessage,
+ "Coder Desktop Updater");
+ Close();
+ return true;
+ }
+}
diff --git a/App/Views/UpdaterUpdateAvailableWindow.xaml b/App/Views/UpdaterUpdateAvailableWindow.xaml
new file mode 100644
index 0000000..9e13972
--- /dev/null
+++ b/App/Views/UpdaterUpdateAvailableWindow.xaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/UpdaterUpdateAvailableWindow.xaml.cs b/App/Views/UpdaterUpdateAvailableWindow.xaml.cs
new file mode 100644
index 0000000..0a8e32b
--- /dev/null
+++ b/App/Views/UpdaterUpdateAvailableWindow.xaml.cs
@@ -0,0 +1,89 @@
+using Coder.Desktop.App.Utils;
+using Coder.Desktop.App.ViewModels;
+using Coder.Desktop.App.Views.Pages;
+using Microsoft.UI.Xaml;
+using NetSparkleUpdater;
+using NetSparkleUpdater.Enums;
+using NetSparkleUpdater.Events;
+using NetSparkleUpdater.Interfaces;
+using WinUIEx;
+
+namespace Coder.Desktop.App.Views;
+
+public sealed partial class UpdaterUpdateAvailableWindow : WindowEx, IUpdateAvailable
+{
+ public readonly UpdaterUpdateAvailableViewModel ViewModel;
+
+ // Implements IUpdateAvailable
+ public UpdateAvailableResult Result => ViewModel.Result;
+ // Implements IUpdateAvailable
+ public AppCastItem CurrentItem => ViewModel.CurrentItem;
+ // Implements IUpdateAvailable
+ public event UserRespondedToUpdate? UserResponded;
+
+ private bool _respondedToUpdate;
+
+ public UpdaterUpdateAvailableWindow(UpdaterUpdateAvailableViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ ViewModel.UserResponded += (_, args) =>
+ UserRespondedToUpdateCheck(args.Result);
+
+ InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+ AppWindow.Hide();
+
+ RootFrame.Content = new UpdaterUpdateAvailableMainPage(ViewModel);
+
+ Closed += UpdaterUpdateAvailableWindow_Closed;
+ }
+
+ private void UpdaterUpdateAvailableWindow_Closed(object sender, WindowEventArgs args)
+ {
+ UserRespondedToUpdateCheck(UpdateAvailableResult.None);
+ }
+
+ void IUpdateAvailable.Show()
+ {
+ AppWindow.Show();
+ this.CenterOnScreen();
+ }
+
+ void IUpdateAvailable.Close()
+ {
+ // The NetSparkle built-in Avalonia UI does this "just in case"
+ UserRespondedToUpdateCheck(UpdateAvailableResult.None);
+ Close();
+ }
+
+ void IUpdateAvailable.HideReleaseNotes()
+ {
+ ViewModel.HideReleaseNotes();
+ }
+
+ void IUpdateAvailable.HideRemindMeLaterButton()
+ {
+ ViewModel.HideRemindMeLaterButton();
+ }
+
+ void IUpdateAvailable.HideSkipButton()
+ {
+ ViewModel.HideSkipButton();
+ }
+
+ void IUpdateAvailable.BringToFront()
+ {
+ Activate();
+ ForegroundWindow.MakeForeground(this);
+ }
+
+ private void UserRespondedToUpdateCheck(UpdateAvailableResult response)
+ {
+ if (_respondedToUpdate)
+ return;
+ _respondedToUpdate = true;
+ UserResponded?.Invoke(this, new UpdateResponseEventArgs(response, CurrentItem));
+ // Prevent further interaction.
+ Close();
+ }
+}
diff --git a/App/packages.lock.json b/App/packages.lock.json
index a47908a..452aae8 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, )",
@@ -105,6 +115,15 @@
"Microsoft.Windows.SDK.BuildTools": "10.0.22621.756"
}
},
+ "NetSparkleUpdater.SparkleUpdater": {
+ "type": "Direct",
+ "requested": "[3.0.2, )",
+ "resolved": "3.0.2",
+ "contentHash": "ruDV/hBjZX7DTFMvcJAgA8bUEB8zkq23i/zwpKKWr/vK/IxWIQESYRfP2JpQfKSqlVFNL5uOlJ86wV6nJAi09w==",
+ "dependencies": {
+ "NetSparkleUpdater.Chaos.NaCl": "0.9.3"
+ }
+ },
"Serilog.Extensions.Hosting": {
"type": "Direct",
"requested": "[9.0.0, )",
@@ -129,6 +148,15 @@
"Serilog": "4.2.0"
}
},
+ "Serilog.Sinks.Debug": {
+ "type": "Direct",
+ "requested": "[3.0.0, )",
+ "resolved": "3.0.0",
+ "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
"Serilog.Sinks.File": {
"type": "Direct",
"requested": "[6.0.0, )",
@@ -152,6 +180,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",
@@ -456,6 +502,11 @@
"resolved": "10.0.22621.756",
"contentHash": "7ZL2sFSioYm1Ry067Kw1hg0SCcW5kuVezC2SwjGbcPE61Nn+gTbH86T73G3LcEOVj0S3IZzNuE/29gZvOLS7VA=="
},
+ "NetSparkleUpdater.Chaos.NaCl": {
+ "type": "Transitive",
+ "resolved": "0.9.3",
+ "contentHash": "Copo3+rYuRVOuc6fmzHwXwehDC8l8DQ3y2VRI/d0sQTwProL4QAjkxRPV0zr3XBz1A8ZODXATOXV0hJXc7YdKg=="
+ },
"Semver": {
"type": "Transitive",
"resolved": "3.0.0",
diff --git a/Installer/Program.cs b/Installer/Program.cs
index f02f9b2..3d300ed 100644
--- a/Installer/Program.cs
+++ b/Installer/Program.cs
@@ -263,7 +263,6 @@ private static int BuildMsiPackage(MsiOptions opts)
programFiles64Folder.AddDir(installDir);
project.AddDir(programFiles64Folder);
-
project.AddRegValues(
// Add registry values that are consumed by the manager. Note that these
// should not be changed. See Vpn.Service/Program.cs (AddDefaultConfig) and
diff --git a/README.md b/README.md
index 74c7101..963be46 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,14 @@
# Coder Desktop for Windows
+Coder Desktop allows you to work on your Coder workspaces as though they're
+on your local network, with no port-forwarding required. It provides seamless
+access to your remote development environments through features like Coder
+Connect (VPN-like connectivity) and file synchronization between local and
+remote directories.
+
+Learn more about Coder Desktop in the
+[official documentation](https://coder.com/docs/user-guides/desktop).
+
This repo contains the C# source code for Coder Desktop for Windows. You can
download the latest version from the GitHub releases.
@@ -26,4 +35,4 @@ files can be found in the same directory as the files.
The binary distributions of Coder Desktop for Windows have some additional
license disclaimers that can be found in
-[scripts/files/License.txt](scripts/files/License.txt) or during installation.
+[scripts/files/License.txt](scripts/files/License.txt) or during installation.
\ No newline at end of file
diff --git a/Tests.App/Services/SettingsManagerTest.cs b/Tests.App/Services/SettingsManagerTest.cs
new file mode 100644
index 0000000..44f5c06
--- /dev/null
+++ b/Tests.App/Services/SettingsManagerTest.cs
@@ -0,0 +1,45 @@
+using Coder.Desktop.App.Models;
+using Coder.Desktop.App.Services;
+
+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_Persists()
+ {
+ var expected = true;
+ 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()
+ {
+ var actual = _sut.Read().GetAwaiter().GetResult();
+ Assert.That(actual.ConnectOnLaunch, Is.False);
+ }
+}
diff --git a/Tests.Vpn.Service/DownloaderTest.cs b/Tests.Vpn.Service/DownloaderTest.cs
index 986ce46..4d95721 100644
--- a/Tests.Vpn.Service/DownloaderTest.cs
+++ b/Tests.Vpn.Service/DownloaderTest.cs
@@ -277,8 +277,8 @@ public async Task Download(CancellationToken ct)
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
await dlTask.Task;
- Assert.That(dlTask.TotalBytes, Is.EqualTo(4));
- Assert.That(dlTask.BytesRead, Is.EqualTo(4));
+ Assert.That(dlTask.BytesTotal, Is.EqualTo(4));
+ Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
Assert.That(dlTask.Progress, Is.EqualTo(1));
Assert.That(dlTask.IsCompleted, Is.True);
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
@@ -300,18 +300,64 @@ public async Task DownloadSameDest(CancellationToken ct)
NullDownloadValidator.Instance, ct);
var dlTask0 = await startTask0;
await dlTask0.Task;
- Assert.That(dlTask0.TotalBytes, Is.EqualTo(5));
- Assert.That(dlTask0.BytesRead, Is.EqualTo(5));
+ Assert.That(dlTask0.BytesTotal, Is.EqualTo(5));
+ Assert.That(dlTask0.BytesWritten, Is.EqualTo(5));
Assert.That(dlTask0.Progress, Is.EqualTo(1));
Assert.That(dlTask0.IsCompleted, Is.True);
var dlTask1 = await startTask1;
await dlTask1.Task;
- Assert.That(dlTask1.TotalBytes, Is.EqualTo(5));
- Assert.That(dlTask1.BytesRead, Is.EqualTo(5));
+ Assert.That(dlTask1.BytesTotal, Is.EqualTo(5));
+ Assert.That(dlTask1.BytesWritten, Is.EqualTo(5));
Assert.That(dlTask1.Progress, Is.EqualTo(1));
Assert.That(dlTask1.IsCompleted, Is.True);
}
+ [Test(Description = "Download with X-Original-Content-Length")]
+ [CancelAfter(30_000)]
+ public async Task DownloadWithXOriginalContentLength(CancellationToken ct)
+ {
+ using var httpServer = new TestHttpServer(async ctx =>
+ {
+ ctx.Response.StatusCode = 200;
+ ctx.Response.Headers.Add("X-Original-Content-Length", "4");
+ ctx.Response.ContentType = "text/plain";
+ // Don't set Content-Length.
+ await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), ct);
+ await ctx.Response.OutputStream.FlushAsync(ct);
+ });
+ var url = new Uri(httpServer.BaseUrl + "/test");
+ var destPath = Path.Combine(_tempDir, "test");
+ var manager = new Downloader(NullLogger.Instance);
+ var req = new HttpRequestMessage(HttpMethod.Get, url);
+ var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct);
+
+ await dlTask.Task;
+ Assert.That(dlTask.BytesTotal, Is.EqualTo(4));
+ Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
+ }
+
+ [Test(Description = "Download with mismatched Content-Length")]
+ [CancelAfter(30_000)]
+ public async Task DownloadWithMismatchedContentLength(CancellationToken ct)
+ {
+ using var httpServer = new TestHttpServer(async ctx =>
+ {
+ ctx.Response.StatusCode = 200;
+ ctx.Response.Headers.Add("X-Original-Content-Length", "5"); // incorrect
+ ctx.Response.ContentType = "text/plain";
+ await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), ct);
+ await ctx.Response.OutputStream.FlushAsync(ct);
+ });
+ var url = new Uri(httpServer.BaseUrl + "/test");
+ var destPath = Path.Combine(_tempDir, "test");
+ var manager = new Downloader(NullLogger.Instance);
+ var req = new HttpRequestMessage(HttpMethod.Get, url);
+ var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct);
+
+ var ex = Assert.ThrowsAsync(() => dlTask.Task);
+ Assert.That(ex.Message, Is.EqualTo("Downloaded file size does not match expected response content length: Expected=5, BytesWritten=4"));
+ }
+
[Test(Description = "Download with custom headers")]
[CancelAfter(30_000)]
public async Task WithHeaders(CancellationToken ct)
@@ -347,7 +393,7 @@ public async Task DownloadExisting(CancellationToken ct)
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
await dlTask.Task;
- Assert.That(dlTask.BytesRead, Is.Zero);
+ Assert.That(dlTask.BytesWritten, Is.Zero);
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
Assert.That(File.GetLastWriteTime(destPath), Is.LessThan(DateTime.Now - TimeSpan.FromDays(1)));
}
@@ -368,7 +414,7 @@ public async Task DownloadExistingDifferentContent(CancellationToken ct)
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
await dlTask.Task;
- Assert.That(dlTask.BytesRead, Is.EqualTo(4));
+ Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
Assert.That(File.GetLastWriteTime(destPath), Is.GreaterThan(DateTime.Now - TimeSpan.FromDays(1)));
}
diff --git a/Vpn.Proto/packages.lock.json b/Vpn.Proto/packages.lock.json
index 706ff4e..3cbfd8e 100644
--- a/Vpn.Proto/packages.lock.json
+++ b/Vpn.Proto/packages.lock.json
@@ -13,9 +13,6 @@
"requested": "[2.69.0, )",
"resolved": "2.69.0",
"contentHash": "W5hW4R1h19FCzKb8ToqIJMI5YxnQqGmREEpV8E5XkfCtLPIK5MSHztwQ8gZUfG8qu9fg5MhItjzyPRqQBjnrbA=="
- },
- "Coder.Desktop.CoderSdk": {
- "type": "Project"
}
}
}
diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto
index 2561a4b..bace7e0 100644
--- a/Vpn.Proto/vpn.proto
+++ b/Vpn.Proto/vpn.proto
@@ -60,7 +60,8 @@ message ServiceMessage {
oneof msg {
StartResponse start = 2;
StopResponse stop = 3;
- Status status = 4; // either in reply to a StatusRequest or broadcasted
+ Status status = 4; // either in reply to a StatusRequest or broadcasted
+ StartProgress start_progress = 5; // broadcasted during startup
}
}
@@ -218,6 +219,28 @@ message StartResponse {
string error_message = 2;
}
+// StartProgress is sent from the manager to the client to indicate the
+// download/startup progress of the tunnel. This will be sent during the
+// processing of a StartRequest before the StartResponse is sent.
+//
+// Note: this is currently a broadcasted message to all clients due to the
+// inability to easily send messages to a specific client in the Speaker
+// implementation. If clients are not expecting these messages, they
+// should ignore them.
+enum StartProgressStage {
+ Initializing = 0;
+ Downloading = 1;
+ Finalizing = 2;
+}
+message StartProgressDownloadProgress {
+ uint64 bytes_written = 1;
+ optional uint64 bytes_total = 2; // unknown in some situations
+}
+message StartProgress {
+ StartProgressStage stage = 1;
+ optional StartProgressDownloadProgress download_progress = 2; // only set when stage == Downloading
+}
+
// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a
// StopResponse.
message StopRequest {}
diff --git a/Vpn.Service/Downloader.cs b/Vpn.Service/Downloader.cs
index 6a3108b..c4a916f 100644
--- a/Vpn.Service/Downloader.cs
+++ b/Vpn.Service/Downloader.cs
@@ -339,31 +339,35 @@ internal static async Task TaskOrCancellation(Task task, CancellationToken cance
}
///
-/// Downloads an Url to a file on disk. The download will be written to a temporary file first, then moved to the final
+/// Downloads a Url to a file on disk. The download will be written to a temporary file first, then moved to the final
/// destination. The SHA1 of any existing file will be calculated and used as an ETag to avoid downloading the file if
/// it hasn't changed.
///
public class DownloadTask
{
- private const int BufferSize = 4096;
+ private const int BufferSize = 64 * 1024;
+ private const string XOriginalContentLengthHeader = "X-Original-Content-Length"; // overrides Content-Length if available
- private static readonly HttpClient HttpClient = new();
+ private static readonly HttpClient HttpClient = new(new HttpClientHandler
+ {
+ AutomaticDecompression = DecompressionMethods.All,
+ });
private readonly string _destinationDirectory;
private readonly ILogger _logger;
private readonly RaiiSemaphoreSlim _semaphore = new(1, 1);
private readonly IDownloadValidator _validator;
- public readonly string DestinationPath;
+ private readonly string _destinationPath;
+ private readonly string _tempDestinationPath;
public readonly HttpRequestMessage Request;
- public readonly string TempDestinationPath;
- public ulong? TotalBytes { get; private set; }
- public ulong BytesRead { get; private set; }
public Task Task { get; private set; } = null!; // Set in EnsureStartedAsync
-
- public double? Progress => TotalBytes == null ? null : (double)BytesRead / TotalBytes.Value;
+ public bool DownloadStarted { get; private set; } // Whether we've received headers yet and started the actual download
+ public ulong BytesWritten { get; private set; }
+ public ulong? BytesTotal { get; private set; }
+ public double? Progress => BytesTotal == null ? null : (double)BytesWritten / BytesTotal.Value;
public bool IsCompleted => Task.IsCompleted;
internal DownloadTask(ILogger logger, HttpRequestMessage req, string destinationPath, IDownloadValidator validator)
@@ -374,17 +378,17 @@ internal DownloadTask(ILogger logger, HttpRequestMessage req, string destination
if (string.IsNullOrWhiteSpace(destinationPath))
throw new ArgumentException("Destination path must not be empty", nameof(destinationPath));
- DestinationPath = Path.GetFullPath(destinationPath);
- if (Path.EndsInDirectorySeparator(DestinationPath))
- throw new ArgumentException($"Destination path '{DestinationPath}' must not end in a directory separator",
+ _destinationPath = Path.GetFullPath(destinationPath);
+ if (Path.EndsInDirectorySeparator(_destinationPath))
+ throw new ArgumentException($"Destination path '{_destinationPath}' must not end in a directory separator",
nameof(destinationPath));
- _destinationDirectory = Path.GetDirectoryName(DestinationPath)
+ _destinationDirectory = Path.GetDirectoryName(_destinationPath)
?? throw new ArgumentException(
- $"Destination path '{DestinationPath}' must have a parent directory",
+ $"Destination path '{_destinationPath}' must have a parent directory",
nameof(destinationPath));
- TempDestinationPath = Path.Combine(_destinationDirectory, "." + Path.GetFileName(DestinationPath) +
+ _tempDestinationPath = Path.Combine(_destinationDirectory, "." + Path.GetFileName(_destinationPath) +
".download-" + Path.GetRandomFileName());
}
@@ -406,9 +410,9 @@ private async Task Start(CancellationToken ct = default)
// If the destination path exists, generate a Coder SHA1 ETag and send
// it in the If-None-Match header to the server.
- if (File.Exists(DestinationPath))
+ if (File.Exists(_destinationPath))
{
- await using var stream = File.OpenRead(DestinationPath);
+ await using var stream = File.OpenRead(_destinationPath);
var etag = Convert.ToHexString(await SHA1.HashDataAsync(stream, ct)).ToLower();
Request.Headers.Add("If-None-Match", "\"" + etag + "\"");
}
@@ -419,11 +423,11 @@ private async Task Start(CancellationToken ct = default)
_logger.LogInformation("File has not been modified, skipping download");
try
{
- await _validator.ValidateAsync(DestinationPath, ct);
+ await _validator.ValidateAsync(_destinationPath, ct);
}
catch (Exception e)
{
- _logger.LogWarning(e, "Existing file '{DestinationPath}' failed custom validation", DestinationPath);
+ _logger.LogWarning(e, "Existing file '{DestinationPath}' failed custom validation", _destinationPath);
throw new Exception("Existing file failed validation after 304 Not Modified", e);
}
@@ -446,24 +450,38 @@ private async Task Start(CancellationToken ct = default)
}
if (res.Content.Headers.ContentLength >= 0)
- TotalBytes = (ulong)res.Content.Headers.ContentLength;
+ BytesTotal = (ulong)res.Content.Headers.ContentLength;
+
+ // X-Original-Content-Length overrules Content-Length if set.
+ if (res.Headers.TryGetValues(XOriginalContentLengthHeader, out var headerValues))
+ {
+ // If there are multiple we only look at the first one.
+ var headerValue = headerValues.ToList().FirstOrDefault();
+ if (!string.IsNullOrEmpty(headerValue) && ulong.TryParse(headerValue, out var originalContentLength))
+ BytesTotal = originalContentLength;
+ else
+ _logger.LogWarning(
+ "Failed to parse {XOriginalContentLengthHeader} header value '{HeaderValue}'",
+ XOriginalContentLengthHeader, headerValue);
+ }
await Download(res, ct);
}
private async Task Download(HttpResponseMessage res, CancellationToken ct)
{
+ DownloadStarted = true;
try
{
var sha1 = res.Headers.Contains("ETag") ? SHA1.Create() : null;
FileStream tempFile;
try
{
- tempFile = File.Create(TempDestinationPath, BufferSize, FileOptions.SequentialScan);
+ tempFile = File.Create(_tempDestinationPath, BufferSize, FileOptions.SequentialScan);
}
catch (Exception e)
{
- _logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", TempDestinationPath);
+ _logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", _tempDestinationPath);
throw;
}
@@ -476,13 +494,14 @@ private async Task Download(HttpResponseMessage res, CancellationToken ct)
{
await tempFile.WriteAsync(buffer.AsMemory(0, n), ct);
sha1?.TransformBlock(buffer, 0, n, null, 0);
- BytesRead += (ulong)n;
+ BytesWritten += (ulong)n;
}
}
- if (TotalBytes != null && BytesRead != TotalBytes)
+ BytesTotal ??= BytesWritten;
+ if (BytesWritten != BytesTotal)
throw new IOException(
- $"Downloaded file size does not match response Content-Length: Content-Length={TotalBytes}, BytesRead={BytesRead}");
+ $"Downloaded file size does not match expected response content length: Expected={BytesTotal}, BytesWritten={BytesWritten}");
// Verify the ETag if it was sent by the server.
if (res.Headers.Contains("ETag") && sha1 != null)
@@ -497,26 +516,34 @@ private async Task Download(HttpResponseMessage res, CancellationToken ct)
try
{
- await _validator.ValidateAsync(TempDestinationPath, ct);
+ await _validator.ValidateAsync(_tempDestinationPath, ct);
}
catch (Exception e)
{
_logger.LogWarning(e, "Downloaded file '{TempDestinationPath}' failed custom validation",
- TempDestinationPath);
+ _tempDestinationPath);
throw new HttpRequestException("Downloaded file failed validation", e);
}
- File.Move(TempDestinationPath, DestinationPath, true);
+ File.Move(_tempDestinationPath, _destinationPath, true);
}
- finally
+ catch
{
#if DEBUG
_logger.LogWarning("Not deleting temporary file '{TempDestinationPath}' in debug mode",
- TempDestinationPath);
+ _tempDestinationPath);
#else
- if (File.Exists(TempDestinationPath))
- File.Delete(TempDestinationPath);
+ try
+ {
+ if (File.Exists(_tempDestinationPath))
+ File.Delete(_tempDestinationPath);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Failed to delete temporary file '{TempDestinationPath}'", _tempDestinationPath);
+ }
#endif
+ throw;
}
}
}
diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs
index fc014c0..fdb62af 100644
--- a/Vpn.Service/Manager.cs
+++ b/Vpn.Service/Manager.cs
@@ -131,6 +131,8 @@ private async ValueTask HandleClientMessageStart(ClientMessage me
{
try
{
+ await BroadcastStartProgress(StartProgressStage.Initializing, cancellationToken: ct);
+
var serverVersion =
await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken, ct);
if (_status == TunnelStatus.Started && _lastStartRequest != null &&
@@ -151,10 +153,14 @@ private async ValueTask HandleClientMessageStart(ClientMessage me
_lastServerVersion = serverVersion;
// TODO: each section of this operation needs a timeout
+
// Stop the tunnel if it's running so we don't have to worry about
// permissions issues when replacing the binary.
await _tunnelSupervisor.StopAsync(ct);
+
await DownloadTunnelBinaryAsync(message.Start.CoderUrl, serverVersion.SemVersion, ct);
+
+ await BroadcastStartProgress(StartProgressStage.Finalizing, cancellationToken: ct);
await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMessage,
HandleTunnelRpcError,
ct);
@@ -237,6 +243,9 @@ private void HandleTunnelRpcMessage(ReplyableRpcMessage CurrentStatus(CancellationToken ct = default)
private async Task BroadcastStatus(TunnelStatus? newStatus = null, CancellationToken ct = default)
{
if (newStatus != null) _status = newStatus.Value;
- await _managerRpc.BroadcastAsync(new ServiceMessage
+ await FallibleBroadcast(new ServiceMessage
{
Status = await CurrentStatus(ct),
}, ct);
}
+ private async Task FallibleBroadcast(ServiceMessage message, CancellationToken ct = default)
+ {
+ // Broadcast the messages out with a low timeout. If clients don't
+ // receive broadcasts in time, it's not a big deal.
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromMilliseconds(30));
+ try
+ {
+ await _managerRpc.BroadcastAsync(message, cts.Token);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Could not broadcast low priority message to all RPC clients: {Message}", message);
+ }
+ }
+
private void HandleTunnelRpcError(Exception e)
{
_logger.LogError(e, "Manager<->Tunnel RPC error");
@@ -425,12 +450,61 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected
_logger.LogDebug("Skipping tunnel binary version validation");
}
+ // Note: all ETag, signature and version validation is performed by the
+ // DownloadTask.
var downloadTask = await _downloader.StartDownloadAsync(req, _config.TunnelBinaryPath, validators, ct);
- // TODO: monitor and report progress when we have a mechanism to do so
+ // Wait for the download to complete, sending progress updates every
+ // 50ms.
+ while (true)
+ {
+ // Wait for the download to complete, or for a short delay before
+ // we send a progress update.
+ var delayTask = Task.Delay(TimeSpan.FromMilliseconds(50), ct);
+ var winner = await Task.WhenAny([
+ downloadTask.Task,
+ delayTask,
+ ]);
+ if (winner == downloadTask.Task)
+ break;
+
+ // Task.WhenAny will not throw if the winner was cancelled, so
+ // check CT afterward and not beforehand.
+ ct.ThrowIfCancellationRequested();
+
+ if (!downloadTask.DownloadStarted)
+ // Don't send progress updates if we don't know what the
+ // progress is yet.
+ continue;
+
+ var progress = new StartProgressDownloadProgress
+ {
+ BytesWritten = downloadTask.BytesWritten,
+ };
+ if (downloadTask.BytesTotal != null)
+ progress.BytesTotal = downloadTask.BytesTotal.Value;
- // Awaiting this will check the checksum (via the ETag) if the file
- // exists, and will also validate the signature and version.
+ await BroadcastStartProgress(StartProgressStage.Downloading, progress, ct);
+ }
+
+ // Await again to re-throw any exceptions that occurred during the
+ // download.
await downloadTask.Task;
+
+ // We don't send a broadcast here as we immediately send one in the
+ // parent routine.
+ _logger.LogInformation("Completed downloading VPN binary");
+ }
+
+ private async Task BroadcastStartProgress(StartProgressStage stage, StartProgressDownloadProgress? downloadProgress = null, CancellationToken cancellationToken = default)
+ {
+ await FallibleBroadcast(new ServiceMessage
+ {
+ StartProgress = new StartProgress
+ {
+ Stage = stage,
+ DownloadProgress = downloadProgress,
+ },
+ }, cancellationToken);
}
}
diff --git a/Vpn.Service/ManagerRpc.cs b/Vpn.Service/ManagerRpc.cs
index c23752f..4920570 100644
--- a/Vpn.Service/ManagerRpc.cs
+++ b/Vpn.Service/ManagerRpc.cs
@@ -127,14 +127,20 @@ public async Task ExecuteAsync(CancellationToken stoppingToken)
public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct)
{
+ // Sends messages to all clients simultaneously and waits for them all
+ // to send or fail/timeout.
+ //
// Looping over a ConcurrentDictionary is exception-safe, but any items
// added or removed during the loop may or may not be included.
- foreach (var (clientId, client) in _activeClients)
+ await Task.WhenAll(_activeClients.Select(async item =>
+ {
try
{
- var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- cts.CancelAfter(5 * 1000);
- await client.Speaker.SendMessage(message, cts.Token);
+ // Enforce upper bound in case a CT with a timeout wasn't
+ // supplied.
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(2));
+ await item.Value.Speaker.SendMessage(message, cts.Token);
}
catch (ObjectDisposedException)
{
@@ -142,11 +148,12 @@ public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct)
}
catch (Exception e)
{
- _logger.LogWarning(e, "Failed to send message to client {ClientId}", clientId);
+ _logger.LogWarning(e, "Failed to send message to client {ClientId}", item.Key);
// TODO: this should probably kill the client, but due to the
// async nature of the client handling, calling Dispose
// will not remove the client from the active clients list
}
+ }));
}
private async Task HandleRpcClientAsync(ulong clientId, Speaker speaker,
diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs
index fc61247..094875d 100644
--- a/Vpn.Service/Program.cs
+++ b/Vpn.Service/Program.cs
@@ -16,10 +16,12 @@ public static class Program
#if !DEBUG
private const string ServiceName = "Coder Desktop";
private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\VpnService";
+ private const string DefaultLogLevel = "Information";
#else
// This value matches Create-Service.ps1.
private const string ServiceName = "Coder Desktop (Debug)";
private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugVpnService";
+ private const string DefaultLogLevel = "Debug";
#endif
private const string ManagerConfigSection = "Manager";
@@ -81,6 +83,10 @@ private static async Task BuildAndRun(string[] args)
builder.Services.AddSingleton();
// Services
+ builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
+
+ // Either run as a Windows service or a console application
if (!Environment.UserInteractive)
{
MainLogger.Information("Running as a windows service");
@@ -91,9 +97,6 @@ private static async Task BuildAndRun(string[] args)
MainLogger.Information("Running as a console application");
}
- builder.Services.AddHostedService();
- builder.Services.AddHostedService();
-
var host = builder.Build();
Log.Logger = (ILogger)host.Services.GetService(typeof(ILogger))!;
MainLogger.Information("Application is starting");
@@ -108,7 +111,7 @@ private static void AddDefaultConfig(IConfigurationBuilder builder)
["Serilog:Using:0"] = "Serilog.Sinks.File",
["Serilog:Using:1"] = "Serilog.Sinks.Console",
- ["Serilog:MinimumLevel"] = "Information",
+ ["Serilog:MinimumLevel"] = DefaultLogLevel,
["Serilog:Enrich:0"] = "FromLogContext",
["Serilog:WriteTo:0:Name"] = "File",
diff --git a/Vpn.Service/TunnelSupervisor.cs b/Vpn.Service/TunnelSupervisor.cs
index a323cac..7dd6738 100644
--- a/Vpn.Service/TunnelSupervisor.cs
+++ b/Vpn.Service/TunnelSupervisor.cs
@@ -99,18 +99,16 @@ public async Task StartAsync(string binPath,
},
};
// TODO: maybe we should change the log format in the inner binary
- // to something without a timestamp
- var outLogger = Log.ForContext("SourceContext", "coder-vpn.exe[OUT]");
- var errLogger = Log.ForContext("SourceContext", "coder-vpn.exe[ERR]");
+ // to something without a timestamp
_subprocess.OutputDataReceived += (_, args) =>
{
if (!string.IsNullOrWhiteSpace(args.Data))
- outLogger.Debug("{Data}", args.Data);
+ _logger.LogInformation("stdout: {Data}", args.Data);
};
_subprocess.ErrorDataReceived += (_, args) =>
{
if (!string.IsNullOrWhiteSpace(args.Data))
- errLogger.Debug("{Data}", args.Data);
+ _logger.LogInformation("stderr: {Data}", args.Data);
};
// Pass the other end of the pipes to the subprocess and dispose
diff --git a/Vpn/RegistryConfigurationSource.cs b/Vpn/RegistryConfigurationSource.cs
index 2e67b87..bcd5a34 100644
--- a/Vpn/RegistryConfigurationSource.cs
+++ b/Vpn/RegistryConfigurationSource.cs
@@ -7,17 +7,19 @@ public class RegistryConfigurationSource : IConfigurationSource
{
private readonly RegistryKey _root;
private readonly string _subKeyName;
+ private readonly string[] _ignoredPrefixes;
// ReSharper disable once ConvertToPrimaryConstructor
- public RegistryConfigurationSource(RegistryKey root, string subKeyName)
+ public RegistryConfigurationSource(RegistryKey root, string subKeyName, params string[] ignoredPrefixes)
{
_root = root;
_subKeyName = subKeyName;
+ _ignoredPrefixes = ignoredPrefixes;
}
public IConfigurationProvider Build(IConfigurationBuilder builder)
{
- return new RegistryConfigurationProvider(_root, _subKeyName);
+ return new RegistryConfigurationProvider(_root, _subKeyName, _ignoredPrefixes);
}
}
@@ -25,12 +27,14 @@ public class RegistryConfigurationProvider : ConfigurationProvider
{
private readonly RegistryKey _root;
private readonly string _subKeyName;
+ private readonly string[] _ignoredPrefixes;
// ReSharper disable once ConvertToPrimaryConstructor
- public RegistryConfigurationProvider(RegistryKey root, string subKeyName)
+ public RegistryConfigurationProvider(RegistryKey root, string subKeyName, string[] ignoredPrefixes)
{
_root = root;
_subKeyName = subKeyName;
+ _ignoredPrefixes = ignoredPrefixes;
}
public override void Load()
@@ -38,6 +42,11 @@ public override void Load()
using var key = _root.OpenSubKey(_subKeyName);
if (key == null) return;
- foreach (var valueName in key.GetValueNames()) Data[valueName] = key.GetValue(valueName)?.ToString();
+ foreach (var valueName in key.GetValueNames())
+ {
+ if (_ignoredPrefixes.Any(prefix => valueName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
+ continue;
+ Data[valueName] = key.GetValue(valueName)?.ToString();
+ }
}
}
diff --git a/Vpn/Speaker.cs b/Vpn/Speaker.cs
index d113a50..37ec554 100644
--- a/Vpn/Speaker.cs
+++ b/Vpn/Speaker.cs
@@ -123,7 +123,7 @@ public async Task StartAsync(CancellationToken ct = default)
// Handshakes should always finish quickly, so enforce a 5s timeout.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
cts.CancelAfter(TimeSpan.FromSeconds(5));
- await PerformHandshake(ct);
+ await PerformHandshake(cts.Token);
// Start ReceiveLoop in the background.
_receiveTask = ReceiveLoop(_cts.Token);
diff --git a/scripts/Create-AppCastSigningKey.ps1 b/scripts/Create-AppCastSigningKey.ps1
new file mode 100644
index 0000000..209e226
--- /dev/null
+++ b/scripts/Create-AppCastSigningKey.ps1
@@ -0,0 +1,27 @@
+# This is mostly just here for reference.
+#
+# Usage: Create-AppCastSigningKey.ps1 -outputKeyPath
+param (
+ [Parameter(Mandatory = $true)]
+ [string] $outputKeyPath
+)
+
+$ErrorActionPreference = "Stop"
+
+& openssl.exe genpkey -algorithm ed25519 -out $outputKeyPath
+if ($LASTEXITCODE -ne 0) { throw "Failed to generate ED25519 private key" }
+
+# Export the public key in DER format
+$pubKeyDerPath = "$outputKeyPath.pub.der"
+& openssl.exe pkey -in $outputKeyPath -pubout -outform DER -out $pubKeyDerPath
+if ($LASTEXITCODE -ne 0) { throw "Failed to export ED25519 public key" }
+
+# Remove the DER header to get the actual key bytes
+$pubBytes = [System.IO.File]::ReadAllBytes($pubKeyDerPath)[-32..-1]
+Remove-Item $pubKeyDerPath
+
+# Base64 encode and print
+Write-Output "NetSparkle formatted public key:"
+Write-Output ([Convert]::ToBase64String($pubBytes))
+Write-Output ""
+Write-Output "Private key written to $outputKeyPath"
diff --git a/scripts/Get-Mutagen.ps1 b/scripts/Get-Mutagen.ps1
index 8689377..4de1143 100644
--- a/scripts/Get-Mutagen.ps1
+++ b/scripts/Get-Mutagen.ps1
@@ -5,6 +5,8 @@ param (
[string] $arch
)
+$ErrorActionPreference = "Stop"
+
function Download-File([string] $url, [string] $outputPath, [string] $etagFile) {
Write-Host "Downloading '$url' to '$outputPath'"
# We use `curl.exe` here because `Invoke-WebRequest` is notoriously slow.
diff --git a/scripts/Get-WindowsAppSdk.ps1 b/scripts/Get-WindowsAppSdk.ps1
index a9ca02a..3db444f 100644
--- a/scripts/Get-WindowsAppSdk.ps1
+++ b/scripts/Get-WindowsAppSdk.ps1
@@ -5,12 +5,13 @@ param (
[string] $arch
)
+$ErrorActionPreference = "Stop"
+
function Download-File([string] $url, [string] $outputPath, [string] $etagFile) {
Write-Host "Downloading '$url' to '$outputPath'"
# We use `curl.exe` here because `Invoke-WebRequest` is notoriously slow.
& curl.exe `
--progress-bar `
- -v `
--show-error `
--fail `
--location `
@@ -31,4 +32,4 @@ $windowsAppSdkFullVersion = "1.6.250228001"
$windowsAppSdkPath = Join-Path $PSScriptRoot "files\windows-app-sdk-$($arch).exe"
$windowsAppSdkUri = "https://aka.ms/windowsappsdk/$($windowsAppSdkMajorVersion)/$($windowsAppSdkFullVersion)/windowsappruntimeinstall-$($arch).exe"
$windowsAppSdkEtagFile = $windowsAppSdkPath + ".etag"
-Download-File $windowsAppSdkUri $windowsAppSdkPath $windowsAppSdkEtagFile
\ No newline at end of file
+Download-File $windowsAppSdkUri $windowsAppSdkPath $windowsAppSdkEtagFile
diff --git a/scripts/Update-AppCast.ps1 b/scripts/Update-AppCast.ps1
new file mode 100644
index 0000000..904dcdf
--- /dev/null
+++ b/scripts/Update-AppCast.ps1
@@ -0,0 +1,194 @@
+# Updates appcast.xml and appcast.xml.signature for a given release.
+#
+# Requires openssl.exe. You can install it via winget:
+# winget install ShiningLight.OpenSSL.Light
+#
+# Usage: Update-AppCast.ps1
+# -tag
+# -channel
+# -x64Path
+# -arm64Path
+# -keyPath
+# -inputAppCastPath
+# -outputAppCastPath
+# -outputAppCastSignaturePath
+param (
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern("^v\d+\.\d+\.\d+$")]
+ [string] $tag,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('stable', 'preview')]
+ [string] $channel,
+
+ [Parameter(Mandatory = $false)]
+ [ValidatePattern("^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} \+00:00$")]
+ [string] $pubDate = (Get-Date).ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss +00:00"),
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({ Test-Path $_ })]
+ [string] $x64Path,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({ Test-Path $_ })]
+ [string] $arm64Path,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({ Test-Path $_ })]
+ [string] $keyPath,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateScript({ Test-Path $_ })]
+ [string] $inputAppCastPath = "appcast.xml",
+
+ [Parameter(Mandatory = $false)]
+ [string] $outputAppCastPath = "appcast.xml",
+
+ [Parameter(Mandatory = $false)]
+ [string] $outputAppCastSignaturePath = "appcast.xml.signature"
+)
+
+$ErrorActionPreference = "Stop"
+
+$repo = "coder/coder-desktop-windows"
+
+$version = $tag.Substring(1) # remove the v prefix
+
+function Get-Ed25519Signature {
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({ Test-Path $_ })]
+ [string] $path
+ )
+
+ # Use a temporary file. We can't just pipe directly because PowerShell
+ # operates with strings for third party commands.
+ $tempPath = Join-Path $env:TEMP "coder-desktop-temp.bin"
+ & openssl.exe pkeyutl -sign -inkey $keyPath -rawin -in $path -out $tempPath
+ if ($LASTEXITCODE -ne 0) { throw "Failed to sign file: $path" }
+ $signature = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($tempPath))
+ Remove-Item -Force $tempPath
+ return $signature
+}
+
+# Retrieve the release notes from the GitHub releases API
+$releaseNotesMarkdown = & gh.exe release view $tag `
+ --json body `
+ --jq ".body"
+if ($LASTEXITCODE -ne 0) { throw "Failed to retrieve release notes markdown" }
+$releaseNotesMarkdown = $releaseNotesMarkdown -replace "`r`n", "`n"
+$releaseNotesMarkdownPath = Join-Path $env:TEMP "coder-desktop-release-notes.md"
+Set-Content -Path $releaseNotesMarkdownPath -Value $releaseNotesMarkdown -Encoding UTF8
+
+Write-Output "---- Release Notes Markdown -----"
+Get-Content $releaseNotesMarkdownPath
+Write-Output "---- End of Release Notes Markdown ----"
+Write-Output ""
+
+# Convert the release notes markdown to HTML using the GitHub API to match
+# GitHub's formatting
+$releaseNotesHtmlPath = Join-Path $env:TEMP "coder-desktop-release-notes.html"
+& gh.exe api `
+ --method POST `
+ -H "Accept: application/vnd.github+json" `
+ -H "X-GitHub-Api-Version: 2022-11-28" `
+ /markdown `
+ -F "text=@$releaseNotesMarkdownPath" `
+ -F "mode=gfm" `
+ -F "context=$repo" `
+ > $releaseNotesHtmlPath
+if ($LASTEXITCODE -ne 0) { throw "Failed to convert release notes markdown to HTML" }
+
+Write-Output "---- Release Notes HTML -----"
+Get-Content $releaseNotesHtmlPath
+Write-Output "---- End of Release Notes HTML ----"
+Write-Output ""
+
+[xml] $appCast = Get-Content $inputAppCastPath
+
+# Set up namespace manager for sparkle: prefix
+$nsManager = New-Object System.Xml.XmlNamespaceManager($appCast.NameTable)
+$nsManager.AddNamespace("sparkle", "http://www.andymatuschak.org/xml-namespaces/sparkle")
+
+# Find the matching channel item
+$channelItem = $appCast.SelectSingleNode("//item[sparkle:channel='$channel']", $nsManager)
+if ($null -eq $channelItem) {
+ throw "Could not find channel item for channel: $channel"
+}
+
+# Update the item properties
+$channelItem.title = $tag
+$channelItem.pubDate = $pubDate
+$channelItem.SelectSingleNode("sparkle:version", $nsManager).InnerText = $version
+$channelItem.SelectSingleNode("sparkle:shortVersionString", $nsManager).InnerText = $version
+$channelItem.SelectSingleNode("sparkle:fullReleaseNotesLink", $nsManager).InnerText = "https://github.com/$repo/releases"
+
+# Set description with proper line breaks
+$descriptionNode = $channelItem.SelectSingleNode("description")
+$descriptionNode.InnerXml = "" # Clear existing content
+$cdata = $appCast.CreateCDataSection([System.IO.File]::ReadAllText($releaseNotesHtmlPath))
+$descriptionNode.AppendChild($cdata) | Out-Null
+
+# Remove existing enclosures
+$existingEnclosures = $channelItem.SelectNodes("enclosure")
+foreach ($enclosure in $existingEnclosures) {
+ $channelItem.RemoveChild($enclosure) | Out-Null
+}
+
+# Add new enclosures
+$enclosures = @(
+ @{
+ path = $x64Path
+ os = "win-x64"
+ },
+ @{
+ path = $arm64Path
+ os = "win-arm64"
+ }
+)
+foreach ($enclosure in $enclosures) {
+ $fileName = Split-Path $enclosure.path -Leaf
+ $url = "https://github.com/$repo/releases/download/$tag/$fileName"
+ $fileSize = (Get-Item $enclosure.path).Length
+ $signature = Get-Ed25519Signature $enclosure.path
+
+ $newEnclosure = $appCast.CreateElement("enclosure")
+ $newEnclosure.SetAttribute("url", $url)
+ $newEnclosure.SetAttribute("type", "application/x-msdos-program")
+ $newEnclosure.SetAttribute("length", $fileSize)
+
+ # Set namespaced attributes
+ $sparkleNs = $nsManager.LookupNamespace("sparkle")
+ $attrs = @{
+ "os" = $enclosure.os
+ "version" = $version
+ "shortVersionString" = $version
+ "criticalUpdate" = "false"
+ "edSignature" = $signature # NetSparkle prefers edSignature over signature
+ }
+ foreach ($key in $attrs.Keys) {
+ $attr = $appCast.CreateAttribute("sparkle", $key, $sparkleNs)
+ $attr.Value = $attrs[$key]
+ $newEnclosure.Attributes.Append($attr) | Out-Null
+ }
+
+ $channelItem.AppendChild($newEnclosure) | Out-Null
+}
+
+# Save the updated XML. Convert CRLF to LF since CRLF seems to break NetSparkle
+$appCast.Save($outputAppCastPath)
+$content = [System.IO.File]::ReadAllText($outputAppCastPath)
+$content = $content -replace "`r`n", "`n"
+[System.IO.File]::WriteAllText($outputAppCastPath, $content)
+
+Write-Output "---- Updated appcast -----"
+Get-Content $outputAppCastPath
+Write-Output "---- End of updated appcast ----"
+Write-Output ""
+
+# Generate the signature for the appcast itself
+$appCastSignature = Get-Ed25519Signature $outputAppCastPath
+[System.IO.File]::WriteAllText($outputAppCastSignaturePath, $appCastSignature)
+Write-Output "---- Updated appcast signature -----"
+Get-Content $outputAppCastSignaturePath
+Write-Output "---- End of updated appcast signature ----"