diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 459579c..ac57947 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,7 +21,7 @@ jobs: cache: true cache-dependency-path: '**/packages.lock.json' - name: dotnet restore - run: dotnet restore --locked-mode + run: dotnet restore --locked-mode /p:BuildWithNetFrameworkHostedCompiler=true - name: dotnet format run: dotnet format --verify-no-changes --no-restore @@ -75,7 +75,7 @@ jobs: cache: true cache-dependency-path: '**/packages.lock.json' - name: dotnet restore - run: dotnet restore --locked-mode + run: dotnet restore --locked-mode /p:BuildWithNetFrameworkHostedCompiler=true # This doesn't call `dotnet publish` on the entire solution, just the # projects we care about building. Doing a full publish includes test # libraries and stuff which is pointless. diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e6849aa..55f6da6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,7 +17,11 @@ permissions: jobs: release: - runs-on: ${{ github.repository_owner == 'coder' && 'windows-latest-16-cores' || 'windows-latest' }} + # windows-2025 is required for an up-to-date version of OpenSSL for the + # appcast generation. + runs-on: ${{ github.repository_owner == 'coder' && 'windows-2025-16-cores' || 'windows-2025' }} + outputs: + version: ${{ steps.version.outputs.VERSION }} timeout-minutes: 15 steps: @@ -66,6 +70,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: | @@ -117,3 +124,123 @@ jobs: ${{ steps.release.outputs.ARM64_OUTPUT_PATH }} 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 }} + GH_TOKEN: ${{ github.token }} + GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }} + + winget: + runs-on: depot-windows-latest + needs: release + steps: + - name: Sync fork + run: gh repo sync cdrci/winget-pkgs -b master + env: + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + # If the event that triggered the build was an annotated tag (which our + # tags are supposed to be), actions/checkout has a bug where the tag in + # question is only a lightweight tag and not a full annotated tag. This + # command seems to fix it. + # https://github.com/actions/checkout/issues/290 + - name: Fetch git tags + run: git fetch --tags --force + + - name: Install wingetcreate + run: | + Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe + + - name: Submit updated manifest to winget-pkgs + run: | + $version = "${{ needs.release.outputs.version }}" + + $release_assets = gh release view --repo coder/coder-desktop-windows "v${version}" --json assets | ` + ConvertFrom-Json + # Get the installer URLs from the release assets. + $amd64_installer_url = $release_assets.assets | ` + Where-Object name -Match ".*-x64.exe$" | ` + Select -ExpandProperty url + $arm64_installer_url = $release_assets.assets | ` + Where-Object name -Match ".*-arm64.exe$" | ` + Select -ExpandProperty url + + echo "amd64 Installer URL: ${amd64_installer_url}" + echo "arm64 Installer URL: ${arm64_installer_url}" + echo "Package version: ${version}" + + .\wingetcreate.exe update Coder.CoderDesktop ` + --submit ` + --version "${version}" ` + --urls "${amd64_installer_url}" "${arm64_installer_url}" ` + --token "$env:WINGET_GH_TOKEN" + + env: + # For gh CLI: + GH_TOKEN: ${{ github.token }} + # For wingetcreate. We need a real token since we're pushing a commit + # 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 + Start-Sleep -Seconds 30.0 + # Find the PR that wingetcreate just made. + $version = "${{ needs.release.outputs.version }}" + $pr_list = gh pr list --repo microsoft/winget-pkgs --search "author:cdrci Coder.CoderDesktop version ${version}" --limit 1 --json number | ` + ConvertFrom-Json + $pr_number = $pr_list[0].number + + gh pr comment --repo microsoft/winget-pkgs "${pr_number}" --body "🤖 cc: @deansheather @matifali" + + env: + # For gh CLI. We need a real token since we're commenting on a PR in a + # different repo. + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} \ No newline at end of file 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 982612f..ca3d3c9 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -1,4 +1,4 @@ - + WinExe net8.0-windows10.0.19041.0 @@ -16,10 +16,17 @@ preview - DISABLE_XAML_GENERATED_MAIN + DISABLE_XAML_GENERATED_MAIN;DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION + + 0.1.0.0 Coder Desktop + Coder Desktop + Coder Technologies Inc. + Coder Desktop + © Coder Technologies Inc. coder.ico + false @@ -31,9 +38,7 @@ - - - + @@ -57,6 +62,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -66,12 +73,17 @@ + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 2c7e87e..a07af43 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; @@ -11,35 +10,51 @@ using Coder.Desktop.App.Views; using Coder.Desktop.App.Views.Pages; using Coder.Desktop.CoderSdk.Agent; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn; using Microsoft.Extensions.Configuration; 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, IDefaultNotificationHandler { - 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() { @@ -52,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; @@ -62,14 +87,29 @@ public App() loggerConfig.ReadFrom.Configuration(builder.Configuration); }); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(_ => this); + services.AddSingleton(_ => this); + services.AddSingleton(_ => + new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddOptions() .Bind(builder.Configuration.GetSection(MutagenControllerConfigSection)); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddOptions() + .Bind(builder.Configuration.GetSection(UpdaterConfigSection)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // SignInWindow views and view models services.AddTransient(); @@ -80,6 +120,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 @@ -90,12 +137,18 @@ public App() services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); _services = services.BuildServiceProvider(); - _logger = (ILogger)_services.GetService(typeof(ILogger))!; + _logger = _services.GetRequiredService>(); + _uriHandler = _services.GetRequiredService(); + _userNotifier = _services.GetRequiredService(); + _settingsManager = _services.GetRequiredService>(); + _appLifetime = _services.GetRequiredService(); InitializeComponent(); } @@ -116,65 +169,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. - var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var syncSessionController = _services.GetRequiredService(); - _ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t => + if (settingsTask.IsFaulted) + _logger.LogError(settingsTask.Exception!.GetBaseException(), + "Failed to fetch Coder Connect settings"); + + // Don't attempt to connect if we failed to load credentials or reconnect. + // This will prevent the app from trying to connect to the VPN service. + dependenciesLoaded = false; + } + + var attemptCoderConnection = settingsTask.Result?.ConnectOnLaunch ?? false; + if (dependenciesLoaded && attemptCoderConnection) { - if (t.IsCanceled || t.Exception != null) + try { - _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled); -#if DEBUG - Debugger.Break(); -#endif + await rpcController.StartVpn(cancellationToken); } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect on launch"); + } + } - syncSessionCts.Dispose(); - }, CancellationToken.None); - - // 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) @@ -189,7 +264,23 @@ public void OnActivated(object? sender, AppActivationArguments args) return; } - HandleURIActivation(protoArgs.Uri); + // don't need to wait for it to complete. + _uriHandler.HandleUri(protoArgs.Uri).ContinueWith(t => + { + if (t.Exception != null) + { + // don't log query params, as they contain secrets. + _logger.LogError(t.Exception, + "unhandled exception while processing URI coder://{authority}{path}", + protoArgs.Uri.Authority, protoArgs.Uri.AbsolutePath); + } + }); + + break; + + case ExtendedActivationKind.AppNotification: + var notificationArgs = (args.Data as AppNotificationActivatedEventArgs)!; + HandleNotification(null, notificationArgs); break; default: @@ -198,10 +289,10 @@ public void OnActivated(object? sender, AppActivationArguments args) } } - public void HandleURIActivation(Uri uri) + public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args) { - // don't log the query string as that's where we include some sensitive information like passwords - _logger.LogInformation("handling URI activation for {path}", uri.AbsolutePath); + _logger.LogInformation("handled notification activation: {Argument}", args.Argument); + _userNotifier.HandleNotificationActivation(args.Arguments); } private static void AddDefaultConfig(IConfigurationBuilder builder) @@ -209,18 +300,45 @@ 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); + } + + public void HandleNotificationActivation(IDictionary _) + { + TrayWindow?.Tray_Open(); + } } 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/Assets/coder_icon_32_dark.ico b/App/Assets/coder_icon_32_dark.ico index 4eaa1bb..dd68b83 100644 Binary files a/App/Assets/coder_icon_32_dark.ico and b/App/Assets/coder_icon_32_dark.ico differ diff --git a/App/Assets/coder_icon_32_light.ico b/App/Assets/coder_icon_32_light.ico index 1fc307f..f4dc2a8 100644 Binary files a/App/Assets/coder_icon_32_light.ico and b/App/Assets/coder_icon_32_light.ico differ diff --git a/App/Controls/ExpandChevron.xaml b/App/Controls/ExpandChevron.xaml new file mode 100644 index 0000000..0b68d4d --- /dev/null +++ b/App/Controls/ExpandChevron.xaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + diff --git a/App/Controls/ExpandChevron.xaml.cs b/App/Controls/ExpandChevron.xaml.cs new file mode 100644 index 0000000..45aa6c4 --- /dev/null +++ b/App/Controls/ExpandChevron.xaml.cs @@ -0,0 +1,19 @@ +using DependencyPropertyGenerator; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Controls; + +[DependencyProperty("IsOpen", DefaultValue = false)] +public sealed partial class ExpandChevron : UserControl +{ + public ExpandChevron() + { + InitializeComponent(); + } + + partial void OnIsOpenChanged(bool oldValue, bool newValue) + { + var newState = newValue ? "NormalOn" : "NormalOff"; + AnimatedIcon.SetState(ChevronIcon, newState); + } +} diff --git a/App/Controls/ExpandContent.xaml b/App/Controls/ExpandContent.xaml new file mode 100644 index 0000000..2cc0eb4 --- /dev/null +++ b/App/Controls/ExpandContent.xaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App/Controls/ExpandContent.xaml.cs b/App/Controls/ExpandContent.xaml.cs new file mode 100644 index 0000000..926af9a --- /dev/null +++ b/App/Controls/ExpandContent.xaml.cs @@ -0,0 +1,61 @@ +using DependencyPropertyGenerator; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Markup; +using System; +using System.Threading.Tasks; + +namespace Coder.Desktop.App.Controls; + + +[ContentProperty(Name = nameof(Children))] +[DependencyProperty("IsOpen", DefaultValue = false)] +public sealed partial class ExpandContent : UserControl +{ + public UIElementCollection Children => CollapsiblePanel.Children; + + private readonly string _expandedState = "ExpandedState"; + private readonly string _collapsedState = "CollapsedState"; + + public ExpandContent() + { + InitializeComponent(); + Loaded += (_, __) => + { + // When we load the control for the first time (after panel swapping) + // we need to set the initial state based on IsOpen. + VisualStateManager.GoToState( + this, + IsOpen ? _expandedState : _collapsedState, + useTransitions: false); // NO animation yet + + // If IsOpen was already true we must also show the panel + if (IsOpen) + { + CollapsiblePanel.Visibility = Visibility.Visible; + // This makes the panel expand to its full height + CollapsiblePanel.ClearValue(FrameworkElement.MaxHeightProperty); + } + }; + } + + partial void OnIsOpenChanged(bool oldValue, bool newValue) + { + var newState = newValue ? _expandedState : _collapsedState; + if (newValue) + { + CollapsiblePanel.Visibility = Visibility.Visible; + // We use BeginTime to ensure other panels are collapsed first. + // If the user clicks the expand button quickly, we want to avoid + // the panel expanding to its full height before the collapse animation completes. + CollapseSb.SkipToFill(); + } + + VisualStateManager.GoToState(this, newState, true); + } + + private void CollapseStoryboard_Completed(object sender, object e) + { + CollapsiblePanel.Visibility = Visibility.Collapsed; + } +} 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/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs index a31c33b..ec586d0 100644 --- a/App/Converters/DependencyObjectSelector.cs +++ b/App/Converters/DependencyObjectSelector.cs @@ -156,6 +156,15 @@ private void UpdateSelectedObject() ClearValue(SelectedObjectProperty); } + private static void VerifyReferencesProperty(IObservableVector references) + { + // Ensure unique keys and that the values are DependencyObjectSelectorItem. + var items = references.OfType>().ToArray(); + var keys = items.Select(i => i.Key).Distinct().ToArray(); + if (keys.Length != references.Count) + throw new ArgumentException("ObservableCollection Keys must be unique."); + } + // Called when the References property is replaced. private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { @@ -166,12 +175,16 @@ private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPr oldValue.VectorChanged -= self.OnVectorChangedReferences; var newValue = args.NewValue as DependencyObjectCollection; if (newValue != null) + { + VerifyReferencesProperty(newValue); newValue.VectorChanged += self.OnVectorChangedReferences; + } } // Called when the References collection changes without being replaced. private void OnVectorChangedReferences(IObservableVector sender, IVectorChangedEventArgs args) { + VerifyReferencesProperty(sender); UpdateSelectedObject(); } diff --git a/App/Models/CredentialModel.cs b/App/Models/CredentialModel.cs index 542c1c0..b38bbba 100644 --- a/App/Models/CredentialModel.cs +++ b/App/Models/CredentialModel.cs @@ -1,3 +1,6 @@ +using System; +using Coder.Desktop.CoderSdk.Coder; + namespace Coder.Desktop.App.Models; public enum CredentialState @@ -5,18 +8,18 @@ public enum CredentialState // Unknown means "we haven't checked yet" Unknown, - // Invalid means "we checked and there's either no saved credentials or they are not valid" + // Invalid means "we checked and there's either no saved credentials, or they are not valid" Invalid, - // Valid means "we checked and there are saved credentials and they are valid" + // Valid means "we checked and there are saved credentials, and they are valid" Valid, } -public class CredentialModel +public class CredentialModel : ICoderApiClientCredentialProvider { public CredentialState State { get; init; } = CredentialState.Unknown; - public string? CoderUrl { get; init; } + public Uri? CoderUrl { get; init; } public string? ApiToken { get; init; } public string? Username { get; init; } @@ -31,4 +34,14 @@ public CredentialModel Clone() Username = Username, }; } + + public CoderApiClientCredential? GetCoderApiClientCredential() + { + if (State != CredentialState.Valid) return null; + return new CoderApiClientCredential + { + ApiToken = ApiToken!, + CoderUrl = CoderUrl!, + }; + } } 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/Program.cs b/App/Program.cs index 1a54b2b..bf4f16e 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -5,6 +5,7 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; using WinRT; namespace Coder.Desktop.App; @@ -28,9 +29,9 @@ private static void Main(string[] args) { ComWrappersSupport.InitializeComWrappers(); var mainInstance = GetMainInstance(); + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); if (!mainInstance.IsCurrent) { - var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait(); return; } @@ -58,6 +59,13 @@ private static void Main(string[] args) // redirections via RedirectActivationToAsync above get routed to the App mainInstance.Activated += app.OnActivated; + var notificationManager = AppNotificationManager.Default; + notificationManager.NotificationInvoked += app.HandleNotification; + notificationManager.Register(); + if (activationArgs.Kind != ExtendedActivationKind.Launch) + // this means we were activated without having already launched, so handle + // the activation as well. + app.OnActivated(null, activationArgs); }); } catch (Exception e) diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index a2f6567..c8030c3 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -21,7 +21,7 @@ public class RawCredentials [JsonSerializable(typeof(RawCredentials))] public partial class RawCredentialsJsonContext : JsonSerializerContext; -public interface ICredentialManager +public interface ICredentialManager : ICoderApiClientCredentialProvider { public event EventHandler CredentialsChanged; @@ -59,7 +59,8 @@ public interface ICredentialBackend /// public class CredentialManager : ICredentialManager { - private const string CredentialsTargetName = "Coder.Desktop.App.Credentials"; + private readonly ICredentialBackend Backend; + private readonly ICoderApiClientFactory CoderApiClientFactory; // _opLock is held for the full duration of SetCredentials, and partially // during LoadCredentials. _opLock protects _inFlightLoad, _loadCts, and @@ -79,14 +80,6 @@ public class CredentialManager : ICredentialManager // immediate). private volatile CredentialModel? _latestCredentials; - private ICredentialBackend Backend { get; } = new WindowsCredentialBackend(CredentialsTargetName); - - private ICoderApiClientFactory CoderApiClientFactory { get; } = new CoderApiClientFactory(); - - public CredentialManager() - { - } - public CredentialManager(ICredentialBackend backend, ICoderApiClientFactory coderApiClientFactory) { Backend = backend; @@ -108,6 +101,20 @@ public CredentialModel GetCachedCredentials() }; } + // Implements ICoderApiClientCredentialProvider + public CoderApiClientCredential? GetCoderApiClientCredential() + { + var latestCreds = _latestCredentials; + if (latestCreds is not { State: CredentialState.Valid } || latestCreds.CoderUrl is null) + return null; + + return new CoderApiClientCredential + { + CoderUrl = latestCreds.CoderUrl, + ApiToken = latestCreds.ApiToken ?? "", + }; + } + public async Task GetSignInUri() { try @@ -216,8 +223,9 @@ private async Task LoadCredentialsInner(CancellationToken ct) }; } - // Grab the lock again so we can update the state. - using (await _opLock.LockAsync(ct)) + // Grab the lock again so we can update the state. Don't use the CT + // here as it may have already been canceled. + using (await _opLock.LockAsync(TimeSpan.FromSeconds(5), CancellationToken.None)) { // Prevent new LoadCredentials calls from returning this task. if (_loadCts != null) @@ -235,11 +243,8 @@ private async Task LoadCredentialsInner(CancellationToken ct) if (latestCreds is not null) return latestCreds; } - // If there aren't any latest credentials after a cancellation, we - // most likely timed out and should throw. - ct.ThrowIfCancellationRequested(); - UpdateState(model); + ct.ThrowIfCancellationRequested(); return model; } } @@ -253,6 +258,12 @@ private async Task PopulateModel(RawCredentials? credentials, C State = CredentialState.Invalid, }; + if (!Uri.TryCreate(credentials.CoderUrl, UriKind.Absolute, out var uri)) + return new CredentialModel + { + State = CredentialState.Invalid, + }; + BuildInfo buildInfo; User me; try @@ -279,7 +290,7 @@ private async Task PopulateModel(RawCredentials? credentials, C return new CredentialModel { State = CredentialState.Valid, - CoderUrl = credentials.CoderUrl, + CoderUrl = uri, ApiToken = credentials.ApiToken, Username = me.Username, }; @@ -298,6 +309,8 @@ private void UpdateState(CredentialModel newModel) public class WindowsCredentialBackend : ICredentialBackend { + public const string CoderCredentialsTargetName = "Coder.Desktop.App.Credentials"; + private readonly string _credentialsTargetName; public WindowsCredentialBackend(string credentialsTargetName) @@ -307,7 +320,7 @@ public WindowsCredentialBackend(string credentialsTargetName) public Task ReadCredentials(CancellationToken ct = default) { - var raw = NativeApi.ReadCredentials(_credentialsTargetName); + var raw = Wincred.ReadCredentials(_credentialsTargetName); if (raw == null) return Task.FromResult(null); RawCredentials? credentials; @@ -326,115 +339,179 @@ public WindowsCredentialBackend(string credentialsTargetName) public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default) { var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials); - NativeApi.WriteCredentials(_credentialsTargetName, raw); + Wincred.WriteCredentials(_credentialsTargetName, raw); return Task.CompletedTask; } public Task DeleteCredentials(CancellationToken ct = default) { - NativeApi.DeleteCredentials(_credentialsTargetName); + Wincred.DeleteCredentials(_credentialsTargetName); return Task.CompletedTask; } - private static class NativeApi +} + +/// +/// Wincred provides relatively low level wrapped calls to the Wincred.h native API. +/// +internal static class Wincred +{ + private const int CredentialTypeGeneric = 1; + private const int CredentialTypeDomainPassword = 2; + private const int PersistenceTypeLocalComputer = 2; + private const int ErrorNotFound = 1168; + private const int CredMaxCredentialBlobSize = 5 * 512; + private const string PackageNTLM = "NTLM"; + + public static string? ReadCredentials(string targetName) { - private const int CredentialTypeGeneric = 1; - private const int PersistenceTypeLocalComputer = 2; - private const int ErrorNotFound = 1168; - private const int CredMaxCredentialBlobSize = 5 * 512; + if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr)) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorNotFound) return null; + throw new InvalidOperationException($"Failed to read credentials (Error {error})"); + } - public static string? ReadCredentials(string targetName) + try { - if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr)) - { - var error = Marshal.GetLastWin32Error(); - if (error == ErrorNotFound) return null; - throw new InvalidOperationException($"Failed to read credentials (Error {error})"); - } + var cred = Marshal.PtrToStructure(credentialPtr); + return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char)); + } + finally + { + CredFree(credentialPtr); + } + } - try - { - var cred = Marshal.PtrToStructure(credentialPtr); - return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char)); - } - finally + public static void WriteCredentials(string targetName, string secret) + { + var byteCount = Encoding.Unicode.GetByteCount(secret); + if (byteCount > CredMaxCredentialBlobSize) + throw new ArgumentOutOfRangeException(nameof(secret), + $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); + + var credentialBlob = Marshal.StringToHGlobalUni(secret); + var cred = new CREDENTIALW + { + Type = CredentialTypeGeneric, + TargetName = targetName, + CredentialBlobSize = byteCount, + CredentialBlob = credentialBlob, + Persist = PersistenceTypeLocalComputer, + }; + try + { + if (!CredWriteW(ref cred, 0)) { - CredFree(credentialPtr); + var error = Marshal.GetLastWin32Error(); + throw new InvalidOperationException($"Failed to write credentials (Error {error})"); } } - - public static void WriteCredentials(string targetName, string secret) + finally { - var byteCount = Encoding.Unicode.GetByteCount(secret); - if (byteCount > CredMaxCredentialBlobSize) - throw new ArgumentOutOfRangeException(nameof(secret), - $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); + Marshal.FreeHGlobal(credentialBlob); + } + } - var credentialBlob = Marshal.StringToHGlobalUni(secret); - var cred = new CREDENTIAL - { - Type = CredentialTypeGeneric, - TargetName = targetName, - CredentialBlobSize = byteCount, - CredentialBlob = credentialBlob, - Persist = PersistenceTypeLocalComputer, - }; - try - { - if (!CredWriteW(ref cred, 0)) - { - var error = Marshal.GetLastWin32Error(); - throw new InvalidOperationException($"Failed to write credentials (Error {error})"); - } - } - finally - { - Marshal.FreeHGlobal(credentialBlob); - } + public static void DeleteCredentials(string targetName) + { + if (!CredDeleteW(targetName, CredentialTypeGeneric, 0)) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorNotFound) return; + throw new InvalidOperationException($"Failed to delete credentials (Error {error})"); } + } + + public static void WriteDomainCredentials(string domainName, string serverName, string username, string password) + { + var targetName = $"{domainName}/{serverName}"; + var targetInfo = new CREDENTIAL_TARGET_INFORMATIONW + { + TargetName = targetName, + DnsServerName = serverName, + DnsDomainName = domainName, + PackageName = PackageNTLM, + }; + var byteCount = Encoding.Unicode.GetByteCount(password); + if (byteCount > CredMaxCredentialBlobSize) + throw new ArgumentOutOfRangeException(nameof(password), + $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); - public static void DeleteCredentials(string targetName) + var credentialBlob = Marshal.StringToHGlobalUni(password); + var cred = new CREDENTIALW { - if (!CredDeleteW(targetName, CredentialTypeGeneric, 0)) + Type = CredentialTypeDomainPassword, + TargetName = targetName, + CredentialBlobSize = byteCount, + CredentialBlob = credentialBlob, + Persist = PersistenceTypeLocalComputer, + UserName = username, + }; + try + { + if (!CredWriteDomainCredentialsW(ref targetInfo, ref cred, 0)) { var error = Marshal.GetLastWin32Error(); - if (error == ErrorNotFound) return; - throw new InvalidOperationException($"Failed to delete credentials (Error {error})"); + throw new InvalidOperationException($"Failed to write credentials (Error {error})"); } } + finally + { + Marshal.FreeHGlobal(credentialBlob); + } + } - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr); - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredWriteW([In] ref CREDENTIAL userCredential, [In] uint flags); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWriteW([In] ref CREDENTIALW userCredential, [In] uint flags); - [DllImport("Advapi32.dll", SetLastError = true)] - private static extern void CredFree([In] IntPtr cred); + [DllImport("Advapi32.dll", SetLastError = true)] + private static extern void CredFree([In] IntPtr cred); - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredDeleteW(string target, int type, int flags); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredDeleteW(string target, int type, int flags); - [StructLayout(LayoutKind.Sequential)] - private struct CREDENTIAL - { - public int Flags; - public int Type; + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWriteDomainCredentialsW([In] ref CREDENTIAL_TARGET_INFORMATIONW target, [In] ref CREDENTIALW userCredential, [In] uint flags); - [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [StructLayout(LayoutKind.Sequential)] + private struct CREDENTIALW + { + public int Flags; + public int Type; - [MarshalAs(UnmanagedType.LPWStr)] public string Comment; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; - public long LastWritten; - public int CredentialBlobSize; - public IntPtr CredentialBlob; - public int Persist; - public int AttributeCount; - public IntPtr Attributes; + [MarshalAs(UnmanagedType.LPWStr)] public string Comment; - [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + public long LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; - [MarshalAs(UnmanagedType.LPWStr)] public string UserName; - } + [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + + [MarshalAs(UnmanagedType.LPWStr)] public string UserName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CREDENTIAL_TARGET_INFORMATIONW + { + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosServerName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsServerName; + [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosDomainName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsDomainName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsTreeName; + [MarshalAs(UnmanagedType.LPWStr)] public string PackageName; + + public uint Flags; + public uint CredTypeCount; + public IntPtr CredTypes; } } 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/HostnameSuffixGetter.cs b/App/Services/HostnameSuffixGetter.cs new file mode 100644 index 0000000..3816623 --- /dev/null +++ b/App/Services/HostnameSuffixGetter.cs @@ -0,0 +1,144 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn.Utilities; +using Microsoft.Extensions.Logging; + +namespace Coder.Desktop.App.Services; + +public interface IHostnameSuffixGetter +{ + public event EventHandler SuffixChanged; + + public string GetCachedSuffix(); +} + +public class HostnameSuffixGetter : IHostnameSuffixGetter +{ + private const string DefaultSuffix = ".coder"; + + private readonly ICredentialManager _credentialManager; + private readonly ICoderApiClientFactory _clientFactory; + private readonly ILogger _logger; + + // _lock protects all private (non-readonly) values + private readonly RaiiSemaphoreSlim _lock = new(1, 1); + private string _domainSuffix = DefaultSuffix; + private bool _dirty = false; + private bool _getInProgress = false; + private CredentialModel _credentialModel = new() { State = CredentialState.Invalid }; + + public event EventHandler? SuffixChanged; + + public HostnameSuffixGetter(ICredentialManager credentialManager, ICoderApiClientFactory apiClientFactory, + ILogger logger) + { + _credentialManager = credentialManager; + _clientFactory = apiClientFactory; + _logger = logger; + credentialManager.CredentialsChanged += HandleCredentialsChanged; + HandleCredentialsChanged(this, _credentialManager.GetCachedCredentials()); + } + + ~HostnameSuffixGetter() + { + _credentialManager.CredentialsChanged -= HandleCredentialsChanged; + } + + private void HandleCredentialsChanged(object? sender, CredentialModel credentials) + { + using var _ = _lock.Lock(); + _logger.LogDebug("credentials updated with state {state}", credentials.State); + _credentialModel = credentials; + if (credentials.State != CredentialState.Valid) return; + + _dirty = true; + if (!_getInProgress) + { + _getInProgress = true; + Task.Run(Refresh).ContinueWith(MaybeRefreshAgain); + } + } + + private async Task Refresh() + { + _logger.LogDebug("refreshing domain suffix"); + CredentialModel credentials; + using (_ = await _lock.LockAsync()) + { + credentials = _credentialModel; + if (credentials.State != CredentialState.Valid) + { + _logger.LogDebug("abandoning refresh because credentials are now invalid"); + return; + } + + _dirty = false; + } + + var client = _clientFactory.Create(credentials); + using var timeoutSrc = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var connInfo = await client.GetAgentConnectionInfoGeneric(timeoutSrc.Token); + + // older versions of Coder might not set this + var suffix = string.IsNullOrEmpty(connInfo.HostnameSuffix) + ? DefaultSuffix + // and, it doesn't include the leading dot. + : "." + connInfo.HostnameSuffix; + + var changed = false; + using (_ = await _lock.LockAsync(CancellationToken.None)) + { + if (_domainSuffix != suffix) changed = true; + _domainSuffix = suffix; + } + + if (changed) + { + _logger.LogInformation("got new domain suffix '{suffix}'", suffix); + // grab a local copy of the EventHandler to avoid TOCTOU race on the `?.` null-check + var del = SuffixChanged; + del?.Invoke(this, suffix); + } + else + { + _logger.LogDebug("domain suffix unchanged '{suffix}'", suffix); + } + } + + private async Task MaybeRefreshAgain(Task prev) + { + if (prev.IsFaulted) + { + _logger.LogError(prev.Exception, "failed to query domain suffix"); + // back off here before retrying. We're just going to use a fixed, long + // delay since this just affects UI stuff; we're not in a huge rush as + // long as we eventually get the right value. + await Task.Delay(TimeSpan.FromSeconds(10)); + } + + using var l = await _lock.LockAsync(CancellationToken.None); + if ((_dirty || prev.IsFaulted) && _credentialModel.State == CredentialState.Valid) + { + // we still have valid credentials and we're either dirty or the last Get failed. + _logger.LogDebug("retrying domain suffix query"); + _ = Task.Run(Refresh).ContinueWith(MaybeRefreshAgain); + return; + } + + // Getting here means either the credentials are not valid or we don't need to + // refresh anyway. + // The next time we get new, valid credentials, HandleCredentialsChanged will kick off + // a new Refresh + _getInProgress = false; + return; + } + + public string GetCachedSuffix() + { + using var _ = _lock.Lock(); + return _domainSuffix; + } +} diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 3931b66..eba0952 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -441,6 +441,7 @@ private async Task UpdateState(MutagenClient cl /// private async Task EnsureDaemon(CancellationToken ct) { + _logger.LogDebug("EnsureDaemon called"); ObjectDisposedException.ThrowIf(_disposing, typeof(MutagenController)); if (_mutagenClient != null && _daemonProcess != null) return _mutagenClient; @@ -479,12 +480,14 @@ private async Task EnsureDaemon(CancellationToken ct) /// private async Task StartDaemon(CancellationToken ct) { + _logger.LogDebug("StartDaemon called"); // Stop the running daemon if (_daemonProcess != null) await StopDaemon(ct); // Attempt to stop any orphaned daemon try { + _logger.LogDebug("creating MutagenClient to stop orphan"); var client = new MutagenClient(_mutagenDataDirectory); await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); } @@ -496,6 +499,10 @@ private async Task StartDaemon(CancellationToken ct) { // Mainline; no daemon running. } + finally + { + _logger.LogDebug("finished with orphan mutagen client"); + } // If we get some failure while creating the log file or starting the process, we'll retry // it up to 5 times x 100ms. Those issues should resolve themselves quickly if they are @@ -528,6 +535,7 @@ private async Task StartDaemon(CancellationToken ct) ct.ThrowIfCancellationRequested(); try { + _logger.LogDebug("creating mainline mutagen client"); var client = new MutagenClient(_mutagenDataDirectory); _ = await client.Daemon.VersionAsync(new VersionRequest(), cancellationToken: ct); _mutagenClient = client; diff --git a/App/Services/RdpConnector.cs b/App/Services/RdpConnector.cs new file mode 100644 index 0000000..a48d0ac --- /dev/null +++ b/App/Services/RdpConnector.cs @@ -0,0 +1,76 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Coder.Desktop.App.Services; + +public struct RdpCredentials(string username, string password) +{ + public readonly string Username = username; + public readonly string Password = password; +} + +public interface IRdpConnector +{ + public const int DefaultPort = 3389; + + public void WriteCredentials(string fqdn, RdpCredentials credentials); + + public Task Connect(string fqdn, int port = DefaultPort, CancellationToken ct = default); +} + +public class RdpConnector(ILogger logger) : IRdpConnector +{ + // Remote Desktop always uses TERMSRV as the domain; RDP is a part of Windows "Terminal Services". + private const string RdpDomain = "TERMSRV"; + + public void WriteCredentials(string fqdn, RdpCredentials credentials) + { + // writing credentials is idempotent for the same domain and server name. + Wincred.WriteDomainCredentials(RdpDomain, fqdn, credentials.Username, credentials.Password); + logger.LogDebug("wrote domain credential for {serverName} with username {username}", fqdn, + credentials.Username); + return; + } + + public Task Connect(string fqdn, int port = IRdpConnector.DefaultPort, CancellationToken ct = default) + { + // use mstsc to launch the RDP connection + var mstscProc = new Process(); + mstscProc.StartInfo.FileName = "mstsc"; + var args = $"/v {fqdn}"; + if (port != IRdpConnector.DefaultPort) + { + args = $"/v {fqdn}:{port}"; + } + + mstscProc.StartInfo.Arguments = args; + mstscProc.StartInfo.CreateNoWindow = true; + mstscProc.StartInfo.UseShellExecute = false; + try + { + if (!mstscProc.Start()) + throw new InvalidOperationException("Failed to start mstsc, Start returned false"); + } + catch (Exception e) + { + logger.LogWarning(e, "mstsc failed to start"); + + try + { + mstscProc.Kill(); + } + catch + { + // ignored, the process likely doesn't exist + } + + mstscProc.Dispose(); + throw; + } + + return mstscProc.WaitForExitAsync(ct); + } +} diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 17d3ccb..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 @@ -170,7 +173,7 @@ public async Task StartVpn(CancellationToken ct = default) { Start = new StartRequest { - CoderUrl = credentials.CoderUrl, + CoderUrl = credentials.CoderUrl?.ToString(), ApiToken = credentials.ApiToken, }, }, ct); @@ -234,6 +237,8 @@ public async Task StopVpn(CancellationToken ct = default) MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; }); throw new VpnLifecycleException($"Failed to stop VPN. Service reported failure: {reply.Stop.ErrorMessage}"); } + + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; }); } public async ValueTask DisposeAsync() @@ -281,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 @@ -311,7 +329,7 @@ private void SpeakerOnError(Exception e) Debug.WriteLine($"Error: {e}"); try { - Reconnect(CancellationToken.None).Wait(); + using var _ = Reconnect(CancellationToken.None); } catch { 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/UriHandler.cs b/App/Services/UriHandler.cs new file mode 100644 index 0000000..16a2543 --- /dev/null +++ b/App/Services/UriHandler.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Specialized; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Coder.Desktop.App.Models; +using Coder.Desktop.Vpn.Proto; +using Microsoft.Extensions.Logging; + + +namespace Coder.Desktop.App.Services; + +public interface IUriHandler +{ + public Task HandleUri(Uri uri, CancellationToken ct = default); +} + +public class UriHandler( + ILogger logger, + IRpcController rpcController, + IUserNotifier userNotifier, + IRdpConnector rdpConnector, + ICredentialManager credentialManager) : IUriHandler +{ + private const string OpenWorkspacePrefix = "/v0/open/ws/"; + + internal class UriException : Exception + { + internal readonly string Title; + internal readonly string Detail; + + internal UriException(string title, string detail) : base($"{title}: {detail}") + { + Title = title; + Detail = detail; + } + } + + public async Task HandleUri(Uri uri, CancellationToken ct = default) + { + try + { + await HandleUriThrowingErrors(uri, ct); + } + catch (UriException e) + { + await userNotifier.ShowErrorNotification(e.Title, e.Detail, ct); + } + } + + private async Task HandleUriThrowingErrors(Uri uri, CancellationToken ct = default) + { + if (uri.AbsolutePath.StartsWith(OpenWorkspacePrefix)) + { + await HandleOpenWorkspaceApp(uri, ct); + return; + } + + logger.LogWarning("unhandled URI path {path}", uri.AbsolutePath); + throw new UriException("URI handling error", + $"URI with path '{uri.AbsolutePath}' is unsupported or malformed"); + } + + public async Task HandleOpenWorkspaceApp(Uri uri, CancellationToken ct = default) + { + const string errTitle = "Open Workspace Application Error"; + CheckAuthority(uri, errTitle); + + var subpath = uri.AbsolutePath[OpenWorkspacePrefix.Length..]; + var components = subpath.Split("/"); + if (components.Length != 4 || components[1] != "agent") + { + logger.LogWarning("unsupported open workspace app format in URI '{path}'", uri.AbsolutePath); + throw new UriException(errTitle, $"Failed to open '{uri.AbsolutePath}' because the format is unsupported."); + } + + var workspaceName = components[0]; + var agentName = components[2]; + var appName = components[3]; + + var state = rpcController.GetState(); + if (state.VpnLifecycle != VpnLifecycle.Started) + { + logger.LogDebug("got URI to open workspace '{workspace}', but Coder Connect is not started", workspaceName); + throw new UriException(errTitle, + $"Failed to open application on '{workspaceName}' because Coder Connect is not started."); + } + + var workspace = state.Workspaces.FirstOrDefault(w => w.Name == workspaceName); + if (workspace == null) + { + logger.LogDebug("got URI to open workspace '{workspace}', but the workspace doesn't exist", workspaceName); + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}' because it doesn't exist"); + } + + var agent = state.Agents.FirstOrDefault(a => a.WorkspaceId == workspace.Id && a.Name == agentName); + if (agent == null) + { + logger.LogDebug( + "got URI to open workspace/agent '{workspaceName}/{agentName}', but the agent doesn't exist", + workspaceName, agentName); + // If the workspace isn't running, that is almost certainly why we can't find the agent, so report that + // to the user. + if (workspace.Status != Workspace.Types.Status.Running) + { + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}', because the workspace is not running."); + } + + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}', because agent '{agentName}' doesn't exist."); + } + + if (appName != "rdp") + { + logger.LogWarning("unsupported agent application type {app}", appName); + throw new UriException(errTitle, + $"Failed to open agent in URI '{uri.AbsolutePath}' because application '{appName}' is unsupported"); + } + + await OpenRDP(agent.Fqdn.First(), uri.Query, ct); + } + + private void CheckAuthority(Uri uri, string errTitle) + { + if (string.IsNullOrEmpty(uri.Authority)) + { + logger.LogWarning("cannot open workspace app without a URI authority on path '{path}'", uri.AbsolutePath); + throw new UriException(errTitle, + $"Failed to open '{uri.AbsolutePath}' because no Coder server was given in the URI"); + } + + var credentialModel = credentialManager.GetCachedCredentials(); + if (credentialModel.State != CredentialState.Valid) + { + logger.LogWarning("cannot open workspace app because credentials are '{state}'", credentialModel.State); + throw new UriException(errTitle, + $"Failed to open '{uri.AbsolutePath}' because you are not signed in."); + } + + // here we assume that the URL is non-null since the credentials are marked valid. If not it's an internal error + // and the App will handle catching the exception and logging it. + var coderUri = credentialModel.CoderUrl!; + if (uri.Authority != coderUri.Authority) + { + logger.LogWarning( + "cannot open workspace app because it was for '{uri_authority}', be we are signed into '{signed_in_authority}'", + uri.Authority, coderUri.Authority); + throw new UriException(errTitle, + $"Failed to open workspace app because it was for '{uri.Authority}', be we are signed into '{coderUri.Authority}'"); + } + } + + public async Task OpenRDP(string domainName, string queryString, CancellationToken ct = default) + { + const string errTitle = "Workspace Remote Desktop Error"; + NameValueCollection query; + try + { + query = HttpUtility.ParseQueryString(queryString); + } + catch (Exception ex) + { + // unfortunately, we can't safely write they query string to logs because it might contain + // sensitive info like a password. This is also why we don't log the exception directly + var trace = new System.Diagnostics.StackTrace(ex, false); + logger.LogWarning("failed to parse open RDP query string: {classMethod}", + trace?.GetFrame(0)?.GetMethod()?.ReflectedType?.FullName); + throw new UriException(errTitle, + "Failed to open remote desktop on a workspace because the URI was malformed"); + } + + var username = query.Get("username"); + var password = query.Get("password"); + if (!string.IsNullOrEmpty(username)) + { + password ??= string.Empty; + rdpConnector.WriteCredentials(domainName, new RdpCredentials(username, password)); + } + + await rdpConnector.Connect(domainName, ct: ct); + } +} diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs new file mode 100644 index 0000000..27f924d --- /dev/null +++ b/App/Services/UserNotifier.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Views; +using Microsoft.Extensions.Logging; +using Microsoft.Windows.AppNotifications; +using Microsoft.Windows.AppNotifications.Builder; + +namespace Coder.Desktop.App.Services; + +public interface INotificationHandler +{ + public void HandleNotificationActivation(IDictionary args); +} + +// This interface is meant to protect the default +// notification handler from being overriden by DI. +public interface IDefaultNotificationHandler : INotificationHandler +{ +} + +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); + /// + /// This method allows to display a Windows-native notification with an action defined in + /// and provided . + /// + /// Title of the notification. + /// Message to be displayed in the notification body. + /// Handler should be e.g. nameof(Handler) where Handler + /// implements . + /// If handler is null the action will open Coder Desktop. + /// Arguments to be provided to the handler when executing the action. + public Task ShowActionNotification(string title, string message, string? handlerName, IDictionary? args = null, CancellationToken ct = default); +} + +public class UserNotifier : IUserNotifier +{ + private const string CoderNotificationHandler = "CoderNotificationHandler"; + private const string DefaultNotificationHandler = "DefaultNotificationHandler"; + + private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default; + private readonly ILogger _logger; + private readonly IDispatcherQueueManager _dispatcherQueueManager; + + private ConcurrentDictionary Handlers { get; } = new(); + + public UserNotifier(ILogger logger, IDispatcherQueueManager dispatcherQueueManager, + IDefaultNotificationHandler notificationHandler) + { + _logger = logger; + _dispatcherQueueManager = dispatcherQueueManager; + var defaultHandlerAdded = Handlers.TryAdd(DefaultNotificationHandler, notificationHandler); + if (!defaultHandlerAdded) + throw new Exception($"UserNotifier failed to be initialized with {nameof(DefaultNotificationHandler)}"); + } + + 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 (name == nameof(DefaultNotificationHandler)) + throw new InvalidOperationException($"You cannot remove '{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 (handlerName == null) + handlerName = nameof(DefaultNotificationHandler); // Use default handler if no handler name is provided + + 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/DisplayScale.cs b/App/Utils/DisplayScale.cs similarity index 94% rename from App/DisplayScale.cs rename to App/Utils/DisplayScale.cs index cd5101c..7cc79d6 100644 --- a/App/DisplayScale.cs +++ b/App/Utils/DisplayScale.cs @@ -3,7 +3,7 @@ using Microsoft.UI.Xaml; using WinRT.Interop; -namespace Coder.Desktop.App; +namespace Coder.Desktop.App.Utils; /// /// A static utility class to house methods related to the visual scale of the display monitor. 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/ModelUpdate.cs b/App/Utils/ModelUpdate.cs new file mode 100644 index 0000000..de8b2b6 --- /dev/null +++ b/App/Utils/ModelUpdate.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Coder.Desktop.App.Utils; + +public interface IModelUpdateable +{ + /// + /// Applies changes from obj to `this` if they represent the same + /// object based on some identifier like an ID or fixed name. + /// + /// + /// True if the two objects represent the same item and the changes + /// were applied. + /// + public bool TryApplyChanges(T obj); +} + +/// +/// A static utility class providing methods for applying model updates +/// with as little UI updates as possible. +/// The main goal of the utilities in this class is to prevent redraws in +/// ItemsRepeater items when nothing has changed. +/// +public static class ModelUpdate +{ + /// + /// Takes all items in `update` and either applies them to existing + /// items in `target`, or adds them to `target` if there are no + /// matching items. + /// Any items in `target` that don't have a corresponding item in + /// `update` will be removed from `target`. + /// Items are inserted in their correct sort position according to + /// `sorter`. It's assumed that the target list is already sorted by + /// `sorter`. + /// + /// Target list to be updated + /// Incoming list to apply to `target` + /// + /// Comparison to use for sorting. Note that the sort order does not + /// need to be the ID/name field used in the IModelUpdateable + /// implementation, and can be by any order. + /// New items will be sorted after existing items. + /// + public static void ApplyLists(IList target, IEnumerable update, Comparison sorter) + where T : IModelUpdateable + { + var newItems = update.ToList(); + + // Update and remove existing items. We use index-based for loops here + // because we remove items, and removing items while using the list as + // an IEnumerable will throw an exception. + for (var i = 0; i < target.Count; i++) + { + // Even though we're removing items before a "break", we still use + // index-based for loops here to avoid exceptions. + for (var j = 0; j < newItems.Count; j++) + { + if (!target[i].TryApplyChanges(newItems[j])) continue; + + // Prevent it from being added below, or checked again. We + // don't need to decrement `j` here because we're breaking + // out of this inner loop. + newItems.RemoveAt(j); + goto OuterLoopEnd; // continue outer loop + } + + // A merge couldn't occur, so we need to remove the old item and + // decrement `i` for the next iteration. + target.RemoveAt(i); + i--; + + // Rider fights `dotnet format` about whether there should be a + // space before the semicolon or not. +#pragma warning disable format + OuterLoopEnd: ; +#pragma warning restore format + } + + // Add any items that were missing into their correct sorted place. + // It's assumed the list is already sorted. + foreach (var newItem in newItems) + { + for (var i = 0; i < target.Count; i++) + // If the new item sorts before the current item, insert it + // after. + if (sorter(newItem, target[i]) < 0) + { + target.Insert(i, newItem); + goto OuterLoopEnd; + } + + // Handle the case where target is empty or the new item is + // equal to or after every other item. + target.Add(newItem); + + // Rider fights `dotnet format` about whether there should be a + // space before the semicolon or not. +#pragma warning disable format + OuterLoopEnd: ; +#pragma warning restore format + } + } +} diff --git a/App/Utils/TitleBarIcon.cs b/App/Utils/TitleBarIcon.cs new file mode 100644 index 0000000..ff6ece4 --- /dev/null +++ b/App/Utils/TitleBarIcon.cs @@ -0,0 +1,11 @@ +using Microsoft.UI.Xaml; + +namespace Coder.Desktop.App.Utils; + +public static class TitleBarIcon +{ + public static void SetTitlebarIcon(Window window) + { + window.AppWindow.SetIcon("coder.ico"); + } +} diff --git a/App/ViewModels/AgentAppViewModel.cs b/App/ViewModels/AgentAppViewModel.cs new file mode 100644 index 0000000..5620eb2 --- /dev/null +++ b/App/ViewModels/AgentAppViewModel.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using Windows.System; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.Vpn.Proto; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Coder.Desktop.App.ViewModels; + +public interface IAgentAppViewModelFactory +{ + public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl); +} + +public class AgentAppViewModelFactory(ILogger childLogger, ICredentialManager credentialManager) + : IAgentAppViewModelFactory +{ + public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl) + { + return new AgentAppViewModel(childLogger, credentialManager) + { + Id = id, + Name = name, + AppUri = appUri, + IconUrl = iconUrl, + }; + } +} + +public partial class AgentAppViewModel : ObservableObject, IModelUpdateable +{ + private const string SessionTokenUriVar = "$SESSION_TOKEN"; + + private readonly ILogger _logger; + private readonly ICredentialManager _credentialManager; + + public required Uuid Id { get; init; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Details))] + public required partial string Name { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Details))] + public required partial Uri AppUri { get; set; } + + [ObservableProperty] public partial Uri? IconUrl { get; set; } + + [ObservableProperty] public partial ImageSource IconImageSource { get; set; } + + [ObservableProperty] public partial bool UseFallbackIcon { get; set; } = true; + + public string Details => + (string.IsNullOrWhiteSpace(Name) ? "(no name)" : Name) + ":\n\n" + AppUri; + + public AgentAppViewModel(ILogger logger, ICredentialManager credentialManager) + { + _logger = logger; + _credentialManager = credentialManager; + + // Apply the icon URL to the icon image source when it is updated. + IconImageSource = UpdateIcon(); + PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(IconUrl)) + IconImageSource = UpdateIcon(); + }; + } + + public bool TryApplyChanges(AgentAppViewModel obj) + { + if (Id != obj.Id) return false; + + // To avoid spurious UI updates which cause flashing, don't actually + // write to values unless they've changed. + if (Name != obj.Name) + Name = obj.Name; + if (AppUri != obj.AppUri) + AppUri = obj.AppUri; + if (IconUrl != obj.IconUrl) + { + UseFallbackIcon = true; + IconUrl = obj.IconUrl; + } + + return true; + } + + private ImageSource UpdateIcon() + { + if (IconUrl is null || (IconUrl.Scheme != "http" && IconUrl.Scheme != "https")) + { + UseFallbackIcon = true; + return new BitmapImage(); + } + + // Determine what image source to use based on extension, use a + // BitmapImage as last resort. + var ext = IconUrl.AbsolutePath.Split('/').LastOrDefault()?.Split('.').LastOrDefault(); + // TODO: this is definitely a hack, URLs shouldn't need to end in .svg + if (ext is "svg") + { + // TODO: Some SVGs like `/icon/cursor.svg` contain PNG data and + // don't render at all. + var svg = new SvgImageSource(IconUrl); + svg.Opened += (_, _) => _logger.LogDebug("app icon opened (svg): {uri}", IconUrl); + svg.OpenFailed += (_, args) => + _logger.LogDebug("app icon failed to open (svg): {uri}: {Status}", IconUrl, args.Status); + return svg; + } + + var bitmap = new BitmapImage(IconUrl); + bitmap.ImageOpened += (_, _) => _logger.LogDebug("app icon opened (bitmap): {uri}", IconUrl); + bitmap.ImageFailed += (_, args) => + _logger.LogDebug("app icon failed to open (bitmap): {uri}: {ErrorMessage}", IconUrl, args.ErrorMessage); + return bitmap; + } + + public void OnImageOpened(object? sender, RoutedEventArgs e) + { + UseFallbackIcon = false; + } + + public void OnImageFailed(object? sender, RoutedEventArgs e) + { + UseFallbackIcon = true; + } + + [RelayCommand] + private void OpenApp(object parameter) + { + try + { + var uri = AppUri; + + // http and https URLs should already be filtered out by + // AgentViewModel, but as a second line of defence don't do session + // token var replacement on those URLs. + if (uri.Scheme is not "http" and not "https") + { + var cred = _credentialManager.GetCachedCredentials(); + if (cred.State is CredentialState.Valid && cred.ApiToken is not null) + uri = new Uri(uri.ToString().Replace(SessionTokenUriVar, cred.ApiToken)); + } + + if (uri.ToString().Contains(SessionTokenUriVar)) + throw new Exception( + $"URI contains {SessionTokenUriVar} variable but could not be replaced (http and https URLs cannot contain {SessionTokenUriVar})"); + + _ = Launcher.LaunchUriAsync(uri); + } + catch (Exception e) + { + _logger.LogWarning(e, "could not parse or launch app"); + + if (parameter is not FrameworkElement frameworkElement) return; + var flyout = new Flyout + { + Content = new TextBlock + { + Text = $"Could not open app: {e.Message}", + Margin = new Thickness(4), + TextWrapping = TextWrapping.Wrap, + }, + FlyoutPresenterStyle = new Style(typeof(FlyoutPresenter)) + { + Setters = + { + new Setter(ScrollViewer.HorizontalScrollModeProperty, ScrollMode.Disabled), + new Setter(ScrollViewer.HorizontalScrollBarVisibilityProperty, ScrollBarVisibility.Disabled), + }, + }, + }; + FlyoutBase.SetAttachedFlyout(frameworkElement, flyout); + FlyoutBase.ShowAttachedFlyout(frameworkElement); + } + } +} diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs index f5b5e0e..0cf2651 100644 --- a/App/ViewModels/AgentViewModel.cs +++ b/App/ViewModels/AgentViewModel.cs @@ -1,30 +1,541 @@ -using Windows.ApplicationModel.DataTransfer; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn.Proto; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Windows.ApplicationModel.DataTransfer; namespace Coder.Desktop.App.ViewModels; +public interface IAgentViewModelFactory +{ + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, + string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, + string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency, DateTime? lastHandshake); + public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName); +} + +public class AgentViewModelFactory( + ILogger childLogger, + ICoderApiClientFactory coderApiClientFactory, + ICredentialManager credentialManager, + IAgentAppViewModelFactory agentAppViewModelFactory) : IAgentViewModelFactory +{ + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, + string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency, + DateTime? lastHandshake) + { + return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, + expanderHost, id) + { + ConfiguredFqdn = fullyQualifiedDomainName, + ConfiguredHostname = string.Empty, + ConfiguredHostnameSuffix = hostnameSuffix, + ConnectionStatus = connectionStatus, + DashboardBaseUrl = dashboardBaseUrl, + WorkspaceName = workspaceName, + DidP2p = didP2p, + PreferredDerp = preferredDerp, + Latency = latency, + PreferredDerpLatency = preferredDerpLatency, + LastHandshake = lastHandshake, + }; + } + + public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName) + { + return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, + expanderHost, id) + { + ConfiguredFqdn = string.Empty, + ConfiguredHostname = workspaceName, + ConfiguredHostnameSuffix = hostnameSuffix, + ConnectionStatus = connectionStatus, + DashboardBaseUrl = dashboardBaseUrl, + WorkspaceName = workspaceName, + }; + } +} + public enum AgentConnectionStatus { - Green, - Yellow, - Red, - Gray, + Healthy, + Connecting, + Unhealthy, + NoRecentHandshake, + Offline } -public partial class AgentViewModel +public static class AgentConnectionStatusExtensions { - public required string Hostname { get; set; } + public static string ToDisplayString(this AgentConnectionStatus status) => + status switch + { + AgentConnectionStatus.Healthy => "Healthy", + AgentConnectionStatus.Connecting => "Connecting", + AgentConnectionStatus.Unhealthy => "High latency", + AgentConnectionStatus.NoRecentHandshake => "No recent handshake", + AgentConnectionStatus.Offline => "Offline", + _ => status.ToString() + }; +} + +public partial class AgentViewModel : ObservableObject, IModelUpdateable +{ + private const string DefaultDashboardUrl = "https://coder.com"; + private const int MaxAppsPerRow = 6; + + // These are fake UUIDs, for UI purposes only. Display apps don't exist on + // the backend as real app resources and therefore don't have an ID. + private static readonly Uuid VscodeAppUuid = new("819828b1-5213-4c3d-855e-1b74db6ddd19"); + private static readonly Uuid VscodeInsidersAppUuid = new("becf1e10-5101-4940-a853-59af86468069"); + + private readonly ILogger _logger; + private readonly ICoderApiClientFactory _coderApiClientFactory; + private readonly ICredentialManager _credentialManager; + private readonly IAgentAppViewModelFactory _agentAppViewModelFactory; + + // The AgentViewModel only gets created on the UI thread. + private readonly DispatcherQueue _dispatcherQueue = + DispatcherQueue.GetForCurrentThread(); + + private readonly IAgentExpanderHost _expanderHost; + + // This isn't an ObservableProperty because the property itself never + // changes. We add an event listener for the collection changing in the + // constructor. + public readonly ObservableCollection Apps = []; + + public readonly Uuid Id; + + // This is set only for "dummy" agents that represent unstarted workspaces. If set, then ConfiguredFqdn + // should be empty, otherwise it will override this. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ViewableHostname))] + [NotifyPropertyChangedFor(nameof(ViewableHostnameSuffix))] + [NotifyPropertyChangedFor(nameof(FullyQualifiedDomainName))] + public required partial string ConfiguredHostname { get; set; } + + // This should be set if we have an FQDN from the VPN service, and overrides ConfiguredHostname if set. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ViewableHostname))] + [NotifyPropertyChangedFor(nameof(ViewableHostnameSuffix))] + [NotifyPropertyChangedFor(nameof(FullyQualifiedDomainName))] + public required partial string ConfiguredFqdn { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ViewableHostname))] + [NotifyPropertyChangedFor(nameof(ViewableHostnameSuffix))] + [NotifyPropertyChangedFor(nameof(FullyQualifiedDomainName))] + public required partial string ConfiguredHostnameSuffix { get; set; } // including leading dot + + + public string FullyQualifiedDomainName + { + get + { + if (!string.IsNullOrEmpty(ConfiguredFqdn)) return ConfiguredFqdn; + return ConfiguredHostname + ConfiguredHostnameSuffix; + } + } + + /// + /// ViewableHostname is the hostname portion of the fully qualified domain name (FQDN) specifically for + /// views that render it differently than the suffix. If the ConfiguredHostnameSuffix doesn't actually + /// match the FQDN, then this will be the entire FQDN, and ViewableHostnameSuffix will be empty. + /// + public string ViewableHostname => !FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix) + ? FullyQualifiedDomainName + : FullyQualifiedDomainName[0..^ConfiguredHostnameSuffix.Length]; + + /// + /// ViewableHostnameSuffix is the domain suffix portion (including leading dot) of the fully qualified + /// domain name (FQDN) specifically for views that render it differently from the rest of the FQDN. If + /// the ConfiguredHostnameSuffix doesn't actually match the FQDN, then this will be empty and the + /// ViewableHostname will contain the entire FQDN. + /// + public string ViewableHostnameSuffix => FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix) + ? ConfiguredHostnameSuffix + : string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public required partial AgentConnectionStatus ConnectionStatus { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DashboardUrl))] + public required partial Uri DashboardBaseUrl { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DashboardUrl))] + public required partial string? WorkspaceName { get; set; } + + [ObservableProperty] public partial bool IsExpanded { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public partial bool FetchingApps { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public partial bool AppFetchErrored { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial bool? DidP2p { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial string? PreferredDerp { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial TimeSpan? Latency { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial TimeSpan? PreferredDerpLatency { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial DateTime? LastHandshake { get; set; } = null; + + public string ConnectionTooltip + { + get + { + var description = new StringBuilder(); + var highLatencyWarning = ConnectionStatus == AgentConnectionStatus.Unhealthy ? $"({AgentConnectionStatus.Unhealthy.ToDisplayString()})" : ""; + + if (DidP2p != null && DidP2p.Value && Latency != null) + { + description.Append($""" + You're connected peer-to-peer. {highLatencyWarning} + + You ↔ {Latency.Value.Milliseconds} ms ↔ {WorkspaceName} + """ + ); + } + else if (Latency != null) + { + description.Append($""" + You're connected through a DERP relay. {highLatencyWarning} + We'll switch over to peer-to-peer when available. + + Total latency: {Latency.Value.Milliseconds} ms + """ + ); + + if (PreferredDerpLatency != null) + { + description.Append($"\nYou ↔ {PreferredDerp}: {PreferredDerpLatency.Value.Milliseconds} ms"); + + var derpToWorkspaceEstimatedLatency = Latency - PreferredDerpLatency; + + // Guard against negative values if the two readings were taken at different times + if (derpToWorkspaceEstimatedLatency > TimeSpan.Zero) + { + description.Append($"\n{PreferredDerp} ms ↔ {WorkspaceName}: {derpToWorkspaceEstimatedLatency.Value.Milliseconds} ms"); + } + } + } + else + { + description.Append(ConnectionStatus.ToDisplayString()); + } + if (LastHandshake != null) + description.Append($"\n\nLast handshake: {LastHandshake?.ToString()}"); + + return description.ToString().TrimEnd('\n', ' '); ; + } + } + + + // We only show 6 apps max, which fills the entire width of the tray + // window. + public IEnumerable VisibleApps => Apps.Count > MaxAppsPerRow ? Apps.Take(MaxAppsPerRow) : Apps; + + public bool ShowExpandAppsMessage => ExpandAppsMessage != null; + + public string? ExpandAppsMessage + { + get + { + if (ConnectionStatus == AgentConnectionStatus.Offline) + return "Your workspace is offline."; + if (FetchingApps && Apps.Count == 0) + // Don't show this message if we have any apps already. When + // they finish loading, we'll just update the screen with any + // changes. + return "Fetching workspace apps..."; + if (AppFetchErrored && Apps.Count == 0) + // There's very limited screen real estate here so we don't + // show the actual error message. + return "Could not fetch workspace apps."; + if (Apps.Count == 0) + return "No apps to show."; + return null; + } + } + + public string DashboardUrl + { + get + { + if (string.IsNullOrWhiteSpace(WorkspaceName)) return DashboardBaseUrl.ToString(); + try + { + return new Uri(DashboardBaseUrl, $"/@me/{WorkspaceName}").ToString(); + } + catch + { + return DefaultDashboardUrl; + } + } + } + + public AgentViewModel(ILogger logger, ICoderApiClientFactory coderApiClientFactory, + ICredentialManager credentialManager, IAgentAppViewModelFactory agentAppViewModelFactory, + IAgentExpanderHost expanderHost, Uuid id) + { + _logger = logger; + _coderApiClientFactory = coderApiClientFactory; + _credentialManager = credentialManager; + _agentAppViewModelFactory = agentAppViewModelFactory; + _expanderHost = expanderHost; + + Id = id; + + PropertyChanging += (x, args) => + { + if (args.PropertyName == nameof(IsExpanded)) + { + var value = !IsExpanded; + if (value) + _expanderHost.HandleAgentExpanded(Id, value); + } + }; + + PropertyChanged += (x, args) => + { + if (args.PropertyName == nameof(IsExpanded)) + { + // Every time the drawer is expanded, re-fetch all apps. + if (IsExpanded && !FetchingApps) + FetchApps(); + } + }; + + // Since the property value itself never changes, we add event + // listeners for the underlying collection changing instead. + Apps.CollectionChanged += (_, _) => + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleApps))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowExpandAppsMessage))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ExpandAppsMessage))); + }; + } + + public bool TryApplyChanges(AgentViewModel model) + { + if (Id != model.Id) return false; + + // To avoid spurious UI updates which cause flashing, don't actually + // write to values unless they've changed. + if (ConfiguredFqdn != model.ConfiguredFqdn) + ConfiguredFqdn = model.ConfiguredFqdn; + if (ConfiguredHostname != model.ConfiguredHostname) + ConfiguredHostname = model.ConfiguredHostname; + if (ConfiguredHostnameSuffix != model.ConfiguredHostnameSuffix) + ConfiguredHostnameSuffix = model.ConfiguredHostnameSuffix; + if (ConnectionStatus != model.ConnectionStatus) + ConnectionStatus = model.ConnectionStatus; + if (DashboardBaseUrl != model.DashboardBaseUrl) + DashboardBaseUrl = model.DashboardBaseUrl; + if (WorkspaceName != model.WorkspaceName) + WorkspaceName = model.WorkspaceName; + if (DidP2p != model.DidP2p) + DidP2p = model.DidP2p; + if (PreferredDerp != model.PreferredDerp) + PreferredDerp = model.PreferredDerp; + if (Latency != model.Latency) + Latency = model.Latency; + if (PreferredDerpLatency != model.PreferredDerpLatency) + PreferredDerpLatency = model.PreferredDerpLatency; + if (LastHandshake != model.LastHandshake) + LastHandshake = model.LastHandshake; + + // Apps are not set externally. + + return true; + } + + [RelayCommand] + private void ToggleExpanded() + { + SetExpanded(!IsExpanded); + } + + public void SetExpanded(bool expanded) + { + if (IsExpanded == expanded) return; + // This will bubble up to the TrayWindowViewModel because of the + // PropertyChanged handler. + IsExpanded = expanded; + } + + partial void OnConnectionStatusChanged(AgentConnectionStatus oldValue, AgentConnectionStatus newValue) + { + if (IsExpanded && newValue is not AgentConnectionStatus.Offline) FetchApps(); + } + + private void FetchApps() + { + if (FetchingApps) return; + FetchingApps = true; + + // If the workspace is off, then there's no agent and there's no apps. + if (ConnectionStatus == AgentConnectionStatus.Offline) + { + FetchingApps = false; + Apps.Clear(); + return; + } + + // API client creation could fail, which would leave FetchingApps true. + ICoderApiClient client; + try + { + client = _coderApiClientFactory.Create(_credentialManager); + } + catch + { + FetchingApps = false; + throw; + } + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + client.GetWorkspaceAgent(Id.ToString(), cts.Token).ContinueWith(t => + { + cts.Dispose(); + ContinueFetchApps(t); + }, CancellationToken.None); + } + + private void ContinueFetchApps(Task task) + { + // Ensure we're on the UI thread. + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => ContinueFetchApps(task)); + return; + } - public required string HostnameSuffix { get; set; } // including leading dot + FetchingApps = false; + AppFetchErrored = !task.IsCompletedSuccessfully; + if (!task.IsCompletedSuccessfully) + { + _logger.LogWarning(task.Exception, "Could not fetch workspace agent"); + return; + } - public required AgentConnectionStatus ConnectionStatus { get; set; } + var workspaceAgent = task.Result; + var apps = new List(); + foreach (var app in workspaceAgent.Apps) + { + if (!app.External || !string.IsNullOrEmpty(app.Command)) continue; + + if (!Uri.TryCreate(app.Url, UriKind.Absolute, out var appUri)) + { + _logger.LogWarning("Could not parse app URI '{Url}' for '{DisplayName}', app will not appear in list", + app.Url, + app.DisplayName); + continue; + } - public string FullHostname => Hostname + HostnameSuffix; + // HTTP or HTTPS external apps are usually things like + // wikis/documentation, which clutters up the app. + if (appUri.Scheme is "http" or "https") + continue; + + // Icon parse failures are not fatal, we will just use the fallback + // icon. + _ = Uri.TryCreate(DashboardBaseUrl, app.Icon, out var iconUrl); + + apps.Add(_agentAppViewModelFactory.Create(app.Id, app.DisplayName, appUri, iconUrl)); + } + + foreach (var displayApp in workspaceAgent.DisplayApps) + { + if (displayApp is not WorkspaceAgent.DisplayAppVscode and not WorkspaceAgent.DisplayAppVscodeInsiders) + continue; - public required string DashboardUrl { get; set; } + var id = VscodeAppUuid; + var displayName = "VS Code"; + var icon = "/icon/code.svg"; + var scheme = "vscode"; + if (displayApp is WorkspaceAgent.DisplayAppVscodeInsiders) + { + id = VscodeInsidersAppUuid; + displayName = "VS Code Insiders"; + icon = "/icon/code-insiders.svg"; + scheme = "vscode-insiders"; + } + + Uri appUri; + try + { + appUri = new UriBuilder + { + Scheme = scheme, + Host = "vscode-remote", + Path = $"/ssh-remote+{FullyQualifiedDomainName}/{workspaceAgent.ExpandedDirectory}", + }.Uri; + } + catch (Exception e) + { + _logger.LogWarning(e, + "Could not craft app URI for display app {displayApp}, app will not appear in list", + displayApp); + continue; + } + + // Icon parse failures are not fatal, we will just use the fallback + // icon. + _ = Uri.TryCreate(DashboardBaseUrl, icon, out var iconUrl); + + apps.Add(_agentAppViewModelFactory.Create(id, displayName, appUri, iconUrl)); + } + + // Sort by name. + ModelUpdate.ApplyLists(Apps, apps, (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + } [RelayCommand] private void CopyHostname(object parameter) @@ -33,7 +544,7 @@ private void CopyHostname(object parameter) { RequestedOperation = DataPackageOperation.Copy, }; - dataPackage.SetText(FullHostname); + dataPackage.SetText(FullyQualifiedDomainName); Clipboard.SetContent(dataPackage); if (parameter is not FrameworkElement frameworkElement) return; diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 9235141..cb84f56 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -48,7 +48,11 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowSessions))] public partial string? Error { get; set; } = null; - [ObservableProperty] public partial bool OperationInProgress { get; set; } = false; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanOpenLocalPath))] + [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial bool OperationInProgress { get; set; } = false; [ObservableProperty] public partial IReadOnlyList Sessions { get; set; } = []; @@ -60,6 +64,7 @@ public partial class FileSyncListViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(CanOpenLocalPath))] public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; [ObservableProperty] @@ -80,10 +85,12 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] public partial bool NewSessionRemotePathDialogOpen { get; set; } = false; - public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0; + public bool CanOpenLocalPath => !NewSessionLocalPathDialogOpen && !OperationInProgress; + + public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0 && !OperationInProgress; public bool NewSessionRemotePathDialogEnabled => - !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen; + !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen && !OperationInProgress; [ObservableProperty] public partial string NewSessionStatus { get; set; } = ""; @@ -136,9 +143,9 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue) var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); - MaybeSetUnavailableMessage(rpcModel, credentialModel); var syncSessionState = _syncSessionController.GetState(); UpdateSyncSessionState(syncSessionState); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); } private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) @@ -152,7 +159,8 @@ private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) } var credentialModel = _credentialManager.GetCachedCredentials(); - MaybeSetUnavailableMessage(rpcModel, credentialModel); + var syncSessionState = _syncSessionController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); } private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel) @@ -166,7 +174,8 @@ private void CredentialManagerCredentialsChanged(object? sender, CredentialModel } var rpcModel = _rpcController.GetState(); - MaybeSetUnavailableMessage(rpcModel, credentialModel); + var syncSessionState = _syncSessionController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); } private void SyncSessionStateChanged(object? sender, SyncSessionControllerStateModel syncSessionState) @@ -182,7 +191,7 @@ private void SyncSessionStateChanged(object? sender, SyncSessionControllerStateM UpdateSyncSessionState(syncSessionState); } - private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel) + private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel, SyncSessionControllerStateModel syncSessionState) { var oldMessage = UnavailableMessage; if (rpcModel.RpcLifecycle != RpcLifecycle.Connected) @@ -198,6 +207,10 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede { UnavailableMessage = "Please start Coder Connect from the tray window to access file sync."; } + else if (syncSessionState.Lifecycle == SyncSessionControllerLifecycle.Uninitialized) + { + UnavailableMessage = "Sync session controller is not initialized. Please wait..."; + } else { UnavailableMessage = null; @@ -212,6 +225,13 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState) { + // This should never happen. + if (syncSessionState == null) + return; + if (syncSessionState.Lifecycle == SyncSessionControllerLifecycle.Uninitialized) + { + MaybeSetUnavailableMessage(_rpcController.GetState(), _credentialManager.GetCachedCredentials(), syncSessionState); + } Error = syncSessionState.DaemonError; Sessions = syncSessionState.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList(); } @@ -339,7 +359,8 @@ public void OpenRemotePathSelectDialog() pickerViewModel.PathSelected += OnRemotePathSelected; _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel); - _remotePickerWindow.SetParent(_window); + if (_window is not null) + _remotePickerWindow.SetParent(_window); _remotePickerWindow.Closed += (_, _) => { _remotePickerWindow = null; 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/TrayWindowDisconnectedViewModel.cs b/App/ViewModels/TrayWindowDisconnectedViewModel.cs index 5fe16a2..ce6582c 100644 --- a/App/ViewModels/TrayWindowDisconnectedViewModel.cs +++ b/App/ViewModels/TrayWindowDisconnectedViewModel.cs @@ -1,8 +1,9 @@ -using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using System; +using System.Threading.Tasks; namespace Coder.Desktop.App.ViewModels; @@ -11,6 +12,8 @@ public partial class TrayWindowDisconnectedViewModel : ObservableObject private readonly IRpcController _rpcController; [ObservableProperty] public partial bool ReconnectButtonEnabled { get; set; } = true; + [ObservableProperty] public partial string ErrorMessage { get; set; } = string.Empty; + [ObservableProperty] public partial bool ReconnectFailed { get; set; } = false; public TrayWindowDisconnectedViewModel(IRpcController rpcController) { @@ -26,6 +29,16 @@ private void UpdateFromRpcModel(RpcModel rpcModel) [RelayCommand] public async Task Reconnect() { - await _rpcController.Reconnect(); + try + { + ReconnectFailed = false; + ErrorMessage = string.Empty; + await _rpcController.Reconnect(); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + ReconnectFailed = true; + } } } diff --git a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs index 628be72..abc1257 100644 --- a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs +++ b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs @@ -2,6 +2,7 @@ using Coder.Desktop.App.Views; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml; namespace Coder.Desktop.App.ViewModels; @@ -31,4 +32,10 @@ public void Login() _signInWindow.Closed += (_, _) => _signInWindow = null; _signInWindow.Activate(); } + + [RelayCommand] + public void Exit() + { + _ = ((App)Application.Current).ExitApplication(); + } } diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index f845521..f57947d 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -1,10 +1,15 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; using System.Linq; +using System.Security.Principal; using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; using Coder.Desktop.App.Views; +using Coder.Desktop.CoderSdk; using Coder.Desktop.Vpn.Proto; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -13,25 +18,45 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Exception = System.Exception; namespace Coder.Desktop.App.ViewModels; -public partial class TrayWindowViewModel : ObservableObject +public interface IAgentExpanderHost +{ + public void HandleAgentExpanded(Uuid id, bool expanded); +} + +public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost { private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; + private readonly TimeSpan HealthyPingThreshold = TimeSpan.FromMilliseconds(150); private readonly IServiceProvider _services; private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; + private readonly IAgentViewModelFactory _agentViewModelFactory; + private readonly IHostnameSuffixGetter _hostnameSuffixGetter; private FileSyncListWindow? _fileSyncListWindow; + private SettingsWindow? _settingsWindow; + private DispatcherQueue? _dispatcherQueue; + // When we transition from 0 online workspaces to >0 online workspaces, the + // first agent will be expanded. This bool tracks whether this has occurred + // yet (or if the user has expanded something themselves). + private bool _hasExpandedAgent; + + // This isn't an ObservableProperty because the property itself never + // changes. We add an event listener for the collection changing in the + // constructor. + public readonly ObservableCollection Agents = []; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowEnableSection))] + [NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))] [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] @@ -42,6 +67,7 @@ public partial class TrayWindowViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowEnableSection))] + [NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))] [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] @@ -50,13 +76,24 @@ public partial class TrayWindowViewModel : ObservableObject public partial string? VpnFailedMessage { get; set; } = null; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(VisibleAgents))] - [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] - [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] - [NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))] - public partial List Agents { get; set; } = []; + [NotifyPropertyChangedFor(nameof(VpnStartProgressIsIndeterminate))] + [NotifyPropertyChangedFor(nameof(VpnStartProgressValueOrDefault))] + public partial int? VpnStartProgressValue { get; set; } = null; + + public int VpnStartProgressValueOrDefault => VpnStartProgressValue ?? 0; - public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started; + [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; @@ -76,14 +113,44 @@ public partial class TrayWindowViewModel : ObservableObject public IEnumerable VisibleAgents => ShowAllAgents ? Agents : Agents.Take(MaxAgents); - [ObservableProperty] public partial string DashboardUrl { get; set; } = "https://coder.com"; + [ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl; public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController, - ICredentialManager credentialManager) + ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory, IHostnameSuffixGetter hostnameSuffixGetter) { _services = services; _rpcController = rpcController; _credentialManager = credentialManager; + _agentViewModelFactory = agentViewModelFactory; + _hostnameSuffixGetter = hostnameSuffixGetter; + + // Since the property value itself never changes, we add event + // listeners for the underlying collection changing instead. + Agents.CollectionChanged += (_, _) => + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleAgents))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowNoAgentsSection))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentsSection))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentOverflowButton))); + }; + } + + // Implements IAgentExpanderHost + public void HandleAgentExpanded(Uuid id, bool expanded) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => HandleAgentExpanded(id, expanded)); + return; + } + + if (!expanded) return; + _hasExpandedAgent = true; + // Collapse every other agent. + foreach (var otherAgent in Agents.Where(a => a.Id != id && a.IsExpanded == true)) + otherAgent.SetExpanded(false); } public void Initialize(DispatcherQueue dispatcherQueue) @@ -93,8 +160,11 @@ public void Initialize(DispatcherQueue dispatcherQueue) _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel); UpdateFromRpcModel(_rpcController.GetState()); - _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel); - UpdateFromCredentialsModel(_credentialManager.GetCachedCredentials()); + _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialModel(credentialModel); + UpdateFromCredentialModel(_credentialManager.GetCachedCredentials()); + + _hostnameSuffixGetter.SuffixChanged += (_, suffix) => HandleHostnameSuffixChanged(suffix); + HandleHostnameSuffixChanged(_hostnameSuffixGetter.GetCachedSuffix()); } private void UpdateFromRpcModel(RpcModel rpcModel) @@ -107,37 +177,44 @@ private void UpdateFromRpcModel(RpcModel rpcModel) return; } - // As a failsafe, if RPC is disconnected we disable the switch. The - // Window should not show the current Page if the RPC is disconnected. - if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected) + // As a failsafe, if RPC is disconnected (or we're not signed in) we + // disable the switch. The Window should not show the current Page if + // the RPC is disconnected. + var credentialModel = _credentialManager.GetCachedCredentials(); + if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected || credentialModel.State is not CredentialState.Valid || + credentialModel.CoderUrl == null) { VpnLifecycle = VpnLifecycle.Unknown; VpnSwitchActive = false; - Agents = []; + Agents.Clear(); return; } VpnLifecycle = rpcModel.VpnLifecycle; VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started; - // Get the current dashboard URL. - var credentialModel = _credentialManager.GetCachedCredentials(); - Uri? coderUri = null; - if (credentialModel.State == CredentialState.Valid && !string.IsNullOrWhiteSpace(credentialModel.CoderUrl)) - try - { - coderUri = new Uri(credentialModel.CoderUrl, UriKind.Absolute); - } - catch - { - // Ignore - } + // 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 = []; foreach (var agent in rpcModel.Agents) { + if (!Uuid.TryFrom(agent.Id.Span, out var uuid)) + continue; + // Find the FQDN with the least amount of dots and split it into // prefix and suffix. var fqdn = agent.Fqdn @@ -147,84 +224,132 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (string.IsNullOrWhiteSpace(fqdn)) continue; - var fqdnPrefix = fqdn; - var fqdnSuffix = ""; - if (fqdn.Contains('.')) + var connectionStatus = AgentConnectionStatus.Healthy; + + if (agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default && agent.LastHandshake.ToDateTime() < DateTime.UtcNow) + { + // For compatibility with older deployments, we assume that if the + // last ping is null, the agent is healthy. + var isLatencyAcceptable = agent.LastPing == null || agent.LastPing.Latency.ToTimeSpan() < HealthyPingThreshold; + + var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); + + if (lastHandshakeAgo > TimeSpan.FromMinutes(5)) + connectionStatus = AgentConnectionStatus.NoRecentHandshake; + else if (!isLatencyAcceptable) + connectionStatus = AgentConnectionStatus.Unhealthy; + } + else { - fqdnPrefix = fqdn[..fqdn.LastIndexOf('.')]; - fqdnSuffix = fqdn[fqdn.LastIndexOf('.')..]; + // If the last handshake is not correct (null, default or in the future), + // we assume the agent is connecting (yellow status icon). + connectionStatus = AgentConnectionStatus.Connecting; } - var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); workspacesWithAgents.Add(agent.WorkspaceId); var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId); - agents.Add(new AgentViewModel - { - Hostname = fqdnPrefix, - HostnameSuffix = fqdnSuffix, - ConnectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) - ? AgentConnectionStatus.Green - : AgentConnectionStatus.Yellow, - DashboardUrl = WorkspaceUri(coderUri, workspace?.Name), - }); + agents.Add(_agentViewModelFactory.Create( + this, + uuid, + fqdn, + _hostnameSuffixGetter.GetCachedSuffix(), + connectionStatus, + credentialModel.CoderUrl, + workspace?.Name, + agent.LastPing?.DidP2P, + agent.LastPing?.PreferredDerp, + agent.LastPing?.Latency?.ToTimeSpan(), + agent.LastPing?.PreferredDerpLatency?.ToTimeSpan(), + agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default ? agent.LastHandshake?.ToDateTime() : null)); } // For every stopped workspace that doesn't have any agents, add a // dummy agent row. foreach (var workspace in rpcModel.Workspaces.Where(w => - w.Status == Workspace.Types.Status.Stopped && !workspacesWithAgents.Contains(w.Id))) - agents.Add(new AgentViewModel - { - // We just assume that it's a single-agent workspace. - Hostname = workspace.Name, - // TODO: this needs to get the suffix from the server - HostnameSuffix = ".coder", - ConnectionStatus = AgentConnectionStatus.Gray, - DashboardUrl = WorkspaceUri(coderUri, workspace.Name), - }); + ShouldShowDummy(w) && !workspacesWithAgents.Contains(w.Id))) + { + if (!Uuid.TryFrom(workspace.Id.Span, out var uuid)) + continue; + + agents.Add(_agentViewModelFactory.CreateDummy( + this, + // Workspace ID is fine as a stand-in here, it shouldn't + // conflict with any agent IDs. + uuid, + _hostnameSuffixGetter.GetCachedSuffix(), + AgentConnectionStatus.Offline, + credentialModel.CoderUrl, + workspace.Name)); + } // Sort by status green, red, gray, then by hostname. - agents.Sort((a, b) => + ModelUpdate.ApplyLists(Agents, agents, (a, b) => { if (a.ConnectionStatus != b.ConnectionStatus) return a.ConnectionStatus.CompareTo(b.ConnectionStatus); - return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal); + return string.Compare(a.FullyQualifiedDomainName, b.FullyQualifiedDomainName, StringComparison.Ordinal); }); - Agents = agents; if (Agents.Count < MaxAgents) ShowAllAgents = false; - } - private string WorkspaceUri(Uri? baseUri, string? workspaceName) - { - if (baseUri == null) return DefaultDashboardUrl; - if (string.IsNullOrWhiteSpace(workspaceName)) return baseUri.ToString(); - try + var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Offline); + if (firstOnlineAgent is null) + _hasExpandedAgent = false; + if (!_hasExpandedAgent && firstOnlineAgent is not null) { - return new Uri(baseUri, $"/@me/{workspaceName}").ToString(); - } - catch - { - return DefaultDashboardUrl; + firstOnlineAgent.SetExpanded(true); + _hasExpandedAgent = true; } } - private void UpdateFromCredentialsModel(CredentialModel credentialModel) + private void UpdateFromCredentialModel(CredentialModel credentialModel) { // Ensure we're on the UI thread. if (_dispatcherQueue == null) return; if (!_dispatcherQueue.HasThreadAccess) { - _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel)); + _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialModel(credentialModel)); return; } + // CredentialModel updates trigger RpcStateModel updates first. This + // resolves an issue on startup where the window would be locked for 5 + // seconds, even if all startup preconditions have been met: + // + // 1. RPC state updates, but credentials are invalid so the window + // enters the invalid loading state to prevent interaction. + // 2. Credential model finally becomes valid after reaching out to the + // server to check credentials. + // 3. UpdateFromCredentialModel previously did not re-trigger RpcModel + // update. + // 4. Five seconds after step 1, a new RPC state update would come in + // and finally unlock the window. + // + // Calling UpdateFromRpcModel at step 3 resolves this issue. + UpdateFromRpcModel(_rpcController.GetState()); + // HACK: the HyperlinkButton crashes the whole app if the initial URI // or this URI is invalid. CredentialModel.CoderUrl should never be // null while the Page is active as the Page is only displayed when // CredentialModel.Status == Valid. - DashboardUrl = credentialModel.CoderUrl ?? DefaultDashboardUrl; + DashboardUrl = credentialModel.CoderUrl?.ToString() ?? DefaultDashboardUrl; + } + + private void HandleHostnameSuffixChanged(string suffix) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => HandleHostnameSuffixChanged(suffix)); + return; + } + + foreach (var agent in Agents) + { + agent.ConfiguredHostnameSuffix = suffix; + } } public void VpnSwitch_Toggled(object sender, RoutedEventArgs e) @@ -273,13 +398,13 @@ private static string MaybeUnwrapTunnelError(Exception e) } [RelayCommand] - public void ToggleShowAllAgents() + private void ToggleShowAllAgents() { ShowAllAgents = !ShowAllAgents; } [RelayCommand] - public void ShowFileSyncListWindow() + private void ShowFileSyncListWindow() { // This is safe against concurrent access since it all happens in the // UI thread. @@ -295,10 +420,48 @@ public void ShowFileSyncListWindow() } [RelayCommand] - public void SignOut() + private void ShowSettingsWindow() { - if (VpnLifecycle is not VpnLifecycle.Stopped) + // This is safe against concurrent access since it all happens in the + // UI thread. + if (_settingsWindow != null) + { + _settingsWindow.Activate(); return; - _credentialManager.ClearCredentials(); + } + + _settingsWindow = _services.GetRequiredService(); + _settingsWindow.Closed += (_, _) => _settingsWindow = null; + _settingsWindow.Activate(); + } + + [RelayCommand] + private async Task SignOut() + { + await _rpcController.StopVpn(); + await _credentialManager.ClearCredentials(); + } + + [RelayCommand] + public void Exit() + { + _ = ((App)Application.Current).ExitApplication(); + } + + private static bool ShouldShowDummy(Workspace workspace) + { + switch (workspace.Status) + { + case Workspace.Types.Status.Unknown: + case Workspace.Types.Status.Pending: + case Workspace.Types.Status.Starting: + case Workspace.Types.Status.Stopping: + case Workspace.Types.Status.Stopped: + return true; + // TODO: should we include and show a different color than Offline for workspaces that are + // failed, canceled or deleting? + default: + return false; + } } } 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..4d3c692 --- /dev/null +++ b/App/ViewModels/UpdaterUpdateAvailableViewModel.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Coder.Desktop.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.Web.WebView2.Core; +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 with a custom user data folder. The default for + // unpackaged WinUI 3 apps is to write to a subfolder in the app's + // install directory, which is Program Files by default and not + // writeable by the user. + var userDataFolder = Path.Join(SettingsManagerUtils.AppSettingsDirectory(), "WebView2"); + _logger.LogDebug("Creating WebView2 user data folder at {UserDataFolder}", userDataFolder); + Directory.CreateDirectory(userDataFolder); + var env = await CoreWebView2Environment.CreateWithOptionsAsync( + null, + userDataFolder, + new CoreWebView2EnvironmentOptions()); + await webView.EnsureCoreWebView2Async(env); + + // 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/DirectoryPickerWindow.xaml b/App/Views/DirectoryPickerWindow.xaml index 8a107cb..ce1623b 100644 --- a/App/Views/DirectoryPickerWindow.xaml +++ b/App/Views/DirectoryPickerWindow.xaml @@ -13,7 +13,7 @@ MinWidth="400" MinHeight="600"> - + diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs index 6ed5f43..d2eb320 100644 --- a/App/Views/DirectoryPickerWindow.xaml.cs +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.InteropServices; using Windows.Graphics; +using Coder.Desktop.App.Utils; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views.Pages; using Microsoft.UI.Windowing; @@ -16,7 +17,7 @@ public sealed partial class DirectoryPickerWindow : WindowEx public DirectoryPickerWindow(DirectoryPickerViewModel viewModel) { InitializeComponent(); - SystemBackdrop = new DesktopAcrylicBackdrop(); + TitleBarIcon.SetTitlebarIcon(this); viewModel.Initialize(this, DispatcherQueue); RootFrame.Content = new DirectoryPickerMainPage(viewModel); diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml index 070efd2..991d02a 100644 --- a/App/Views/FileSyncListWindow.xaml +++ b/App/Views/FileSyncListWindow.xaml @@ -13,7 +13,7 @@ MinWidth="1000" MinHeight="300"> - + diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index 428363b..9d8510b 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -1,3 +1,4 @@ +using Coder.Desktop.App.Utils; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views.Pages; using Microsoft.UI.Xaml.Media; @@ -13,7 +14,7 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) { ViewModel = viewModel; InitializeComponent(); - SystemBackdrop = new DesktopAcrylicBackdrop(); + TitleBarIcon.SetTitlebarIcon(this); ViewModel.Initialize(this, DispatcherQueue); RootFrame.Content = new FileSyncListMainPage(ViewModel); 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #(loc.FailureHyperlinkLogText) + + + + + + + diff --git a/scripts/files/logo.png b/scripts/files/logo.png index 7d87306..bdb8b9b 100644 Binary files a/scripts/files/logo.png and b/scripts/files/logo.png differ pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy