From 74b8658eec937b7a7eefefc40debfdc221253c33 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 3 Jun 2025 20:19:38 +1000 Subject: [PATCH 1/5] chore: remove verbose curl from Get-WindowsAppSdk.ps1 (#116) --- scripts/Get-WindowsAppSdk.ps1 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/Get-WindowsAppSdk.ps1 b/scripts/Get-WindowsAppSdk.ps1 index a9ca02a..655d043 100644 --- a/scripts/Get-WindowsAppSdk.ps1 +++ b/scripts/Get-WindowsAppSdk.ps1 @@ -10,7 +10,6 @@ function Download-File([string] $url, [string] $outputPath, [string] $etagFile) # We use `curl.exe` here because `Invoke-WebRequest` is notoriously slow. & curl.exe ` --progress-bar ` - -v ` --show-error ` --fail ` --location ` @@ -31,4 +30,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 From d49de5b36472f7a00dba7f8875913e17f20ba2f2 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 6 Jun 2025 22:06:22 +1000 Subject: [PATCH 2/5] feat: add vpn start progress (#114) --- .../.idea/projectSettingsUpdater.xml | 1 + App/Models/RpcModel.cs | 163 +++++++++++++++++- App/Services/RpcController.cs | 22 ++- App/ViewModels/TrayWindowViewModel.cs | 37 +++- .../Pages/TrayWindowLoginRequiredPage.xaml | 2 +- App/Views/Pages/TrayWindowMainPage.xaml | 11 +- Tests.Vpn.Service/DownloaderTest.cs | 60 ++++++- Vpn.Proto/vpn.proto | 25 ++- Vpn.Service/Downloader.cs | 91 ++++++---- Vpn.Service/Manager.cs | 82 ++++++++- Vpn.Service/ManagerRpc.cs | 17 +- Vpn.Service/Program.cs | 11 +- Vpn.Service/TunnelSupervisor.cs | 8 +- Vpn/Speaker.cs | 2 +- 14 files changed, 464 insertions(+), 68 deletions(-) diff --git a/.idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml b/.idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml index 64af657..ef20cb0 100644 --- a/.idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml +++ b/.idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml @@ -2,6 +2,7 @@ \ No newline at end of file diff --git a/App/Models/RpcModel.cs b/App/Models/RpcModel.cs index 034f405..08d2303 100644 --- a/App/Models/RpcModel.cs +++ b/App/Models/RpcModel.cs @@ -1,4 +1,7 @@ +using System; using System.Collections.Generic; +using System.Diagnostics; +using Coder.Desktop.App.Converters; using Coder.Desktop.Vpn.Proto; namespace Coder.Desktop.App.Models; @@ -19,11 +22,168 @@ public enum VpnLifecycle Stopping, } +public enum VpnStartupStage +{ + Unknown, + Initializing, + Downloading, + Finalizing, +} + +public class VpnDownloadProgress +{ + public ulong BytesWritten { get; set; } = 0; + public ulong? BytesTotal { get; set; } = null; // null means unknown total size + + public double Progress + { + get + { + if (BytesTotal is > 0) + { + return (double)BytesWritten / BytesTotal.Value; + } + return 0.0; + } + } + + public override string ToString() + { + // TODO: it would be nice if the two suffixes could match + var s = FriendlyByteConverter.FriendlyBytes(BytesWritten); + if (BytesTotal != null) + s += $" of {FriendlyByteConverter.FriendlyBytes(BytesTotal.Value)}"; + else + s += " of unknown"; + if (BytesTotal != null) + s += $" ({Progress:0%})"; + return s; + } + + public VpnDownloadProgress Clone() + { + return new VpnDownloadProgress + { + BytesWritten = BytesWritten, + BytesTotal = BytesTotal, + }; + } + + public static VpnDownloadProgress FromProto(StartProgressDownloadProgress proto) + { + return new VpnDownloadProgress + { + BytesWritten = proto.BytesWritten, + BytesTotal = proto.HasBytesTotal ? proto.BytesTotal : null, + }; + } +} + +public class VpnStartupProgress +{ + public const string DefaultStartProgressMessage = "Starting Coder Connect..."; + + // Scale the download progress to an overall progress value between these + // numbers. + private const double DownloadProgressMin = 0.05; + private const double DownloadProgressMax = 0.80; + + public VpnStartupStage Stage { get; init; } = VpnStartupStage.Unknown; + public VpnDownloadProgress? DownloadProgress { get; init; } = null; + + // 0.0 to 1.0 + public double Progress + { + get + { + switch (Stage) + { + case VpnStartupStage.Unknown: + case VpnStartupStage.Initializing: + return 0.0; + case VpnStartupStage.Downloading: + var progress = DownloadProgress?.Progress ?? 0.0; + return DownloadProgressMin + (DownloadProgressMax - DownloadProgressMin) * progress; + case VpnStartupStage.Finalizing: + return DownloadProgressMax; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + public override string ToString() + { + switch (Stage) + { + case VpnStartupStage.Unknown: + case VpnStartupStage.Initializing: + return DefaultStartProgressMessage; + case VpnStartupStage.Downloading: + var s = "Downloading Coder Connect binary..."; + if (DownloadProgress is not null) + { + s += "\n" + DownloadProgress; + } + + return s; + case VpnStartupStage.Finalizing: + return "Finalizing Coder Connect startup..."; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public VpnStartupProgress Clone() + { + return new VpnStartupProgress + { + Stage = Stage, + DownloadProgress = DownloadProgress?.Clone(), + }; + } + + public static VpnStartupProgress FromProto(StartProgress proto) + { + return new VpnStartupProgress + { + Stage = proto.Stage switch + { + StartProgressStage.Initializing => VpnStartupStage.Initializing, + StartProgressStage.Downloading => VpnStartupStage.Downloading, + StartProgressStage.Finalizing => VpnStartupStage.Finalizing, + _ => VpnStartupStage.Unknown, + }, + DownloadProgress = proto.Stage is StartProgressStage.Downloading ? + VpnDownloadProgress.FromProto(proto.DownloadProgress) : + null, + }; + } +} + public class RpcModel { public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected; - public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; + public VpnLifecycle VpnLifecycle + { + get; + set + { + if (VpnLifecycle != value && value == VpnLifecycle.Starting) + // Reset the startup progress when the VPN lifecycle changes to + // Starting. + VpnStartupProgress = null; + field = value; + } + } + + // Nullable because it is only set when the VpnLifecycle is Starting + public VpnStartupProgress? VpnStartupProgress + { + get => VpnLifecycle is VpnLifecycle.Starting ? field ?? new VpnStartupProgress() : null; + set; + } public IReadOnlyList Workspaces { get; set; } = []; @@ -35,6 +195,7 @@ public RpcModel Clone() { RpcLifecycle = RpcLifecycle, VpnLifecycle = VpnLifecycle, + VpnStartupProgress = VpnStartupProgress?.Clone(), Workspaces = Workspaces, Agents = Agents, }; diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 7461ba8..532c878 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -161,7 +161,10 @@ public async Task StartVpn(CancellationToken ct = default) throw new RpcOperationException( $"Cannot start VPN without valid credentials, current state: {credentials.State}"); - MutateState(state => { state.VpnLifecycle = VpnLifecycle.Starting; }); + MutateState(state => + { + state.VpnLifecycle = VpnLifecycle.Starting; + }); ServiceMessage reply; try @@ -283,15 +286,28 @@ private void ApplyStatusUpdate(Status status) }); } + private void ApplyStartProgressUpdate(StartProgress message) + { + MutateState(state => + { + // The model itself will ignore this value if we're not in the + // starting state. + state.VpnStartupProgress = VpnStartupProgress.FromProto(message); + }); + } + private void SpeakerOnReceive(ReplyableRpcMessage message) { switch (message.Message.MsgCase) { + case ServiceMessage.MsgOneofCase.Start: + case ServiceMessage.MsgOneofCase.Stop: case ServiceMessage.MsgOneofCase.Status: ApplyStatusUpdate(message.Message.Status); break; - case ServiceMessage.MsgOneofCase.Start: - case ServiceMessage.MsgOneofCase.Stop: + case ServiceMessage.MsgOneofCase.StartProgress: + ApplyStartProgressUpdate(message.Message.StartProgress); + break; case ServiceMessage.MsgOneofCase.None: default: // TODO: log unexpected message diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index d8b3182..820ff12 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -29,7 +29,6 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost { private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; - private const string DefaultHostnameSuffix = ".coder"; private readonly IServiceProvider _services; private readonly IRpcController _rpcController; @@ -53,6 +52,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowEnableSection))] + [NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))] [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] @@ -63,6 +63,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowEnableSection))] + [NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))] [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] @@ -70,7 +71,25 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost [NotifyPropertyChangedFor(nameof(ShowFailedSection))] public partial string? VpnFailedMessage { get; set; } = null; - public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VpnStartProgressIsIndeterminate))] + [NotifyPropertyChangedFor(nameof(VpnStartProgressValueOrDefault))] + public partial int? VpnStartProgressValue { get; set; } = null; + + public int VpnStartProgressValueOrDefault => VpnStartProgressValue ?? 0; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VpnStartProgressMessageOrDefault))] + public partial string? VpnStartProgressMessage { get; set; } = null; + + public string VpnStartProgressMessageOrDefault => + string.IsNullOrEmpty(VpnStartProgressMessage) ? VpnStartupProgress.DefaultStartProgressMessage : VpnStartProgressMessage; + + public bool VpnStartProgressIsIndeterminate => VpnStartProgressValueOrDefault == 0; + + public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Starting and not VpnLifecycle.Started; + + public bool ShowVpnStartProgressSection => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Starting; public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started; @@ -170,6 +189,20 @@ private void UpdateFromRpcModel(RpcModel rpcModel) VpnLifecycle = rpcModel.VpnLifecycle; VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started; + // VpnStartupProgress is only set when the VPN is starting. + if (rpcModel.VpnLifecycle is VpnLifecycle.Starting && rpcModel.VpnStartupProgress != null) + { + // Convert 0.00-1.00 to 0-100. + var progress = (int)(rpcModel.VpnStartupProgress.Progress * 100); + VpnStartProgressValue = Math.Clamp(progress, 0, 100); + VpnStartProgressMessage = rpcModel.VpnStartupProgress.ToString(); + } + else + { + VpnStartProgressValue = null; + VpnStartProgressMessage = null; + } + // Add every known agent. HashSet workspacesWithAgents = []; List agents = []; 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..f488454 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -43,6 +43,8 @@ + + + HorizontalContentAlignment="Left"> diff --git a/Tests.Vpn.Service/DownloaderTest.cs b/Tests.Vpn.Service/DownloaderTest.cs index 986ce46..bb9b39c 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,62 @@ 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); + }); + 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); + }); + 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 +391,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 +412,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/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/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); From 059179c6d8f79c70252e5d771b7edefa63cb3454 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 10 Jun 2025 15:55:59 +0200 Subject: [PATCH 3/5] added new settings dialog + settings manager (#113) Closes: #57 & #55 Adds: - **SettingsManager** that manages settings located in AppData - **Settings** views to manage the settings - **StartupManager** that allows to control registry access to enable load on startup ![image](https://github.com/user-attachments/assets/deb834cb-44fd-4282-8db8-918bd11b1ab8) --- App/App.csproj | 1 + App/App.xaml.cs | 138 +++++++++++++-------- App/Models/Settings.cs | 62 ++++++++++ App/Services/SettingsManager.cs | 144 ++++++++++++++++++++++ App/Services/StartupManager.cs | 84 +++++++++++++ App/ViewModels/SettingsViewModel.cs | 81 ++++++++++++ App/ViewModels/TrayWindowViewModel.cs | 18 +++ App/Views/Pages/SettingsMainPage.xaml | 50 ++++++++ App/Views/Pages/SettingsMainPage.xaml.cs | 15 +++ App/Views/Pages/TrayWindowMainPage.xaml | 15 ++- App/Views/SettingsWindow.xaml | 20 +++ App/Views/SettingsWindow.xaml.cs | 25 ++++ App/packages.lock.json | 28 +++++ Tests.App/Services/SettingsManagerTest.cs | 45 +++++++ 14 files changed, 669 insertions(+), 57 deletions(-) create mode 100644 App/Models/Settings.cs create mode 100644 App/Services/SettingsManager.cs create mode 100644 App/Services/StartupManager.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.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 06ab676..87afcb3 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -44,6 +43,10 @@ public partial class App : Application private readonly ILogger _logger; private readonly IUriHandler _uriHandler; + private readonly ISettingsManager _settingsManager; + + private readonly IHostApplicationLifetime _appLifetime; + public App() { var builder = Host.CreateApplicationBuilder(); @@ -90,6 +93,13 @@ public App() // FileSyncListMainPage is created by FileSyncListWindow. services.AddTransient(); + services.AddSingleton, SettingsManager>(); + services.AddSingleton(); + // 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 @@ -107,8 +117,10 @@ public App() services.AddTransient(); _services = services.BuildServiceProvider(); - _logger = (ILogger)_services.GetService(typeof(ILogger))!; - _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!; + _logger = _services.GetRequiredService>(); + _uriHandler = _services.GetRequiredService(); + _settingsManager = _services.GetRequiredService>(); + _appLifetime = _services.GetRequiredService(); InitializeComponent(); } @@ -129,58 +141,8 @@ public async Task ExitApplication() protected override void OnLaunched(LaunchActivatedEventArgs args) { _logger.LogInformation("new instance launched"); - // 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) - { - _logger.LogError(t.Exception, "failed to connect to VPN service"); -#if DEBUG - Debug.WriteLine(t.Exception); - Debugger.Break(); -#endif - } - }); - - // 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. - // 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 => - { - if (t.IsCanceled || t.Exception != null) - { - _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled); - } - syncSessionCts.Dispose(); - }, CancellationToken.None); - }); + _ = InitializeServicesAsync(_appLifetime.ApplicationStopping); // Prevent the TrayWindow from closing, just hide it. var trayWindow = _services.GetRequiredService(); @@ -192,6 +154,74 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) }; } + /// + /// 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(); + var rpcController = _services.GetRequiredService(); + + using var credsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + credsCts.CancelAfter(TimeSpan.FromSeconds(15)); + + 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, settingsTask); + } + catch (Exception) + { + if (loadCredsTask.IsFaulted) + _logger.LogError(loadCredsTask.Exception!.GetBaseException(), + "Failed to load credentials"); + + if (reconnectTask.IsFaulted) + _logger.LogError(reconnectTask.Exception!.GetBaseException(), + "Failed to connect to VPN service"); + + 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; + } + + var attemptCoderConnection = settingsTask.Result?.ConnectOnLaunch ?? false; + if (dependenciesLoaded && attemptCoderConnection) + { + try + { + await rpcController.StartVpn(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect on launch"); + } + } + + // Initialize file sync. + using var syncSessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + syncSessionCts.CancelAfter(TimeSpan.FromSeconds(10)); + var syncSessionController = _services.GetRequiredService(); + try + { + await syncSessionController.RefreshState(syncSessionCts.Token); + } + catch (Exception ex) + { + _logger.LogError($"Failed to refresh sync session state {ex.Message}", ex); + } + } + public void OnActivated(object? sender, AppActivationArguments args) { switch (args.Kind) diff --git a/App/Models/Settings.cs b/App/Models/Settings.cs new file mode 100644 index 0000000..ec4c61b --- /dev/null +++ b/App/Models/Settings.cs @@ -0,0 +1,62 @@ +namespace Coder.Desktop.App.Models; + +public interface ISettings : ICloneable +{ + /// + /// FileName where the settings are stored. + /// + static abstract string SettingsFileName { get; } + + /// + /// Gets the version of the settings schema. + /// + int Version { get; } +} + +public interface ICloneable +{ + /// + /// Creates a deep copy of the settings object. + /// + /// A new instance of the settings object with the same values. + T Clone(); +} + +/// +/// CoderConnect settings class that holds the settings for the CoderConnect feature. +/// +public class CoderConnectSettings : ISettings +{ + public static string SettingsFileName { get; } = "coder-connect-settings.json"; + public int Version { get; set; } + /// + /// When this is true, CoderConnect will automatically connect to the Coder VPN when the application starts. + /// + public bool ConnectOnLaunch { get; set; } + + /// + /// CoderConnect current settings version. Increment this when the settings schema changes. + /// In future iterations we will be able to handle migrations when the user has + /// an older version. + /// + private const int VERSION = 1; + + public CoderConnectSettings() + { + Version = VERSION; + + ConnectOnLaunch = false; + } + + public CoderConnectSettings(int? version, bool connectOnLaunch) + { + Version = version ?? VERSION; + + ConnectOnLaunch = connectOnLaunch; + } + + public CoderConnectSettings Clone() + { + return new CoderConnectSettings(Version, ConnectOnLaunch); + } +} diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs new file mode 100644 index 0000000..886d5d2 --- /dev/null +++ b/App/Services/SettingsManager.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Models; + +namespace Coder.Desktop.App.Services; + +/// +/// Settings contract exposing properties for app settings. +/// +public interface ISettingsManager where T : ISettings, new() +{ + /// + /// Reads the settings from the file system or returns from cache if available. + /// Returned object is always a cloned instance, so it can be modified without affecting the stored settings. + /// + /// + /// + Task Read(CancellationToken ct = default); + /// + /// Writes the settings to the file system. + /// + /// Object containing the settings. + /// + /// + Task Write(T settings, CancellationToken ct = default); +} + +/// +/// Implemention of that persists settings to a JSON file +/// located in the user's local application data folder. +/// +public sealed class SettingsManager : ISettingsManager where T : ISettings, new() +{ + private readonly string _settingsFilePath; + private readonly string _appName = "CoderDesktop"; + private string _fileName; + + private T? _cachedSettings; + + 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. + /// Otherwise the settings file will be created in the user's local application data folder. + /// + public SettingsManager(string? settingsFilePath = null) + { + if (settingsFilePath is null) + { + settingsFilePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + } + else if (!Path.IsPathRooted(settingsFilePath)) + { + throw new ArgumentException("settingsFilePath must be an absolute path if provided", nameof(settingsFilePath)); + } + + var folder = Path.Combine( + settingsFilePath, + _appName); + + Directory.CreateDirectory(folder); + + _fileName = T.SettingsFileName; + _settingsFilePath = Path.Combine(folder, _fileName); + } + + public async Task Read(CancellationToken ct = default) + { + if (_cachedSettings is not null) + { + // return cached settings if available + return _cachedSettings.Clone(); + } + + // 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 + { + 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 _cachedSettings.Clone(); // return a fresh instance of the settings + } + catch (OperationCanceledException) + { + throw; // propagate caller-requested cancellation + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to read settings from {_settingsFilePath}. " + + "The file may be corrupted, malformed or locked.", ex); + } + finally + { + _gate.Release(); + } + } + + 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 + { + // 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 persist settings to {_settingsFilePath}. " + + "The file may be corrupted, malformed or locked.", ex); + } + finally + { + _gate.Release(); + } + } +} diff --git a/App/Services/StartupManager.cs b/App/Services/StartupManager.cs new file mode 100644 index 0000000..2ab7631 --- /dev/null +++ b/App/Services/StartupManager.cs @@ -0,0 +1,84 @@ +using Microsoft.Win32; +using System; +using System.Diagnostics; +using System.Security; + +namespace Coder.Desktop.App.Services; + +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"; + 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"; + + public 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; } + } + + public void Disable() + { + using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true); + key?.DeleteValue(_defaultValueName, throwOnMissingValue: false); + } + + public bool IsEnabled() + { + using var key = Registry.CurrentUser.OpenSubKey(RunKey); + return key?.GetValue(_defaultValueName) != null; + } + + public 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; + } + return false; + } +} + diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..721ea95 --- /dev/null +++ b/App/ViewModels/SettingsViewModel.cs @@ -0,0 +1,81 @@ +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using System; + +namespace Coder.Desktop.App.ViewModels; + +public partial class SettingsViewModel : ObservableObject +{ + private readonly ILogger _logger; + + [ObservableProperty] + public partial bool ConnectOnLaunch { get; set; } + + [ObservableProperty] + public partial bool StartOnLoginDisabled { get; set; } + + [ObservableProperty] + public partial bool StartOnLogin { get; set; } + + private ISettingsManager _connectSettingsManager; + private CoderConnectSettings _connectSettings = new CoderConnectSettings(); + private IStartupManager _startupManager; + + public SettingsViewModel(ILogger logger, ISettingsManager settingsManager, IStartupManager startupManager) + { + _connectSettingsManager = settingsManager; + _startupManager = startupManager; + _logger = logger; + _connectSettings = settingsManager.Read().GetAwaiter().GetResult(); + 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. + StartOnLoginDisabled = _startupManager.IsDisabledByPolicy(); + + // 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 + { + _connectSettings.ConnectOnLaunch = ConnectOnLaunch; + _connectSettingsManager.Write(_connectSettings); + } + catch (Exception ex) + { + _logger.LogError($"Error saving Coder Connect settings: {ex.Message}"); + } + } + + partial void OnStartOnLoginChanged(bool oldValue, bool newValue) + { + if (oldValue == newValue) + return; + try + { + if (StartOnLogin) + { + _startupManager.Enable(); + } + else + { + _startupManager.Disable(); + } + } + catch (Exception ex) + { + _logger.LogError($"Error setting StartOnLogin in registry: {ex.Message}"); + } + } +} diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 820ff12..8540453 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -38,6 +38,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 @@ -392,6 +394,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..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/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index f488454..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"> @@ -340,9 +340,18 @@ + + + + + @@ -351,7 +360,7 @@ 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/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", 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); + } +} From 56003ed5a27e36496fbe400fc199f364c5151f27 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 08:28:30 +0000 Subject: [PATCH 4/5] docs: add Coder Desktop description and documentation link (#131) Add a descriptive blurb explaining what Coder Desktop does and link to the official documentation at coder.com. This helps users understand the purpose and capabilities of Coder Desktop before diving into the technical details. Changes: - Added explanation of Coder Desktop's core functionality (workspace access without port-forwarding) - Highlighted key features like Coder Connect and file synchronization - Included direct link to official documentation at https://coder.com/docs/user-guides/desktop Closes #130 --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: deansheather <11241812+deansheather@users.noreply.github.com> --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 From 46849a57c90656d20d724cfbc3b0aba997f58161 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 24 Jun 2025 14:15:52 +1000 Subject: [PATCH 5/5] feat: add auto updater (#117) --- .github/workflows/release.yaml | 49 +- .gitignore | 9 + App/App.csproj | 13 +- App/App.xaml.cs | 87 +- App/Assets/changelog.css | 1237 +++++++++++++++++ App/Controls/TrayIcon.xaml | 21 + App/Controls/TrayIcon.xaml.cs | 1 + App/Services/DispatcherQueueManager.cs | 8 + App/Services/SettingsManager.cs | 51 +- App/Services/UpdateController.cs | 307 ++++ App/Services/UserNotifier.cs | 84 +- App/Utils/ForegroundWindow.cs | 22 + App/Utils/TitleBarIcon.cs | 7 +- .../UpdaterDownloadProgressViewModel.cs | 91 ++ .../UpdaterUpdateAvailableViewModel.cs | 242 ++++ App/Views/MessageWindow.xaml | 43 + App/Views/MessageWindow.xaml.cs | 32 + .../UpdaterDownloadProgressMainPage.xaml | 40 + .../UpdaterDownloadProgressMainPage.xaml.cs | 14 + .../Pages/UpdaterUpdateAvailableMainPage.xaml | 84 ++ .../UpdaterUpdateAvailableMainPage.xaml.cs | 15 + App/Views/TrayWindow.xaml | 2 +- App/Views/TrayWindow.xaml.cs | 22 +- .../UpdaterCheckingForUpdatesWindow.xaml | 28 + .../UpdaterCheckingForUpdatesWindow.xaml.cs | 32 + App/Views/UpdaterDownloadProgressWindow.xaml | 20 + .../UpdaterDownloadProgressWindow.xaml.cs | 83 ++ App/Views/UpdaterUpdateAvailableWindow.xaml | 21 + .../UpdaterUpdateAvailableWindow.xaml.cs | 89 ++ App/packages.lock.json | 23 + Installer/Program.cs | 1 - Tests.Vpn.Service/DownloaderTest.cs | 2 + Vpn.Proto/packages.lock.json | 3 - Vpn/RegistryConfigurationSource.cs | 17 +- scripts/Create-AppCastSigningKey.ps1 | 27 + scripts/Get-Mutagen.ps1 | 2 + scripts/Get-WindowsAppSdk.ps1 | 2 + scripts/Update-AppCast.ps1 | 194 +++ 38 files changed, 2960 insertions(+), 65 deletions(-) create mode 100644 App/Assets/changelog.css create mode 100644 App/Services/DispatcherQueueManager.cs create mode 100644 App/Services/UpdateController.cs create mode 100644 App/Utils/ForegroundWindow.cs create mode 100644 App/ViewModels/UpdaterDownloadProgressViewModel.cs create mode 100644 App/ViewModels/UpdaterUpdateAvailableViewModel.cs create mode 100644 App/Views/MessageWindow.xaml create mode 100644 App/Views/MessageWindow.xaml.cs create mode 100644 App/Views/Pages/UpdaterDownloadProgressMainPage.xaml create mode 100644 App/Views/Pages/UpdaterDownloadProgressMainPage.xaml.cs create mode 100644 App/Views/Pages/UpdaterUpdateAvailableMainPage.xaml create mode 100644 App/Views/Pages/UpdaterUpdateAvailableMainPage.xaml.cs create mode 100644 App/Views/UpdaterCheckingForUpdatesWindow.xaml create mode 100644 App/Views/UpdaterCheckingForUpdatesWindow.xaml.cs create mode 100644 App/Views/UpdaterDownloadProgressWindow.xaml create mode 100644 App/Views/UpdaterDownloadProgressWindow.xaml.cs create mode 100644 App/Views/UpdaterUpdateAvailableWindow.xaml create mode 100644 App/Views/UpdaterUpdateAvailableWindow.xaml.cs create mode 100644 scripts/Create-AppCastSigningKey.ps1 create mode 100644 scripts/Update-AppCast.ps1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9ad6c16..c4280b6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -68,6 +68,9 @@ jobs: service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} token_format: "access_token" + - name: Install gcloud + uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # 2.1.4 + - name: Install wix shell: pwsh run: | @@ -120,6 +123,51 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Update appcast + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + # The Update-AppCast.ps1 script fetches the release notes from GitHub, + # which might take a few seconds to be ready. + Start-Sleep -Seconds 10 + + # Save the appcast signing key to a temporary file. + $keyPath = Join-Path $env:TEMP "appcast-key.pem" + $key = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($env:APPCAST_SIGNATURE_KEY_BASE64)) + Set-Content -Path $keyPath -Value $key + + # Download the old appcast from GCS. + $oldAppCastPath = Join-Path $env:TEMP "appcast.old.xml" + & gsutil cp $env:APPCAST_GCS_URI $oldAppCastPath + if ($LASTEXITCODE -ne 0) { throw "Failed to download appcast" } + + # Generate the new appcast and signature. + $newAppCastPath = Join-Path $env:TEMP "appcast.new.xml" + $newAppCastSignaturePath = $newAppCastPath + ".signature" + & ./scripts/Update-AppCast.ps1 ` + -tag "${{ github.ref_name }}" ` + -channel stable ` + -x64Path "${{ steps.release.outputs.X64_OUTPUT_PATH }}" ` + -arm64Path "${{ steps.release.outputs.ARM64_OUTPUT_PATH }}" ` + -keyPath $keyPath ` + -inputAppCastPath $oldAppCastPath ` + -outputAppCastPath $newAppCastPath ` + -outputAppCastSignaturePath $newAppCastSignaturePath + if ($LASTEXITCODE -ne 0) { throw "Failed to generate new appcast" } + + # Upload the new appcast and signature to GCS. + & gsutil -h "Cache-Control:no-cache,max-age=0" cp $newAppCastPath $env:APPCAST_GCS_URI + if ($LASTEXITCODE -ne 0) { throw "Failed to upload new appcast" } + & gsutil -h "Cache-Control:no-cache,max-age=0" cp $newAppCastSignaturePath $env:APPCAST_SIGNATURE_GCS_URI + if ($LASTEXITCODE -ne 0) { throw "Failed to upload new appcast signature" } + env: + APPCAST_GCS_URI: gs://releases.coder.com/coder-desktop/windows/appcast.xml + APPCAST_SIGNATURE_GCS_URI: gs://releases.coder.com/coder-desktop/windows/appcast.xml.signature + APPCAST_SIGNATURE_KEY_BASE64: ${{ secrets.APPCAST_SIGNATURE_KEY_BASE64 }} + GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }} + winget: runs-on: depot-windows-latest needs: release @@ -177,7 +225,6 @@ jobs: # to GitHub and then making a PR in a different repo. WINGET_GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} - - name: Comment on PR run: | # wait 30 seconds diff --git a/.gitignore b/.gitignore index 0ebfb2c..43a71cc 100644 --- a/.gitignore +++ b/.gitignore @@ -411,3 +411,12 @@ publish *.wixmdb *.wixprj *.wixproj + +appcast.xml +appcast.xml.signature +*.key +*.key.* +*.pem +*.pem.* +*.pub +*.pub.* diff --git a/App/App.csproj b/App/App.csproj index 68cef65..bd36f38 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -19,6 +19,10 @@ DISABLE_XAML_GENERATED_MAIN;DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION Coder Desktop + Coder Desktop + Coder Technologies Inc. + Coder Desktop + © Coder Technologies Inc. coder.ico @@ -31,9 +35,7 @@ - - - + @@ -68,12 +70,17 @@ + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 87afcb3..f4c05a2 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -16,37 +16,46 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.Win32; using Microsoft.Windows.AppLifecycle; using Microsoft.Windows.AppNotifications; +using NetSparkleUpdater.Interfaces; using Serilog; using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; namespace Coder.Desktop.App; -public partial class App : Application +public partial class App : Application, IDispatcherQueueManager { - private readonly IServiceProvider _services; - - private bool _handleWindowClosed = true; private const string MutagenControllerConfigSection = "MutagenController"; + private const string UpdaterConfigSection = "Updater"; #if !DEBUG private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\App"; - private const string logFilename = "app.log"; + private const string LogFilename = "app.log"; + private const string DefaultLogLevel = "Information"; #else private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugApp"; - private const string logFilename = "debug-app.log"; + private const string LogFilename = "debug-app.log"; + private const string DefaultLogLevel = "Debug"; #endif + // HACK: This is exposed for dispatcher queue access. The notifier uses + // this to ensure action callbacks run in the UI thread (as + // activation events aren't in the main thread). + public TrayWindow? TrayWindow; + + private readonly IServiceProvider _services; private readonly ILogger _logger; private readonly IUriHandler _uriHandler; - + private readonly IUserNotifier _userNotifier; private readonly ISettingsManager _settingsManager; - private readonly IHostApplicationLifetime _appLifetime; + private bool _handleWindowClosed = true; + public App() { var builder = Host.CreateApplicationBuilder(); @@ -58,7 +67,17 @@ public App() configBuilder.Add( new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey)); configBuilder.Add( - new RegistryConfigurationSource(Registry.CurrentUser, ConfigSubKey)); + new RegistryConfigurationSource( + Registry.CurrentUser, + ConfigSubKey, + // Block "Updater:" configuration from HKCU, so that updater + // settings can only be set at the HKLM level. + // + // HACK: This isn't super robust, but the security risk is + // minor anyway. Malicious apps running as the user could + // likely override this setting by altering the memory of + // this app. + UpdaterConfigSection + ":")); var services = builder.Services; @@ -71,6 +90,7 @@ public App() services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(_ => this); services.AddSingleton(_ => new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName)); services.AddSingleton(); @@ -84,6 +104,12 @@ public App() services.AddSingleton(); services.AddSingleton(); + services.AddOptions() + .Bind(builder.Configuration.GetSection(UpdaterConfigSection)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // SignInWindow views and view models services.AddTransient(); services.AddTransient(); @@ -119,6 +145,7 @@ public App() _services = services.BuildServiceProvider(); _logger = _services.GetRequiredService>(); _uriHandler = _services.GetRequiredService(); + _userNotifier = _services.GetRequiredService(); _settingsManager = _services.GetRequiredService>(); _appLifetime = _services.GetRequiredService(); @@ -142,16 +169,18 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) { _logger.LogInformation("new instance launched"); - _ = InitializeServicesAsync(_appLifetime.ApplicationStopping); - // Prevent the TrayWindow from closing, just hide it. - var trayWindow = _services.GetRequiredService(); - trayWindow.Closed += (_, closedArgs) => + if (TrayWindow != null) + throw new InvalidOperationException("OnLaunched was called multiple times? TrayWindow is already set"); + TrayWindow = _services.GetRequiredService(); + TrayWindow.Closed += (_, closedArgs) => { if (!_handleWindowClosed) return; closedArgs.Handled = true; - trayWindow.AppWindow.Hide(); + TrayWindow.AppWindow.Hide(); }; + + _ = InitializeServicesAsync(_appLifetime.ApplicationStopping); } /// @@ -261,8 +290,8 @@ public void OnActivated(object? sender, AppActivationArguments args) public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args) { - // right now, we don't do anything other than log - _logger.LogInformation("handled notification activation"); + _logger.LogInformation("handled notification activation: {Argument}", args.Argument); + _userNotifier.HandleNotificationActivation(args.Arguments); } private static void AddDefaultConfig(IConfigurationBuilder builder) @@ -270,18 +299,40 @@ private static void AddDefaultConfig(IConfigurationBuilder builder) var logPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CoderDesktop", - logFilename); + LogFilename); builder.AddInMemoryCollection(new Dictionary { [MutagenControllerConfigSection + ":MutagenExecutablePath"] = @"C:\mutagen.exe", + ["Serilog:Using:0"] = "Serilog.Sinks.File", - ["Serilog:MinimumLevel"] = "Information", + ["Serilog:MinimumLevel"] = DefaultLogLevel, ["Serilog:Enrich:0"] = "FromLogContext", ["Serilog:WriteTo:0:Name"] = "File", ["Serilog:WriteTo:0:Args:path"] = logPath, ["Serilog:WriteTo:0:Args:outputTemplate"] = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", ["Serilog:WriteTo:0:Args:rollingInterval"] = "Day", + +#if DEBUG + ["Serilog:Using:1"] = "Serilog.Sinks.Debug", + ["Serilog:Enrich:1"] = "FromLogContext", + ["Serilog:WriteTo:1:Name"] = "Debug", + ["Serilog:WriteTo:1:Args:outputTemplate"] = + "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", +#endif }); } + + public void RunInUiThread(DispatcherQueueHandler action) + { + var dispatcherQueue = TrayWindow?.DispatcherQueue; + if (dispatcherQueue is null) + throw new InvalidOperationException("DispatcherQueue is not available"); + if (dispatcherQueue.HasThreadAccess) + { + action(); + return; + } + dispatcherQueue.TryEnqueue(action); + } } diff --git a/App/Assets/changelog.css b/App/Assets/changelog.css new file mode 100644 index 0000000..e3fda84 --- /dev/null +++ b/App/Assets/changelog.css @@ -0,0 +1,1237 @@ +/* + This file was taken from: + https://github.com/sindresorhus/github-markdown-css/blob/bedb4b771f5fa1ae117df597c79993fd1eb4dff0/github-markdown.css + + Licensed under the MIT license. + + Changes: + - Removed @media queries in favor of requiring `[data-theme]` attributes + on the body themselves + - Overrides `--bgColor-default` to transparent +*/ + +.markdown-body { + --base-size-4: 0.25rem; + --base-size-8: 0.5rem; + --base-size-16: 1rem; + --base-size-24: 1.5rem; + --base-size-40: 2.5rem; + --base-text-weight-normal: 400; + --base-text-weight-medium: 500; + --base-text-weight-semibold: 600; + --fontStack-monospace: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + --fgColor-accent: Highlight; + + --bgColor-default: transparent !important; +} +body[data-theme="dark"] .markdown-body { + /* dark */ + color-scheme: dark; + --focus-outlineColor: #1f6feb; + --fgColor-default: #f0f6fc; + --fgColor-muted: #9198a1; + --fgColor-accent: #4493f8; + --fgColor-success: #3fb950; + --fgColor-attention: #d29922; + --fgColor-danger: #f85149; + --fgColor-done: #ab7df8; + --bgColor-default: #0d1117; + --bgColor-muted: #151b23; + --bgColor-neutral-muted: #656c7633; + --bgColor-attention-muted: #bb800926; + --borderColor-default: #3d444d; + --borderColor-muted: #3d444db3; + --borderColor-neutral-muted: #3d444db3; + --borderColor-accent-emphasis: #1f6feb; + --borderColor-success-emphasis: #238636; + --borderColor-attention-emphasis: #9e6a03; + --borderColor-danger-emphasis: #da3633; + --borderColor-done-emphasis: #8957e5; + --color-prettylights-syntax-comment: #9198a1; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #f0f6fc; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-brackethighlighter-angle: #9198a1; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #f0f6fc; + --color-prettylights-syntax-markup-bold: #f0f6fc; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #f0f6fc; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-sublimelinter-gutter-mark: #3d444d; +} +body[data-theme=""] .markdown-body { + /* light */ + color-scheme: light; + --focus-outlineColor: #0969da; + --fgColor-default: #1f2328; + --fgColor-muted: #59636e; + --fgColor-accent: #0969da; + --fgColor-success: #1a7f37; + --fgColor-attention: #9a6700; + --fgColor-danger: #d1242f; + --fgColor-done: #8250df; + --bgColor-default: #ffffff; + --bgColor-muted: #f6f8fa; + --bgColor-neutral-muted: #818b981f; + --bgColor-attention-muted: #fff8c5; + --borderColor-default: #d1d9e0; + --borderColor-muted: #d1d9e0b3; + --borderColor-neutral-muted: #d1d9e0b3; + --borderColor-accent-emphasis: #0969da; + --borderColor-success-emphasis: #1a7f37; + --borderColor-attention-emphasis: #9a6700; + --borderColor-danger-emphasis: #cf222e; + --borderColor-done-emphasis: #8250df; + --color-prettylights-syntax-comment: #59636e; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-prettylights-syntax-entity: #6639ba; + --color-prettylights-syntax-storage-modifier-import: #1f2328; + --color-prettylights-syntax-entity-tag: #0550ae; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-brackethighlighter-angle: #59636e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #1f2328; + --color-prettylights-syntax-markup-bold: #1f2328; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #d1d9e0; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-sublimelinter-gutter-mark: #818b98; +} + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: var(--fgColor-default); + background-color: var(--bgColor-default); + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); +} + +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + color: var(--fgColor-accent); + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid var(--borderColor-muted); +} + +.markdown-body mark { + background-color: var(--bgColor-attention-muted); + color: var(--fgColor-default); +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em var(--base-size-40); +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: 1px solid var(--borderColor-muted); + height: .25em; + padding: 0; + margin: var(--base-size-24) 0; + background-color: var(--borderColor-default); + border: 0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: var(--fgColor-muted); + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + font-variant: tabular-nums; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid var(--focus-outlineColor); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid var(--focus-outlineColor); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: var(--base-size-4); + font: 11px var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + line-height: 10px; + color: var(--fgColor-default); + vertical-align: middle; + background-color: var(--bgColor-muted); + border: solid 1px var(--borderColor-neutral-muted); + border-bottom-color: var(--borderColor-neutral-muted); + border-radius: 6px; + box-shadow: inset 0 -1px 0 var(--borderColor-neutral-muted); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: var(--base-size-24); + margin-bottom: var(--base-size-16); + font-weight: var(--base-text-weight-semibold, 600); + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid var(--borderColor-muted); +} + +.markdown-body h3 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1em; +} + +.markdown-body h5 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .875em; +} + +.markdown-body h6 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .85em; + color: var(--fgColor-muted); +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: var(--fgColor-muted); + border-left: .25em solid var(--borderColor-default); +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.markdown-body .mr-2 { + margin-right: var(--base-size-8, 8px) !important; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body>*:first-child { + margin-top: 0 !important; +} + +.markdown-body>*:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: var(--fgColor-danger); +} + +.markdown-body .anchor { + float: left; + padding-right: var(--base-size-4); + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: var(--base-size-16); +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: var(--fgColor-default); + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div>ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li>p { + margin-top: var(--base-size-16); +} + +.markdown-body li+li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: var(--base-size-16); + font-size: 1em; + font-style: italic; + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dl dd { + padding: 0 var(--base-size-16); + margin-bottom: var(--base-size-16); +} + +.markdown-body table th { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--borderColor-default); +} + +.markdown-body table td>:last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + background-color: var(--bgColor-default); + border-top: 1px solid var(--borderColor-muted); +} + +.markdown-body table tr:nth-child(2n) { + background-color: var(--bgColor-muted); +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame>span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid var(--borderColor-default); +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: var(--fgColor-default); +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right>span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--bgColor-neutral-muted); + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: var(--base-size-16); +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: var(--base-size-16); + overflow: auto; + font-size: 85%; + line-height: 1.45; + color: var(--fgColor-default); + background-color: var(--bgColor-muted); + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px var(--base-size-8) 9px; + text-align: right; + background: var(--bgColor-default); + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: var(--base-text-weight-semibold, 600); + background: var(--bgColor-muted); + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: var(--fgColor-muted); + border-top: 1px solid var(--borderColor-default); +} + +.markdown-body .footnotes ol { + padding-left: var(--base-size-16); +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: var(--base-size-16); + margin-top: var(--base-size-16); +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: calc(var(--base-size-8)*-1); + right: calc(var(--base-size-8)*-1); + bottom: calc(var(--base-size-8)*-1); + left: calc(var(--base-size-24)*-1); + pointer-events: none; + content: ""; + border: 2px solid var(--borderColor-accent-emphasis); + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: var(--fgColor-default); +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body body:has(:modal) { + padding-right: var(--dialog-scrollgutter) !important; +} + +.markdown-body .pl-c { + color: var(--color-prettylights-syntax-comment); +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: var(--color-prettylights-syntax-entity); +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); +} + +.markdown-body .pl-ent { + color: var(--color-prettylights-syntax-entity-tag); +} + +.markdown-body .pl-k { + color: var(--color-prettylights-syntax-keyword); +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: var(--color-prettylights-syntax-variable); +} + +.markdown-body .pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); +} + +.markdown-body .pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); +} + +.markdown-body .pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); +} + +.markdown-body .pl-ml { + color: var(--color-prettylights-syntax-markup-list); +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); +} + +.markdown-body .pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); +} + +.markdown-body .pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); +} + +.markdown-body .pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); +} + +.markdown-body .pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); +} + +.markdown-body .pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); +} + +.markdown-body .pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); +} + +.markdown-body .pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); +} + +.markdown-body .pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); +} + +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), +.markdown-body button:focus:not(:focus-visible), +.markdown-body summary:focus:not(:focus-visible), +.markdown-body a:focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +.markdown-body [tabindex="0"]:focus:not(:focus-visible), +.markdown-body details-dialog:focus:not(:focus-visible) { + outline: none; +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: var(--base-text-weight-normal, 400); + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: var(--base-text-weight-normal, 400); +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: var(--base-size-4); +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; +} + +.markdown-body ul:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body ol:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} + +.markdown-body .markdown-alert { + padding: var(--base-size-8) var(--base-size-16); + margin-bottom: var(--base-size-16); + color: inherit; + border-left: .25em solid var(--borderColor-default); +} + +.markdown-body .markdown-alert>:first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert>:last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert .markdown-alert-title { + display: flex; + font-weight: var(--base-text-weight-medium, 500); + align-items: center; + line-height: 1; +} + +.markdown-body .markdown-alert.markdown-alert-note { + border-left-color: var(--borderColor-accent-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { + color: var(--fgColor-accent); +} + +.markdown-body .markdown-alert.markdown-alert-important { + border-left-color: var(--borderColor-done-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { + color: var(--fgColor-done); +} + +.markdown-body .markdown-alert.markdown-alert-warning { + border-left-color: var(--borderColor-attention-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { + color: var(--fgColor-attention); +} + +.markdown-body .markdown-alert.markdown-alert-tip { + border-left-color: var(--borderColor-success-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { + color: var(--fgColor-success); +} + +.markdown-body .markdown-alert.markdown-alert-caution { + border-left-color: var(--borderColor-danger-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { + color: var(--fgColor-danger); +} + +.markdown-body>*:first-child>.heading-element:first-child { + margin-top: 0 !important; +} + +.markdown-body .highlight pre:has(+.zeroclipboard-container) { + min-height: 52px; +} diff --git a/App/Controls/TrayIcon.xaml b/App/Controls/TrayIcon.xaml index fa6cd90..875b0c7 100644 --- a/App/Controls/TrayIcon.xaml +++ b/App/Controls/TrayIcon.xaml @@ -48,6 +48,27 @@ + + + + + + + + + + + + + + + + ("OpenCommand")] [DependencyProperty("ExitCommand")] +[DependencyProperty("CheckForUpdatesCommand")] public sealed partial class TrayIcon : UserControl { private readonly UISettings _uiSettings = new(); diff --git a/App/Services/DispatcherQueueManager.cs b/App/Services/DispatcherQueueManager.cs new file mode 100644 index 0000000..d562067 --- /dev/null +++ b/App/Services/DispatcherQueueManager.cs @@ -0,0 +1,8 @@ +using Microsoft.UI.Dispatching; + +namespace Coder.Desktop.App.Services; + +public interface IDispatcherQueueManager +{ + public void RunInUiThread(DispatcherQueueHandler action); +} diff --git a/App/Services/SettingsManager.cs b/App/Services/SettingsManager.cs index 886d5d2..17e9ef2 100644 --- a/App/Services/SettingsManager.cs +++ b/App/Services/SettingsManager.cs @@ -28,15 +28,43 @@ namespace Coder.Desktop.App.Services; Task Write(T settings, CancellationToken ct = default); } +public static class SettingsManagerUtils +{ + private const string AppName = "CoderDesktop"; + + /// + /// Generates the settings directory path and ensures it exists. + /// + /// Custom settings root, defaults to AppData/Local + public static string AppSettingsDirectory(string? settingsFilePath = null) + { + if (settingsFilePath is null) + { + settingsFilePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + } + else if (!Path.IsPathRooted(settingsFilePath)) + { + throw new ArgumentException("settingsFilePath must be an absolute path if provided", nameof(settingsFilePath)); + } + + var folder = Path.Combine( + settingsFilePath, + AppName); + + Directory.CreateDirectory(folder); + return folder; + } +} + /// -/// Implemention of that persists settings to a JSON file -/// located in the user's local application data folder. +/// Implementation of that persists settings to +/// a JSON file located in the user's local application data folder. /// public sealed class SettingsManager : ISettingsManager where T : ISettings, new() { + private readonly string _settingsFilePath; - private readonly string _appName = "CoderDesktop"; - private string _fileName; + private readonly string _fileName; private T? _cachedSettings; @@ -49,20 +77,7 @@ namespace Coder.Desktop.App.Services; /// public SettingsManager(string? settingsFilePath = null) { - if (settingsFilePath is null) - { - settingsFilePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - } - else if (!Path.IsPathRooted(settingsFilePath)) - { - throw new ArgumentException("settingsFilePath must be an absolute path if provided", nameof(settingsFilePath)); - } - - var folder = Path.Combine( - settingsFilePath, - _appName); - - Directory.CreateDirectory(folder); + var folder = SettingsManagerUtils.AppSettingsDirectory(settingsFilePath); _fileName = T.SettingsFileName; _settingsFilePath = Path.Combine(folder, _fileName); diff --git a/App/Services/UpdateController.cs b/App/Services/UpdateController.cs new file mode 100644 index 0000000..ab5acd5 --- /dev/null +++ b/App/Services/UpdateController.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.ViewModels; +using Coder.Desktop.App.Views; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.UI.Xaml; +using NetSparkleUpdater; +using NetSparkleUpdater.AppCastHandlers; +using NetSparkleUpdater.AssemblyAccessors; +using NetSparkleUpdater.Configurations; +using NetSparkleUpdater.Enums; +using NetSparkleUpdater.Interfaces; +using NetSparkleUpdater.SignatureVerifiers; +using SparkleLogger = NetSparkleUpdater.Interfaces.ILogger; + +namespace Coder.Desktop.App.Services; + +// TODO: add preview channel +public enum UpdateChannel +{ + Stable, +} + +public static class UpdateChannelExtensions +{ + public static string ChannelName(this UpdateChannel channel) + { + switch (channel) + { + case UpdateChannel.Stable: + return "stable"; + default: + throw new ArgumentOutOfRangeException(nameof(channel), channel, null); + } + } +} + +public class UpdaterConfig +{ + public bool Enable { get; set; } = true; + [Required] public string AppCastUrl { get; set; } = "https://releases.coder.com/coder-desktop/windows/appcast.xml"; + [Required] public string PublicKeyBase64 { get; set; } = "NNWN4c+3PmMuAf2G1ERLlu0EwhzHfSiUugOt120hrH8="; + // This preference forces an update channel to be used and prevents the + // user from picking their own channel. + public UpdateChannel? ForcedChannel { get; set; } = null; +} + +public interface IUpdateController : IAsyncDisposable +{ + // Must be called from UI thread. + public Task CheckForUpdatesNow(); +} + +public class SparkleUpdateController : IUpdateController, INotificationHandler +{ + internal const string NotificationHandlerName = "SparkleUpdateNotification"; + + private static readonly TimeSpan UpdateCheckInterval = TimeSpan.FromHours(24); + + private readonly ILogger _logger; + private readonly UpdaterConfig _config; + private readonly IUserNotifier _userNotifier; + private readonly IUIFactory _uiFactory; + + private readonly SparkleUpdater? _sparkle; + + public SparkleUpdateController(ILogger logger, IOptions config, IUserNotifier userNotifier, IUIFactory uiFactory) + { + _logger = logger; + _config = config.Value; + _userNotifier = userNotifier; + _uiFactory = uiFactory; + + _userNotifier.RegisterHandler(NotificationHandlerName, this); + + if (!_config.Enable) + { + _logger.LogInformation("updater disabled by policy"); + return; + } + + _logger.LogInformation("updater enabled, creating NetSparkle instance"); + + // This behavior differs from the macOS version of Coder Desktop, which + // does not verify app cast signatures. + // + // Swift's Sparkle does not support verifying app cast signatures yet, + // but we use this functionality on Windows for added security against + // malicious release notes. + var checker = new Ed25519Checker(SecurityMode.Strict, + publicKey: _config.PublicKeyBase64, + readFileBeingVerifiedInChunks: true); + + // Tell NetSparkle to store its configuration in the same directory as + // our other config files. + var appConfigDir = SettingsManagerUtils.AppSettingsDirectory(); + var sparkleConfigPath = Path.Combine(appConfigDir, "updater.json"); + var sparkleAssemblyAccessor = new AssemblyDiagnosticsAccessor(null); // null => use current executable path + var sparkleConfig = new JSONConfiguration(sparkleAssemblyAccessor, sparkleConfigPath); + + _sparkle = new SparkleUpdater(_config.AppCastUrl, checker) + { + Configuration = sparkleConfig, + // GitHub releases endpoint returns a random UUID as the filename, + // so we tell NetSparkle to ignore it and use the last segment of + // the URL instead. + CheckServerFileName = false, + LogWriter = new CoderSparkleLogger(logger), + AppCastHelper = new CoderSparkleAppCastHelper(_config.ForcedChannel), + UIFactory = uiFactory, + UseNotificationToast = uiFactory.CanShowToastMessages(), + RelaunchAfterUpdate = true, + }; + + _sparkle.CloseApplicationAsync += SparkleOnCloseApplicationAsync; + + // TODO: user preference for automatic checking. Remember to + // StopLoop/StartLoop if it changes. +#if !DEBUG + _ = _sparkle.StartLoop(true, UpdateCheckInterval); +#endif + } + + private static async Task SparkleOnCloseApplicationAsync() + { + await ((App)Application.Current).ExitApplication(); + } + + public async Task CheckForUpdatesNow() + { + if (_sparkle == null) + { + _ = new MessageWindow( + "Updates disabled", + "The built-in updater is disabled by policy.", + "Coder Desktop Updater"); + return; + } + + // NetSparkle will not open the UpdateAvailable window if it can send a + // toast, even if the user requested the update. We work around this by + // temporarily disabling toasts during this operation. + var coderFactory = _uiFactory as CoderSparkleUIFactory; + try + { + if (coderFactory is not null) + coderFactory.ForceDisableToastMessages = true; + + await _sparkle.CheckForUpdatesAtUserRequest(true); + } + finally + { + if (coderFactory is not null) + coderFactory.ForceDisableToastMessages = false; + } + } + + public ValueTask DisposeAsync() + { + _userNotifier.UnregisterHandler(NotificationHandlerName); + _sparkle?.Dispose(); + return ValueTask.CompletedTask; + } + + public void HandleNotificationActivation(IDictionary args) + { + _ = CheckForUpdatesNow(); + } +} + +public class CoderSparkleLogger(ILogger logger) : SparkleLogger +{ + public void PrintMessage(string message, params object[]? arguments) + { + logger.LogInformation("[sparkle] " + message, arguments ?? []); + } +} + +public class CoderSparkleAppCastHelper(UpdateChannel? forcedChannel) : AppCastHelper +{ + // This might return some other OS if the user compiled the app for some + // different arch, but the end result is the same: no updates will be found + // for that arch. + private static string CurrentOperatingSystem => $"win-{RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant()}"; + + public override List FilterUpdates(List items) + { + items = base.FilterUpdates(items); + + // TODO: factor in user channel choice too once we have a settings page + var channel = forcedChannel ?? UpdateChannel.Stable; + return items.FindAll(i => i.Channel == channel.ChannelName() && i.OperatingSystem == CurrentOperatingSystem); + } +} + +// ReSharper disable once InconsistentNaming // the interface name is "UI", not "Ui" +public class CoderSparkleUIFactory(IUserNotifier userNotifier, IUpdaterUpdateAvailableViewModelFactory updateAvailableViewModelFactory) : IUIFactory +{ + public bool ForceDisableToastMessages; + + public bool HideReleaseNotes { get; set; } + public bool HideSkipButton { get; set; } + public bool HideRemindMeLaterButton { get; set; } + + // This stuff is ignored as we use our own template in the ViewModel + // directly: + string? IUIFactory.ReleaseNotesHTMLTemplate { get; set; } + string? IUIFactory.AdditionalReleaseNotesHeaderHTML { get; set; } + + public IUpdateAvailable CreateUpdateAvailableWindow(List updates, ISignatureVerifier? signatureVerifier, + string currentVersion = "", string appName = "Coder Desktop", bool isUpdateAlreadyDownloaded = false) + { + var viewModel = updateAvailableViewModelFactory.Create( + updates, + signatureVerifier, + currentVersion, + appName, + isUpdateAlreadyDownloaded); + + var window = new UpdaterUpdateAvailableWindow(viewModel); + if (HideReleaseNotes) + (window as IUpdateAvailable).HideReleaseNotes(); + if (HideSkipButton) + (window as IUpdateAvailable).HideSkipButton(); + if (HideRemindMeLaterButton) + (window as IUpdateAvailable).HideRemindMeLaterButton(); + + return window; + } + + IDownloadProgress IUIFactory.CreateProgressWindow(string downloadTitle, string actionButtonTitleAfterDownload) + { + var viewModel = new UpdaterDownloadProgressViewModel(); + return new UpdaterDownloadProgressWindow(viewModel); + } + + ICheckingForUpdates IUIFactory.ShowCheckingForUpdates() + { + return new UpdaterCheckingForUpdatesWindow(); + } + + void IUIFactory.ShowUnknownInstallerFormatMessage(string downloadFileName) + { + _ = new MessageWindow("Installer format error", + $"The installer format for the downloaded file '{downloadFileName}' is unknown. Please check application logs for more information.", + "Coder Desktop Updater"); + } + + void IUIFactory.ShowVersionIsUpToDate() + { + _ = new MessageWindow( + "No updates available", + "Coder Desktop is up to date!", + "Coder Desktop Updater"); + } + + void IUIFactory.ShowVersionIsSkippedByUserRequest() + { + _ = new MessageWindow( + "Update skipped", + "You have elected to skip this update.", + "Coder Desktop Updater"); + } + + void IUIFactory.ShowCannotDownloadAppcast(string? appcastUrl) + { + _ = new MessageWindow("Cannot fetch update information", + $"Unable to download the updates manifest from '{appcastUrl}'. Please check your internet connection or firewall settings and try again.", + "Coder Desktop Updater"); + } + + void IUIFactory.ShowDownloadErrorMessage(string message, string? appcastUrl) + { + _ = new MessageWindow("Download error", + $"An error occurred while downloading the update. Please check your internet connection or firewall settings and try again.\n\n{message}", + "Coder Desktop Updater"); + } + + bool IUIFactory.CanShowToastMessages() + { + return !ForceDisableToastMessages; + } + + void IUIFactory.ShowToast(Action clickHandler) + { + // We disregard the Action passed to us by NetSparkle as it uses cached + // data and does not perform a new update check. The + // INotificationHandler is registered by SparkleUpdateController. + _ = userNotifier.ShowActionNotification( + "Coder Desktop", + "Updates are available, click for more information.", + SparkleUpdateController.NotificationHandlerName, + null, + CancellationToken.None); + } + + void IUIFactory.Shutdown() + { + ((App)Application.Current).ExitApplication().Wait(); + } +} diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs index 3b4ac05..5ad8e38 100644 --- a/App/Services/UserNotifier.cs +++ b/App/Services/UserNotifier.cs @@ -1,29 +1,109 @@ using System; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.Windows.AppNotifications; using Microsoft.Windows.AppNotifications.Builder; namespace Coder.Desktop.App.Services; -public interface IUserNotifier : IAsyncDisposable +public interface INotificationHandler { + public void HandleNotificationActivation(IDictionary args); +} + +public interface IUserNotifier : INotificationHandler, IAsyncDisposable +{ + public void RegisterHandler(string name, INotificationHandler handler); + public void UnregisterHandler(string name); + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default); + public Task ShowActionNotification(string title, string message, string handlerName, IDictionary? args = null, CancellationToken ct = default); } -public class UserNotifier : IUserNotifier +public class UserNotifier(ILogger logger, IDispatcherQueueManager dispatcherQueueManager) : IUserNotifier { + private const string CoderNotificationHandler = "CoderNotificationHandler"; + private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default; + private ConcurrentDictionary Handlers { get; } = new(); + public ValueTask DisposeAsync() { return ValueTask.CompletedTask; } + public void RegisterHandler(string name, INotificationHandler handler) + { + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + if (handler is IUserNotifier) + throw new ArgumentException("Handler cannot be an IUserNotifier", nameof(handler)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name cannot be null or whitespace", nameof(name)); + if (!Handlers.TryAdd(name, handler)) + throw new InvalidOperationException($"A handler with the name '{name}' is already registered."); + } + + public void UnregisterHandler(string name) + { + if (!Handlers.TryRemove(name, out _)) + throw new InvalidOperationException($"No handler with the name '{name}' is registered."); + } + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default) { var builder = new AppNotificationBuilder().AddText(title).AddText(message); _notificationManager.Show(builder.BuildNotification()); return Task.CompletedTask; } + + public Task ShowActionNotification(string title, string message, string handlerName, IDictionary? args = null, CancellationToken ct = default) + { + if (!Handlers.TryGetValue(handlerName, out _)) + throw new InvalidOperationException($"No action handler with the name '{handlerName}' is registered."); + + var builder = new AppNotificationBuilder() + .AddText(title) + .AddText(message) + .AddArgument(CoderNotificationHandler, handlerName); + if (args != null) + foreach (var arg in args) + { + if (arg.Key == CoderNotificationHandler) + continue; + builder.AddArgument(arg.Key, arg.Value); + } + + _notificationManager.Show(builder.BuildNotification()); + return Task.CompletedTask; + } + + public void HandleNotificationActivation(IDictionary args) + { + if (!args.TryGetValue(CoderNotificationHandler, out var handlerName)) + // Not an action notification, ignore + return; + + if (!Handlers.TryGetValue(handlerName, out var handler)) + { + logger.LogWarning("no action handler '{HandlerName}' found for notification activation, ignoring", handlerName); + return; + } + + dispatcherQueueManager.RunInUiThread(() => + { + try + { + handler.HandleNotificationActivation(args); + } + catch (Exception ex) + { + logger.LogWarning(ex, "could not handle activation for notification with handler '{HandlerName}", handlerName); + } + }); + } } diff --git a/App/Utils/ForegroundWindow.cs b/App/Utils/ForegroundWindow.cs new file mode 100644 index 0000000..f58eb5b --- /dev/null +++ b/App/Utils/ForegroundWindow.cs @@ -0,0 +1,22 @@ +using System; +using System.Runtime.InteropServices; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using WinRT.Interop; + +namespace Coder.Desktop.App.Utils; + +public static class ForegroundWindow +{ + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hwnd); + + public static void MakeForeground(Window window) + { + var hwnd = WindowNative.GetWindowHandle(window); + var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); + _ = SetForegroundWindow(hwnd); + // Not a big deal if it fails. + } +} diff --git a/App/Utils/TitleBarIcon.cs b/App/Utils/TitleBarIcon.cs index 283453d..ff6ece4 100644 --- a/App/Utils/TitleBarIcon.cs +++ b/App/Utils/TitleBarIcon.cs @@ -1,7 +1,4 @@ -using Microsoft.UI; -using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; -using WinRT.Interop; namespace Coder.Desktop.App.Utils; @@ -9,8 +6,6 @@ public static class TitleBarIcon { public static void SetTitlebarIcon(Window window) { - var hwnd = WindowNative.GetWindowHandle(window); - var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); - AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico"); + window.AppWindow.SetIcon("coder.ico"); } } diff --git a/App/ViewModels/UpdaterDownloadProgressViewModel.cs b/App/ViewModels/UpdaterDownloadProgressViewModel.cs new file mode 100644 index 0000000..cd66f83 --- /dev/null +++ b/App/ViewModels/UpdaterDownloadProgressViewModel.cs @@ -0,0 +1,91 @@ +using Coder.Desktop.App.Converters; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.UI.Xaml; +using NetSparkleUpdater.Events; + +namespace Coder.Desktop.App.ViewModels; + +public partial class UpdaterDownloadProgressViewModel : ObservableObject +{ + // Partially implements IDownloadProgress + public event DownloadInstallEventHandler? DownloadProcessCompleted; + + [ObservableProperty] + public partial bool IsDownloading { get; set; } = false; + + [ObservableProperty] + public partial string DownloadingTitle { get; set; } = "Downloading..."; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DownloadProgressValue))] + [NotifyPropertyChangedFor(nameof(UserReadableDownloadProgress))] + public partial ulong DownloadedBytes { get; set; } = 0; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DownloadProgressValue))] + [NotifyPropertyChangedFor(nameof(DownloadProgressIndeterminate))] + [NotifyPropertyChangedFor(nameof(UserReadableDownloadProgress))] + public partial ulong TotalBytes { get; set; } = 0; // 0 means unknown + + public int DownloadProgressValue => (int)(TotalBytes > 0 ? DownloadedBytes * 100 / TotalBytes : 0); + + public bool DownloadProgressIndeterminate => TotalBytes == 0; + + public string UserReadableDownloadProgress + { + get + { + if (DownloadProgressValue == 100) + return "Download complete"; + + // TODO: FriendlyByteConverter should allow for matching suffixes + // on both + var str = FriendlyByteConverter.FriendlyBytes(DownloadedBytes) + " of "; + if (TotalBytes > 0) + str += FriendlyByteConverter.FriendlyBytes(TotalBytes); + else + str += "unknown"; + str += " downloaded"; + if (DownloadProgressValue > 0) + str += $" ({DownloadProgressValue}%)"; + return str; + } + } + + // TODO: is this even necessary? + [ObservableProperty] + public partial string ActionButtonTitle { get; set; } = "Cancel"; // Default action string from the built-in NetSparkle UI + + [ObservableProperty] + public partial bool IsActionButtonEnabled { get; set; } = true; + + public void SetFinishedDownloading(bool isDownloadedFileValid) + { + IsDownloading = false; + TotalBytes = DownloadedBytes; // In case the total bytes were unknown + if (isDownloadedFileValid) + { + DownloadingTitle = "Ready to install"; + ActionButtonTitle = "Install"; + } + + // We don't need to handle the error/invalid state here as the window + // will handle that for us by showing a MessageWindow. + } + + public void SetDownloadProgress(ulong bytesReceived, ulong totalBytesToReceive) + { + DownloadedBytes = bytesReceived; + TotalBytes = totalBytesToReceive; + } + + public void SetActionButtonEnabled(bool enabled) + { + IsActionButtonEnabled = enabled; + } + + public void ActionButton_Click(object? sender, RoutedEventArgs e) + { + DownloadProcessCompleted?.Invoke(this, new DownloadInstallEventArgs(!IsDownloading)); + } +} diff --git a/App/ViewModels/UpdaterUpdateAvailableViewModel.cs b/App/ViewModels/UpdaterUpdateAvailableViewModel.cs new file mode 100644 index 0000000..9fd6dd9 --- /dev/null +++ b/App/ViewModels/UpdaterUpdateAvailableViewModel.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using NetSparkleUpdater; +using NetSparkleUpdater.Enums; +using NetSparkleUpdater.Events; +using NetSparkleUpdater.Interfaces; + +namespace Coder.Desktop.App.ViewModels; + +public interface IUpdaterUpdateAvailableViewModelFactory +{ + public UpdaterUpdateAvailableViewModel Create(List updates, ISignatureVerifier? signatureVerifier, string currentVersion, string appName, bool isUpdateAlreadyDownloaded); +} + +public class UpdaterUpdateAvailableViewModelFactory(ILogger childLogger) : IUpdaterUpdateAvailableViewModelFactory +{ + public UpdaterUpdateAvailableViewModel Create(List updates, ISignatureVerifier? signatureVerifier, string currentVersion, string appName, bool isUpdateAlreadyDownloaded) + { + return new UpdaterUpdateAvailableViewModel(childLogger, updates, signatureVerifier, currentVersion, appName, isUpdateAlreadyDownloaded); + } +} + +public partial class UpdaterUpdateAvailableViewModel : ObservableObject +{ + private readonly ILogger _logger; + + // All the unchanging stuff we get from NetSparkle: + public readonly IReadOnlyList Updates; + public readonly ISignatureVerifier? SignatureVerifier; + public readonly string CurrentVersion; + public readonly string AppName; + public readonly bool IsUpdateAlreadyDownloaded; + + // Partial implementation of IUpdateAvailable: + public UpdateAvailableResult Result { get; set; } = UpdateAvailableResult.None; + // We only show the first update. + public AppCastItem CurrentItem => Updates[0]; // always has at least one item + public event UserRespondedToUpdate? UserResponded; + + // Other computed fields based on readonly data: + public bool MissingCriticalUpdate => Updates.Any(u => u.IsCriticalUpdate); + + [ObservableProperty] + public partial bool ReleaseNotesVisible { get; set; } = true; + + [ObservableProperty] + public partial bool RemindMeLaterButtonVisible { get; set; } = true; + + [ObservableProperty] + public partial bool SkipButtonVisible { get; set; } = true; + + public string MainText + { + get + { + var actionText = IsUpdateAlreadyDownloaded ? "install" : "download"; + return $"{AppName} {CurrentItem.Version} is now available (you have {CurrentVersion}). Would you like to {actionText} it now?"; + } + } + + public UpdaterUpdateAvailableViewModel(ILogger logger, List updates, ISignatureVerifier? signatureVerifier, string currentVersion, string appName, bool isUpdateAlreadyDownloaded) + { + if (updates.Count == 0) + throw new InvalidOperationException("No updates available, cannot create UpdaterUpdateAvailableViewModel"); + + _logger = logger; + Updates = updates; + SignatureVerifier = signatureVerifier; + CurrentVersion = currentVersion; + AppName = appName; + IsUpdateAlreadyDownloaded = isUpdateAlreadyDownloaded; + } + + public void HideReleaseNotes() + { + ReleaseNotesVisible = false; + } + + public void HideRemindMeLaterButton() + { + RemindMeLaterButtonVisible = false; + } + + public void HideSkipButton() + { + SkipButtonVisible = false; + } + + public async Task ChangelogHtml(AppCastItem item) + { + const string cssResourceName = "Coder.Desktop.App.Assets.changelog.css"; + const string htmlTemplate = @" + + + + + + + + + +
+ {{CONTENT}} +
+ + +"; + + 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 @@ + + + + + + + + + + + + + + + + + + +