+
+
+";
+
+ 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..833c303
--- /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..c1671b3
--- /dev/null
+++ b/App/Views/MessageWindow.xaml.cs
@@ -0,0 +1,37 @@
+using System.Diagnostics;
+using Windows.Foundation;
+using Windows.Graphics;
+using Coder.Desktop.App.Utils;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+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);
+ SystemBackdrop = new DesktopAcrylicBackdrop();
+ 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/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/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..cb32dfc
--- /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..0b2e36e
--- /dev/null
+++ b/App/Views/UpdaterCheckingForUpdatesWindow.xaml.cs
@@ -0,0 +1,34 @@
+using Microsoft.UI.Xaml.Media;
+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);
+ SystemBackdrop = new DesktopAcrylicBackdrop();
+ 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..893e1d2
--- /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..9c4618b
--- /dev/null
+++ b/App/Views/UpdaterDownloadProgressWindow.xaml.cs
@@ -0,0 +1,85 @@
+using Microsoft.UI.Xaml.Media;
+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 _didCallDownloadProcessCompletedHandler;
+
+ public UpdaterDownloadProgressWindow(UpdaterDownloadProgressViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ ViewModel.DownloadProcessCompleted += (_, args) => SendResponse(args);
+
+ InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+ SystemBackdrop = new DesktopAcrylicBackdrop();
+ AppWindow.Hide();
+
+ RootFrame.Content = new UpdaterDownloadProgressMainPage(ViewModel);
+
+ Closed += UpdaterDownloadProgressWindow_Closed;
+ }
+
+ public void SendResponse(DownloadInstallEventArgs args)
+ {
+ if (_didCallDownloadProcessCompletedHandler)
+ return;
+ _didCallDownloadProcessCompletedHandler = 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..dc94306
--- /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..9ecc717
--- /dev/null
+++ b/App/Views/UpdaterUpdateAvailableWindow.xaml.cs
@@ -0,0 +1,90 @@
+using Microsoft.UI.Xaml.Media;
+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);
+ SystemBackdrop = new DesktopAcrylicBackdrop();
+ 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()
+ {
+ UserRespondedToUpdateCheck(UpdateAvailableResult.None); // the Avalonia UI does this "just in case"
+ 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..7bd41d9 100644
--- a/App/packages.lock.json
+++ b/App/packages.lock.json
@@ -105,6 +105,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 +138,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, )",
@@ -456,6 +474,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/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/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/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 655d043..3db444f 100644
--- a/scripts/Get-WindowsAppSdk.ps1
+++ b/scripts/Get-WindowsAppSdk.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/Update-AppCast.ps1 b/scripts/Update-AppCast.ps1
new file mode 100644
index 0000000..f8abf63
--- /dev/null
+++ b/scripts/Update-AppCast.ps1
@@ -0,0 +1,197 @@
+# 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
+# -version
+# -channel
+# -x64Path
+# -arm64Path
+# -keyPath
+# -inputAppCastPath
+# -outputAppCastPath
+# -outputAppCastSignaturePath
+param (
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern("^v\d+\.\d+\.\d+$")]
+ [string] $tag,
+
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern("^\d+\.\d+\.\d+$")]
+ [string] $version,
+
+ [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"
+
+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 ----"