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..bd36f38 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -1,4 +1,4 @@ - + WinExe net8.0-windows10.0.19041.0 @@ -16,9 +16,13 @@ preview - DISABLE_XAML_GENERATED_MAIN + DISABLE_XAML_GENERATED_MAIN;DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION Coder Desktop + Coder Desktop + Coder Technologies Inc. + Coder Desktop + © Coder Technologies Inc. coder.ico @@ -31,9 +35,7 @@ - - - + @@ -57,6 +59,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -66,12 +70,17 @@ + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 2c7e87e..f4c05a2 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -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 { - 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,28 @@ public App() loggerConfig.ReadFrom.Configuration(builder.Configuration); }); + services.AddSingleton(); services.AddSingleton(); + 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 +119,13 @@ public App() // FileSyncListMainPage is created by FileSyncListWindow. services.AddTransient(); + services.AddSingleton, SettingsManager>(); + services.AddSingleton(); + // SettingsWindow views and view models + services.AddTransient(); + // SettingsMainPage is created by SettingsWindow. + services.AddTransient(); + // DirectoryPickerWindow views and view models are created by FileSyncListViewModel. // TrayWindow views and view models @@ -90,12 +136,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 +168,87 @@ public async Task ExitApplication() protected override void OnLaunched(LaunchActivatedEventArgs args) { _logger.LogInformation("new instance launched"); - // Start connecting to the manager in the background. - var rpcController = _services.GetRequiredService(); - if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected) - // Passing in a CT with no cancellation is desired here, because - // the named pipe open will block until the pipe comes up. - _logger.LogDebug("reconnecting with VPN service"); - _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t => + + // Prevent the TrayWindow from closing, just hide it. + if (TrayWindow != null) + throw new InvalidOperationException("OnLaunched was called multiple times? TrayWindow is already set"); + TrayWindow = _services.GetRequiredService(); + TrayWindow.Closed += (_, closedArgs) => { - if (t.Exception != null) - { - _logger.LogError(t.Exception, "failed to connect to VPN service"); -#if DEBUG - Debug.WriteLine(t.Exception); - Debugger.Break(); -#endif - } - }); + if (!_handleWindowClosed) return; + closedArgs.Handled = true; + TrayWindow.AppWindow.Hide(); + }; + + _ = InitializeServicesAsync(_appLifetime.ApplicationStopping); + } - // Load the credentials in the background. - var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + /// + /// 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 +263,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 +288,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 +299,40 @@ private static void AddDefaultConfig(IConfigurationBuilder builder) var logPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CoderDesktop", - logFilename); + LogFilename); builder.AddInMemoryCollection(new Dictionary { [MutagenControllerConfigSection + ":MutagenExecutablePath"] = @"C:\mutagen.exe", + ["Serilog:Using:0"] = "Serilog.Sinks.File", - ["Serilog:MinimumLevel"] = "Information", + ["Serilog:MinimumLevel"] = DefaultLogLevel, ["Serilog:Enrich:0"] = "FromLogContext", ["Serilog:WriteTo:0:Name"] = "File", ["Serilog:WriteTo:0:Args:path"] = logPath, ["Serilog:WriteTo:0:Args:outputTemplate"] = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", ["Serilog:WriteTo:0:Args:rollingInterval"] = "Day", + +#if DEBUG + ["Serilog:Using:1"] = "Serilog.Sinks.Debug", + ["Serilog:Enrich:1"] = "FromLogContext", + ["Serilog:WriteTo:1:Name"] = "Debug", + ["Serilog:WriteTo:1:Args:outputTemplate"] = + "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", +#endif }); } + + public void RunInUiThread(DispatcherQueueHandler action) + { + var dispatcherQueue = TrayWindow?.DispatcherQueue; + if (dispatcherQueue is null) + throw new InvalidOperationException("DispatcherQueue is not available"); + if (dispatcherQueue.HasThreadAccess) + { + action(); + return; + } + dispatcherQueue.TryEnqueue(action); + } } diff --git a/App/Assets/changelog.css b/App/Assets/changelog.css new file mode 100644 index 0000000..e3fda84 --- /dev/null +++ b/App/Assets/changelog.css @@ -0,0 +1,1237 @@ +/* + This file was taken from: + https://github.com/sindresorhus/github-markdown-css/blob/bedb4b771f5fa1ae117df597c79993fd1eb4dff0/github-markdown.css + + Licensed under the MIT license. + + Changes: + - Removed @media queries in favor of requiring `[data-theme]` attributes + on the body themselves + - Overrides `--bgColor-default` to transparent +*/ + +.markdown-body { + --base-size-4: 0.25rem; + --base-size-8: 0.5rem; + --base-size-16: 1rem; + --base-size-24: 1.5rem; + --base-size-40: 2.5rem; + --base-text-weight-normal: 400; + --base-text-weight-medium: 500; + --base-text-weight-semibold: 600; + --fontStack-monospace: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + --fgColor-accent: Highlight; + + --bgColor-default: transparent !important; +} +body[data-theme="dark"] .markdown-body { + /* dark */ + color-scheme: dark; + --focus-outlineColor: #1f6feb; + --fgColor-default: #f0f6fc; + --fgColor-muted: #9198a1; + --fgColor-accent: #4493f8; + --fgColor-success: #3fb950; + --fgColor-attention: #d29922; + --fgColor-danger: #f85149; + --fgColor-done: #ab7df8; + --bgColor-default: #0d1117; + --bgColor-muted: #151b23; + --bgColor-neutral-muted: #656c7633; + --bgColor-attention-muted: #bb800926; + --borderColor-default: #3d444d; + --borderColor-muted: #3d444db3; + --borderColor-neutral-muted: #3d444db3; + --borderColor-accent-emphasis: #1f6feb; + --borderColor-success-emphasis: #238636; + --borderColor-attention-emphasis: #9e6a03; + --borderColor-danger-emphasis: #da3633; + --borderColor-done-emphasis: #8957e5; + --color-prettylights-syntax-comment: #9198a1; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #f0f6fc; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-brackethighlighter-angle: #9198a1; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #f0f6fc; + --color-prettylights-syntax-markup-bold: #f0f6fc; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #f0f6fc; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-sublimelinter-gutter-mark: #3d444d; +} +body[data-theme=""] .markdown-body { + /* light */ + color-scheme: light; + --focus-outlineColor: #0969da; + --fgColor-default: #1f2328; + --fgColor-muted: #59636e; + --fgColor-accent: #0969da; + --fgColor-success: #1a7f37; + --fgColor-attention: #9a6700; + --fgColor-danger: #d1242f; + --fgColor-done: #8250df; + --bgColor-default: #ffffff; + --bgColor-muted: #f6f8fa; + --bgColor-neutral-muted: #818b981f; + --bgColor-attention-muted: #fff8c5; + --borderColor-default: #d1d9e0; + --borderColor-muted: #d1d9e0b3; + --borderColor-neutral-muted: #d1d9e0b3; + --borderColor-accent-emphasis: #0969da; + --borderColor-success-emphasis: #1a7f37; + --borderColor-attention-emphasis: #9a6700; + --borderColor-danger-emphasis: #cf222e; + --borderColor-done-emphasis: #8250df; + --color-prettylights-syntax-comment: #59636e; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-prettylights-syntax-entity: #6639ba; + --color-prettylights-syntax-storage-modifier-import: #1f2328; + --color-prettylights-syntax-entity-tag: #0550ae; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-brackethighlighter-angle: #59636e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #1f2328; + --color-prettylights-syntax-markup-bold: #1f2328; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #d1d9e0; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-sublimelinter-gutter-mark: #818b98; +} + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: var(--fgColor-default); + background-color: var(--bgColor-default); + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); +} + +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + color: var(--fgColor-accent); + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid var(--borderColor-muted); +} + +.markdown-body mark { + background-color: var(--bgColor-attention-muted); + color: var(--fgColor-default); +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em var(--base-size-40); +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: 1px solid var(--borderColor-muted); + height: .25em; + padding: 0; + margin: var(--base-size-24) 0; + background-color: var(--borderColor-default); + border: 0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: var(--fgColor-muted); + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + font-variant: tabular-nums; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid var(--focus-outlineColor); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid var(--focus-outlineColor); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: var(--base-size-4); + font: 11px var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + line-height: 10px; + color: var(--fgColor-default); + vertical-align: middle; + background-color: var(--bgColor-muted); + border: solid 1px var(--borderColor-neutral-muted); + border-bottom-color: var(--borderColor-neutral-muted); + border-radius: 6px; + box-shadow: inset 0 -1px 0 var(--borderColor-neutral-muted); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: var(--base-size-24); + margin-bottom: var(--base-size-16); + font-weight: var(--base-text-weight-semibold, 600); + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid var(--borderColor-muted); +} + +.markdown-body h3 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1em; +} + +.markdown-body h5 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .875em; +} + +.markdown-body h6 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .85em; + color: var(--fgColor-muted); +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: var(--fgColor-muted); + border-left: .25em solid var(--borderColor-default); +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.markdown-body .mr-2 { + margin-right: var(--base-size-8, 8px) !important; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body>*:first-child { + margin-top: 0 !important; +} + +.markdown-body>*:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: var(--fgColor-danger); +} + +.markdown-body .anchor { + float: left; + padding-right: var(--base-size-4); + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: var(--base-size-16); +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: var(--fgColor-default); + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div>ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li>p { + margin-top: var(--base-size-16); +} + +.markdown-body li+li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: var(--base-size-16); + font-size: 1em; + font-style: italic; + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dl dd { + padding: 0 var(--base-size-16); + margin-bottom: var(--base-size-16); +} + +.markdown-body table th { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--borderColor-default); +} + +.markdown-body table td>:last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + background-color: var(--bgColor-default); + border-top: 1px solid var(--borderColor-muted); +} + +.markdown-body table tr:nth-child(2n) { + background-color: var(--bgColor-muted); +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame>span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid var(--borderColor-default); +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: var(--fgColor-default); +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right>span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--bgColor-neutral-muted); + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: var(--base-size-16); +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: var(--base-size-16); + overflow: auto; + font-size: 85%; + line-height: 1.45; + color: var(--fgColor-default); + background-color: var(--bgColor-muted); + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px var(--base-size-8) 9px; + text-align: right; + background: var(--bgColor-default); + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: var(--base-text-weight-semibold, 600); + background: var(--bgColor-muted); + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: var(--fgColor-muted); + border-top: 1px solid var(--borderColor-default); +} + +.markdown-body .footnotes ol { + padding-left: var(--base-size-16); +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: var(--base-size-16); + margin-top: var(--base-size-16); +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: calc(var(--base-size-8)*-1); + right: calc(var(--base-size-8)*-1); + bottom: calc(var(--base-size-8)*-1); + left: calc(var(--base-size-24)*-1); + pointer-events: none; + content: ""; + border: 2px solid var(--borderColor-accent-emphasis); + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: var(--fgColor-default); +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body body:has(:modal) { + padding-right: var(--dialog-scrollgutter) !important; +} + +.markdown-body .pl-c { + color: var(--color-prettylights-syntax-comment); +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: var(--color-prettylights-syntax-entity); +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); +} + +.markdown-body .pl-ent { + color: var(--color-prettylights-syntax-entity-tag); +} + +.markdown-body .pl-k { + color: var(--color-prettylights-syntax-keyword); +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: var(--color-prettylights-syntax-variable); +} + +.markdown-body .pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); +} + +.markdown-body .pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); +} + +.markdown-body .pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); +} + +.markdown-body .pl-ml { + color: var(--color-prettylights-syntax-markup-list); +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); +} + +.markdown-body .pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); +} + +.markdown-body .pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); +} + +.markdown-body .pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); +} + +.markdown-body .pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); +} + +.markdown-body .pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); +} + +.markdown-body .pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); +} + +.markdown-body .pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); +} + +.markdown-body .pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); +} + +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), +.markdown-body button:focus:not(:focus-visible), +.markdown-body summary:focus:not(:focus-visible), +.markdown-body a:focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +.markdown-body [tabindex="0"]:focus:not(:focus-visible), +.markdown-body details-dialog:focus:not(:focus-visible) { + outline: none; +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: var(--base-text-weight-normal, 400); + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: var(--base-text-weight-normal, 400); +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: var(--base-size-4); +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; +} + +.markdown-body ul:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body ol:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} + +.markdown-body .markdown-alert { + padding: var(--base-size-8) var(--base-size-16); + margin-bottom: var(--base-size-16); + color: inherit; + border-left: .25em solid var(--borderColor-default); +} + +.markdown-body .markdown-alert>:first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert>:last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert .markdown-alert-title { + display: flex; + font-weight: var(--base-text-weight-medium, 500); + align-items: center; + line-height: 1; +} + +.markdown-body .markdown-alert.markdown-alert-note { + border-left-color: var(--borderColor-accent-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { + color: var(--fgColor-accent); +} + +.markdown-body .markdown-alert.markdown-alert-important { + border-left-color: var(--borderColor-done-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { + color: var(--fgColor-done); +} + +.markdown-body .markdown-alert.markdown-alert-warning { + border-left-color: var(--borderColor-attention-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { + color: var(--fgColor-attention); +} + +.markdown-body .markdown-alert.markdown-alert-tip { + border-left-color: var(--borderColor-success-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { + color: var(--fgColor-success); +} + +.markdown-body .markdown-alert.markdown-alert-caution { + border-left-color: var(--borderColor-danger-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { + color: var(--fgColor-danger); +} + +.markdown-body>*:first-child>.heading-element:first-child { + margin-top: 0 !important; +} + +.markdown-body .highlight pre:has(+.zeroclipboard-container) { + min-height: 52px; +} diff --git a/App/Controls/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..6868ae7 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 @@ -253,6 +260,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 +292,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 +311,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 +322,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 +341,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 DeleteCredentials(string targetName) + public static void WriteDomainCredentials(string domainName, string serverName, string username, string password) + { + var targetName = $"{domainName}/{serverName}"; + var targetInfo = new CREDENTIAL_TARGET_INFORMATIONW { - if (!CredDeleteW(targetName, CredentialTypeGeneric, 0)) + 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"); + + var credentialBlob = Marshal.StringToHGlobalUni(password); + var cred = new CREDENTIALW + { + 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..5ad8e38 --- /dev/null +++ b/App/Services/UserNotifier.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Windows.AppNotifications; +using Microsoft.Windows.AppNotifications.Builder; + +namespace Coder.Desktop.App.Services; + +public interface INotificationHandler +{ + public void HandleNotificationActivation(IDictionary args); +} + +public interface IUserNotifier : INotificationHandler, IAsyncDisposable +{ + public void RegisterHandler(string name, INotificationHandler handler); + public void UnregisterHandler(string name); + + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default); + public Task ShowActionNotification(string title, string message, string handlerName, IDictionary? args = null, CancellationToken ct = default); +} + +public class UserNotifier(ILogger logger, IDispatcherQueueManager dispatcherQueueManager) : IUserNotifier +{ + private const string CoderNotificationHandler = "CoderNotificationHandler"; + + private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default; + + private ConcurrentDictionary Handlers { get; } = new(); + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public void RegisterHandler(string name, INotificationHandler handler) + { + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + if (handler is IUserNotifier) + throw new ArgumentException("Handler cannot be an IUserNotifier", nameof(handler)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name cannot be null or whitespace", nameof(name)); + if (!Handlers.TryAdd(name, handler)) + throw new InvalidOperationException($"A handler with the name '{name}' is already registered."); + } + + public void UnregisterHandler(string name) + { + if (!Handlers.TryRemove(name, out _)) + throw new InvalidOperationException($"No handler with the name '{name}' is registered."); + } + + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default) + { + var builder = new AppNotificationBuilder().AddText(title).AddText(message); + _notificationManager.Show(builder.BuildNotification()); + return Task.CompletedTask; + } + + public Task ShowActionNotification(string title, string message, string handlerName, IDictionary? args = null, CancellationToken ct = default) + { + if (!Handlers.TryGetValue(handlerName, out _)) + throw new InvalidOperationException($"No action handler with the name '{handlerName}' is registered."); + + var builder = new AppNotificationBuilder() + .AddText(title) + .AddText(message) + .AddArgument(CoderNotificationHandler, handlerName); + if (args != null) + foreach (var arg in args) + { + if (arg.Key == CoderNotificationHandler) + continue; + builder.AddArgument(arg.Key, arg.Value); + } + + _notificationManager.Show(builder.BuildNotification()); + return Task.CompletedTask; + } + + public void HandleNotificationActivation(IDictionary args) + { + if (!args.TryGetValue(CoderNotificationHandler, out var handlerName)) + // Not an action notification, ignore + return; + + if (!Handlers.TryGetValue(handlerName, out var handler)) + { + logger.LogWarning("no action handler '{HandlerName}' found for notification activation, ignoring", handlerName); + return; + } + + dispatcherQueueManager.RunInUiThread(() => + { + try + { + handler.HandleNotificationActivation(args); + } + catch (Exception ex) + { + logger.LogWarning(ex, "could not handle activation for notification with handler '{HandlerName}", handlerName); + } + }); + } +} diff --git a/App/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..cd5907b 100644 --- a/App/ViewModels/AgentViewModel.cs +++ b/App/ViewModels/AgentViewModel.cs @@ -1,11 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; 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; 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); + + 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) + { + return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, + expanderHost, id) + { + ConfiguredFqdn = fullyQualifiedDomainName, + ConfiguredHostname = string.Empty, + ConfiguredHostnameSuffix = hostnameSuffix, + ConnectionStatus = connectionStatus, + DashboardBaseUrl = dashboardBaseUrl, + WorkspaceName = workspaceName, + }; + } + + 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, @@ -14,17 +79,358 @@ public enum AgentConnectionStatus Gray, } -public partial class AgentViewModel +public partial class AgentViewModel : ObservableObject, IModelUpdateable { - public required string Hostname { get; set; } + 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))] + 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; + + // 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.Gray) + 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; + + // Apps are not set externally. - public required string HostnameSuffix { get; set; } // including leading dot + 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; + } - public required AgentConnectionStatus ConnectionStatus { get; set; } + partial void OnConnectionStatusChanged(AgentConnectionStatus oldValue, AgentConnectionStatus newValue) + { + if (IsExpanded && newValue is not AgentConnectionStatus.Gray) FetchApps(); + } - public string FullHostname => Hostname + HostnameSuffix; + private void FetchApps() + { + if (FetchingApps) return; + FetchingApps = true; - public required string DashboardUrl { get; set; } + // If the workspace is off, then there's no agent and there's no apps. + if (ConnectionStatus == AgentConnectionStatus.Gray) + { + 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; + } + + FetchingApps = false; + AppFetchErrored = !task.IsCompletedSuccessfully; + if (!task.IsCompletedSuccessfully) + { + _logger.LogWarning(task.Exception, "Could not fetch workspace agent"); + return; + } + + 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; + } + + // 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; + + 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 +439,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..8540453 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -1,10 +1,14 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; using System.Linq; 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,11 +17,15 @@ 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"; @@ -25,13 +33,28 @@ public partial class TrayWindowViewModel : ObservableObject 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 +65,7 @@ public partial class TrayWindowViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowEnableSection))] + [NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))] [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] @@ -50,13 +74,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 bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started; + public int VpnStartProgressValueOrDefault => VpnStartProgressValue ?? 0; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VpnStartProgressMessageOrDefault))] + public partial string? VpnStartProgressMessage { get; set; } = null; + + public string VpnStartProgressMessageOrDefault => + string.IsNullOrEmpty(VpnStartProgressMessage) ? VpnStartupProgress.DefaultStartProgressMessage : VpnStartProgressMessage; + + public bool VpnStartProgressIsIndeterminate => VpnStartProgressValueOrDefault == 0; + + public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Starting and not VpnLifecycle.Started; + + public bool ShowVpnStartProgressSection => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Starting; public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started; @@ -76,14 +111,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 +158,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 +175,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 +222,109 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (string.IsNullOrWhiteSpace(fqdn)) continue; - var fqdnPrefix = fqdn; - var fqdnSuffix = ""; - if (fqdn.Contains('.')) - { - fqdnPrefix = fqdn[..fqdn.LastIndexOf('.')]; - fqdnSuffix = fqdn[fqdn.LastIndexOf('.')..]; - } - var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); + var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) + ? AgentConnectionStatus.Green + : AgentConnectionStatus.Yellow; 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)); } // 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.Gray, + 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 - { - return new Uri(baseUri, $"/@me/{workspaceName}").ToString(); - } - catch + var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Gray); + if (firstOnlineAgent is null) + _hasExpandedAgent = false; + if (!_hasExpandedAgent && firstOnlineAgent is not null) { - 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 +373,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 +395,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 Gray 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..9fd6dd9 --- /dev/null +++ b/App/ViewModels/UpdaterUpdateAvailableViewModel.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using NetSparkleUpdater; +using NetSparkleUpdater.Enums; +using NetSparkleUpdater.Events; +using NetSparkleUpdater.Interfaces; + +namespace Coder.Desktop.App.ViewModels; + +public interface IUpdaterUpdateAvailableViewModelFactory +{ + public UpdaterUpdateAvailableViewModel Create(List updates, ISignatureVerifier? signatureVerifier, string currentVersion, string appName, bool isUpdateAlreadyDownloaded); +} + +public class UpdaterUpdateAvailableViewModelFactory(ILogger childLogger) : IUpdaterUpdateAvailableViewModelFactory +{ + public UpdaterUpdateAvailableViewModel Create(List updates, ISignatureVerifier? signatureVerifier, string currentVersion, string appName, bool isUpdateAlreadyDownloaded) + { + return new UpdaterUpdateAvailableViewModel(childLogger, updates, signatureVerifier, currentVersion, appName, isUpdateAlreadyDownloaded); + } +} + +public partial class UpdaterUpdateAvailableViewModel : ObservableObject +{ + private readonly ILogger _logger; + + // All the unchanging stuff we get from NetSparkle: + public readonly IReadOnlyList Updates; + public readonly ISignatureVerifier? SignatureVerifier; + public readonly string CurrentVersion; + public readonly string AppName; + public readonly bool IsUpdateAlreadyDownloaded; + + // Partial implementation of IUpdateAvailable: + public UpdateAvailableResult Result { get; set; } = UpdateAvailableResult.None; + // We only show the first update. + public AppCastItem CurrentItem => Updates[0]; // always has at least one item + public event UserRespondedToUpdate? UserResponded; + + // Other computed fields based on readonly data: + public bool MissingCriticalUpdate => Updates.Any(u => u.IsCriticalUpdate); + + [ObservableProperty] + public partial bool ReleaseNotesVisible { get; set; } = true; + + [ObservableProperty] + public partial bool RemindMeLaterButtonVisible { get; set; } = true; + + [ObservableProperty] + public partial bool SkipButtonVisible { get; set; } = true; + + public string MainText + { + get + { + var actionText = IsUpdateAlreadyDownloaded ? "install" : "download"; + return $"{AppName} {CurrentItem.Version} is now available (you have {CurrentVersion}). Would you like to {actionText} it now?"; + } + } + + public UpdaterUpdateAvailableViewModel(ILogger logger, List updates, ISignatureVerifier? signatureVerifier, string currentVersion, string appName, bool isUpdateAlreadyDownloaded) + { + if (updates.Count == 0) + throw new InvalidOperationException("No updates available, cannot create UpdaterUpdateAvailableViewModel"); + + _logger = logger; + Updates = updates; + SignatureVerifier = signatureVerifier; + CurrentVersion = currentVersion; + AppName = appName; + IsUpdateAlreadyDownloaded = isUpdateAlreadyDownloaded; + } + + public void HideReleaseNotes() + { + ReleaseNotesVisible = false; + } + + public void HideRemindMeLaterButton() + { + RemindMeLaterButtonVisible = false; + } + + public void HideSkipButton() + { + SkipButtonVisible = false; + } + + public async Task ChangelogHtml(AppCastItem item) + { + const string cssResourceName = "Coder.Desktop.App.Assets.changelog.css"; + const string htmlTemplate = @" + + + + + + + + + +
+ {{CONTENT}} +
+ + +"; + + const string githubMarkdownCssToken = "{{GITHUB_MARKDOWN_CSS}}"; + const string themeToken = "{{THEME}}"; + const string contentToken = "{{CONTENT}}"; + + // We load the CSS from an embedded asset since it's large. + var css = ""; + try + { + await using var stream = typeof(App).Assembly.GetManifestResourceStream(cssResourceName) + ?? throw new FileNotFoundException($"Embedded resource not found: {cssResourceName}"); + using var reader = new StreamReader(stream); + css = await reader.ReadToEndAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "failed to load changelog CSS theme from embedded asset, ignoring"); + } + + // We store the changelog in the description field, rather than using + // the release notes URL to avoid extra requests. + var innerHtml = item.Description; + if (string.IsNullOrWhiteSpace(innerHtml)) + { + innerHtml = "

No release notes available.

"; + } + + // The theme doesn't automatically update. + var currentTheme = Application.Current.RequestedTheme == ApplicationTheme.Dark ? "dark" : "light"; + return htmlTemplate + .Replace(githubMarkdownCssToken, css) + .Replace(themeToken, currentTheme) + .Replace(contentToken, innerHtml); + } + + public async Task Changelog_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not WebView2 webView) + return; + + // Start the engine. + await webView.EnsureCoreWebView2Async(); + + // Disable unwanted features. + var settings = webView.CoreWebView2.Settings; + settings.IsScriptEnabled = false; // disables JS + settings.AreHostObjectsAllowed = false; // disables interaction with app code +#if !DEBUG + settings.AreDefaultContextMenusEnabled = false; // disables right-click + settings.AreDevToolsEnabled = false; +#endif + settings.IsZoomControlEnabled = false; + settings.IsStatusBarEnabled = false; + + // Hijack navigation to prevent links opening in the web view. + webView.CoreWebView2.NavigationStarting += (_, e) => + { + // webView.NavigateToString uses data URIs, so allow those to work. + if (e.Uri.StartsWith("data:text/html", StringComparison.OrdinalIgnoreCase)) + return; + + // Prevent the web view from trying to navigate to it. + e.Cancel = true; + + // Launch HTTP or HTTPS URLs in the default browser. + if (Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri) && uri is { Scheme: "http" or "https" }) + Process.Start(new ProcessStartInfo(e.Uri) { UseShellExecute = true }); + }; + webView.CoreWebView2.NewWindowRequested += (_, e) => + { + // Prevent new windows from being launched (e.g. target="_blank"). + e.Handled = true; + // Launch HTTP or HTTPS URLs in the default browser. + if (Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri) && uri is { Scheme: "http" or "https" }) + Process.Start(new ProcessStartInfo(e.Uri) { UseShellExecute = true }); + }; + + var html = await ChangelogHtml(CurrentItem); + webView.NavigateToString(html); + } + + private void SendResponse(UpdateAvailableResult result) + { + Result = result; + UserResponded?.Invoke(this, new UpdateResponseEventArgs(result, CurrentItem)); + } + + public void SkipButton_Click(object sender, RoutedEventArgs e) + { + if (!SkipButtonVisible || MissingCriticalUpdate) + return; + SendResponse(UpdateAvailableResult.SkipUpdate); + } + + public void RemindMeLaterButton_Click(object sender, RoutedEventArgs e) + { + if (!RemindMeLaterButtonVisible || MissingCriticalUpdate) + return; + SendResponse(UpdateAvailableResult.RemindMeLater); + } + + public void InstallButton_Click(object sender, RoutedEventArgs e) + { + SendResponse(UpdateAvailableResult.InstallUpdate); + } +} diff --git a/App/Views/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) + + + + + + +