From 2f8f3a12113b872b5a5e8007fa2af53da873c14a Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 4 Jun 2025 17:58:33 +1000 Subject: [PATCH 1/8] feat: add auto updater --- .../.idea/projectSettingsUpdater.xml | 1 + App/App.csproj | 19 +- App/App.xaml.cs | 81 +- App/Assets/changelog.css | 1233 +++++++++++++++++ App/Controls/TrayIcon.xaml | 25 +- App/Controls/TrayIcon.xaml.cs | 1 + App/Services/UpdateController.cs | 282 ++++ App/Services/UserNotifier.cs | 67 +- App/Utils/ForegroundWindow.cs | 22 + App/Utils/TitleBarIcon.cs | 7 +- .../UpdaterDownloadProgressViewModel.cs | 91 ++ .../UpdaterUpdateAvailableViewModel.cs | 234 ++++ App/Views/MessageWindow.xaml | 43 + App/Views/MessageWindow.xaml.cs | 37 + .../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 | 34 + App/Views/UpdaterDownloadProgressWindow.xaml | 20 + .../UpdaterDownloadProgressWindow.xaml.cs | 85 ++ App/Views/UpdaterUpdateAvailableWindow.xaml | 21 + .../UpdaterUpdateAvailableWindow.xaml.cs | 90 ++ App/packages.lock.json | 23 + Vpn.Proto/packages.lock.json | 3 - Vpn/RegistryConfigurationSource.cs | 17 +- 29 files changed, 2594 insertions(+), 47 deletions(-) create mode 100644 App/Assets/changelog.css 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 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/App.csproj b/App/App.csproj index fcfb92f..700435c 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 @@ -30,10 +34,12 @@ - + + + @@ -67,16 +73,27 @@ + + + + + + + + + MSBuild:Compile + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 5b82ced..b65df88 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -21,6 +21,7 @@ using Microsoft.Win32; using Microsoft.Windows.AppLifecycle; using Microsoft.Windows.AppNotifications; +using NetSparkleUpdater.Interfaces; using Serilog; using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; @@ -28,21 +29,30 @@ namespace Coder.Desktop.App; public partial class App : Application { - 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 bool _handleWindowClosed = true; public App() { @@ -55,7 +65,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; @@ -81,6 +101,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(); @@ -107,8 +133,9 @@ 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(); + _userNotifier = _services.GetRequiredService(); InitializeComponent(); } @@ -129,6 +156,18 @@ public async Task ExitApplication() protected override void OnLaunched(LaunchActivatedEventArgs args) { _logger.LogInformation("new instance launched"); + + // Prevent the TrayWindow from closing, just hide it. + 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(); + }; + // Start connecting to the manager in the background. var rpcController = _services.GetRequiredService(); if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected) @@ -179,15 +218,6 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) syncSessionCts.Dispose(); }, CancellationToken.None); - - // Prevent the TrayWindow from closing, just hide it. - var trayWindow = _services.GetRequiredService(); - trayWindow.Closed += (_, closedArgs) => - { - if (!_handleWindowClosed) return; - closedArgs.Handled = true; - trayWindow.AppWindow.Hide(); - }; } public void OnActivated(object? sender, AppActivationArguments args) @@ -229,8 +259,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.HandleActivation(args); } private static void AddDefaultConfig(IConfigurationBuilder builder) @@ -238,18 +268,27 @@ 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 }); } } diff --git a/App/Assets/changelog.css b/App/Assets/changelog.css new file mode 100644 index 0000000..604a35b --- /dev/null +++ b/App/Assets/changelog.css @@ -0,0 +1,1233 @@ +/* + 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 +*/ + +.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; +} +.markdown-body[data-theme="dark"] { + /* 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; +} +.markdown-body[data-theme="light"] { + /* 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..96a1b4b 100644 --- a/App/Controls/TrayIcon.xaml +++ b/App/Controls/TrayIcon.xaml @@ -32,7 +32,7 @@ + Command="{x:Bind OpenCommand, Mode=OneWay}"> @@ -48,12 +48,33 @@ + + + + + + + + + + + + + + + + + Command="{x:Bind ExitCommand, Mode=OneWay}"> diff --git a/App/Controls/TrayIcon.xaml.cs b/App/Controls/TrayIcon.xaml.cs index ecff24c..bbf44fe 100644 --- a/App/Controls/TrayIcon.xaml.cs +++ b/App/Controls/TrayIcon.xaml.cs @@ -10,6 +10,7 @@ namespace Coder.Desktop.App.Controls; [DependencyProperty("OpenCommand")] [DependencyProperty("ExitCommand")] +[DependencyProperty("CheckForUpdatesCommand")] public sealed partial class TrayIcon : UserControl { private readonly UISettings _uiSettings = new(); diff --git a/App/Services/UpdateController.cs b/App/Services/UpdateController.cs new file mode 100644 index 0000000..525804f --- /dev/null +++ b/App/Services/UpdateController.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +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.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); + } + } +} + +// TODO: SET THESE TO THE CORRECT SETTINGS +public class UpdaterConfig +{ + public bool EnableUpdater { get; set; } = true; + //[Required] public string UpdateAppCastUrl { get; set; } = "https://releases.coder.com/coder-desktop/windows/appcast.xml"; + [Required] public string UpdateAppCastUrl { get; set; } = "http://localhost:8000/appcast.xml"; + [Required] public string UpdatePublicKeyBase64 { get; set; } = "Uxc0ir6j3GMhkL5D1O/W3lsD4BNk5puwM9hohNfm32k="; + public UpdateChannel? ForcedUpdateChannel { get; set; } = null; +} + +public interface IUpdateController : IAsyncDisposable +{ + // Must be called from UI thread. + public Task CheckForUpdatesNow(); +} + +public class SparkleUpdateController : IUpdateController +{ + private static readonly TimeSpan UpdateCheckInterval = TimeSpan.FromHours(24); + + private readonly ILogger _logger; + private readonly UpdaterConfig _config; + private readonly IUIFactory _uiFactory; + + private readonly SparkleUpdater? _sparkle; + + public SparkleUpdateController(ILogger logger, IOptions config, IUIFactory uiFactory) + { + _logger = logger; + _config = config.Value; + _uiFactory = uiFactory; + + if (!_config.EnableUpdater) + { + _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. + // TODO: REENABLE STRICT CHECKING + var checker = new Ed25519Checker(SecurityMode.Unsafe, + publicKey: _config.UpdatePublicKeyBase64, + readFileBeingVerifiedInChunks: true); + + _sparkle = new SparkleUpdater(_config.UpdateAppCastUrl, checker) + { + // TODO: custom Configuration for persistence, could just specify + // our own save path with JSONConfiguration TBH + LogWriter = new CoderSparkleLogger(logger), + AppCastHelper = new CoderSparkleAppCastHelper(logger, _config.ForcedUpdateChannel), + UIFactory = uiFactory, + UseNotificationToast = uiFactory.CanShowToastMessages(), + RelaunchAfterUpdate = true, + }; + + _sparkle.CloseApplicationAsync += SparkleOnCloseApplicationAsync; + + // TODO: user preference for automatic checking +#if !DEBUG || true + _ = _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.ForceDisableToasts = true; + + await _sparkle.CheckForUpdatesAtUserRequest(true); + } + finally + { + if (coderFactory is not null) + coderFactory.ForceDisableToasts = false; + } + } + + public ValueTask DisposeAsync() + { + _sparkle?.Dispose(); + return ValueTask.CompletedTask; + } +} + +public class CoderSparkleLogger(ILogger logger) : SparkleLogger +{ + public void PrintMessage(string message, params object[]? arguments) + { + logger.LogInformation("[sparkle] " + message, arguments ?? []); + } +} + +public class CoderSparkleAppCastHelper : AppCastHelper +{ + private readonly UpdateChannel? _forcedChannel; + + public CoderSparkleAppCastHelper(ILogger logger, UpdateChannel? forcedChannel) : base() + { + _forcedChannel = forcedChannel; + } + + public override List FilterUpdates(List items) + { + items = base.FilterUpdates(items); + + // TODO: factor in user choice too once we have a settings page + var channel = _forcedChannel ?? UpdateChannel.Stable; + return items.FindAll(i => i.Channel != null && i.Channel == channel.ChannelName()); + } +} + +// ReSharper disable once InconsistentNaming // the interface name is "UI", not "Ui" +public class CoderSparkleUIFactory(IUserNotifier userNotifier, IUpdaterUpdateAvailableViewModelFactory updateAvailableViewModelFactory) : IUIFactory +{ + public bool ForceDisableToasts; + + bool IUIFactory.HideReleaseNotes { get; set; } + bool IUIFactory.HideSkipButton { get; set; } + bool IUIFactory.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) + { + IUIFactory factory = this; + + var viewModel = updateAvailableViewModelFactory.Create( + updates, + signatureVerifier, + currentVersion, + appName, + isUpdateAlreadyDownloaded); + + var window = new UpdaterUpdateAvailableWindow(viewModel); + if (factory.HideReleaseNotes) + (window as IUpdateAvailable).HideReleaseNotes(); + if (factory.HideSkipButton) + (window as IUpdateAvailable).HideSkipButton(); + if (factory.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 !ForceDisableToasts; + } + + void IUIFactory.ShowToast(Action clickHandler) + { + userNotifier.ShowActionNotification( + "Coder Desktop", + "Updates are available, click for more information.", + clickHandler, + CancellationToken.None) + .Wait(); + } + + void IUIFactory.Shutdown() + { + ((App)Application.Current).ExitApplication().Wait(); + } +} diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs index 3b4ac05..c85cd35 100644 --- a/App/Services/UserNotifier.cs +++ b/App/Services/UserNotifier.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml; using Microsoft.Windows.AppNotifications; using Microsoft.Windows.AppNotifications.Builder; @@ -9,12 +12,19 @@ namespace Coder.Desktop.App.Services; public interface IUserNotifier : IAsyncDisposable { public Task ShowErrorNotification(string title, string message, CancellationToken ct = default); + public Task ShowActionNotification(string title, string message, Action action, CancellationToken ct = default); + + public void HandleActivation(AppNotificationActivatedEventArgs args); } -public class UserNotifier : IUserNotifier +public class UserNotifier(ILogger logger) : IUserNotifier { + private const string CoderNotificationId = "CoderNotificationId"; + private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default; + public ConcurrentDictionary ActionHandlers { get; } = new(); + public ValueTask DisposeAsync() { return ValueTask.CompletedTask; @@ -26,4 +36,59 @@ public Task ShowErrorNotification(string title, string message, CancellationToke _notificationManager.Show(builder.BuildNotification()); return Task.CompletedTask; } + + public Task ShowActionNotification(string title, string message, Action action, CancellationToken ct = default) + { + var id = Guid.NewGuid().ToString(); + var notification = new AppNotificationBuilder() + .AddText(title) + .AddText(message) + .AddArgument(CoderNotificationId, id) + .BuildNotification(); + ActionHandlers[id] = action; + _notificationManager.Show(notification); + return Task.CompletedTask; + } + + public void HandleActivation(AppNotificationActivatedEventArgs args) + { + // Must not be an Action notification. + if (!args.Arguments.TryGetValue(CoderNotificationId, out var id)) + return; + + if (!ActionHandlers.TryRemove(id, out var action)) + { + logger.LogWarning("no action handler found for notification with ID {NotificationId}, ignoring", id); + return; + } + + var dispatcherQueue = ((App)Application.Current).TrayWindow?.DispatcherQueue; + if (dispatcherQueue == null) + { + logger.LogError("could not acquire DispatcherQueue for notification event handling, is TrayWindow active?"); + return; + } + if (!dispatcherQueue.HasThreadAccess) + { + dispatcherQueue.TryEnqueue(RunAction); + return; + } + + RunAction(); + + return; + + void RunAction() + { + try + { + action(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "could not handle activation for notification with ID {NotificationId}", id); + } + } + } + } 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..59dcb85 --- /dev/null +++ b/App/ViewModels/UpdaterUpdateAvailableViewModel.cs @@ -0,0 +1,234 @@ +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 }); + }; + + var html = await ChangelogHtml(CurrentItem); + webView.NavigateToString(html); + } + + private void SendResponse(UpdateAvailableResult result) + { + Result = result; + UserResponded?.Invoke(this, new UpdateResponseEventArgs(result, CurrentItem)); + } + + public void SkipButton_Click(object sender, RoutedEventArgs e) + { + if (!SkipButtonVisible || MissingCriticalUpdate) + return; + SendResponse(UpdateAvailableResult.SkipUpdate); + } + + public void RemindMeLaterButton_Click(object sender, RoutedEventArgs e) + { + if (!RemindMeLaterButtonVisible || MissingCriticalUpdate) + return; + SendResponse(UpdateAvailableResult.RemindMeLater); + } + + public void InstallButton_Click(object sender, RoutedEventArgs e) + { + SendResponse(UpdateAvailableResult.InstallUpdate); + } +} diff --git a/App/Views/MessageWindow.xaml b/App/Views/MessageWindow.xaml new file mode 100644 index 0000000..833c303 --- /dev/null +++ b/App/Views/MessageWindow.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + +