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/.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..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 @@ - - - + @@ -57,6 +59,7 @@ + all @@ -67,12 +70,17 @@ + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 06ab676..f4c05a2 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; @@ -17,32 +16,45 @@ 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() { @@ -55,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; @@ -68,6 +90,7 @@ public App() services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(_ => this); services.AddSingleton(_ => new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName)); services.AddSingleton(); @@ -81,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(); @@ -90,6 +119,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 +143,11 @@ 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(); + _settingsManager = _services.GetRequiredService>(); + _appLifetime = _services.GetRequiredService(); InitializeComponent(); } @@ -129,67 +168,87 @@ 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 => + + // 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 (t.Exception != null) - { - _logger.LogError(t.Exception, "failed to connect to VPN service"); -#if DEBUG - Debug.WriteLine(t.Exception); - Debugger.Break(); -#endif - } - }); + if (!_handleWindowClosed) return; + closedArgs.Handled = true; + TrayWindow.AppWindow.Hide(); + }; - // Load the credentials in the background. - var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + _ = InitializeServicesAsync(_appLifetime.ApplicationStopping); + } + + /// + /// Loads stored VPN credentials, reconnects the RPC controller, + /// and (optionally) starts the VPN tunnel on application launch. + /// + private async Task InitializeServicesAsync(CancellationToken cancellationToken = default) + { var credentialManager = _services.GetRequiredService(); - _ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t => + var rpcController = _services.GetRequiredService(); + + using var credsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + credsCts.CancelAfter(TimeSpan.FromSeconds(15)); + + var loadCredsTask = credentialManager.LoadCredentials(credsCts.Token); + var reconnectTask = rpcController.Reconnect(cancellationToken); + var settingsTask = _settingsManager.Read(cancellationToken); + + var dependenciesLoaded = true; + + try { - if (t.Exception != null) - { - _logger.LogError(t.Exception, "failed to load credentials"); -#if DEBUG - Debug.WriteLine(t.Exception); - Debugger.Break(); -#endif - } + await Task.WhenAll(loadCredsTask, reconnectTask, settingsTask); + } + catch (Exception) + { + if (loadCredsTask.IsFaulted) + _logger.LogError(loadCredsTask.Exception!.GetBaseException(), + "Failed to load credentials"); - credentialManagerCts.Dispose(); - }, CancellationToken.None); + if (reconnectTask.IsFaulted) + _logger.LogError(reconnectTask.Exception!.GetBaseException(), + "Failed to connect to VPN service"); - // Initialize file sync. - // We're adding a 5s delay here to avoid race conditions when loading the mutagen binary. + 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; + } - _ = Task.Delay(5000).ContinueWith((_) => + var attemptCoderConnection = settingsTask.Result?.ConnectOnLaunch ?? false; + if (dependenciesLoaded && attemptCoderConnection) { - 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); - }); + try + { + await rpcController.StartVpn(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect on launch"); + } + } - // Prevent the TrayWindow from closing, just hide it. - var trayWindow = _services.GetRequiredService(); - trayWindow.Closed += (_, closedArgs) => + // Initialize file sync. + using var syncSessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + syncSessionCts.CancelAfter(TimeSpan.FromSeconds(10)); + var syncSessionController = _services.GetRequiredService(); + try { - if (!_handleWindowClosed) return; - closedArgs.Handled = true; - trayWindow.AppWindow.Hide(); - }; + 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) @@ -231,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) @@ -240,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/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/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/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/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/Services/SettingsManager.cs b/App/Services/SettingsManager.cs new file mode 100644 index 0000000..17e9ef2 --- /dev/null +++ b/App/Services/SettingsManager.cs @@ -0,0 +1,159 @@ +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); +} + +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; + } +} + +/// +/// 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 _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) + { + var folder = SettingsManagerUtils.AppSettingsDirectory(settingsFilePath); + + _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/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/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 d8b3182..8540453 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; @@ -39,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 @@ -53,6 +54,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 +65,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 +73,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 +191,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 = []; @@ -359,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/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 @@ + + + + + + + + + + + + + + + + + + +