diff --git a/.editorconfig b/.editorconfig index d0323ad..cd2fd68 100644 --- a/.editorconfig +++ b/.editorconfig @@ -76,6 +76,10 @@ resharper_web_config_module_not_resolved_highlighting = warning resharper_web_config_type_not_resolved_highlighting = warning resharper_web_config_wrong_module_highlighting = warning -[{*.json,*.jsonc,*.yml,*.yaml,*.proto}] +[{*.json,*.jsonc,*.yml,*.yaml}] indent_style = space indent_size = 2 + +[{*.proto}] +indent_style = tab +indent_size = 1 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5272401 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +MutagenSdk/Proto/**/*.proto linguist-generated=true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b5059b2..ac57947 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,6 +11,7 @@ on: jobs: fmt: runs-on: windows-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -20,12 +21,13 @@ 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 test: runs-on: windows-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -34,13 +36,36 @@ jobs: dotnet-version: 8.0.x cache: true cache-dependency-path: '**/packages.lock.json' + - name: Install Windows App SDK Runtime + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $filename = ".\WindowsAppRuntimeInstall-x64.exe" + $url = "https://download.microsoft.com/download/7a3a6a44-b07e-4ca5-8b63-2de185769dbc/WindowsAppRuntimeInstall-x64.exe" # 1.6.5 (1.6.250205002) + & curl.exe --progress-bar --show-error --fail --location --output $filename $url + if ($LASTEXITCODE -ne 0) { throw "Failed to download Windows App SDK" } + + $process = Start-Process -FilePath $filename -ArgumentList "--quiet --force" -NoNewWindow -Wait -PassThru + if ($process.ExitCode -ne 0) { throw "Failed to install Windows App SDK: exit code is $($process.ExitCode)" } - name: dotnet restore run: dotnet restore --locked-mode - name: dotnet test - run: dotnet test --no-restore + run: dotnet test --no-restore --blame-hang --blame-hang-dump-type full --blame-hang-timeout 2m -p:Platform=x64 + - name: Upload test binaries and TestResults + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results + retention-days: 1 + path: | + ./**/bin + ./**/obj + ./**/TestResults build: runs-on: windows-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -50,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 99ed8c1..55f6da6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,7 +17,12 @@ 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: - uses: actions/checkout@v4 @@ -61,10 +66,13 @@ jobs: id: gcloud_auth uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 with: - workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }} - service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }} + 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: | @@ -116,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 cd1df42..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 @@ -30,10 +34,8 @@ - - - - + + @@ -56,17 +58,32 @@ + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + + + + + + + diff --git a/App/App.xaml b/App/App.xaml index a5b6d8b..c614e0e 100644 --- a/App/App.xaml +++ b/App/App.xaml @@ -3,12 +3,18 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:converters="using:Coder.Desktop.App.Converters"> + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index af4217e..f4c05a2 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,66 +1,338 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; using System.Threading.Tasks; +using Windows.ApplicationModel.Activation; +using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; using Coder.Desktop.App.ViewModels; 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 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 DefaultLogLevel = "Information"; +#else + private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugApp"; + private const string LogFilename = "debug-app.log"; + private const string DefaultLogLevel = "Debug"; +#endif + + // HACK: This is exposed for dispatcher queue access. The notifier uses + // this to ensure action callbacks run in the UI thread (as + // activation events aren't in the main thread). + public TrayWindow? TrayWindow; + private readonly IServiceProvider _services; + private readonly ILogger _logger; + private readonly IUriHandler _uriHandler; + private readonly IUserNotifier _userNotifier; + private readonly ISettingsManager _settingsManager; + private readonly IHostApplicationLifetime _appLifetime; private bool _handleWindowClosed = true; public App() { - var services = new ServiceCollection(); + var builder = Host.CreateApplicationBuilder(); + var configBuilder = builder.Configuration as IConfigurationBuilder; + + // Add config in increasing order of precedence: first builtin defaults, then HKLM, finally HKCU + // so that the user's settings in the registry take precedence. + AddDefaultConfig(configBuilder); + configBuilder.Add( + new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey)); + configBuilder.Add( + 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; + + // Logging + builder.Services.AddSerilog((_, loggerConfig) => + { + 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(); services.AddTransient(); + // FileSyncListWindow views and view models + services.AddTransient(); + // 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 + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); _services = services.BuildServiceProvider(); + _logger = _services.GetRequiredService>(); + _uriHandler = _services.GetRequiredService(); + _userNotifier = _services.GetRequiredService(); + _settingsManager = _services.GetRequiredService>(); + _appLifetime = _services.GetRequiredService(); InitializeComponent(); } public async Task ExitApplication() { + _logger.LogDebug("exiting app"); _handleWindowClosed = false; Exit(); - var rpcManager = _services.GetRequiredService(); + var syncController = _services.GetRequiredService(); + await syncController.DisposeAsync(); + var rpcController = _services.GetRequiredService(); // TODO: send a StopRequest if we're connected??? - await rpcManager.DisposeAsync(); + await rpcController.DisposeAsync(); Environment.Exit(0); } protected override void OnLaunched(LaunchActivatedEventArgs args) { - var trayWindow = _services.GetRequiredService(); + _logger.LogInformation("new instance launched"); // Prevent the TrayWindow from closing, just hide it. - trayWindow.Closed += (sender, args) => + if (TrayWindow != null) + throw new InvalidOperationException("OnLaunched was called multiple times? TrayWindow is already set"); + TrayWindow = _services.GetRequiredService(); + TrayWindow.Closed += (_, closedArgs) => { if (!_handleWindowClosed) return; - args.Handled = true; - trayWindow.AppWindow.Hide(); + closedArgs.Handled = true; + TrayWindow.AppWindow.Hide(); }; + + _ = InitializeServicesAsync(_appLifetime.ApplicationStopping); + } + + /// + /// Loads stored VPN credentials, reconnects the RPC controller, + /// and (optionally) starts the VPN tunnel on application launch. + /// + private async Task InitializeServicesAsync(CancellationToken cancellationToken = default) + { + var credentialManager = _services.GetRequiredService(); + var rpcController = _services.GetRequiredService(); + + using var credsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + credsCts.CancelAfter(TimeSpan.FromSeconds(15)); + + var loadCredsTask = credentialManager.LoadCredentials(credsCts.Token); + var reconnectTask = rpcController.Reconnect(cancellationToken); + var settingsTask = _settingsManager.Read(cancellationToken); + + var dependenciesLoaded = true; + + try + { + await Task.WhenAll(loadCredsTask, reconnectTask, settingsTask); + } + catch (Exception) + { + if (loadCredsTask.IsFaulted) + _logger.LogError(loadCredsTask.Exception!.GetBaseException(), + "Failed to load credentials"); + + if (reconnectTask.IsFaulted) + _logger.LogError(reconnectTask.Exception!.GetBaseException(), + "Failed to connect to VPN service"); + + if (settingsTask.IsFaulted) + _logger.LogError(settingsTask.Exception!.GetBaseException(), + "Failed to fetch Coder Connect settings"); + + // Don't attempt to connect if we failed to load credentials or reconnect. + // This will prevent the app from trying to connect to the VPN service. + dependenciesLoaded = false; + } + + var attemptCoderConnection = settingsTask.Result?.ConnectOnLaunch ?? false; + if (dependenciesLoaded && attemptCoderConnection) + { + try + { + await rpcController.StartVpn(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect on launch"); + } + } + + // Initialize file sync. + using var syncSessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + syncSessionCts.CancelAfter(TimeSpan.FromSeconds(10)); + var syncSessionController = _services.GetRequiredService(); + try + { + await syncSessionController.RefreshState(syncSessionCts.Token); + } + catch (Exception ex) + { + _logger.LogError($"Failed to refresh sync session state {ex.Message}", ex); + } + } + + public void OnActivated(object? sender, AppActivationArguments args) + { + switch (args.Kind) + { + case ExtendedActivationKind.Protocol: + var protoArgs = args.Data as IProtocolActivatedEventArgs; + if (protoArgs == null) + { + _logger.LogWarning("URI activation with null data"); + return; + } + + // 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: + _logger.LogWarning("activation for {kind}, which is unhandled", args.Kind); + break; + } + } + + public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args) + { + _logger.LogInformation("handled notification activation: {Argument}", args.Argument); + _userNotifier.HandleNotificationActivation(args.Arguments); + } + + private static void AddDefaultConfig(IConfigurationBuilder builder) + { + var logPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CoderDesktop", + LogFilename); + builder.AddInMemoryCollection(new Dictionary + { + [MutagenControllerConfigSection + ":MutagenExecutablePath"] = @"C:\mutagen.exe", + + ["Serilog:Using:0"] = "Serilog.Sinks.File", + ["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/SizedFrame.cs b/App/Controls/SizedFrame.cs new file mode 100644 index 0000000..bd2462b --- /dev/null +++ b/App/Controls/SizedFrame.cs @@ -0,0 +1,67 @@ +using System; +using Windows.Foundation; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Controls; + +public class SizedFrameEventArgs : EventArgs +{ + public Size NewSize { get; init; } +} + +/// +/// SizedFrame extends Frame by adding a SizeChanged event, which will be triggered when: +/// - The contained Page's content's size changes +/// - We switch to a different page. +/// Sadly this is necessary because Window.Content.SizeChanged doesn't trigger when the Page's content changes. +/// +public class SizedFrame : Frame +{ + public delegate void SizeChangeDelegate(object sender, SizedFrameEventArgs e); + + public new event SizeChangeDelegate? SizeChanged; + + private Size _lastSize; + + public void SetPage(Page page) + { + if (ReferenceEquals(page, Content)) return; + + // Set the new event listener. + if (page.Content is not FrameworkElement newElement) + throw new Exception("Failed to get Page.Content as FrameworkElement on SizedFrame navigation"); + newElement.SizeChanged += Content_SizeChanged; + + // Unset the previous event listener. + if (Content is Page { Content: FrameworkElement oldElement }) + oldElement.SizeChanged -= Content_SizeChanged; + + // We don't use RootFrame.Navigate here because it doesn't let you + // instantiate the page yourself. We also don't need forwards/backwards + // capabilities. + Content = page; + + // Fire an event. + Content_SizeChanged(newElement, null); + } + + public Size GetContentSize() + { + if (Content is not Page { Content: FrameworkElement frameworkElement }) + throw new Exception("Failed to get Content as FrameworkElement for SizedFrame"); + + frameworkElement.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + return new Size(frameworkElement.ActualWidth, frameworkElement.ActualHeight); + } + + private void Content_SizeChanged(object sender, SizeChangedEventArgs? _) + { + var size = GetContentSize(); + if (size == _lastSize) return; + _lastSize = size; + + var args = new SizedFrameEventArgs { NewSize = size }; + SizeChanged?.Invoke(this, args); + } +} 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/AgentStatusToColorConverter.cs b/App/Converters/AgentStatusToColorConverter.cs deleted file mode 100644 index 25f1f66..0000000 --- a/App/Converters/AgentStatusToColorConverter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using Windows.UI; -using Coder.Desktop.App.ViewModels; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Media; - -namespace Coder.Desktop.App.Converters; - -public class AgentStatusToColorConverter : IValueConverter -{ - private static readonly SolidColorBrush Green = new(Color.FromArgb(255, 52, 199, 89)); - private static readonly SolidColorBrush Yellow = new(Color.FromArgb(255, 204, 1, 0)); - private static readonly SolidColorBrush Red = new(Color.FromArgb(255, 255, 59, 48)); - private static readonly SolidColorBrush Gray = new(Color.FromArgb(255, 142, 142, 147)); - - public object Convert(object value, Type targetType, object parameter, string language) - { - if (value is not AgentConnectionStatus status) return Gray; - - return status switch - { - AgentConnectionStatus.Green => Green, - AgentConnectionStatus.Yellow => Yellow, - AgentConnectionStatus.Red => Red, - _ => Gray, - }; - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } -} diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs new file mode 100644 index 0000000..ec586d0 --- /dev/null +++ b/App/Converters/DependencyObjectSelector.cs @@ -0,0 +1,205 @@ +using System; +using System.Linq; +using Windows.Foundation.Collections; +using Windows.UI.Xaml.Markup; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace Coder.Desktop.App.Converters; + +// This file uses manual DependencyProperty properties rather than +// DependencyPropertyGenerator since it doesn't seem to work properly with +// generics. + +/// +/// An item in a DependencyObjectSelector. Each item has a key and a value. +/// The default item in a DependencyObjectSelector will be the only item +/// with a null key. +/// +/// Key type +/// Value type +public class DependencyObjectSelectorItem : DependencyObject + where TK : IEquatable +{ + public static readonly DependencyProperty KeyProperty = + DependencyProperty.Register(nameof(Key), + typeof(TK?), + typeof(DependencyObjectSelectorItem), + new PropertyMetadata(null)); + + public static readonly DependencyProperty ValueProperty = + DependencyProperty.Register(nameof(Value), + typeof(TV?), + typeof(DependencyObjectSelectorItem), + new PropertyMetadata(null)); + + public TK? Key + { + get => (TK?)GetValue(KeyProperty); + set => SetValue(KeyProperty, value); + } + + public TV? Value + { + get => (TV?)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } +} + +/// +/// Allows selecting between multiple value references based on a selected +/// key. This allows for dynamic mapping of model values to other objects. +/// The main use case is for selecting between other bound values, which +/// you cannot do with a simple ValueConverter. +/// +/// Key type +/// Value type +[ContentProperty(Name = nameof(References))] +public class DependencyObjectSelector : DependencyObject + where TK : IEquatable +{ + public static readonly DependencyProperty ReferencesProperty = + DependencyProperty.Register(nameof(References), + typeof(DependencyObjectCollection), + typeof(DependencyObjectSelector), + new PropertyMetadata(null, ReferencesPropertyChanged)); + + public static readonly DependencyProperty SelectedKeyProperty = + DependencyProperty.Register(nameof(SelectedKey), + typeof(TK?), + typeof(DependencyObjectSelector), + new PropertyMetadata(null, SelectedKeyPropertyChanged)); + + public static readonly DependencyProperty SelectedObjectProperty = + DependencyProperty.Register(nameof(SelectedObject), + typeof(TV?), + typeof(DependencyObjectSelector), + new PropertyMetadata(null)); + + public DependencyObjectCollection? References + { + get => (DependencyObjectCollection?)GetValue(ReferencesProperty); + set + { + // Ensure unique keys and that the values are DependencyObjectSelectorItem. + if (value != null) + { + var items = value.OfType>().ToArray(); + var keys = items.Select(i => i.Key).Distinct().ToArray(); + if (keys.Length != value.Count) + throw new ArgumentException("ObservableCollection Keys must be unique."); + } + + SetValue(ReferencesProperty, value); + } + } + + /// + /// The key of the selected item. This should be bound to a property on + /// the model. + /// + public TK? SelectedKey + { + get => (TK?)GetValue(SelectedKeyProperty); + set => SetValue(SelectedKeyProperty, value); + } + + /// + /// The selected object. This can be read from to get the matching + /// object for the selected key. If the selected key doesn't match any + /// object, this will be the value of the null key. If there is no null + /// key, this will be null. + /// + public TV? SelectedObject + { + get => (TV?)GetValue(SelectedObjectProperty); + set => SetValue(SelectedObjectProperty, value); + } + + public DependencyObjectSelector() + { + References = []; + } + + private void UpdateSelectedObject() + { + if (References != null) + { + // Look for a matching item a matching key, or fallback to the null + // key. + var references = References.OfType>().ToArray(); + var item = references + .FirstOrDefault(i => + (i.Key == null && SelectedKey == null) || + (i.Key != null && SelectedKey != null && i.Key!.Equals(SelectedKey!))) + ?? references.FirstOrDefault(i => i.Key == null); + if (item is not null) + { + // Bind the SelectedObject property to the reference's Value. + // If the underlying Value changes, it will propagate to the + // SelectedObject. + BindingOperations.SetBinding + ( + this, + SelectedObjectProperty, + new Binding + { + Source = item, + Path = new PropertyPath(nameof(DependencyObjectSelectorItem.Value)), + } + ); + return; + } + } + + 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) + { + var self = obj as DependencyObjectSelector; + if (self == null) return; + var oldValue = args.OldValue as DependencyObjectCollection; + if (oldValue != null) + 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(); + } + + // Called when SelectedKey changes. + private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + var self = obj as DependencyObjectSelector; + self?.UpdateSelectedObject(); + } +} + +public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem; + +public sealed class StringToBrushSelector : DependencyObjectSelector; + +public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem; + +public sealed class StringToStringSelector : DependencyObjectSelector; diff --git a/App/Converters/FriendlyByteConverter.cs b/App/Converters/FriendlyByteConverter.cs new file mode 100644 index 0000000..c2bce4e --- /dev/null +++ b/App/Converters/FriendlyByteConverter.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.UI.Xaml.Data; + +namespace Coder.Desktop.App.Converters; + +public class FriendlyByteConverter : IValueConverter +{ + private static readonly string[] Suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + + public object Convert(object value, Type targetType, object parameter, string language) + { + switch (value) + { + case int i: + if (i < 0) i = 0; + return FriendlyBytes((ulong)i); + case uint ui: + return FriendlyBytes(ui); + case long l: + if (l < 0) l = 0; + return FriendlyBytes((ulong)l); + case ulong ul: + return FriendlyBytes(ul); + default: + return FriendlyBytes(0); + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + + public static string FriendlyBytes(ulong bytes) + { + if (bytes == 0) + return $"0 {Suffixes[0]}"; + + var place = System.Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024))); + var num = Math.Round(bytes / Math.Pow(1024, place), 1); + return $"{num} {Suffixes[place]}"; + } +} diff --git a/App/Converters/InverseBoolConverter.cs b/App/Converters/InverseBoolConverter.cs new file mode 100644 index 0000000..927b420 --- /dev/null +++ b/App/Converters/InverseBoolConverter.cs @@ -0,0 +1,17 @@ +using System; +using Microsoft.UI.Xaml.Data; + +namespace Coder.Desktop.App.Converters; + +public class InverseBoolConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + return value is false; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/App/Converters/VpnLifecycleToVisibilityConverter.cs b/App/Converters/VpnLifecycleToVisibilityConverter.cs deleted file mode 100644 index bf83bea..0000000 --- a/App/Converters/VpnLifecycleToVisibilityConverter.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Data; - -namespace Coder.Desktop.App.Converters; - -public partial class VpnLifecycleToVisibilityConverter : VpnLifecycleToBoolConverter, IValueConverter -{ - public new object Convert(object value, Type targetType, object parameter, string language) - { - var boolValue = base.Convert(value, targetType, parameter, language); - return boolValue is true ? Visibility.Visible : Visibility.Collapsed; - } -} diff --git a/App/Models/CredentialModel.cs b/App/Models/CredentialModel.cs index 5388722..b38bbba 100644 --- a/App/Models/CredentialModel.cs +++ b/App/Models/CredentialModel.cs @@ -1,17 +1,28 @@ +using System; +using Coder.Desktop.CoderSdk.Coder; + namespace Coder.Desktop.App.Models; 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, + + // 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; set; } = CredentialState.Invalid; + public CredentialState State { get; init; } = CredentialState.Unknown; - public string? CoderUrl { get; set; } - public string? ApiToken { get; set; } + public Uri? CoderUrl { get; init; } + public string? ApiToken { get; init; } + + public string? Username { get; init; } public CredentialModel Clone() { @@ -20,6 +31,17 @@ public CredentialModel Clone() State = State, CoderUrl = CoderUrl, ApiToken = ApiToken, + 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 dacef38..08d2303 100644 --- a/App/Models/RpcModel.cs +++ b/App/Models/RpcModel.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; -using System.Linq; +using System.Diagnostics; +using Coder.Desktop.App.Converters; using Coder.Desktop.Vpn.Proto; namespace Coder.Desktop.App.Models; @@ -20,15 +22,172 @@ 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 List Workspaces { get; set; } = []; + public IReadOnlyList Workspaces { get; set; } = []; - public List Agents { get; set; } = []; + public IReadOnlyList Agents { get; set; } = []; public RpcModel Clone() { @@ -36,8 +195,9 @@ public RpcModel Clone() { RpcLifecycle = RpcLifecycle, VpnLifecycle = VpnLifecycle, - Workspaces = Workspaces.ToList(), - Agents = Agents.ToList(), + 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/Models/SyncSessionControllerStateModel.cs b/App/Models/SyncSessionControllerStateModel.cs new file mode 100644 index 0000000..524a858 --- /dev/null +++ b/App/Models/SyncSessionControllerStateModel.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace Coder.Desktop.App.Models; + +public enum SyncSessionControllerLifecycle +{ + // Uninitialized means that the daemon has not been started yet. This can + // be resolved by calling RefreshState (or any other RPC method + // successfully). + Uninitialized, + + // Stopped means that the daemon is not running. This could be because: + // - It was never started (pre-Initialize) + // - It was stopped due to no sync sessions (post-Initialize, post-operation) + // - The last start attempt failed (DaemonError will be set) + // - The last daemon process crashed (DaemonError will be set) + Stopped, + + // Running is the normal state where the daemon is running and managing + // sync sessions. This is only set after a successful start (including + // being able to connect to the daemon). + Running, +} + +public class SyncSessionControllerStateModel +{ + public SyncSessionControllerLifecycle Lifecycle { get; init; } = SyncSessionControllerLifecycle.Stopped; + + /// + /// May be set when Lifecycle is Stopped to signify that the daemon failed + /// to start or unexpectedly crashed. + /// + public string? DaemonError { get; init; } + + public required string DaemonLogFilePath { get; init; } + + /// + /// This contains the last known state of all sync sessions. Sync sessions + /// are periodically refreshed if the daemon is running. This list is + /// sorted by creation time. + /// + public IReadOnlyList SyncSessions { get; init; } = []; +} diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs new file mode 100644 index 0000000..46137f5 --- /dev/null +++ b/App/Models/SyncSessionModel.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Coder.Desktop.App.Converters; +using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core; +using Coder.Desktop.MutagenSdk.Proto.Url; + +namespace Coder.Desktop.App.Models; + +// This is a much slimmer enum than the original enum from Mutagen and only +// contains the overarching states that we care about from a code perspective. +// We still store the original state in the model for rendering purposes. +public enum SyncSessionStatusCategory +{ + Unknown, + Paused, + + // Halted is a combination of Error and Paused. If the session + // automatically pauses due to a safety check, we want to show it as an + // error, but also show that it can be resumed. + Halted, + Error, + + // If there are any conflicts, the state will be set to Conflicts, + // overriding Working and Ok. + Conflicts, + Working, + Ok, +} + +public sealed class SyncSessionModelEndpointSize +{ + public ulong SizeBytes { get; init; } + public ulong FileCount { get; init; } + public ulong DirCount { get; init; } + public ulong SymlinkCount { get; init; } + + public string Description(string linePrefix = "") + { + var str = + $"{linePrefix}{FriendlyByteConverter.FriendlyBytes(SizeBytes)}\n" + + $"{linePrefix}{FileCount:N0} files\n" + + $"{linePrefix}{DirCount:N0} directories"; + if (SymlinkCount > 0) str += $"\n{linePrefix} {SymlinkCount:N0} symlinks"; + + return str; + } +} + +public class SyncSessionModel +{ + public readonly string Identifier; + public readonly DateTime CreatedAt; + + public readonly string AlphaName; + public readonly string AlphaPath; + public readonly string BetaName; + public readonly string BetaPath; + + public readonly SyncSessionStatusCategory StatusCategory; + public readonly string StatusString; + public readonly string StatusDescription; + + public readonly SyncSessionModelEndpointSize AlphaSize; + public readonly SyncSessionModelEndpointSize BetaSize; + + public readonly IReadOnlyList Conflicts; // Conflict descriptions + public readonly ulong OmittedConflicts; + public readonly IReadOnlyList Errors; + + // If Paused is true, the session can be resumed. If false, the session can + // be paused. + public bool Paused => StatusCategory is SyncSessionStatusCategory.Paused or SyncSessionStatusCategory.Halted; + + public string StatusDetails + { + get + { + var str = StatusString; + if (StatusCategory.ToString() != StatusString) str += $" ({StatusCategory})"; + str += $"\n\n{StatusDescription}"; + foreach (var err in Errors) str += $"\n\n-----\n\n{err}"; + foreach (var conflict in Conflicts) str += $"\n\n-----\n\n{conflict}"; + if (OmittedConflicts > 0) str += $"\n\n-----\n\n{OmittedConflicts:N0} conflicts omitted"; + return str; + } + } + + public string SizeDetails + { + get + { + var str = "Alpha:\n" + AlphaSize.Description(" ") + "\n\n" + + "Remote:\n" + BetaSize.Description(" "); + return str; + } + } + + public SyncSessionModel(State state) + { + Identifier = state.Session.Identifier; + CreatedAt = state.Session.CreationTime.ToDateTime(); + + (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha); + (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta); + + switch (state.Status) + { + case Status.Disconnected: + StatusCategory = SyncSessionStatusCategory.Error; + StatusString = "Disconnected"; + StatusDescription = + "The session is unpaused but not currently connected or connecting to either endpoint."; + break; + case Status.HaltedOnRootEmptied: + StatusCategory = SyncSessionStatusCategory.Halted; + StatusString = "Halted on root emptied"; + StatusDescription = "The session is halted due to the root emptying safety check."; + break; + case Status.HaltedOnRootDeletion: + StatusCategory = SyncSessionStatusCategory.Halted; + StatusString = "Halted on root deletion"; + StatusDescription = "The session is halted due to the root deletion safety check."; + break; + case Status.HaltedOnRootTypeChange: + StatusCategory = SyncSessionStatusCategory.Halted; + StatusString = "Halted on root type change"; + StatusDescription = "The session is halted due to the root type change safety check."; + break; + case Status.ConnectingAlpha: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Connecting (alpha)"; + StatusDescription = "The session is attempting to connect to the alpha endpoint."; + break; + case Status.ConnectingBeta: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Connecting (beta)"; + StatusDescription = "The session is attempting to connect to the beta endpoint."; + break; + case Status.Watching: + StatusCategory = SyncSessionStatusCategory.Ok; + StatusString = "Watching"; + StatusDescription = "The session is watching for filesystem changes."; + break; + case Status.Scanning: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Scanning"; + StatusDescription = "The session is scanning the filesystem on each endpoint."; + break; + case Status.WaitingForRescan: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Waiting for rescan"; + StatusDescription = + "The session is waiting to retry scanning after an error during the previous scanning operation."; + break; + case Status.Reconciling: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Reconciling"; + StatusDescription = "The session is performing reconciliation."; + break; + case Status.StagingAlpha: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Staging (alpha)"; + StatusDescription = "The session is staging files on alpha."; + break; + case Status.StagingBeta: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Staging (beta)"; + StatusDescription = "The session is staging files on beta."; + break; + case Status.Transitioning: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Transitioning"; + StatusDescription = "The session is performing transition operations on each endpoint."; + break; + case Status.Saving: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Saving"; + StatusDescription = "The session is recording synchronization history to disk."; + break; + default: + StatusCategory = SyncSessionStatusCategory.Unknown; + StatusString = state.Status.ToString(); + StatusDescription = "Unknown status message."; + break; + } + + // If the session is paused, override all other statuses except Halted. + if (state.Session.Paused && StatusCategory is not SyncSessionStatusCategory.Halted) + { + StatusCategory = SyncSessionStatusCategory.Paused; + StatusString = "Paused"; + StatusDescription = "The session is paused."; + } + + // If there are any conflicts, override Working and Ok. + if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts) + { + StatusCategory = SyncSessionStatusCategory.Conflicts; + StatusString = "Conflicts"; + StatusDescription = "The session has conflicts that need to be resolved."; + } + + Conflicts = state.Conflicts.Select(ConflictToString).ToList(); + OmittedConflicts = state.ExcludedConflicts; + + AlphaSize = new SyncSessionModelEndpointSize + { + SizeBytes = state.AlphaState.TotalFileSize, + FileCount = state.AlphaState.Files, + DirCount = state.AlphaState.Directories, + SymlinkCount = state.AlphaState.SymbolicLinks, + }; + BetaSize = new SyncSessionModelEndpointSize + { + SizeBytes = state.BetaState.TotalFileSize, + FileCount = state.BetaState.Files, + DirCount = state.BetaState.Directories, + SymlinkCount = state.BetaState.SymbolicLinks, + }; + + List errors = []; + if (!string.IsNullOrWhiteSpace(state.LastError)) errors.Add($"Last error:\n {state.LastError}"); + // TODO: scan problems + transition problems + omissions should probably be fields + foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Alpha scan problem: {scanProblem}"); + if (state.AlphaState.ExcludedScanProblems > 0) + errors.Add($"Alpha scan problems omitted: {state.AlphaState.ExcludedScanProblems}"); + foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Beta scan problem: {scanProblem}"); + if (state.BetaState.ExcludedScanProblems > 0) + errors.Add($"Beta scan problems omitted: {state.BetaState.ExcludedScanProblems}"); + foreach (var transitionProblem in state.AlphaState.TransitionProblems) + errors.Add($"Alpha transition problem: {transitionProblem}"); + if (state.AlphaState.ExcludedTransitionProblems > 0) + errors.Add($"Alpha transition problems omitted: {state.AlphaState.ExcludedTransitionProblems}"); + foreach (var transitionProblem in state.AlphaState.TransitionProblems) + errors.Add($"Beta transition problem: {transitionProblem}"); + if (state.BetaState.ExcludedTransitionProblems > 0) + errors.Add($"Beta transition problems omitted: {state.BetaState.ExcludedTransitionProblems}"); + Errors = errors; + } + + private static (string, string) NameAndPathFromUrl(URL url) + { + var name = "Local"; + var path = !string.IsNullOrWhiteSpace(url.Path) ? url.Path : "Unknown"; + + if (url.Protocol is not Protocol.Local) + name = !string.IsNullOrWhiteSpace(url.Host) ? url.Host : "Unknown"; + if (string.IsNullOrWhiteSpace(url.Host)) name = url.Host; + + return (name, path); + } + + private static string ConflictToString(Conflict conflict) + { + string? friendlyProblem = null; + if (conflict.AlphaChanges.Count == 1 && conflict.BetaChanges.Count == 1 && + conflict.AlphaChanges[0].Old == null && + conflict.BetaChanges[0].Old == null && + conflict.AlphaChanges[0].New != null && + conflict.BetaChanges[0].New != null) + friendlyProblem = + "An entry was created on both endpoints and they do not match. You can resolve this conflict by deleting one of the entries on either side."; + + var str = $"Conflict at path '{conflict.Root}':"; + foreach (var change in conflict.AlphaChanges) + str += $"\n (alpha) {ChangeToString(change)}"; + foreach (var change in conflict.BetaChanges) + str += $"\n (beta) {ChangeToString(change)}"; + if (friendlyProblem != null) + str += $"\n\n{friendlyProblem}"; + + return str; + } + + private static string ChangeToString(Change change) + { + return $"{change.Path} ({EntryToString(change.Old)} -> {EntryToString(change.New)})"; + } + + private static string EntryToString(Entry? entry) + { + if (entry == null) return ""; + var str = entry.Kind.ToString(); + switch (entry.Kind) + { + case EntryKind.Directory: + str += $" ({entry.Contents.Count} entries)"; + break; + case EntryKind.File: + var digest = BitConverter.ToString(entry.Digest.ToByteArray()).Replace("-", "").ToLower(); + str += $" ({digest}, executable: {entry.Executable})"; + break; + case EntryKind.SymbolicLink: + str += $" (target: {entry.Target})"; + break; + case EntryKind.Problematic: + str += $" ({entry.Problem})"; + break; + } + + return str; + } +} diff --git a/App/Package.appxmanifest b/App/Package.appxmanifest deleted file mode 100644 index e3ad480..0000000 --- a/App/Package.appxmanifest +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - Coder Desktop (Package) - Coder Technologies Inc. - Images\StoreLogo.png - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/App/Program.cs b/App/Program.cs index 2918caa..bf4f16e 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -1,9 +1,11 @@ using System; +using System.Reflection; using System.Runtime.InteropServices; using System.Threading; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; using WinRT; namespace Coder.Desktop.App; @@ -26,7 +28,23 @@ private static void Main(string[] args) try { ComWrappersSupport.InitializeComWrappers(); - if (!CheckSingleInstance()) return; + var mainInstance = GetMainInstance(); + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); + if (!mainInstance.IsCurrent) + { + mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait(); + return; + } + + // Register for URI handling (known as "protocol activation") +#if DEBUG + const string scheme = "coder-debug"; +#else + const string scheme = "coder"; +#endif + var thisBin = Assembly.GetExecutingAssembly().Location; + ActivationRegistrationManager.RegisterForProtocolActivation(scheme, thisBin + ",1", "Coder Desktop", ""); + Application.Start(p => { var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); @@ -38,6 +56,16 @@ private static void Main(string[] args) e.Handled = true; ShowExceptionAndCrash(e.Exception); }; + + // 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) @@ -46,8 +74,7 @@ private static void Main(string[] args) } } - [STAThread] - private static bool CheckSingleInstance() + private static AppInstance GetMainInstance() { #if !DEBUG const string appInstanceName = "Coder.Desktop.App"; @@ -55,11 +82,9 @@ private static bool CheckSingleInstance() const string appInstanceName = "Coder.Desktop.App.Debug"; #endif - var instance = AppInstance.FindOrRegisterForKey(appInstanceName); - return instance.IsCurrent; + return AppInstance.FindOrRegisterForKey(appInstanceName); } - [STAThread] private static void ShowExceptionAndCrash(Exception e) { const string title = "Coder Desktop Fatal Error"; diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index a3456b7..6868ae7 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -6,8 +6,9 @@ using System.Threading; using System.Threading.Tasks; using Coder.Desktop.App.Models; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Utilities; -using CoderSdk; namespace Coder.Desktop.App.Services; @@ -18,119 +19,311 @@ public class RawCredentials } [JsonSerializable(typeof(RawCredentials))] -public partial class RawCredentialsJsonContext : JsonSerializerContext -{ -} +public partial class RawCredentialsJsonContext : JsonSerializerContext; -public interface ICredentialManager +public interface ICredentialManager : ICoderApiClientCredentialProvider { public event EventHandler CredentialsChanged; - public CredentialModel GetCredentials(); + /// + /// Returns cached credentials or an invalid credential model if none are cached. It's preferable to use + /// LoadCredentials if you are operating in an async context. + /// + public CredentialModel GetCachedCredentials(); + + /// + /// Get any sign-in URL. The returned value is not parsed to check if it's a valid URI. + /// + public Task GetSignInUri(); + + /// + /// Returns cached credentials or loads/verifies them from storage if not cached. + /// + public Task LoadCredentials(CancellationToken ct = default); public Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default); - public void ClearCredentials(); + public Task ClearCredentials(CancellationToken ct = default); } -public class CredentialManager : ICredentialManager +public interface ICredentialBackend { - private const string CredentialsTargetName = "Coder.Desktop.App.Credentials"; + public Task ReadCredentials(CancellationToken ct = default); + public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default); + public Task DeleteCredentials(CancellationToken ct = default); +} - private readonly RaiiSemaphoreSlim _lock = new(1, 1); - private CredentialModel? _latestCredentials; +/// +/// Implements ICredentialManager using an ICredentialBackend to store +/// credentials. +/// +public class CredentialManager : ICredentialManager +{ + 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 + // writes to _latestCredentials. + private readonly RaiiSemaphoreSlim _opLock = new(1, 1); + + // _inFlightLoad and _loadCts are set at the beginning of a LoadCredentials + // call. + private Task? _inFlightLoad; + private CancellationTokenSource? _loadCts; + + // Reading and writing a reference in C# is always atomic, so this doesn't + // need to be protected on reads with a lock in GetCachedCredentials. + // + // The volatile keyword disables optimizations on reads/writes which helps + // other threads see the new value quickly (no guarantee that it's + // immediate). + private volatile CredentialModel? _latestCredentials; + + public CredentialManager(ICredentialBackend backend, ICoderApiClientFactory coderApiClientFactory) + { + Backend = backend; + CoderApiClientFactory = coderApiClientFactory; + } public event EventHandler? CredentialsChanged; - public CredentialModel GetCredentials() + public CredentialModel GetCachedCredentials() { - using var _ = _lock.Lock(); - if (_latestCredentials != null) return _latestCredentials.Clone(); + // No lock required to read the reference. + var latestCreds = _latestCredentials; + // No clone needed as the model is immutable. + if (latestCreds != null) return latestCreds; - var rawCredentials = ReadCredentials(); - if (rawCredentials is null) - _latestCredentials = new CredentialModel - { - State = CredentialState.Invalid, - }; - else - _latestCredentials = new CredentialModel - { - State = CredentialState.Valid, - CoderUrl = rawCredentials.CoderUrl, - ApiToken = rawCredentials.ApiToken, - }; - return _latestCredentials.Clone(); + return new CredentialModel + { + State = CredentialState.Unknown, + }; } - public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default) + // Implements ICoderApiClientCredentialProvider + public CoderApiClientCredential? GetCoderApiClientCredential() { - if (string.IsNullOrWhiteSpace(coderUrl)) throw new ArgumentException("Coder URL is required", nameof(coderUrl)); - coderUrl = coderUrl.Trim(); - if (coderUrl.Length > 128) throw new ArgumentOutOfRangeException(nameof(coderUrl), "Coder URL is too long"); - if (!Uri.TryCreate(coderUrl, UriKind.Absolute, out var uri)) - throw new ArgumentException($"Coder URL '{coderUrl}' is not a valid URL", nameof(coderUrl)); - if (uri.PathAndQuery != "/") throw new ArgumentException("Coder URL must be the root URL", nameof(coderUrl)); - if (string.IsNullOrWhiteSpace(apiToken)) throw new ArgumentException("API token is required", nameof(apiToken)); - apiToken = apiToken.Trim(); - if (apiToken.Length != 33) - throw new ArgumentOutOfRangeException(nameof(apiToken), "API token must be 33 characters long"); + 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 { - var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(15)); - var sdkClient = new CoderApiClient(uri); - sdkClient.SetSessionToken(apiToken); - // TODO: we should probably perform a version check here too, - // rather than letting the service do it on Start - _ = await sdkClient.GetBuildInfo(cts.Token); - _ = await sdkClient.GetUser(User.Me, cts.Token); + var raw = await Backend.ReadCredentials(); + if (raw is not null && !string.IsNullOrWhiteSpace(raw.CoderUrl)) return raw.CoderUrl; } - catch (Exception e) + catch { - throw new InvalidOperationException("Could not connect to or verify Coder server", e); + // ignored } - WriteCredentials(new RawCredentials + return null; + } + + // LoadCredentials may be preempted by SetCredentials. + public Task LoadCredentials(CancellationToken ct = default) + { + // This function is not `async` because we may return an existing task. + // However, we still want to acquire the lock with the + // CancellationToken so it can be canceled if needed. + using var _ = _opLock.LockAsync(ct).Result; + + // If we already have a cached value, return it. + var latestCreds = _latestCredentials; + if (latestCreds != null) return Task.FromResult(latestCreds); + + // If we are already loading, return the existing task. + if (_inFlightLoad != null) return _inFlightLoad; + + // Otherwise, kick off a new load. + // Note: subsequent loads returned from above will ignore the passed in + // CancellationToken. We set a maximum timeout of 15 seconds anyway. + _loadCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _loadCts.CancelAfter(TimeSpan.FromSeconds(15)); + _inFlightLoad = LoadCredentialsInner(_loadCts.Token); + return _inFlightLoad; + } + + public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct) + { + using var _ = await _opLock.LockAsync(ct); + + // If there's an ongoing load, cancel it. + if (_loadCts != null) { - CoderUrl = coderUrl, - ApiToken = apiToken, - }); + await _loadCts.CancelAsync(); + _loadCts.Dispose(); + _loadCts = null; + _inFlightLoad = null; + } - UpdateState(new CredentialModel + if (string.IsNullOrWhiteSpace(coderUrl)) throw new ArgumentException("Coder URL is required", nameof(coderUrl)); + coderUrl = coderUrl.Trim(); + if (coderUrl.Length > 128) throw new ArgumentException("Coder URL is too long", nameof(coderUrl)); + if (!Uri.TryCreate(coderUrl, UriKind.Absolute, out var uri)) + throw new ArgumentException($"Coder URL '{coderUrl}' is not a valid URL", nameof(coderUrl)); + if (uri.Scheme != "http" && uri.Scheme != "https") + throw new ArgumentException("Coder URL must be HTTP or HTTPS", nameof(coderUrl)); + if (uri.PathAndQuery != "/") throw new ArgumentException("Coder URL must be the root URL", nameof(coderUrl)); + if (string.IsNullOrWhiteSpace(apiToken)) throw new ArgumentException("API token is required", nameof(apiToken)); + apiToken = apiToken.Trim(); + + var raw = new RawCredentials { - State = CredentialState.Valid, CoderUrl = coderUrl, ApiToken = apiToken, - }); + }; + var populateCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + populateCts.CancelAfter(TimeSpan.FromSeconds(15)); + var model = await PopulateModel(raw, populateCts.Token); + await Backend.WriteCredentials(raw, ct); + UpdateState(model); } - public void ClearCredentials() + public async Task ClearCredentials(CancellationToken ct = default) { - NativeApi.DeleteCredentials(CredentialsTargetName); + using var _ = await _opLock.LockAsync(ct); + await Backend.DeleteCredentials(ct); UpdateState(new CredentialModel { State = CredentialState.Invalid, - CoderUrl = null, - ApiToken = null, }); } - private void UpdateState(CredentialModel newModel) + private async Task LoadCredentialsInner(CancellationToken ct) { - using (_lock.Lock()) + CredentialModel model; + try + { + var raw = await Backend.ReadCredentials(ct); + model = await PopulateModel(raw, ct); + } + catch { - _latestCredentials = newModel.Clone(); + // This catch will be hit if a SetCredentials operation started, or + // if the read/populate failed for some other reason (e.g. HTTP + // timeout). + // + // We don't need to clear the credentials here, the app will think + // they're unset and any subsequent SetCredentials call after the + // user signs in again will overwrite the old invalid ones. + model = new CredentialModel + { + State = CredentialState.Invalid, + }; } - CredentialsChanged?.Invoke(this, newModel.Clone()); + // Grab the lock again so we can update the state. + using (await _opLock.LockAsync(ct)) + { + // Prevent new LoadCredentials calls from returning this task. + if (_loadCts != null) + { + _loadCts.Dispose(); + _loadCts = null; + _inFlightLoad = null; + } + + // If we were canceled but made it this far, try to return the + // latest credentials instead. + if (ct.IsCancellationRequested) + { + var latestCreds = _latestCredentials; + if (latestCreds is not null) return latestCreds; + } + + // If there aren't any latest credentials after a cancellation, we + // most likely timed out and should throw. + ct.ThrowIfCancellationRequested(); + + UpdateState(model); + return model; + } } - private static RawCredentials? ReadCredentials() + private async Task PopulateModel(RawCredentials? credentials, CancellationToken ct) { - var raw = NativeApi.ReadCredentials(CredentialsTargetName); - if (raw == null) return null; + if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) || + string.IsNullOrWhiteSpace(credentials.ApiToken)) + return new CredentialModel + { + State = CredentialState.Invalid, + }; + + if (!Uri.TryCreate(credentials.CoderUrl, UriKind.Absolute, out var uri)) + return new CredentialModel + { + State = CredentialState.Invalid, + }; + + BuildInfo buildInfo; + User me; + try + { + var sdkClient = CoderApiClientFactory.Create(credentials.CoderUrl); + // BuildInfo does not require authentication. + buildInfo = await sdkClient.GetBuildInfo(ct); + sdkClient.SetSessionToken(credentials.ApiToken); + me = await sdkClient.GetUser(User.Me, ct); + } + catch (CoderApiHttpException) + { + throw; + } + catch (Exception e) + { + throw new InvalidOperationException("Could not connect to or verify Coder server", e); + } + + ServerVersionUtilities.ParseAndValidateServerVersion(buildInfo.Version); + if (string.IsNullOrWhiteSpace(me.Username)) + throw new InvalidOperationException("Could not retrieve user information, username is empty"); + + return new CredentialModel + { + State = CredentialState.Valid, + CoderUrl = uri, + ApiToken = credentials.ApiToken, + Username = me.Username, + }; + } + + // Lock must be held when calling this function. + private void UpdateState(CredentialModel newModel) + { + _latestCredentials = newModel; + // Since the event handlers could block (or call back the + // CredentialManager and deadlock), we run these in a new task. + if (CredentialsChanged == null) return; + Task.Run(() => { CredentialsChanged?.Invoke(this, newModel); }); + } +} + +public class WindowsCredentialBackend : ICredentialBackend +{ + public const string CoderCredentialsTargetName = "Coder.Desktop.App.Credentials"; + + private readonly string _credentialsTargetName; + + public WindowsCredentialBackend(string credentialsTargetName) + { + _credentialsTargetName = credentialsTargetName; + } + + public Task ReadCredentials(CancellationToken ct = default) + { + var raw = Wincred.ReadCredentials(_credentialsTargetName); + if (raw == null) return Task.FromResult(null); RawCredentials? credentials; try @@ -139,120 +332,188 @@ private void UpdateState(CredentialModel newModel) } catch (JsonException) { - return null; + credentials = null; } - if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) || - string.IsNullOrWhiteSpace(credentials.ApiToken)) return null; - - return credentials; + return Task.FromResult(credentials); } - private static void WriteCredentials(RawCredentials credentials) + 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; } - private static class NativeApi + public Task DeleteCredentials(CancellationToken ct = default) { - private const int CredentialTypeGeneric = 1; - private const int PersistenceTypeLocalComputer = 2; - private const int ErrorNotFound = 1168; - private const int CredMaxCredentialBlobSize = 5 * 512; + Wincred.DeleteCredentials(_credentialsTargetName); + return Task.CompletedTask; + } - public static string? ReadCredentials(string targetName) - { - 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})"); - } +} - try - { - var cred = Marshal.PtrToStructure(credentialPtr); - return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char)); - } - finally - { - CredFree(credentialPtr); - } +/// +/// 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) + { + 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 void WriteCredentials(string targetName, string secret) + try + { + var cred = Marshal.PtrToStructure(credentialPtr); + return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char)); + } + finally { - var byteCount = Encoding.Unicode.GetByteCount(secret); - if (byteCount > CredMaxCredentialBlobSize) - throw new ArgumentOutOfRangeException(nameof(secret), - $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); + CredFree(credentialPtr); + } + } - 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 + 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)) { - Marshal.FreeHGlobal(credentialBlob); + var error = Marshal.GetLastWin32Error(); + throw new InvalidOperationException($"Failed to write credentials (Error {error})"); } } + finally + { + Marshal.FreeHGlobal(credentialBlob); + } + } - public static void DeleteCredentials(string targetName) + public static void DeleteCredentials(string targetName) + { + if (!CredDeleteW(targetName, CredentialTypeGeneric, 0)) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorNotFound) return; + throw new InvalidOperationException($"Failed to delete credentials (Error {error})"); + } + } + + public static void WriteDomainCredentials(string domainName, string serverName, string username, string password) + { + var targetName = $"{domainName}/{serverName}"; + var targetInfo = new CREDENTIAL_TARGET_INFORMATIONW + { + TargetName = targetName, + DnsServerName = serverName, + DnsDomainName = domainName, + PackageName = PackageNTLM, + }; + var byteCount = Encoding.Unicode.GetByteCount(password); + if (byteCount > CredMaxCredentialBlobSize) + throw new ArgumentOutOfRangeException(nameof(password), + $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); + + var credentialBlob = Marshal.StringToHGlobalUni(password); + var cred = new CREDENTIALW { - if (!CredDeleteW(targetName, CredentialTypeGeneric, 0)) + Type = CredentialTypeDomainPassword, + TargetName = targetName, + CredentialBlobSize = byteCount, + CredentialBlob = credentialBlob, + Persist = PersistenceTypeLocalComputer, + UserName = username, + }; + try + { + if (!CredWriteDomainCredentialsW(ref targetInfo, ref cred, 0)) { var error = Marshal.GetLastWin32Error(); - if (error == ErrorNotFound) return; - throw new InvalidOperationException($"Failed to delete credentials (Error {error})"); + throw new InvalidOperationException($"Failed to write credentials (Error {error})"); } } + finally + { + Marshal.FreeHGlobal(credentialBlob); + } + } - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr); - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredWriteW([In] ref CREDENTIAL userCredential, [In] uint flags); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWriteW([In] ref CREDENTIALW userCredential, [In] uint flags); - [DllImport("Advapi32.dll", SetLastError = true)] - private static extern void CredFree([In] IntPtr cred); + [DllImport("Advapi32.dll", SetLastError = true)] + private static extern void CredFree([In] IntPtr cred); - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredDeleteW(string target, int type, int flags); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredDeleteW(string target, int type, int flags); - [StructLayout(LayoutKind.Sequential)] - private struct CREDENTIAL - { - public int Flags; - public int Type; + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWriteDomainCredentialsW([In] ref CREDENTIAL_TARGET_INFORMATIONW target, [In] ref CREDENTIALW userCredential, [In] uint flags); - [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [StructLayout(LayoutKind.Sequential)] + private struct CREDENTIALW + { + public int Flags; + public int Type; - [MarshalAs(UnmanagedType.LPWStr)] public string Comment; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; - public long LastWritten; - public int CredentialBlobSize; - public IntPtr CredentialBlob; - public int Persist; - public int AttributeCount; - public IntPtr Attributes; + [MarshalAs(UnmanagedType.LPWStr)] public string Comment; - [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + public long LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; - [MarshalAs(UnmanagedType.LPWStr)] public string UserName; - } + [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + + [MarshalAs(UnmanagedType.LPWStr)] public string UserName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CREDENTIAL_TARGET_INFORMATIONW + { + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosServerName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsServerName; + [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosDomainName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsDomainName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsTreeName; + [MarshalAs(UnmanagedType.LPWStr)] public string PackageName; + + public uint Flags; + public uint CredTypeCount; + public IntPtr CredTypes; } } diff --git a/App/Services/DispatcherQueueManager.cs b/App/Services/DispatcherQueueManager.cs new file mode 100644 index 0000000..d562067 --- /dev/null +++ b/App/Services/DispatcherQueueManager.cs @@ -0,0 +1,8 @@ +using Microsoft.UI.Dispatching; + +namespace Coder.Desktop.App.Services; + +public interface IDispatcherQueueManager +{ + public void RunInUiThread(DispatcherQueueHandler action); +} diff --git a/App/Services/HostnameSuffixGetter.cs b/App/Services/HostnameSuffixGetter.cs new file mode 100644 index 0000000..3816623 --- /dev/null +++ b/App/Services/HostnameSuffixGetter.cs @@ -0,0 +1,144 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn.Utilities; +using Microsoft.Extensions.Logging; + +namespace Coder.Desktop.App.Services; + +public interface IHostnameSuffixGetter +{ + public event EventHandler SuffixChanged; + + public string GetCachedSuffix(); +} + +public class HostnameSuffixGetter : IHostnameSuffixGetter +{ + private const string DefaultSuffix = ".coder"; + + private readonly ICredentialManager _credentialManager; + private readonly ICoderApiClientFactory _clientFactory; + private readonly ILogger _logger; + + // _lock protects all private (non-readonly) values + private readonly RaiiSemaphoreSlim _lock = new(1, 1); + private string _domainSuffix = DefaultSuffix; + private bool _dirty = false; + private bool _getInProgress = false; + private CredentialModel _credentialModel = new() { State = CredentialState.Invalid }; + + public event EventHandler? SuffixChanged; + + public HostnameSuffixGetter(ICredentialManager credentialManager, ICoderApiClientFactory apiClientFactory, + ILogger logger) + { + _credentialManager = credentialManager; + _clientFactory = apiClientFactory; + _logger = logger; + credentialManager.CredentialsChanged += HandleCredentialsChanged; + HandleCredentialsChanged(this, _credentialManager.GetCachedCredentials()); + } + + ~HostnameSuffixGetter() + { + _credentialManager.CredentialsChanged -= HandleCredentialsChanged; + } + + private void HandleCredentialsChanged(object? sender, CredentialModel credentials) + { + using var _ = _lock.Lock(); + _logger.LogDebug("credentials updated with state {state}", credentials.State); + _credentialModel = credentials; + if (credentials.State != CredentialState.Valid) return; + + _dirty = true; + if (!_getInProgress) + { + _getInProgress = true; + Task.Run(Refresh).ContinueWith(MaybeRefreshAgain); + } + } + + private async Task Refresh() + { + _logger.LogDebug("refreshing domain suffix"); + CredentialModel credentials; + using (_ = await _lock.LockAsync()) + { + credentials = _credentialModel; + if (credentials.State != CredentialState.Valid) + { + _logger.LogDebug("abandoning refresh because credentials are now invalid"); + return; + } + + _dirty = false; + } + + var client = _clientFactory.Create(credentials); + using var timeoutSrc = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var connInfo = await client.GetAgentConnectionInfoGeneric(timeoutSrc.Token); + + // older versions of Coder might not set this + var suffix = string.IsNullOrEmpty(connInfo.HostnameSuffix) + ? DefaultSuffix + // and, it doesn't include the leading dot. + : "." + connInfo.HostnameSuffix; + + var changed = false; + using (_ = await _lock.LockAsync(CancellationToken.None)) + { + if (_domainSuffix != suffix) changed = true; + _domainSuffix = suffix; + } + + if (changed) + { + _logger.LogInformation("got new domain suffix '{suffix}'", suffix); + // grab a local copy of the EventHandler to avoid TOCTOU race on the `?.` null-check + var del = SuffixChanged; + del?.Invoke(this, suffix); + } + else + { + _logger.LogDebug("domain suffix unchanged '{suffix}'", suffix); + } + } + + private async Task MaybeRefreshAgain(Task prev) + { + if (prev.IsFaulted) + { + _logger.LogError(prev.Exception, "failed to query domain suffix"); + // back off here before retrying. We're just going to use a fixed, long + // delay since this just affects UI stuff; we're not in a huge rush as + // long as we eventually get the right value. + await Task.Delay(TimeSpan.FromSeconds(10)); + } + + using var l = await _lock.LockAsync(CancellationToken.None); + if ((_dirty || prev.IsFaulted) && _credentialModel.State == CredentialState.Valid) + { + // we still have valid credentials and we're either dirty or the last Get failed. + _logger.LogDebug("retrying domain suffix query"); + _ = Task.Run(Refresh).ContinueWith(MaybeRefreshAgain); + return; + } + + // Getting here means either the credentials are not valid or we don't need to + // refresh anyway. + // The next time we get new, valid credentials, HandleCredentialsChanged will kick off + // a new Refresh + _getInProgress = false; + return; + } + + public string GetCachedSuffix() + { + using var _ = _lock.Lock(); + return _domainSuffix; + } +} diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs new file mode 100644 index 0000000..eba0952 --- /dev/null +++ b/App/Services/MutagenController.cs @@ -0,0 +1,813 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using Coder.Desktop.MutagenSdk; +using Coder.Desktop.MutagenSdk.Proto.Selection; +using Coder.Desktop.MutagenSdk.Proto.Service.Daemon; +using Coder.Desktop.MutagenSdk.Proto.Service.Prompting; +using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core.Ignore; +using Coder.Desktop.MutagenSdk.Proto.Url; +using Coder.Desktop.Vpn.Utilities; +using Grpc.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Serilog; +using DaemonTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Daemon.TerminateRequest; +using MutagenProtocol = Coder.Desktop.MutagenSdk.Proto.Url.Protocol; +using SynchronizationTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Synchronization.TerminateRequest; + +namespace Coder.Desktop.App.Services; + +public class CreateSyncSessionRequest +{ + public required Endpoint Alpha { get; init; } + public required Endpoint Beta { get; init; } + + public class Endpoint + { + public enum ProtocolKind + { + Local, + Ssh, + } + + public required ProtocolKind Protocol { get; init; } + public string User { get; init; } = ""; + public string Host { get; init; } = ""; + public uint Port { get; init; } = 0; + public string Path { get; init; } = ""; + + public URL MutagenUrl + { + get + { + var protocol = Protocol switch + { + ProtocolKind.Local => MutagenProtocol.Local, + ProtocolKind.Ssh => MutagenProtocol.Ssh, + _ => throw new ArgumentException($"Invalid protocol '{Protocol}'", nameof(Protocol)), + }; + + return new URL + { + Kind = Kind.Synchronization, + Protocol = protocol, + User = User, + Host = Host, + Port = Port, + Path = Path, + }; + } + } + } +} + +public interface ISyncSessionController : IAsyncDisposable +{ + public event EventHandler StateChanged; + + /// + /// Gets the current state of the controller. + /// + SyncSessionControllerStateModel GetState(); + + // All the following methods will raise a StateChanged event *BEFORE* they return. + + /// + /// Starts the daemon (if it's not running) and fully refreshes the state of the controller. This should be + /// called at startup and after any unexpected daemon crashes to attempt to retry. + /// Additionally, the first call to RefreshState will start a background task to keep the state up-to-date while + /// the daemon is running. + /// + Task RefreshState(CancellationToken ct = default); + + Task CreateSyncSession(CreateSyncSessionRequest req, Action progressCallback, + CancellationToken ct = default); + + Task PauseSyncSession(string identifier, CancellationToken ct = default); + Task ResumeSyncSession(string identifier, CancellationToken ct = default); + Task TerminateSyncSession(string identifier, CancellationToken ct = default); +} + +// These values are the config option names used in the registry. Any option +// here can be configured with `(Debug)?AppMutagenController:OptionName` in the registry. +// +// They should not be changed without backwards compatibility considerations. +// If changed here, they should also be changed in the installer. +public class MutagenControllerConfig +{ + // This is set to "[INSTALLFOLDER]\vpn\mutagen.exe" by the installer. + [Required] public string MutagenExecutablePath { get; set; } = @"c:\mutagen.exe"; +} + +/// +/// A file synchronization controller based on the Mutagen Daemon. +/// +public sealed class MutagenController : ISyncSessionController +{ + // Protects all private non-readonly class members. + private readonly RaiiSemaphoreSlim _lock = new(1, 1); + + private readonly ILogger _logger; + + private readonly CancellationTokenSource _stateUpdateCts = new(); + private Task? _stateUpdateTask; + + // _state is the current state of the controller. It is updated + // continuously while the daemon is running and after most operations. + private SyncSessionControllerStateModel? _state; + + // _daemonProcess is non-null while the daemon is running, starting, or + // in the process of stopping. + private Process? _daemonProcess; + + private LogWriter? _logWriter; + + // holds a client connected to the running mutagen daemon, if the daemon is running. + private MutagenClient? _mutagenClient; + + // set to true if we are disposing the controller. Prevents the daemon from being + // restarted. + private bool _disposing; + + private readonly string _mutagenExecutablePath; + + private readonly string _mutagenDataDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CoderDesktop", + "mutagen"); + + private string MutagenDaemonLog => Path.Combine(_mutagenDataDirectory, "daemon.log"); + + public MutagenController(IOptions config, ILogger logger) + { + _mutagenExecutablePath = config.Value.MutagenExecutablePath; + _logger = logger; + } + + public MutagenController(string executablePath, string dataDirectory) + { + _mutagenExecutablePath = executablePath; + _mutagenDataDirectory = dataDirectory; + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + _logger = (ILogger)builder.Build().Services.GetService(typeof(ILogger))!; + } + + public event EventHandler? StateChanged; + + public async ValueTask DisposeAsync() + { + using var _ = await _lock.LockAsync(CancellationToken.None); + _disposing = true; + + await _stateUpdateCts.CancelAsync(); + if (_stateUpdateTask != null) + try + { + await _stateUpdateTask; + } + catch + { + // ignored + } + + _stateUpdateCts.Dispose(); + + using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await StopDaemon(stopCts.Token); + + GC.SuppressFinalize(this); + } + + public SyncSessionControllerStateModel GetState() + { + // No lock required to read the reference. + var state = _state; + // No clone needed as the model is immutable. + if (state != null) return state; + return new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Uninitialized, + DaemonError = null, + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }; + } + + public async Task RefreshState(CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + var state = await UpdateState(client, ct); + _stateUpdateTask ??= UpdateLoop(_stateUpdateCts.Token); + return state; + } + + public async Task CreateSyncSession(CreateSyncSessionRequest req, + Action? progressCallback = null, CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + + await using var prompter = await Prompter.Create(client, true, ct); + if (progressCallback != null) + prompter.OnProgress += (_, progress) => progressCallback(progress); + + var createRes = await client.Synchronization.CreateAsync(new CreateRequest + { + Prompter = prompter.Identifier, + Specification = new CreationSpecification + { + Alpha = req.Alpha.MutagenUrl, + Beta = req.Beta.MutagenUrl, + // TODO: probably should add a configuration page for these at some point + Configuration = new Configuration + { + IgnoreVCSMode = IgnoreVCSMode.Ignore, + }, + ConfigurationAlpha = new Configuration(), + ConfigurationBeta = new Configuration(), + }, + }, cancellationToken: ct); + if (createRes == null) throw new InvalidOperationException("CreateAsync returned null"); + + var session = await GetSyncSession(client, createRes.Session, ct); + await UpdateState(client, ct); + return session; + } + + public async Task PauseSyncSession(string identifier, CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + + // Pausing sessions doesn't require prompting as seen in the mutagen CLI. + await using var prompter = await Prompter.Create(client, false, ct); + await client.Synchronization.PauseAsync(new PauseRequest + { + Prompter = prompter.Identifier, + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + + var session = await GetSyncSession(client, identifier, ct); + await UpdateState(client, ct); + return session; + } + + public async Task ResumeSyncSession(string identifier, CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + + await using var prompter = await Prompter.Create(client, true, ct); + await client.Synchronization.ResumeAsync(new ResumeRequest + { + Prompter = prompter.Identifier, + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + + var session = await GetSyncSession(client, identifier, ct); + await UpdateState(client, ct); + return session; + } + + public async Task TerminateSyncSession(string identifier, CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + + // Terminating sessions doesn't require prompting as seen in the mutagen CLI. + await using var prompter = await Prompter.Create(client, true, ct); + + await client.Synchronization.TerminateAsync(new SynchronizationTerminateRequest + { + Prompter = prompter.Identifier, + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + + await UpdateState(client, ct); + } + + private async Task UpdateLoop(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(2), ct); // 2s matches macOS app + try + { + // We use a zero timeout here to avoid waiting. If another + // operation is holding the lock, it will update the state once + // it completes anyway. + var locker = await _lock.LockAsync(TimeSpan.Zero, ct); + if (locker == null) continue; + using (locker) + { + if (_mutagenClient == null) continue; + await UpdateState(_mutagenClient, ct); + } + } + catch + { + // ignore + } + } + } + + private static async Task GetSyncSession(MutagenClient client, string identifier, + CancellationToken ct) + { + var listRes = await client.Synchronization.ListAsync(new ListRequest + { + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + if (listRes == null) throw new InvalidOperationException("ListAsync returned null"); + if (listRes.SessionStates.Count != 1) + throw new InvalidOperationException("ListAsync returned wrong number of sessions"); + + return new SyncSessionModel(listRes.SessionStates[0]); + } + + private void ReplaceState(SyncSessionControllerStateModel state) + { + _state = state; + // Since the event handlers could block (or call back the + // SyncSessionController and deadlock), we run these in a new task. + var stateChanged = StateChanged; + if (stateChanged == null) return; + Task.Run(() => stateChanged.Invoke(this, state)); + } + + /// + /// Refreshes state and potentially stops the daemon if there are no sessions. The client must not be used after + /// this method is called. + /// Must be called AND awaited with the lock held. + /// + private async Task UpdateState(MutagenClient client, + CancellationToken ct = default) + { + ListResponse listResponse; + try + { + listResponse = await client.Synchronization.ListAsync(new ListRequest + { + Selection = new Selection { All = true }, + }, cancellationToken: ct); + if (listResponse == null) + throw new InvalidOperationException("ListAsync returned null"); + } + catch (Exception e) + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var error = $"Failed to UpdateState: ListAsync: {e}"; + try + { + await StopDaemon(cts.Token); + } + catch (Exception e2) + { + error = $"Failed to UpdateState: StopDaemon failed after failed ListAsync call: {e2}"; + } + + ReplaceState(new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = error, + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }); + throw; + } + + var lifecycle = SyncSessionControllerLifecycle.Running; + if (listResponse.SessionStates.Count == 0) + { + lifecycle = SyncSessionControllerLifecycle.Stopped; + try + { + await StopDaemon(ct); + } + catch (Exception e) + { + ReplaceState(new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = $"Failed to stop daemon after no sessions: {e}", + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }); + throw new InvalidOperationException("Failed to stop daemon after no sessions", e); + } + } + + var sessions = listResponse.SessionStates + .Select(s => new SyncSessionModel(s)) + .ToList(); + sessions.Sort((a, b) => a.CreatedAt < b.CreatedAt ? -1 : 1); + var state = new SyncSessionControllerStateModel + { + Lifecycle = lifecycle, + DaemonError = null, + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = sessions, + }; + ReplaceState(state); + return state; + } + + /// + /// Starts the daemon if it's not running and returns a client to it. + /// Must be called AND awaited with the lock held. + /// + private async Task EnsureDaemon(CancellationToken ct) + { + _logger.LogDebug("EnsureDaemon called"); + ObjectDisposedException.ThrowIf(_disposing, typeof(MutagenController)); + if (_mutagenClient != null && _daemonProcess != null) + return _mutagenClient; + + try + { + return await StartDaemon(ct); + } + catch (Exception e) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await StopDaemon(cts.Token); + } + catch (Exception stopEx) + { + _logger.LogError(stopEx, "failed to stop daemon"); + } + + ReplaceState(new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = $"Failed to start daemon: {e}", + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }); + + throw; + } + } + + /// + /// Starts the daemon and returns a client to it. + /// Must be called AND awaited with the lock held. + /// + 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); + } + catch (FileNotFoundException) + { + // Mainline; no daemon running. + } + catch (InvalidOperationException) + { + // 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 + // going to at all. + const int maxAttempts = 5; + for (var attempts = 1; attempts <= maxAttempts; attempts++) + { + ct.ThrowIfCancellationRequested(); + try + { + StartDaemonProcess(); + } + catch (Exception e) when (e is not OperationCanceledException) + { + _logger.LogWarning(e, "failed to start daemon process, attempt {attempt} of {maxAttempts}", attempts, + maxAttempts); + if (attempts == maxAttempts) + throw; + // back off a little and try again. + await Task.Delay(100, ct); + continue; + } + + break; + } + + // Wait for the RPC to be available. + while (true) + { + ct.ThrowIfCancellationRequested(); + try + { + _logger.LogDebug("creating mainline mutagen client"); + var client = new MutagenClient(_mutagenDataDirectory); + _ = await client.Daemon.VersionAsync(new VersionRequest(), cancellationToken: ct); + _mutagenClient = client; + return client; + } + catch (Exception e) when + (e is not OperationCanceledException) // TODO: Are there other permanent errors we can detect? + { + // just wait a little longer for the daemon to come up + await Task.Delay(100, ct); + } + } + } + + /// + /// Starts the daemon process. + /// Must be called AND awaited with the lock held. + /// + private void StartDaemonProcess() + { + if (_daemonProcess != null) + throw new InvalidOperationException("StartDaemonProcess called when _daemonProcess already present"); + + // create the log file first, so ensure we have permissions + Directory.CreateDirectory(_mutagenDataDirectory); + var logPath = Path.Combine(_mutagenDataDirectory, "daemon.log"); + var logStream = new StreamWriter(logPath, true); + + _logger.LogInformation("starting mutagen daemon process with executable path '{path}'", _mutagenExecutablePath); + _logger.LogInformation("mutagen data directory '{path}'", _mutagenDataDirectory); + _logger.LogInformation("mutagen daemon log path '{path}'", logPath); + + var daemonProcess = new Process(); + daemonProcess.StartInfo.FileName = _mutagenExecutablePath; + daemonProcess.StartInfo.Arguments = "daemon run"; + daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory); + daemonProcess.StartInfo.Environment.Add("MUTAGEN_SSH_CONFIG_PATH", "none"); // do not use ~/.ssh/config + // hide the console window + daemonProcess.StartInfo.CreateNoWindow = true; + // shell needs to be disabled since we set the environment + // https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.environment?view=net-8.0 + daemonProcess.StartInfo.UseShellExecute = false; + daemonProcess.StartInfo.RedirectStandardError = true; + daemonProcess.EnableRaisingEvents = true; + daemonProcess.Exited += (_, _) => + { + var exitCode = -1; + try + { + // ReSharper disable once AccessToDisposedClosure + exitCode = daemonProcess.ExitCode; + } + catch + { + // ignored + } + + _logger.LogInformation("mutagen daemon exited with code {exitCode}", exitCode); + }; + + try + { + if (!daemonProcess.Start()) + throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); + } + catch (Exception e) + { + _logger.LogWarning(e, "mutagen daemon failed to start"); + + logStream.Dispose(); + try + { + daemonProcess.Kill(); + } + catch + { + // ignored, the process likely doesn't exist + } + + daemonProcess.Dispose(); + throw; + } + + _daemonProcess = daemonProcess; + var writer = new LogWriter(_daemonProcess.StandardError, logStream); + Task.Run(() => { _ = writer.Run(); }); + _logWriter = writer; + } + + /// + /// Stops the daemon process. + /// Must be called AND awaited with the lock held. + /// + private async Task StopDaemon(CancellationToken ct) + { + _logger.LogDebug("stopping mutagen daemon"); + var process = _daemonProcess; + var client = _mutagenClient; + var writer = _logWriter; + _daemonProcess = null; + _mutagenClient = null; + _logWriter = null; + + try + { + if (client == null) + { + if (process == null) return; + _logger.LogDebug("no client; killing daemon process"); + process.Kill(true); + } + else + { + try + { + _logger.LogDebug("sending DaemonTerminateRequest"); + await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); + } + catch (Exception e) + { + _logger.LogError(e, "failed to gracefully terminate agent"); + if (process == null) return; + _logger.LogDebug("killing daemon process after failed graceful termination"); + process.Kill(true); + } + } + + if (process == null) return; + var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + _logger.LogDebug("waiting for process to exit"); + await process.WaitForExitAsync(cts.Token); + } + finally + { + _logger.LogDebug("cleaning up daemon process objects"); + client?.Dispose(); + process?.Dispose(); + writer?.Dispose(); + } + } + + private class Prompter : IAsyncDisposable + { + public event EventHandler? OnProgress; + + private readonly AsyncDuplexStreamingCall _dup; + private readonly CancellationTokenSource _cts; + private readonly Task _handleRequestsTask; + public string Identifier { get; } + + private Prompter(string identifier, AsyncDuplexStreamingCall dup, + CancellationToken ct) + { + Identifier = identifier; + _dup = dup; + + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _handleRequestsTask = HandleRequests(_cts.Token); + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + try + { + await _handleRequestsTask; + } + catch + { + // ignored + } + + _cts.Dispose(); + GC.SuppressFinalize(this); + } + + public static async Task Create(MutagenClient client, bool allowPrompts = false, + CancellationToken ct = default) + { + var dup = client.Prompting.Host(cancellationToken: ct); + if (dup == null) throw new InvalidOperationException("Prompting.Host returned null"); + + try + { + // Write first request. + await dup.RequestStream.WriteAsync(new HostRequest + { + AllowPrompts = allowPrompts, + }, ct); + + // Read initial response. + if (!await dup.ResponseStream.MoveNext(ct)) + throw new InvalidOperationException("Prompting.Host response stream ended early"); + var response = dup.ResponseStream.Current; + if (response == null) + throw new InvalidOperationException("Prompting.Host response stream returned null"); + if (string.IsNullOrEmpty(response.Identifier)) + throw new InvalidOperationException("Prompting.Host response stream returned empty identifier"); + + return new Prompter(response.Identifier, dup, ct); + } + catch + { + await dup.RequestStream.CompleteAsync(); + dup.Dispose(); + throw; + } + } + + private async Task HandleRequests(CancellationToken ct) + { + try + { + while (true) + { + ct.ThrowIfCancellationRequested(); + + // Read next request and validate it. + if (!await _dup.ResponseStream.MoveNext(ct)) + throw new InvalidOperationException("Prompting.Host response stream ended early"); + var response = _dup.ResponseStream.Current; + if (response == null) + throw new InvalidOperationException("Prompting.Host response stream returned null"); + if (response.Message == null) + throw new InvalidOperationException("Prompting.Host response stream returned a null message"); + + if (!response.IsPrompt) + OnProgress?.Invoke(this, response.Message); + + // Currently we only reply to SSH fingerprint messages with + // "yes" and send an empty reply for everything else. + var reply = ""; + if (response.IsPrompt && response.Message.Contains("yes/no/[fingerprint]")) reply = "yes"; + + await _dup.RequestStream.WriteAsync(new HostRequest + { + Response = reply, + }, ct); + } + } + catch + { + await _dup.RequestStream.CompleteAsync(); + // TODO: log? + } + } + } + + private class LogWriter(StreamReader reader, StreamWriter writer) : IDisposable + { + public void Dispose() + { + reader.Dispose(); + writer.Dispose(); + GC.SuppressFinalize(this); + } + + public async Task Run() + { + try + { + while (await reader.ReadLineAsync() is { } line) await writer.WriteLineAsync(line); + } + catch + { + // TODO: Log? + } + finally + { + Dispose(); + } + } + } +} 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 a02347f..532c878 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -96,8 +96,8 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Connecting; state.VpnLifecycle = VpnLifecycle.Stopped; - state.Workspaces.Clear(); - state.Agents.Clear(); + state.Workspaces = []; + state.Agents = []; }); if (_speaker != null) @@ -127,8 +127,8 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Disconnected; state.VpnLifecycle = VpnLifecycle.Unknown; - state.Workspaces.Clear(); - state.Agents.Clear(); + state.Workspaces = []; + state.Agents = []; }); throw new RpcOperationException("Failed to reconnect to the RPC server", e); } @@ -137,8 +137,8 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Connected; state.VpnLifecycle = VpnLifecycle.Unknown; - state.Workspaces.Clear(); - state.Agents.Clear(); + state.Workspaces = []; + state.Agents = []; }); var statusReply = await _speaker.SendRequestAwaitReply(new ClientMessage @@ -146,7 +146,8 @@ public async Task Reconnect(CancellationToken ct = default) Status = new StatusRequest(), }, ct); if (statusReply.MsgCase != ServiceMessage.MsgOneofCase.Status) - throw new InvalidOperationException($"Unexpected reply message type: {statusReply.MsgCase}"); + throw new VpnLifecycleException( + $"Failed to get VPN status. Unexpected reply message type: {statusReply.MsgCase}"); ApplyStatusUpdate(statusReply.Status); } @@ -155,11 +156,15 @@ public async Task StartVpn(CancellationToken ct = default) using var _ = await AcquireOperationLockNowAsync(); AssertRpcConnected(); - var credentials = _credentialManager.GetCredentials(); + var credentials = _credentialManager.GetCachedCredentials(); if (credentials.State != CredentialState.Valid) - throw new RpcOperationException("Cannot start VPN without valid credentials"); + 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 @@ -168,12 +173,10 @@ public async Task StartVpn(CancellationToken ct = default) { Start = new StartRequest { - CoderUrl = credentials.CoderUrl, + CoderUrl = credentials.CoderUrl?.ToString(), ApiToken = credentials.ApiToken, }, }, ct); - if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start) - throw new InvalidOperationException($"Unexpected reply message type: {reply.MsgCase}"); } catch (Exception e) { @@ -181,11 +184,19 @@ public async Task StartVpn(CancellationToken ct = default) throw new RpcOperationException("Failed to send start command to service", e); } + if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start) + { + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; }); + throw new VpnLifecycleException($"Failed to start VPN. Unexpected reply message type: {reply.MsgCase}"); + } + if (!reply.Start.Success) { + // We use Stopped instead of Unknown here as it's usually the case + // that a failed start got cleaned up successfully. MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; }); - throw new VpnLifecycleException("Failed to start VPN", - new InvalidOperationException($"Service reported failure: {reply.Start.ErrorMessage}")); + throw new VpnLifecycleException( + $"Failed to start VPN. Service reported failure: {reply.Start.ErrorMessage}"); } MutateState(state => { state.VpnLifecycle = VpnLifecycle.Started; }); @@ -212,16 +223,22 @@ public async Task StopVpn(CancellationToken ct = default) } finally { - // Technically the state is unknown now. - MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; }); + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; }); } if (reply.MsgCase != ServiceMessage.MsgOneofCase.Stop) - throw new VpnLifecycleException("Failed to stop VPN", - new InvalidOperationException($"Unexpected reply message type: {reply.MsgCase}")); + { + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; }); + throw new VpnLifecycleException($"Failed to stop VPN. Unexpected reply message type: {reply.MsgCase}"); + } + if (!reply.Stop.Success) - throw new VpnLifecycleException("Failed to stop VPN", - new InvalidOperationException($"Service reported failure: {reply.Stop.ErrorMessage}")); + { + 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() @@ -264,10 +281,18 @@ private void ApplyStatusUpdate(Status status) Status.Types.Lifecycle.Stopped => VpnLifecycle.Stopped, _ => VpnLifecycle.Stopped, }; - state.Workspaces.Clear(); - state.Workspaces.AddRange(status.PeerUpdate.UpsertedWorkspaces); - state.Agents.Clear(); - state.Agents.AddRange(status.PeerUpdate.UpsertedAgents); + state.Workspaces = status.PeerUpdate.UpsertedWorkspaces; + state.Agents = status.PeerUpdate.UpsertedAgents; + }); + } + + 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); }); } @@ -275,11 +300,14 @@ private void SpeakerOnReceive(ReplyableRpcMessage { 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 @@ -301,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/DirectoryPickerViewModel.cs b/App/ViewModels/DirectoryPickerViewModel.cs new file mode 100644 index 0000000..131934f --- /dev/null +++ b/App/ViewModels/DirectoryPickerViewModel.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.CoderSdk.Agent; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.ViewModels; + +public class DirectoryPickerBreadcrumb +{ + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + + public required string Name { get; init; } + + public required IReadOnlyList AbsolutePathSegments { get; init; } + + // HACK: we need to know which one is first so we don't prepend an arrow + // icon. You can't get the index of the current ItemsRepeater item in XAML. + public required bool IsFirst { get; init; } +} + +public enum DirectoryPickerItemKind +{ + ParentDirectory, // aka. ".." + Directory, + File, // includes everything else +} + +public class DirectoryPickerItem +{ + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + + public required DirectoryPickerItemKind Kind { get; init; } + public required string Name { get; init; } + public required IReadOnlyList AbsolutePathSegments { get; init; } + + public bool Selectable => Kind is DirectoryPickerItemKind.ParentDirectory or DirectoryPickerItemKind.Directory; +} + +public partial class DirectoryPickerViewModel : ObservableObject +{ + // PathSelected will be called ONCE when the user either cancels or selects + // a directory. If the user cancelled, the path will be null. + public event EventHandler? PathSelected; + + private const int RequestTimeoutMilliseconds = 15_000; + + private readonly IAgentApiClient _client; + + private Window? _window; + private DispatcherQueue? _dispatcherQueue; + + public readonly string AgentFqdn; + + // The initial loading screen is differentiated from subsequent loading + // screens because: + // 1. We don't want to show a broken state while the page is loading. + // 2. An error dialog allows the user to get to a broken state with no + // breadcrumbs, no items, etc. with no chance to reload. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial bool InitialLoading { get; set; } = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowErrorScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial string? InitialLoadError { get; set; } = null; + + [ObservableProperty] public partial bool NavigatingLoading { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSelectable))] + public partial string CurrentDirectory { get; set; } = ""; + + [ObservableProperty] public partial IReadOnlyList Breadcrumbs { get; set; } = []; + + [ObservableProperty] public partial IReadOnlyList Items { get; set; } = []; + + public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading; + public bool ShowErrorScreen => InitialLoadError != null; + public bool ShowListScreen => InitialLoadError == null && !InitialLoading; + + // The "root" directory on Windows isn't a real thing, but in our model + // it's a drive listing. We don't allow users to select the fake drive + // listing directory. + // + // On Linux, this will never be empty since the highest you can go is "/". + public bool IsSelectable => CurrentDirectory != ""; + + public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, string agentFqdn) + { + _client = clientFactory.Create(agentFqdn); + AgentFqdn = agentFqdn; + } + + public void Initialize(Window window, DispatcherQueue dispatcherQueue) + { + _window = window; + _dispatcherQueue = dispatcherQueue; + if (!_dispatcherQueue.HasThreadAccess) + throw new InvalidOperationException("Initialize must be called from the UI thread"); + + InitialLoading = true; + InitialLoadError = null; + // Initial load is in the home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Home, []).ContinueWith(ContinueInitialLoad); + } + + [RelayCommand] + private void RetryLoad() + { + InitialLoading = true; + InitialLoadError = null; + // Subsequent loads after the initial failure are always in the root + // directory in case there's a permanent issue preventing listing the + // home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Root, []).ContinueWith(ContinueInitialLoad); + } + + private async Task BackgroundLoad(ListDirectoryRelativity relativity, List path) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + return await _client.ListDirectory(new ListDirectoryRequest + { + Path = path, + Relativity = relativity, + }, cts.Token); + } + + private void ContinueInitialLoad(Task task) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => ContinueInitialLoad(task)); + return; + } + + if (task.IsCompletedSuccessfully) + { + ProcessResponse(task.Result); + return; + } + + InitialLoadError = "Could not list home directory in workspace: "; + if (task.IsCanceled) InitialLoadError += new TaskCanceledException(); + else if (task.IsFaulted) InitialLoadError += task.Exception; + else InitialLoadError += "no successful result or error"; + InitialLoading = false; + } + + [RelayCommand] + public async Task ListPath(IReadOnlyList path) + { + if (_window is null || NavigatingLoading) return; + NavigatingLoading = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(RequestTimeoutMilliseconds)); + try + { + var res = await _client.ListDirectory(new ListDirectoryRequest + { + Path = path.ToList(), + Relativity = ListDirectoryRelativity.Root, + }, cts.Token); + ProcessResponse(res); + } + catch (Exception e) + { + // Subsequent listing errors are just shown as dialog boxes. + var dialog = new ContentDialog + { + Title = "Failed to list remote directory", + Content = $"{e}", + CloseButtonText = "Ok", + XamlRoot = _window.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + NavigatingLoading = false; + } + } + + [RelayCommand] + public void Cancel() + { + PathSelected?.Invoke(this, null); + _window?.Close(); + } + + [RelayCommand] + public void Select() + { + if (CurrentDirectory == "") return; + PathSelected?.Invoke(this, CurrentDirectory); + _window?.Close(); + } + + private void ProcessResponse(ListDirectoryResponse res) + { + InitialLoading = false; + InitialLoadError = null; + NavigatingLoading = false; + + var breadcrumbs = new List(res.AbsolutePath.Count + 1) + { + new() + { + Name = "🖥️", + AbsolutePathSegments = [], + IsFirst = true, + ViewModel = this, + }, + }; + for (var i = 0; i < res.AbsolutePath.Count; i++) + breadcrumbs.Add(new DirectoryPickerBreadcrumb + { + Name = res.AbsolutePath[i], + AbsolutePathSegments = res.AbsolutePath[..(i + 1)], + IsFirst = false, + ViewModel = this, + }); + + var items = new List(res.Contents.Count + 1); + if (res.AbsolutePath.Count != 0) + items.Add(new DirectoryPickerItem + { + Kind = DirectoryPickerItemKind.ParentDirectory, + Name = "..", + AbsolutePathSegments = res.AbsolutePath[..^1], + ViewModel = this, + }); + + foreach (var item in res.Contents) + { + if (item.Name.StartsWith(".")) continue; + items.Add(new DirectoryPickerItem + { + Kind = item.IsDir ? DirectoryPickerItemKind.Directory : DirectoryPickerItemKind.File, + Name = item.Name, + AbsolutePathSegments = res.AbsolutePath.Append(item.Name).ToList(), + ViewModel = this, + }); + } + + CurrentDirectory = res.AbsolutePathString; + Breadcrumbs = breadcrumbs; + Items = items; + } +} diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs new file mode 100644 index 0000000..cb84f56 --- /dev/null +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -0,0 +1,529 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Windows.Storage.Pickers; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Views; +using Coder.Desktop.CoderSdk.Agent; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using WinRT.Interop; + +namespace Coder.Desktop.App.ViewModels; + +public partial class FileSyncListViewModel : ObservableObject +{ + private Window? _window; + private DispatcherQueue? _dispatcherQueue; + private DirectoryPickerWindow? _remotePickerWindow; + + private readonly ISyncSessionController _syncSessionController; + private readonly IRpcController _rpcController; + private readonly ICredentialManager _credentialManager; + private readonly IAgentApiClientFactory _agentApiClientFactory; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUnavailable))] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowError))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial string? UnavailableMessage { get; set; } = null; + + // Initially we use the current cached state, the loading screen is only + // shown when the user clicks "Reload" on the error screen. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial bool Loading { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowError))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial string? Error { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanOpenLocalPath))] + [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial bool OperationInProgress { get; set; } = false; + + [ObservableProperty] public partial IReadOnlyList Sessions { get; set; } = []; + + [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionLocalPath { get; set; } = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(CanOpenLocalPath))] + public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] + public partial IReadOnlyList AvailableHosts { get; set; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial string? NewSessionRemoteHost { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionRemotePath { get; set; } = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial bool NewSessionRemotePathDialogOpen { get; set; } = false; + + public bool CanOpenLocalPath => !NewSessionLocalPathDialogOpen && !OperationInProgress; + + public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0 && !OperationInProgress; + + public bool NewSessionRemotePathDialogEnabled => + !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen && !OperationInProgress; + + [ObservableProperty] public partial string NewSessionStatus { get; set; } = ""; + + public bool NewSessionCreateEnabled + { + get + { + if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false; + if (NewSessionLocalPathDialogOpen) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; + if (NewSessionRemotePathDialogOpen) return false; + return true; + } + } + + // TODO: this could definitely be improved + public bool ShowUnavailable => UnavailableMessage != null; + public bool ShowLoading => Loading && UnavailableMessage == null && Error == null; + public bool ShowError => UnavailableMessage == null && Error != null; + public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null; + + public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController, + ICredentialManager credentialManager, IAgentApiClientFactory agentApiClientFactory) + { + _syncSessionController = syncSessionController; + _rpcController = rpcController; + _credentialManager = credentialManager; + _agentApiClientFactory = agentApiClientFactory; + } + + public void Initialize(Window window, DispatcherQueue dispatcherQueue) + { + _window = window; + _dispatcherQueue = dispatcherQueue; + if (!_dispatcherQueue.HasThreadAccess) + throw new InvalidOperationException("Initialize must be called from the UI thread"); + + _rpcController.StateChanged += RpcControllerStateChanged; + _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; + _syncSessionController.StateChanged += SyncSessionStateChanged; + _window.Closed += (_, _) => + { + _remotePickerWindow?.Close(); + + _rpcController.StateChanged -= RpcControllerStateChanged; + _credentialManager.CredentialsChanged -= CredentialManagerCredentialsChanged; + _syncSessionController.StateChanged -= SyncSessionStateChanged; + }; + + var rpcModel = _rpcController.GetState(); + var credentialModel = _credentialManager.GetCachedCredentials(); + var syncSessionState = _syncSessionController.GetState(); + UpdateSyncSessionState(syncSessionState); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); + } + + private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => RpcControllerStateChanged(sender, rpcModel)); + return; + } + + var credentialModel = _credentialManager.GetCachedCredentials(); + var syncSessionState = _syncSessionController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); + } + + private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => CredentialManagerCredentialsChanged(sender, credentialModel)); + return; + } + + var rpcModel = _rpcController.GetState(); + var syncSessionState = _syncSessionController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); + } + + private void SyncSessionStateChanged(object? sender, SyncSessionControllerStateModel syncSessionState) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => SyncSessionStateChanged(sender, syncSessionState)); + return; + } + + UpdateSyncSessionState(syncSessionState); + } + + private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel, SyncSessionControllerStateModel syncSessionState) + { + var oldMessage = UnavailableMessage; + if (rpcModel.RpcLifecycle != RpcLifecycle.Connected) + { + UnavailableMessage = + "Disconnected from the Windows service. Please see the tray window for more information."; + } + else if (credentialModel.State != CredentialState.Valid) + { + UnavailableMessage = "Please sign in to access file sync."; + } + else if (rpcModel.VpnLifecycle != VpnLifecycle.Started) + { + 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; + // Reload if we transitioned from unavailable to available. + if (oldMessage != null) ReloadSessions(); + } + + // When transitioning from available to unavailable: + if (oldMessage == null && UnavailableMessage != null) + ClearNewForm(); + } + + 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(); + } + + private void ClearNewForm() + { + CreatingNewSession = false; + NewSessionLocalPath = ""; + NewSessionRemoteHost = ""; + NewSessionRemotePath = ""; + NewSessionStatus = ""; + _remotePickerWindow?.Close(); + } + + [RelayCommand] + private void ReloadSessions() + { + Loading = true; + Error = null; + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + _syncSessionController.RefreshState(cts.Token).ContinueWith(HandleRefresh, CancellationToken.None); + } + + private void HandleRefresh(Task t) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => HandleRefresh(t)); + return; + } + + if (t.IsCompletedSuccessfully) + { + Sessions = t.Result.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList(); + Loading = false; + Error = t.Result.DaemonError; + return; + } + + Error = "Could not list sync sessions: "; + if (t.IsCanceled) Error += new TaskCanceledException(); + else if (t.IsFaulted) Error += t.Exception; + else Error += "no successful result or error"; + Loading = false; + } + + // Overriding AvailableHosts seems to make the ComboBox clear its value, so + // we only do this while the create form is not open. + // Must be called in UI thread. + private void SetAvailableHostsFromRpcModel(RpcModel rpcModel) + { + var hosts = new List(rpcModel.Agents.Count); + // Agents will only contain started agents. + foreach (var agent in rpcModel.Agents) + { + var fqdn = agent.Fqdn + .Select(a => a.Trim('.')) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b); + if (string.IsNullOrWhiteSpace(fqdn)) + continue; + hosts.Add(fqdn); + } + + NewSessionRemoteHost = null; + AvailableHosts = hosts; + } + + [RelayCommand] + private void StartCreatingNewSession() + { + ClearNewForm(); + // Ensure we have a fresh hosts list before we open the form. We don't + // bind directly to the list on RPC state updates as updating the list + // while in use seems to break it. + SetAvailableHostsFromRpcModel(_rpcController.GetState()); + CreatingNewSession = true; + } + + [RelayCommand] + public async Task OpenLocalPathSelectDialog() + { + if (_window is null) return; + + var picker = new FolderPicker + { + SuggestedStartLocation = PickerLocationId.ComputerFolder, + }; + + var hwnd = WindowNative.GetWindowHandle(_window); + InitializeWithWindow.Initialize(picker, hwnd); + + NewSessionLocalPathDialogOpen = true; + try + { + var path = await picker.PickSingleFolderAsync(); + if (path == null) return; + NewSessionLocalPath = path.Path; + } + catch + { + // ignored + } + finally + { + NewSessionLocalPathDialogOpen = false; + } + } + + [RelayCommand] + public void OpenRemotePathSelectDialog() + { + if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) + return; + if (_remotePickerWindow is not null) + { + _remotePickerWindow.Activate(); + return; + } + + NewSessionRemotePathDialogOpen = true; + var pickerViewModel = new DirectoryPickerViewModel(_agentApiClientFactory, NewSessionRemoteHost); + pickerViewModel.PathSelected += OnRemotePathSelected; + + _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel); + if (_window is not null) + _remotePickerWindow.SetParent(_window); + _remotePickerWindow.Closed += (_, _) => + { + _remotePickerWindow = null; + NewSessionRemotePathDialogOpen = false; + }; + _remotePickerWindow.Activate(); + } + + private void OnRemotePathSelected(object? sender, string? path) + { + if (sender is not DirectoryPickerViewModel pickerViewModel) return; + pickerViewModel.PathSelected -= OnRemotePathSelected; + + if (path == null) return; + NewSessionRemotePath = path; + } + + [RelayCommand] + private void CancelNewSession() + { + ClearNewForm(); + } + + private void OnCreateSessionProgress(string message) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => OnCreateSessionProgress(message)); + return; + } + + NewSessionStatus = message; + } + + [RelayCommand] + private async Task ConfirmNewSession() + { + if (OperationInProgress || !NewSessionCreateEnabled) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + try + { + // The controller will send us a state changed event. + await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = NewSessionLocalPath, + }, + Beta = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh, + Host = NewSessionRemoteHost!, + Path = NewSessionRemotePath, + }, + }, OnCreateSessionProgress, cts.Token); + + ClearNewForm(); + } + catch (Exception e) + { + var dialog = new ContentDialog + { + Title = "Failed to create sync session", + Content = $"{e}", + CloseButtonText = "Ok", + XamlRoot = _window?.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + OperationInProgress = false; + NewSessionStatus = ""; + } + } + + public async Task PauseOrResumeSession(string identifier) + { + if (OperationInProgress) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var actionString = "resume/pause"; + try + { + if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session) + throw new InvalidOperationException("Session not found"); + + // The controller will send us a state changed event. + if (session.Model.Paused) + { + actionString = "resume"; + await _syncSessionController.ResumeSyncSession(session.Model.Identifier, cts.Token); + } + else + { + actionString = "pause"; + await _syncSessionController.PauseSyncSession(session.Model.Identifier, cts.Token); + } + } + catch (Exception e) + { + var dialog = new ContentDialog + { + Title = $"Failed to {actionString} sync session", + Content = $"Identifier: {identifier}\n{e}", + CloseButtonText = "Ok", + XamlRoot = _window?.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + OperationInProgress = false; + } + } + + public async Task TerminateSession(string identifier) + { + if (OperationInProgress) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + try + { + if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session) + throw new InvalidOperationException("Session not found"); + + var confirmDialog = new ContentDialog + { + Title = "Terminate sync session", + Content = "Are you sure you want to terminate this sync session?", + PrimaryButtonText = "Terminate", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = _window?.Content.XamlRoot, + }; + var res = await confirmDialog.ShowAsync(); + if (res is not ContentDialogResult.Primary) + return; + + // The controller will send us a state changed event. + await _syncSessionController.TerminateSyncSession(session.Model.Identifier, cts.Token); + } + catch (Exception e) + { + var dialog = new ContentDialog + { + Title = "Failed to terminate sync session", + Content = $"Identifier: {identifier}\n{e}", + CloseButtonText = "Ok", + XamlRoot = _window?.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + OperationInProgress = false; + } + } +} diff --git a/App/ViewModels/SettingsViewModel.cs b/App/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..721ea95 --- /dev/null +++ b/App/ViewModels/SettingsViewModel.cs @@ -0,0 +1,81 @@ +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using System; + +namespace Coder.Desktop.App.ViewModels; + +public partial class SettingsViewModel : ObservableObject +{ + private readonly ILogger _logger; + + [ObservableProperty] + public partial bool ConnectOnLaunch { get; set; } + + [ObservableProperty] + public partial bool StartOnLoginDisabled { get; set; } + + [ObservableProperty] + public partial bool StartOnLogin { get; set; } + + private ISettingsManager _connectSettingsManager; + private CoderConnectSettings _connectSettings = new CoderConnectSettings(); + private IStartupManager _startupManager; + + public SettingsViewModel(ILogger logger, ISettingsManager settingsManager, IStartupManager startupManager) + { + _connectSettingsManager = settingsManager; + _startupManager = startupManager; + _logger = logger; + _connectSettings = settingsManager.Read().GetAwaiter().GetResult(); + StartOnLogin = startupManager.IsEnabled(); + ConnectOnLaunch = _connectSettings.ConnectOnLaunch; + + // Various policies can disable the "Start on login" option. + // We disable the option in the UI if the policy is set. + StartOnLoginDisabled = _startupManager.IsDisabledByPolicy(); + + // Ensure the StartOnLogin property matches the current startup state. + if (StartOnLogin != _startupManager.IsEnabled()) + { + StartOnLogin = _startupManager.IsEnabled(); + } + } + + partial void OnConnectOnLaunchChanged(bool oldValue, bool newValue) + { + if (oldValue == newValue) + return; + try + { + _connectSettings.ConnectOnLaunch = ConnectOnLaunch; + _connectSettingsManager.Write(_connectSettings); + } + catch (Exception ex) + { + _logger.LogError($"Error saving Coder Connect settings: {ex.Message}"); + } + } + + partial void OnStartOnLoginChanged(bool oldValue, bool newValue) + { + if (oldValue == newValue) + return; + try + { + if (StartOnLogin) + { + _startupManager.Enable(); + } + else + { + _startupManager.Disable(); + } + } + catch (Exception ex) + { + _logger.LogError($"Error setting StartOnLogin in registry: {ex.Message}"); + } + } +} diff --git a/App/ViewModels/SignInViewModel.cs b/App/ViewModels/SignInViewModel.cs index ae64f2b..fcd47d4 100644 --- a/App/ViewModels/SignInViewModel.cs +++ b/App/ViewModels/SignInViewModel.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; namespace Coder.Desktop.App.ViewModels; @@ -33,8 +34,6 @@ public partial class SignInViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ApiTokenError))] public partial bool ApiTokenTouched { get; set; } = false; - [ObservableProperty] public partial string? SignInError { get; set; } = null; - [ObservableProperty] public partial bool SignInLoading { get; set; } = false; public string? CoderUrlError => CoderUrlTouched ? _coderUrlError : null; @@ -82,6 +81,29 @@ public SignInViewModel(ICredentialManager credentialManager) _credentialManager = credentialManager; } + // When the URL box loads, get the old URI from the credential manager. + // This is an async operation on paper, but we would expect it to be + // synchronous or extremely quick in practice. + public void CoderUrl_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not TextBox textBox) return; + + var dispatcherQueue = textBox.DispatcherQueue; + _credentialManager.GetSignInUri().ContinueWith(t => + { + if (t.IsCompleted && !string.IsNullOrWhiteSpace(t.Result)) + dispatcherQueue.TryEnqueue(() => + { + if (!CoderUrlTouched) + { + CoderUrl = t.Result; + CoderUrlTouched = true; + textBox.SelectionStart = CoderUrl.Length; + } + }); + }); + } + public void CoderUrl_FocusLost(object sender, RoutedEventArgs e) { CoderUrlTouched = true; @@ -117,7 +139,6 @@ public async Task TokenPage_SignIn(SignInWindow signInWindow) try { SignInLoading = true; - SignInError = null; var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); await _credentialManager.SetCredentials(CoderUrl.Trim(), ApiToken.Trim(), cts.Token); @@ -126,7 +147,14 @@ public async Task TokenPage_SignIn(SignInWindow signInWindow) } catch (Exception e) { - SignInError = $"Failed to sign in: {e}"; + var dialog = new ContentDialog + { + Title = "Failed to sign in", + Content = $"{e}", + CloseButtonText = "Ok", + XamlRoot = signInWindow.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); } finally { diff --git a/App/ViewModels/SyncSessionViewModel.cs b/App/ViewModels/SyncSessionViewModel.cs new file mode 100644 index 0000000..7de6500 --- /dev/null +++ b/App/ViewModels/SyncSessionViewModel.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.ViewModels; + +public partial class SyncSessionViewModel : ObservableObject +{ + public SyncSessionModel Model { get; } + + private FileSyncListViewModel Parent { get; } + + public string Icon => Model.Paused ? "\uE768" : "\uE769"; + + public SyncSessionViewModel(FileSyncListViewModel parent, SyncSessionModel model) + { + Parent = parent; + Model = model; + } + + [RelayCommand] + public async Task PauseOrResumeSession() + { + await Parent.PauseOrResumeSession(Model.Identifier); + } + + [RelayCommand] + public async Task TerminateSession() + { + await Parent.TerminateSession(Model.Identifier); + } + + // Check the comments in FileSyncListMainPage.xaml to see why this tooltip + // stuff is necessary. + private void SetToolTip(FrameworkElement element, string text) + { + // Get current tooltip and compare the text. Setting the tooltip with + // the same text causes it to dismiss itself. + var currentToolTip = ToolTipService.GetToolTip(element) as ToolTip; + if (currentToolTip?.Content as string == text) return; + + ToolTipService.SetToolTip(element, new ToolTip { Content = text }); + } + + public void OnStatusTextLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement element) return; + SetToolTip(element, Model.StatusDetails); + } + + public void OnStatusTextDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + SetToolTip(sender, Model.StatusDetails); + } + + public void OnSizeTextLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement element) return; + SetToolTip(element, Model.SizeDetails); + } + + public void OnSizeTextDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + SetToolTip(sender, Model.SizeDetails); + } +} 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 1fccb7e..8540453 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -1,44 +1,109 @@ 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; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; namespace Coder.Desktop.App.ViewModels; -public partial class TrayWindowViewModel : ObservableObject +public interface IAgentExpanderHost +{ + public void HandleAgentExpanded(Uuid id, bool expanded); +} + +public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost { private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; + private readonly 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; - [ObservableProperty] public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; + // 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))] + public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; // This is a separate property because we need the switch to be 2-way. [ObservableProperty] public partial bool VpnSwitchActive { get; set; } = false; - [ObservableProperty] public partial string? VpnFailedMessage { get; set; } = null; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowEnableSection))] + [NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))] + [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] + [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] + [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] + [NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))] + [NotifyPropertyChangedFor(nameof(ShowFailedSection))] + public partial string? VpnFailedMessage { get; set; } = null; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(NoAgents))] - [NotifyPropertyChangedFor(nameof(AgentOverflow))] - [NotifyPropertyChangedFor(nameof(VisibleAgents))] - public partial List Agents { get; set; } = []; + [NotifyPropertyChangedFor(nameof(VpnStartProgressIsIndeterminate))] + [NotifyPropertyChangedFor(nameof(VpnStartProgressValueOrDefault))] + public partial int? VpnStartProgressValue { get; set; } = null; + + public int VpnStartProgressValueOrDefault => VpnStartProgressValue ?? 0; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VpnStartProgressMessageOrDefault))] + public partial string? VpnStartProgressMessage { get; set; } = null; + + public string VpnStartProgressMessageOrDefault => + string.IsNullOrEmpty(VpnStartProgressMessage) ? VpnStartupProgress.DefaultStartProgressMessage : VpnStartProgressMessage; - public bool NoAgents => Agents.Count == 0; + public bool VpnStartProgressIsIndeterminate => VpnStartProgressValueOrDefault == 0; - public bool AgentOverflow => Agents.Count > MaxAgents; + 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; + + public bool ShowNoAgentsSection => + VpnFailedMessage is null && Agents.Count == 0 && VpnLifecycle is VpnLifecycle.Started; + + public bool ShowAgentsSection => + VpnFailedMessage is null && Agents.Count > 0 && VpnLifecycle is VpnLifecycle.Started; + + public bool ShowFailedSection => VpnFailedMessage is not null; + + public bool ShowAgentOverflowButton => VpnFailedMessage is null && Agents.Count > MaxAgents; [ObservableProperty] [NotifyPropertyChangedFor(nameof(VisibleAgents))] @@ -46,12 +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(IRpcController rpcController, ICredentialManager credentialManager) + public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController, + 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) @@ -61,8 +158,11 @@ public void Initialize(DispatcherQueue dispatcherQueue) _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel); UpdateFromRpcModel(_rpcController.GetState()); - _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel); - UpdateFromCredentialsModel(_credentialManager.GetCredentials()); + _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialModel(credentialModel); + UpdateFromCredentialModel(_credentialManager.GetCachedCredentials()); + + _hostnameSuffixGetter.SuffixChanged += (_, suffix) => HandleHostnameSuffixChanged(suffix); + HandleHostnameSuffixChanged(_hostnameSuffixGetter.GetCachedSuffix()); } private void UpdateFromRpcModel(RpcModel rpcModel) @@ -75,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.GetCredentials(); - 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 @@ -115,110 +222,221 @@ 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, - 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(() => 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) { if (sender is not ToggleSwitch toggleSwitch) return; - VpnFailedMessage = ""; + VpnFailedMessage = null; + + // The start/stop methods will call back to update the state. + if (toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Stopped) + _ = StartVpn(); // in the background + else if (!toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Started) + _ = StopVpn(); // in the background + else + toggleSwitch.IsOn = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started; + } + + private async Task StartVpn() + { + try + { + await _rpcController.StartVpn(); + } + catch (Exception e) + { + VpnFailedMessage = "Failed to start CoderVPN: " + MaybeUnwrapTunnelError(e); + } + } + + private async Task StopVpn() + { try { - // The start/stop methods will call back to update the state. - if (toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Stopped) - _rpcController.StartVpn(); - else if (!toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Started) - _rpcController.StopVpn(); - else - toggleSwitch.IsOn = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started; + await _rpcController.StopVpn(); } - catch + catch (Exception e) { - // TODO: display error - VpnFailedMessage = e.ToString(); + VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e); } } + private static string MaybeUnwrapTunnelError(Exception e) + { + if (e is VpnLifecycleException vpnError) return vpnError.Message; + return e.ToString(); + } + [RelayCommand] - public void ToggleShowAllAgents() + private void ToggleShowAllAgents() { ShowAllAgents = !ShowAllAgents; } [RelayCommand] - public void SignOut() + private void ShowFileSyncListWindow() + { + // This is safe against concurrent access since it all happens in the + // UI thread. + if (_fileSyncListWindow != null) + { + _fileSyncListWindow.Activate(); + return; + } + + _fileSyncListWindow = _services.GetRequiredService(); + _fileSyncListWindow.Closed += (_, _) => _fileSyncListWindow = null; + _fileSyncListWindow.Activate(); + } + + [RelayCommand] + 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 new file mode 100644 index 0000000..ce1623b --- /dev/null +++ b/App/Views/DirectoryPickerWindow.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs new file mode 100644 index 0000000..d2eb320 --- /dev/null +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -0,0 +1,94 @@ +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; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using WinRT.Interop; +using WinUIEx; + +namespace Coder.Desktop.App.Views; + +public sealed partial class DirectoryPickerWindow : WindowEx +{ + public DirectoryPickerWindow(DirectoryPickerViewModel viewModel) + { + InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); + + viewModel.Initialize(this, DispatcherQueue); + RootFrame.Content = new DirectoryPickerMainPage(viewModel); + + // This will be moved to the center of the parent window in SetParent. + this.CenterOnScreen(); + } + + public void SetParent(Window parentWindow) + { + // Move the window to the center of the parent window. + var scale = DisplayScale.WindowScale(parentWindow); + var windowPos = new PointInt32( + parentWindow.AppWindow.Position.X + parentWindow.AppWindow.Size.Width / 2 - AppWindow.Size.Width / 2, + parentWindow.AppWindow.Position.Y + parentWindow.AppWindow.Size.Height / 2 - AppWindow.Size.Height / 2 + ); + + // Ensure we stay within the display. + var workArea = DisplayArea.GetFromPoint(parentWindow.AppWindow.Position, DisplayAreaFallback.Primary).WorkArea; + if (windowPos.X + AppWindow.Size.Width > workArea.X + workArea.Width) // right edge + windowPos.X = workArea.X + workArea.Width - AppWindow.Size.Width; + if (windowPos.Y + AppWindow.Size.Height > workArea.Y + workArea.Height) // bottom edge + windowPos.Y = workArea.Y + workArea.Height - AppWindow.Size.Height; + if (windowPos.X < workArea.X) // left edge + windowPos.X = workArea.X; + if (windowPos.Y < workArea.Y) // top edge + windowPos.Y = workArea.Y; + + AppWindow.Move(windowPos); + + var parentHandle = WindowNative.GetWindowHandle(parentWindow); + var thisHandle = WindowNative.GetWindowHandle(this); + + // Set the parent window in win API. + NativeApi.SetWindowParent(thisHandle, parentHandle); + + // Override the presenter, which allows us to enable modal-like + // behavior for this window: + // - Disables the parent window + // - Any activations of the parent window will play a bell sound and + // focus the modal window + // + // This behavior is very similar to the native file/directory picker on + // Windows. + var presenter = OverlappedPresenter.CreateForDialog(); + presenter.IsModal = true; + AppWindow.SetPresenter(presenter); + AppWindow.Show(); + + // Cascade close events. + parentWindow.Closed += OnParentWindowClosed; + Closed += (_, _) => + { + parentWindow.Closed -= OnParentWindowClosed; + parentWindow.Activate(); + }; + } + + private void OnParentWindowClosed(object? sender, WindowEventArgs e) + { + Close(); + } + + private static class NativeApi + { + [DllImport("user32.dll")] + private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + public static void SetWindowParent(IntPtr window, IntPtr parent) + { + SetWindowLongPtr(window, -8, parent); + } + } +} diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml new file mode 100644 index 0000000..991d02a --- /dev/null +++ b/App/Views/FileSyncListWindow.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs new file mode 100644 index 0000000..9d8510b --- /dev/null +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -0,0 +1,24 @@ +using Coder.Desktop.App.Utils; +using Coder.Desktop.App.ViewModels; +using Coder.Desktop.App.Views.Pages; +using Microsoft.UI.Xaml.Media; +using WinUIEx; + +namespace Coder.Desktop.App.Views; + +public sealed partial class FileSyncListWindow : WindowEx +{ + public readonly FileSyncListViewModel ViewModel; + + public FileSyncListWindow(FileSyncListViewModel viewModel) + { + ViewModel = viewModel; + InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); + + ViewModel.Initialize(this, DispatcherQueue); + RootFrame.Content = new FileSyncListMainPage(ViewModel); + + this.CenterOnScreen(); + } +} 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs new file mode 100644 index 0000000..a677522 --- /dev/null +++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs @@ -0,0 +1,28 @@ +using Coder.Desktop.App.ViewModels; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Views.Pages; + +public sealed partial class FileSyncListMainPage : Page +{ + public FileSyncListViewModel ViewModel; + + public FileSyncListMainPage(FileSyncListViewModel viewModel) + { + ViewModel = viewModel; // already initialized + InitializeComponent(); + } + + // Adds a tooltip with the full text when it's ellipsized. + private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedChangedEventArgs e) + { + ToolTipService.SetToolTip(sender, null); + if (!sender.IsTextTrimmed) return; + + var toolTip = new ToolTip + { + Content = sender.Text, + }; + ToolTipService.SetToolTip(sender, toolTip); + } +} diff --git a/App/Views/Pages/SettingsMainPage.xaml b/App/Views/Pages/SettingsMainPage.xaml new file mode 100644 index 0000000..36b471d --- /dev/null +++ b/App/Views/Pages/SettingsMainPage.xaml @@ -0,0 +1,50 @@ + + + + + + + 4 + + + + + + + + + + + + + + + + + + diff --git a/App/Views/Pages/SettingsMainPage.xaml.cs b/App/Views/Pages/SettingsMainPage.xaml.cs new file mode 100644 index 0000000..f2494b1 --- /dev/null +++ b/App/Views/Pages/SettingsMainPage.xaml.cs @@ -0,0 +1,15 @@ +using Coder.Desktop.App.ViewModels; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Views.Pages; + +public sealed partial class SettingsMainPage : Page +{ + public SettingsViewModel ViewModel; + + public SettingsMainPage(SettingsViewModel viewModel) + { + ViewModel = viewModel; + InitializeComponent(); + } +} diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml index 93a1796..7f20b69 100644 --- a/App/Views/Pages/SignInTokenPage.xaml +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -6,8 +6,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" - Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> + mc:Ignorable="d"> + Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> + CommandParameter="{x:Bind SignInWindow}" />
public class CombinationDownloadValidator : IDownloadValidator { - private readonly IDownloadValidator[] _validators; + private readonly List _validators; /// Validators to run public CombinationDownloadValidator(params IDownloadValidator[] validators) { - _validators = validators; + _validators = validators.ToList(); } public async Task ValidateAsync(string path, CancellationToken ct = default) @@ -116,6 +251,11 @@ public async Task ValidateAsync(string path, CancellationToken ct = default) foreach (var validator in _validators) await validator.ValidateAsync(path, ct); } + + public void Add(IDownloadValidator validator) + { + _validators.Add(validator); + } } /// @@ -147,9 +287,24 @@ public async Task StartDownloadAsync(HttpRequestMessage req, strin { while (true) { + ct.ThrowIfCancellationRequested(); var task = _downloads.GetOrAdd(destinationPath, _ => new DownloadTask(_logger, req, destinationPath, validator)); - await task.EnsureStartedAsync(ct); + // EnsureStarted is a no-op if we didn't create a new DownloadTask. + // So, we will only remove the destination once for each time we start a new task. + task.EnsureStarted(tsk => + { + // remove the key first, before checking the exception, to ensure + // we still clean up. + _downloads.TryRemove(destinationPath, out _); + if (tsk.Exception == null) return; + + if (tsk.Exception.InnerException != null) + ExceptionDispatchInfo.Capture(tsk.Exception.InnerException).Throw(); + + // not sure if this is hittable, but just in case: + throw tsk.Exception; + }, ct); // If the existing (or new) task is for the same URL, return it. if (task.Request.RequestUri == req.RequestUri) @@ -163,37 +318,56 @@ public async Task StartDownloadAsync(HttpRequestMessage req, strin _logger.LogWarning( "Download for '{DestinationPath}' is already in progress, but is for a different Url - awaiting completion", destinationPath); - await task.Task; + await TaskOrCancellation(task.Task, ct); + } + } + + /// + /// TaskOrCancellation waits for either the task to complete, or the given token to be canceled. + /// + internal static async Task TaskOrCancellation(Task task, CancellationToken cancellationToken) + { + var cancellationTask = new TaskCompletionSource(); + await using (cancellationToken.Register(() => cancellationTask.TrySetCanceled())) + { + // Wait for either the task or the cancellation + var completedTask = await Task.WhenAny(task, cancellationTask.Task); + // Await to propagate exceptions, if any + await completedTask; } } } /// -/// Downloads an Url to a file on disk. The download will be written to a temporary file first, then moved to the final +/// Downloads a Url to a file on disk. The download will be written to a temporary file first, then moved to the final /// destination. The SHA1 of any existing file will be calculated and used as an ETag to avoid downloading the file if /// it hasn't changed. /// public class DownloadTask { - private const int BufferSize = 4096; + private const int BufferSize = 64 * 1024; + private const string XOriginalContentLengthHeader = "X-Original-Content-Length"; // overrides Content-Length if available - private static readonly HttpClient HttpClient = new(); + private static readonly HttpClient HttpClient = new(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All, + }); private readonly string _destinationDirectory; private readonly ILogger _logger; private readonly RaiiSemaphoreSlim _semaphore = new(1, 1); private readonly IDownloadValidator _validator; - public readonly string DestinationPath; + private readonly string _destinationPath; + private readonly string _tempDestinationPath; public readonly HttpRequestMessage Request; - public readonly string TempDestinationPath; - public ulong? TotalBytes { get; private set; } - public ulong BytesRead { get; private set; } public Task Task { get; private set; } = null!; // Set in EnsureStartedAsync - - public double? Progress => TotalBytes == null ? null : (double)BytesRead / TotalBytes.Value; + public bool DownloadStarted { get; private set; } // Whether we've received headers yet and started the actual download + public ulong BytesWritten { get; private set; } + public ulong? BytesTotal { get; private set; } + public double? Progress => BytesTotal == null ? null : (double)BytesWritten / BytesTotal.Value; public bool IsCompleted => Task.IsCompleted; internal DownloadTask(ILogger logger, HttpRequestMessage req, string destinationPath, IDownloadValidator validator) @@ -204,27 +378,25 @@ internal DownloadTask(ILogger logger, HttpRequestMessage req, string destination if (string.IsNullOrWhiteSpace(destinationPath)) throw new ArgumentException("Destination path must not be empty", nameof(destinationPath)); - DestinationPath = Path.GetFullPath(destinationPath); - if (Path.EndsInDirectorySeparator(DestinationPath)) - throw new ArgumentException($"Destination path '{DestinationPath}' must not end in a directory separator", + _destinationPath = Path.GetFullPath(destinationPath); + if (Path.EndsInDirectorySeparator(_destinationPath)) + throw new ArgumentException($"Destination path '{_destinationPath}' must not end in a directory separator", nameof(destinationPath)); - _destinationDirectory = Path.GetDirectoryName(DestinationPath) + _destinationDirectory = Path.GetDirectoryName(_destinationPath) ?? throw new ArgumentException( - $"Destination path '{DestinationPath}' must have a parent directory", + $"Destination path '{_destinationPath}' must have a parent directory", nameof(destinationPath)); - TempDestinationPath = Path.Combine(_destinationDirectory, "." + Path.GetFileName(DestinationPath) + + _tempDestinationPath = Path.Combine(_destinationDirectory, "." + Path.GetFileName(_destinationPath) + ".download-" + Path.GetRandomFileName()); } - internal async Task EnsureStartedAsync(CancellationToken ct = default) + internal void EnsureStarted(Action continuation, CancellationToken ct = default) { - using var _ = await _semaphore.LockAsync(ct); + using var _ = _semaphore.Lock(); if (Task == null!) - Task = await StartDownloadAsync(ct); - - return Task; + Task = Start(ct).ContinueWith(continuation, ct); } /// @@ -232,15 +404,15 @@ internal async Task EnsureStartedAsync(CancellationToken ct = default) /// and the download will continue in the background. The provided CancellationToken can be used to cancel the /// download. /// - private async Task StartDownloadAsync(CancellationToken ct = default) + private async Task Start(CancellationToken ct = default) { Directory.CreateDirectory(_destinationDirectory); // If the destination path exists, generate a Coder SHA1 ETag and send // it in the If-None-Match header to the server. - if (File.Exists(DestinationPath)) + if (File.Exists(_destinationPath)) { - await using var stream = File.OpenRead(DestinationPath); + await using var stream = File.OpenRead(_destinationPath); var etag = Convert.ToHexString(await SHA1.HashDataAsync(stream, ct)).ToLower(); Request.Headers.Add("If-None-Match", "\"" + etag + "\""); } @@ -251,16 +423,15 @@ private async Task StartDownloadAsync(CancellationToken ct = default) _logger.LogInformation("File has not been modified, skipping download"); try { - await _validator.ValidateAsync(DestinationPath, ct); + await _validator.ValidateAsync(_destinationPath, ct); } catch (Exception e) { - _logger.LogWarning(e, "Existing file '{DestinationPath}' failed custom validation", DestinationPath); + _logger.LogWarning(e, "Existing file '{DestinationPath}' failed custom validation", _destinationPath); throw new Exception("Existing file failed validation after 304 Not Modified", e); } - Task = Task.CompletedTask; - return Task; + return; } if (res.StatusCode != HttpStatusCode.OK) @@ -279,29 +450,41 @@ private async Task StartDownloadAsync(CancellationToken ct = default) } if (res.Content.Headers.ContentLength >= 0) - TotalBytes = (ulong)res.Content.Headers.ContentLength; + BytesTotal = (ulong)res.Content.Headers.ContentLength; - FileStream tempFile; - try + // X-Original-Content-Length overrules Content-Length if set. + if (res.Headers.TryGetValues(XOriginalContentLengthHeader, out var headerValues)) { - tempFile = File.Create(TempDestinationPath, BufferSize, - FileOptions.Asynchronous | FileOptions.SequentialScan); - } - catch (Exception e) - { - _logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", TempDestinationPath); - throw; + // If there are multiple we only look at the first one. + var headerValue = headerValues.ToList().FirstOrDefault(); + if (!string.IsNullOrEmpty(headerValue) && ulong.TryParse(headerValue, out var originalContentLength)) + BytesTotal = originalContentLength; + else + _logger.LogWarning( + "Failed to parse {XOriginalContentLengthHeader} header value '{HeaderValue}'", + XOriginalContentLengthHeader, headerValue); } - Task = DownloadAsync(res, tempFile, ct); - return Task; + await Download(res, ct); } - private async Task DownloadAsync(HttpResponseMessage res, FileStream tempFile, CancellationToken ct) + private async Task Download(HttpResponseMessage res, CancellationToken ct) { + DownloadStarted = true; try { var sha1 = res.Headers.Contains("ETag") ? SHA1.Create() : null; + FileStream tempFile; + try + { + tempFile = File.Create(_tempDestinationPath, BufferSize, FileOptions.SequentialScan); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", _tempDestinationPath); + throw; + } + await using (tempFile) { var stream = await res.Content.ReadAsStreamAsync(ct); @@ -311,13 +494,14 @@ private async Task DownloadAsync(HttpResponseMessage res, FileStream tempFile, C { await tempFile.WriteAsync(buffer.AsMemory(0, n), ct); sha1?.TransformBlock(buffer, 0, n, null, 0); - BytesRead += (ulong)n; + BytesWritten += (ulong)n; } } - if (TotalBytes != null && BytesRead != TotalBytes) + BytesTotal ??= BytesWritten; + if (BytesWritten != BytesTotal) throw new IOException( - $"Downloaded file size does not match response Content-Length: Content-Length={TotalBytes}, BytesRead={BytesRead}"); + $"Downloaded file size does not match expected response content length: Expected={BytesTotal}, BytesWritten={BytesWritten}"); // Verify the ETag if it was sent by the server. if (res.Headers.Contains("ETag") && sha1 != null) @@ -332,26 +516,34 @@ private async Task DownloadAsync(HttpResponseMessage res, FileStream tempFile, C try { - await _validator.ValidateAsync(TempDestinationPath, ct); + await _validator.ValidateAsync(_tempDestinationPath, ct); } catch (Exception e) { _logger.LogWarning(e, "Downloaded file '{TempDestinationPath}' failed custom validation", - TempDestinationPath); + _tempDestinationPath); throw new HttpRequestException("Downloaded file failed validation", e); } - File.Move(TempDestinationPath, DestinationPath, true); + File.Move(_tempDestinationPath, _destinationPath, true); } - finally + catch { #if DEBUG _logger.LogWarning("Not deleting temporary file '{TempDestinationPath}' in debug mode", - TempDestinationPath); + _tempDestinationPath); #else - if (File.Exists(TempDestinationPath)) - File.Delete(TempDestinationPath); + try + { + if (File.Exists(_tempDestinationPath)) + File.Delete(_tempDestinationPath); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to delete temporary file '{TempDestinationPath}'", _tempDestinationPath); + } #endif + throw; } } } diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 93c08dd..fdb62af 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -1,7 +1,7 @@ using System.Runtime.InteropServices; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Proto; using Coder.Desktop.Vpn.Utilities; -using CoderSdk; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Semver; @@ -16,12 +16,6 @@ public enum TunnelStatus Stopped, } -public class ServerVersion -{ - public required string String { get; set; } - public required SemVersion SemVersion { get; set; } -} - public interface IManager : IDisposable { public Task StopAsync(CancellationToken ct = default); @@ -32,14 +26,12 @@ public interface IManager : IDisposable /// public class Manager : IManager { - // TODO: determine a suitable value for this - private static readonly SemVersionRange ServerVersionRange = SemVersionRange.All; - private readonly ManagerConfig _config; private readonly IDownloader _downloader; private readonly ILogger _logger; private readonly ITunnelSupervisor _tunnelSupervisor; private readonly IManagerRpc _managerRpc; + private readonly ITelemetryEnricher _telemetryEnricher; private volatile TunnelStatus _status = TunnelStatus.Stopped; @@ -55,7 +47,7 @@ public class Manager : IManager // ReSharper disable once ConvertToPrimaryConstructor public Manager(IOptions config, ILogger logger, IDownloader downloader, - ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc) + ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc, ITelemetryEnricher telemetryEnricher) { _config = config.Value; _logger = logger; @@ -63,6 +55,7 @@ public Manager(IOptions config, ILogger logger, IDownloa _tunnelSupervisor = tunnelSupervisor; _managerRpc = managerRpc; _managerRpc.OnReceive += HandleClientRpcMessage; + _telemetryEnricher = telemetryEnricher; } public void Dispose() @@ -138,10 +131,12 @@ private async ValueTask HandleClientMessageStart(ClientMessage me { try { + await BroadcastStartProgress(StartProgressStage.Initializing, cancellationToken: ct); + var serverVersion = await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken, ct); if (_status == TunnelStatus.Started && _lastStartRequest != null && - _lastStartRequest.Equals(message.Start) && _lastServerVersion?.String == serverVersion.String) + _lastStartRequest.Equals(message.Start) && _lastServerVersion?.RawString == serverVersion.RawString) { // The client is requesting to start an identical tunnel while // we're already running it. @@ -158,17 +153,21 @@ private async ValueTask HandleClientMessageStart(ClientMessage me _lastServerVersion = serverVersion; // TODO: each section of this operation needs a timeout + // Stop the tunnel if it's running so we don't have to worry about // permissions issues when replacing the binary. await _tunnelSupervisor.StopAsync(ct); + await DownloadTunnelBinaryAsync(message.Start.CoderUrl, serverVersion.SemVersion, ct); + + await BroadcastStartProgress(StartProgressStage.Finalizing, cancellationToken: ct); await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMessage, HandleTunnelRpcError, ct); var reply = await _tunnelSupervisor.SendRequestAwaitReply(new ManagerMessage { - Start = message.Start, + Start = _telemetryEnricher.EnrichStartRequest(message.Start), }, ct); if (reply.MsgCase != TunnelMessage.MsgOneofCase.Start) throw new InvalidOperationException("Tunnel did not reply with a Start response"); @@ -244,6 +243,9 @@ private void HandleTunnelRpcMessage(ReplyableRpcMessage CurrentStatus(CancellationToken ct = default) private async Task BroadcastStatus(TunnelStatus? newStatus = null, CancellationToken ct = default) { if (newStatus != null) _status = newStatus.Value; - await _managerRpc.BroadcastAsync(new ServiceMessage + await FallibleBroadcast(new ServiceMessage { Status = await CurrentStatus(ct), }, ct); } + private async Task FallibleBroadcast(ServiceMessage message, CancellationToken ct = default) + { + // Broadcast the messages out with a low timeout. If clients don't + // receive broadcasts in time, it's not a big deal. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromMilliseconds(30)); + try + { + await _managerRpc.BroadcastAsync(message, cts.Token); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not broadcast low priority message to all RPC clients: {Message}", message); + } + } + private void HandleTunnelRpcError(Exception e) { _logger.LogError(e, "Manager<->Tunnel RPC error"); @@ -373,20 +391,11 @@ private async ValueTask CheckServerVersionAndCredentials(string b var buildInfo = await client.GetBuildInfo(ct); _logger.LogInformation("Fetched server version '{ServerVersion}'", buildInfo.Version); - if (buildInfo.Version.StartsWith('v')) buildInfo.Version = buildInfo.Version[1..]; - var serverVersion = SemVersion.Parse(buildInfo.Version); - if (!serverVersion.Satisfies(ServerVersionRange)) - throw new InvalidOperationException( - $"Server version '{serverVersion}' is not within required server version range '{ServerVersionRange}'"); - + var serverVersion = ServerVersionUtilities.ParseAndValidateServerVersion(buildInfo.Version); var user = await client.GetUser(User.Me, ct); _logger.LogInformation("Authenticated to server as '{Username}'", user.Username); - return new ServerVersion - { - String = buildInfo.Version, - SemVersion = serverVersion, - }; + return serverVersion; } /// @@ -417,21 +426,85 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected _logger.LogInformation("Downloading VPN binary from '{url}' to '{DestinationPath}'", url, _config.TunnelBinaryPath); var req = new HttpRequestMessage(HttpMethod.Get, url); - var validators = new NullDownloadValidator(); - // TODO: re-enable when the binaries are signed and have versions - /* - var validators = new CombinationDownloadValidator( - AuthenticodeDownloadValidator.Coder, - new AssemblyVersionDownloadValidator( - $"{expectedVersion.Major}.{expectedVersion.Minor}.{expectedVersion.Patch}.0") - ); - */ + + var validators = new CombinationDownloadValidator(); + if (!string.IsNullOrEmpty(_config.TunnelBinarySignatureSigner)) + { + _logger.LogDebug("Adding Authenticode signature validator for signer '{Signer}'", + _config.TunnelBinarySignatureSigner); + validators.Add(new AuthenticodeDownloadValidator(_config.TunnelBinarySignatureSigner)); + } + else + { + _logger.LogDebug("Skipping Authenticode signature validation"); + } + + if (!_config.TunnelBinaryAllowVersionMismatch) + { + _logger.LogDebug("Adding version validator for version '{ExpectedVersion}'", expectedVersion); + validators.Add(new AssemblyVersionDownloadValidator((int)expectedVersion.Major, (int)expectedVersion.Minor, + (int)expectedVersion.Patch)); + } + else + { + _logger.LogDebug("Skipping tunnel binary version validation"); + } + + // Note: all ETag, signature and version validation is performed by the + // DownloadTask. var downloadTask = await _downloader.StartDownloadAsync(req, _config.TunnelBinaryPath, validators, ct); - // TODO: monitor and report progress when we have a mechanism to do so + // Wait for the download to complete, sending progress updates every + // 50ms. + while (true) + { + // Wait for the download to complete, or for a short delay before + // we send a progress update. + var delayTask = Task.Delay(TimeSpan.FromMilliseconds(50), ct); + var winner = await Task.WhenAny([ + downloadTask.Task, + delayTask, + ]); + if (winner == downloadTask.Task) + break; + + // Task.WhenAny will not throw if the winner was cancelled, so + // check CT afterward and not beforehand. + ct.ThrowIfCancellationRequested(); + + if (!downloadTask.DownloadStarted) + // Don't send progress updates if we don't know what the + // progress is yet. + continue; + + var progress = new StartProgressDownloadProgress + { + BytesWritten = downloadTask.BytesWritten, + }; + if (downloadTask.BytesTotal != null) + progress.BytesTotal = downloadTask.BytesTotal.Value; + + await BroadcastStartProgress(StartProgressStage.Downloading, progress, ct); + } - // Awaiting this will check the checksum (via the ETag) if the file - // exists, and will also validate the signature and version. + // Await again to re-throw any exceptions that occurred during the + // download. await downloadTask.Task; + + // We don't send a broadcast here as we immediately send one in the + // parent routine. + _logger.LogInformation("Completed downloading VPN binary"); + } + + private async Task BroadcastStartProgress(StartProgressStage stage, StartProgressDownloadProgress? downloadProgress = null, CancellationToken cancellationToken = default) + { + await FallibleBroadcast(new ServiceMessage + { + StartProgress = new StartProgress + { + Stage = stage, + DownloadProgress = downloadProgress, + }, + }, cancellationToken); } } diff --git a/Vpn.Service/ManagerConfig.cs b/Vpn.Service/ManagerConfig.cs index bfd7ff5..c60b1b8 100644 --- a/Vpn.Service/ManagerConfig.cs +++ b/Vpn.Service/ManagerConfig.cs @@ -2,6 +2,11 @@ namespace Coder.Desktop.Vpn.Service; +// These values are the config option names used in the registry. Any option +// here can be configured with `(Debug)?Manager:OptionName` in the registry. +// +// They should not be changed without backwards compatibility considerations. +// If changed here, they should also be changed in the installer. public class ManagerConfig { [Required] @@ -10,5 +15,8 @@ public class ManagerConfig [Required] public string TunnelBinaryPath { get; set; } = @"C:\coder-vpn.exe"; - [Required] public string LogFileLocation { get; set; } = @"C:\coder-desktop-service.log"; + // If empty, signatures will not be verified. + [Required] public string TunnelBinarySignatureSigner { get; set; } = "Coder Technologies Inc."; + + [Required] public bool TunnelBinaryAllowVersionMismatch { get; set; } = false; } diff --git a/Vpn.Service/ManagerRpc.cs b/Vpn.Service/ManagerRpc.cs index c23752f..4920570 100644 --- a/Vpn.Service/ManagerRpc.cs +++ b/Vpn.Service/ManagerRpc.cs @@ -127,14 +127,20 @@ public async Task ExecuteAsync(CancellationToken stoppingToken) public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct) { + // Sends messages to all clients simultaneously and waits for them all + // to send or fail/timeout. + // // Looping over a ConcurrentDictionary is exception-safe, but any items // added or removed during the loop may or may not be included. - foreach (var (clientId, client) in _activeClients) + await Task.WhenAll(_activeClients.Select(async item => + { try { - var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(5 * 1000); - await client.Speaker.SendMessage(message, cts.Token); + // Enforce upper bound in case a CT with a timeout wasn't + // supplied. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + await item.Value.Speaker.SendMessage(message, cts.Token); } catch (ObjectDisposedException) { @@ -142,11 +148,12 @@ public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct) } catch (Exception e) { - _logger.LogWarning(e, "Failed to send message to client {ClientId}", clientId); + _logger.LogWarning(e, "Failed to send message to client {ClientId}", item.Key); // TODO: this should probably kill the client, but due to the // async nature of the client handling, calling Dispose // will not remove the client from the active clients list } + })); } private async Task HandleRpcClientAsync(ulong clientId, Speaker speaker, diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs index e5a7d80..094875d 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -3,33 +3,36 @@ using Microsoft.Extensions.Hosting; using Microsoft.Win32; using Serilog; +using ILogger = Serilog.ILogger; namespace Coder.Desktop.Vpn.Service; public static class Program { + // These values are the service name and the prefix on registry value names. + // They should not be changed without backwards compatibility + // considerations. If changed here, they should also be changed in the + // installer. #if !DEBUG private const string ServiceName = "Coder Desktop"; + private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\VpnService"; + private const string DefaultLogLevel = "Information"; #else + // This value matches Create-Service.ps1. private const string ServiceName = "Coder Desktop (Debug)"; + private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugVpnService"; + private const string DefaultLogLevel = "Debug"; #endif - private const string ConsoleOutputTemplate = - "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}"; - - private const string FileOutputTemplate = - "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}"; + private const string ManagerConfigSection = "Manager"; private static ILogger MainLogger => Log.ForContext("SourceContext", "Coder.Desktop.Vpn.Service.Program"); - private static LoggerConfiguration BaseLogConfig => new LoggerConfiguration() - .Enrich.FromLogContext() - .MinimumLevel.Debug() - .WriteTo.Console(outputTemplate: ConsoleOutputTemplate); - public static async Task Main(string[] args) { - Log.Logger = BaseLogConfig.CreateLogger(); + // This logger will only be used until we load our full logging configuration and replace it. + Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console() + .CreateLogger(); MainLogger.Information("Application is starting"); try { @@ -51,35 +54,39 @@ public static async Task Main(string[] args) private static async Task BuildAndRun(string[] args) { var builder = Host.CreateApplicationBuilder(args); + var configBuilder = builder.Configuration as IConfigurationBuilder; // Configuration sources builder.Configuration.Sources.Clear(); - (builder.Configuration as IConfigurationBuilder).Add( - new RegistryConfigurationSource(Registry.LocalMachine, @"SOFTWARE\Coder Desktop")); + AddDefaultConfig(configBuilder); + configBuilder.Add( + new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey)); builder.Configuration.AddEnvironmentVariables("CODER_MANAGER_"); builder.Configuration.AddCommandLine(args); // Options types (these get registered as IOptions singletons) builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection("Manager")) - .ValidateDataAnnotations() - .PostConfigure(config => - { - Log.Logger = BaseLogConfig - .WriteTo.File(config.LogFileLocation, outputTemplate: FileOutputTemplate) - .CreateLogger(); - }); + .Bind(builder.Configuration.GetSection(ManagerConfigSection)) + .ValidateDataAnnotations(); // Logging - builder.Services.AddSerilog(); + builder.Services.AddSerilog((_, loggerConfig) => + { + loggerConfig.ReadFrom.Configuration(builder.Configuration); + }); // Singletons builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Services + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + + // Either run as a Windows service or a console application if (!Environment.UserInteractive) { MainLogger.Information("Running as a windows service"); @@ -90,9 +97,32 @@ private static async Task BuildAndRun(string[] args) MainLogger.Information("Running as a console application"); } - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); + var host = builder.Build(); + Log.Logger = (ILogger)host.Services.GetService(typeof(ILogger))!; + MainLogger.Information("Application is starting"); + + await host.RunAsync(); + } + + private static void AddDefaultConfig(IConfigurationBuilder builder) + { + builder.AddInMemoryCollection(new Dictionary + { + ["Serilog:Using:0"] = "Serilog.Sinks.File", + ["Serilog:Using:1"] = "Serilog.Sinks.Console", + + ["Serilog:MinimumLevel"] = DefaultLogLevel, + ["Serilog:Enrich:0"] = "FromLogContext", + + ["Serilog:WriteTo:0:Name"] = "File", + ["Serilog:WriteTo:0:Args:path"] = @"C:\coder-desktop-service.log", + ["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", - await builder.Build().RunAsync(); + ["Serilog:WriteTo:1:Name"] = "Console", + ["Serilog:WriteTo:1:Args:outputTemplate"] = + "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", + }); } } diff --git a/Vpn.Service/TelemetryEnricher.cs b/Vpn.Service/TelemetryEnricher.cs new file mode 100644 index 0000000..2169334 --- /dev/null +++ b/Vpn.Service/TelemetryEnricher.cs @@ -0,0 +1,51 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Coder.Desktop.Vpn.Proto; +using Microsoft.Win32; + +namespace Coder.Desktop.Vpn.Service; + +// +// ITelemetryEnricher contains methods for enriching messages with telemetry +// information +// +public interface ITelemetryEnricher +{ + public StartRequest EnrichStartRequest(StartRequest original); +} + +public class TelemetryEnricher : ITelemetryEnricher +{ + private readonly string? _version; + private readonly string? _deviceID; + + public TelemetryEnricher() + { + var assembly = Assembly.GetExecutingAssembly(); + _version = assembly.GetName().Version?.ToString(); + + using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SQMClient"); + if (key != null) + { + // this is the "Device ID" shown in settings. I don't think it's personally + // identifiable, but let's hash it just to be sure. + var deviceID = key.GetValue("MachineId") as string; + if (!string.IsNullOrEmpty(deviceID)) + { + var idBytes = Encoding.UTF8.GetBytes(deviceID); + var hash = SHA256.HashData(idBytes); + _deviceID = Convert.ToBase64String(hash); + } + } + } + + public StartRequest EnrichStartRequest(StartRequest original) + { + var req = original.Clone(); + req.DeviceOs = "Windows"; + if (_version != null) req.CoderDesktopVersion = _version; + if (_deviceID != null) req.DeviceId = _deviceID; + return req; + } +} diff --git a/Vpn.Service/TunnelSupervisor.cs b/Vpn.Service/TunnelSupervisor.cs index a323cac..7dd6738 100644 --- a/Vpn.Service/TunnelSupervisor.cs +++ b/Vpn.Service/TunnelSupervisor.cs @@ -99,18 +99,16 @@ public async Task StartAsync(string binPath, }, }; // TODO: maybe we should change the log format in the inner binary - // to something without a timestamp - var outLogger = Log.ForContext("SourceContext", "coder-vpn.exe[OUT]"); - var errLogger = Log.ForContext("SourceContext", "coder-vpn.exe[ERR]"); + // to something without a timestamp _subprocess.OutputDataReceived += (_, args) => { if (!string.IsNullOrWhiteSpace(args.Data)) - outLogger.Debug("{Data}", args.Data); + _logger.LogInformation("stdout: {Data}", args.Data); }; _subprocess.ErrorDataReceived += (_, args) => { if (!string.IsNullOrWhiteSpace(args.Data)) - errLogger.Debug("{Data}", args.Data); + _logger.LogInformation("stderr: {Data}", args.Data); }; // Pass the other end of the pipes to the subprocess and dispose diff --git a/Vpn.Service/Vpn.Service.csproj b/Vpn.Service/Vpn.Service.csproj index eed5386..aaed3cc 100644 --- a/Vpn.Service/Vpn.Service.csproj +++ b/Vpn.Service/Vpn.Service.csproj @@ -7,6 +7,7 @@ enable enable true + 13 CoderVpnService coder.ico @@ -20,16 +21,17 @@ - + - - - + + + + diff --git a/Vpn.Service/packages.lock.json b/Vpn.Service/packages.lock.json index b2fba99..09c7b76 100644 --- a/Vpn.Service/packages.lock.json +++ b/Vpn.Service/packages.lock.json @@ -4,53 +4,53 @@ "net8.0-windows7.0": { "Microsoft.Extensions.Hosting": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "3wZNcVvC8RW44HDqqmIq+BqF5pgmTQdbNdR9NyYw33JSMnJuclwoJ2PEkrJ/KvD1U/hmqHVL3l5If+Hn3D1fWA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.Configuration.CommandLine": "9.0.1", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.Configuration.UserSecrets": "9.0.1", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Logging.Console": "9.0.1", - "Microsoft.Extensions.Logging.Debug": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "Microsoft.Extensions.Logging.EventSource": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "1rZwLE+tTUIyZRUzmlk/DQj+v+Eqox+rjb+X7Fi+cYTbQfIZPYwpf1pVybsV3oje8+Pe4GaNukpBVUlPYeQdeQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.Configuration.CommandLine": "9.0.4", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.Configuration.UserSecrets": "9.0.4", + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Logging.Console": "9.0.4", + "Microsoft.Extensions.Logging.Debug": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "Microsoft.Extensions.Logging.EventSource": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Hosting.WindowsServices": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "FLapgOXQzPjUsbMqjjagCFCiGjroRmrmHQVK3/PEovRIvDU6nLk7KKs4PalzEHaIfqG+PySlY/BeLTyZtjcshg==", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "QFeUS0NG4Kwq91Mf1WzVZSbBtw+nKxyOQTi4xTRUEQ2gC7HWiyCUiX0arMJxt9lWwbjXxQY9TQjDptm+ct7BkQ==", "dependencies": { - "Microsoft.Extensions.Hosting": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "System.ServiceProcess.ServiceController": "9.0.1" + "Microsoft.Extensions.Hosting": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "System.ServiceProcess.ServiceController": "9.0.4" } }, "Microsoft.Extensions.Options.DataAnnotations": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "T16k12gDWOoi9W/oueC7knsZxm3ZjqmrQBFLXx9UH3Kv4fbehMyiOdhi5u1Vw7M4g0uMj21InBfgDE0570byEQ==", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "jJq7xO1PLi//cts59Yp6dKNN07xV0Day/JmVR7aXCdo2rYHAoFlyARyxrfB0CTzsErA+TOhYTz2Ee0poR8SPeQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Security.Extensions": { @@ -81,6 +81,17 @@ "Serilog.Extensions.Logging": "9.0.0" } }, + "Serilog.Settings.Configuration": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "4/Et4Cqwa+F88l5SeFeNZ4c4Z6dEAIKbu3MaQb2Zz9F/g27T5a3wvfMcmCOaAiACjfUb4A6wrlTVfyYUZk3RRQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyModel": "9.0.0", + "Serilog": "4.2.0" + } + }, "Serilog.Sinks.Console": { "type": "Direct", "requested": "[6.0.0, )", @@ -106,257 +117,266 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "resolved": "9.0.4", + "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "resolved": "9.0.4", + "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w7kAyu1Mm7eParRV6WvGNNwA8flPTub16fwH49h7b/yqJZFTgYxnOVCuiah3G2bgseJMEq4DLjjsyQRvsdzRgA==", + "resolved": "9.0.4", + "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5WC1OsXfljC1KHEyL0yefpAyt1UZjrZ0/xyOqFowc5VntbE79JpCYOTSYFlxEuXm3Oq5xsgU2YXeZLTgAAX+DA==", + "resolved": "9.0.4", + "contentHash": "TbM2HElARG7z1gxwakdppmOkm1SykPqDcu3EF97daEwSb/+TXnRrFfJtF+5FWWxcsNhbRrmLfS2WszYcab7u1A==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5HShUdF8KFAUSzoEu0DOFbX09FlcFtHxEalowyjM7Kji0EjdF0DLjHajb2IBvoqsExAYox+Z2GfbfGF7dH7lKQ==", + "resolved": "9.0.4", + "contentHash": "2IGiG3FtVnD83IA6HYGuNei8dOw455C09yEhGl8bjcY6aGZgoC6yhYvDnozw8wlTowfoG9bxVrdTsr2ACZOYHg==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "QBOI8YVAyKqeshYOyxSe6co22oag431vxMu5xQe1EjXMkYE4xK4J71xLCW3/bWKmr9Aoy1VqGUARSLFnotk4Bg==", + "resolved": "9.0.4", + "contentHash": "UY864WQ3AS2Fkc8fYLombWnjrXwYt+BEHHps0hY4sxlgqaVW06AxbpgRZjfYf8PyRbplJqruzZDB/nSLT+7RLQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "z+g+lgPET1JRDjsOkFe51rkkNcnJgvOK5UIpeTfF1iAi0GkBJz5/yUuTa8a9V8HUh4gj4xFT5WGoMoXoSDKfGg==", + "resolved": "9.0.4", + "contentHash": "vVXI70CgT/dmXV3MM+n/BR2rLXEoAyoK0hQT+8MrbCMuJBiLRxnTtSrksNiASWCwOtxo/Tyy7CO8AGthbsYxnw==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "esGPOgLZ1tZddEomexhrU+LJ5YIsuJdkh0tU7r4WVpNZ15dLuMPqPW4Xe4txf3T2PDUX2ILe3nYQEDjZjfSEJg==", + "resolved": "9.0.4", + "contentHash": "zuvyC72gJkJyodyGowCuz3EQ1QvzNXJtKusuRzmjoHr17aeB3X0aSiKFB++HMHnQIWWlPOBf9YHTQfEqzbgl1g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==", + "resolved": "9.0.4", + "contentHash": "f2MTUaS2EQ3lX4325ytPAISZqgBfXmY0WvgD80ji6Z20AoDNiCESxsqo6mFRwHJD/jfVKRw9FsW6+86gNre3ug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA==" + "resolved": "9.0.4", + "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==", + "dependencies": { + "System.Text.Encodings.Web": "9.0.0", + "System.Text.Json": "9.0.0" + } }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "4ZmP6turxMFsNwK/MCko2fuIITaYYN/eXyyIRq1FjLDKnptdbn6xMb7u0zfSMzCGpzkx4RxH/g1jKN2IchG7uA==", + "resolved": "9.0.4", + "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pfAPuVtHvG6dvZtAa0OQbXdDqq6epnr8z0/IIUjdmV0tMeI8Aj9KxDXvdDvqr+qNHTkmA7pZpChNxwNZt4GXVg==", + "resolved": "9.0.4", + "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "DguZYt1DWL05+8QKWL3b6bW7A2pC5kYFMY5iXM6W2M23jhvcNa8v6AU8PvVJBcysxHwr9/jax0agnwoBumsSwg==", + "resolved": "9.0.4", + "contentHash": "gQN2o/KnBfVk6Bd71E2YsvO5lsqrqHmaepDGk+FB/C4aiQY9B0XKKNKfl5/TqcNOs9OEithm4opiMHAErMFyEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "TKDMNRS66UTMEVT38/tU9hA63UTMvzI3DyNm5mx8+JCf3BaOtxgrvWLCI1y3J52PzT5yNl/T2KN5Z0KbApLZcg==", + "resolved": "9.0.4", + "contentHash": "qkQ9V7KFZdTWNThT7ke7E/Jad38s46atSs3QUYZB8f3thBTrcrousdY4Y/tyCtcH5YjsPSiByjuN+L8W/ThMQg==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileSystemGlobbing": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileSystemGlobbing": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Mxcp9NXuQMvAnudRZcgIb5SqlWrlullQzntBLTwuv0MPIJ5LqiGwbRqiyxgdk+vtCoUkplb0oXy5kAw1t469Ug==" + "resolved": "9.0.4", + "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "CwSMhLNe8HLkfbFzdz0CHWJhtWH3TtfZSicLBd/itFD+NqQtfGHmvqXHQbaFFl3mQB5PBb2gxwzWQyW2pIj7PA==", + "resolved": "9.0.4", + "contentHash": "bXkwRPMo4x19YKH6/V9XotU7KYQJlihXhcWO1RDclAY3yfY3XNg4QtSEBvng4kK/DnboE0O/nwSl+6Jiv9P+FA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "E/k5r7S44DOW+08xQPnNbO8DKAQHhkspDboTThNJ6Z3/QBb4LC6gStNWzVmy3IvW7sUD+iJKf4fj0xEkqE7vnQ==", + "resolved": "9.0.4", + "contentHash": "xW6QPYsqhbuWBO9/1oA43g/XPKbohJx+7G8FLQgQXIriYvY7s+gxr2wjQJfRoPO900dvvv2vVH7wZovG+M1m6w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==", + "resolved": "9.0.4", + "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "MeZePlyu3/74Wk4FHYSzXijADJUhWa7gxtaphLxhS8zEPWdJuBCrPo0sezdCSZaKCL+cZLSLobrb7xt2zHOxZQ==", + "resolved": "9.0.4", + "contentHash": "/kF+rSnoo3/nIwGzWsR4RgBnoTOdZ3lzz2qFRyp/GgaNid4j6hOAQrs/O+QHXhlcAdZxjg37MvtIE+pAvIgi9g==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Logging.Console": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "YUzguHYlWfp4upfYlpVe3dnY59P25wc+/YLJ9/NQcblT3EvAB1CObQulClll7NtnFbbx4Js0a0UfyS8SbRsWXQ==", + "resolved": "9.0.4", + "contentHash": "cI0lQe0js65INCTCtAgnlVJWKgzgoRHVAW1B1zwCbmcliO4IZoTf92f1SYbLeLk7FzMJ/GlCvjLvJegJ6kltmQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pzdyibIV8k4sym0Sszcp2MJCuXrpOGs9qfOvY+hCRu8k4HbdVoeKOLnacxHK6vEPITX5o5FjjsZW2zScLXTjYA==", + "resolved": "9.0.4", + "contentHash": "D1jy+jy+huUUxnkZ0H480RZK8vqKn8NsQxYpMpPL/ALPPh1WATVLcr/uXI3RUBB45wMW5265O+hk9x3jnnXFuA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+a4RlbwFWjsMujNNhf1Jy9Nm5CpMT+nxXxfgrkRSloPo0OAWhPSPsrFo6VWpvgIPPS41qmfAVWr3DqAmOoVZgQ==", + "resolved": "9.0.4", + "contentHash": "bApxdklf7QTsONOLR5ow6SdDFXR5ncHvumSEg2+QnCvxvkzc2z5kNn7yQCyupRLRN4jKbnlTkVX8x9qLlwL6Qg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.EventLog": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.EventLog": "9.0.4" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "d47ZRZUOg1dGOX+yisWScQ7w4+92OlR9beS2UXaiadUCA3RFoZzobzVgrzBX7Oo/qefx9LxdRcaeFpWKb3BNBw==", + "resolved": "9.0.4", + "contentHash": "R600zTxVJNw2IeAEOvdOJGNA1lHr1m3vo460hSF5G1DjwP0FNpyeH4lpLDMuf34diKwB1LTt5hBw1iF1/iuwsQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==", + "resolved": "9.0.4", + "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "8RRKWtuU4fR+8MQLR/8CqZwZ9yc2xCpllw/WPRY7kskIqEq0hMcEI4AfUJO72yGiK2QJkrsDcUvgB5Yc+3+lyg==", + "resolved": "9.0.4", + "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + "resolved": "9.0.4", + "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" }, "Serilog": { "type": "Transitive", @@ -374,39 +394,39 @@ }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "yOcDWx4P/s1I83+7gQlgQLmhny2eNcU0cfo1NBWi+en4EAI38Jau+/neT85gUW6w1s7+FUJc2qNOmmwGLIREqA==" + "resolved": "9.0.4", + "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" }, "System.IO.Pipelines": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg==" + "resolved": "9.0.4", + "contentHash": "luF2Xba+lTe2GOoNQdZLe8q7K6s7nSpWZl9jIwWNMszN4/Yv0lmxk9HISgMmwdyZ83i3UhAGXaSY9o6IJBUuuA==" }, "System.ServiceProcess.ServiceController": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Ghm4yP29P3cC65Qof8CrgU3WO/q3ERtht6/CrvcUl1FgRs6D7exj75GuG4ciRv0sjygtvyd675924DFsxxnEgA==", + "resolved": "9.0.4", + "contentHash": "j6Z+ED1d/yxe/Cm+UlFf+LNw2HSYBSgtFh71KnEEmUtHIwgoTVQxji5URvXPOAZ7iuKHItjMIzpCLyRZc8OmrQ==", "dependencies": { - "System.Diagnostics.EventLog": "9.0.1" + "System.Diagnostics.EventLog": "9.0.4" } }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" + "resolved": "9.0.4", + "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" }, "System.Text.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "eqWHDZqYPv1PvuvoIIx5pF74plL3iEOZOl/0kQP+Y0TEbtgNnM2W6k8h8EPYs+LTJZsXuWa92n5W5sHTWvE3VA==", + "resolved": "9.0.4", + "contentHash": "pYtmpcO6R3Ef1XilZEHgXP2xBPVORbYEzRP7dl0IAAbN8Dm+kfwio8aCKle97rAWXOExr292MuxWYurIuwN62g==", "dependencies": { - "System.IO.Pipelines": "9.0.1", - "System.Text.Encodings.Web": "9.0.1" + "System.IO.Pipelines": "9.0.4", + "System.Text.Encodings.Web": "9.0.4" } }, "Coder.Desktop.CoderSdk": { @@ -416,6 +436,8 @@ "type": "Project", "dependencies": { "Coder.Desktop.Vpn.Proto": "[1.0.0, )", + "Microsoft.Extensions.Configuration": "[9.0.1, )", + "Semver": "[3.0.0, )", "System.IO.Pipelines": "[9.0.1, )" } }, diff --git a/Vpn.Service/RegistryConfigurationSource.cs b/Vpn/RegistryConfigurationSource.cs similarity index 63% rename from Vpn.Service/RegistryConfigurationSource.cs rename to Vpn/RegistryConfigurationSource.cs index 8e2dd0d..bcd5a34 100644 --- a/Vpn.Service/RegistryConfigurationSource.cs +++ b/Vpn/RegistryConfigurationSource.cs @@ -1,23 +1,25 @@ using Microsoft.Extensions.Configuration; using Microsoft.Win32; -namespace Coder.Desktop.Vpn.Service; +namespace Coder.Desktop.Vpn; public class RegistryConfigurationSource : IConfigurationSource { private readonly RegistryKey _root; private readonly string _subKeyName; + private readonly string[] _ignoredPrefixes; // ReSharper disable once ConvertToPrimaryConstructor - public RegistryConfigurationSource(RegistryKey root, string subKeyName) + public RegistryConfigurationSource(RegistryKey root, string subKeyName, params string[] ignoredPrefixes) { _root = root; _subKeyName = subKeyName; + _ignoredPrefixes = ignoredPrefixes; } public IConfigurationProvider Build(IConfigurationBuilder builder) { - return new RegistryConfigurationProvider(_root, _subKeyName); + return new RegistryConfigurationProvider(_root, _subKeyName, _ignoredPrefixes); } } @@ -25,12 +27,14 @@ public class RegistryConfigurationProvider : ConfigurationProvider { private readonly RegistryKey _root; private readonly string _subKeyName; + private readonly string[] _ignoredPrefixes; // ReSharper disable once ConvertToPrimaryConstructor - public RegistryConfigurationProvider(RegistryKey root, string subKeyName) + public RegistryConfigurationProvider(RegistryKey root, string subKeyName, string[] ignoredPrefixes) { _root = root; _subKeyName = subKeyName; + _ignoredPrefixes = ignoredPrefixes; } public override void Load() @@ -38,6 +42,11 @@ public override void Load() using var key = _root.OpenSubKey(_subKeyName); if (key == null) return; - foreach (var valueName in key.GetValueNames()) Data[valueName] = key.GetValue(valueName)?.ToString(); + foreach (var valueName in key.GetValueNames()) + { + if (_ignoredPrefixes.Any(prefix => valueName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) + continue; + Data[valueName] = key.GetValue(valueName)?.ToString(); + } } } diff --git a/Vpn/Speaker.cs b/Vpn/Speaker.cs index d113a50..37ec554 100644 --- a/Vpn/Speaker.cs +++ b/Vpn/Speaker.cs @@ -123,7 +123,7 @@ public async Task StartAsync(CancellationToken ct = default) // Handshakes should always finish quickly, so enforce a 5s timeout. using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token); cts.CancelAfter(TimeSpan.FromSeconds(5)); - await PerformHandshake(ct); + await PerformHandshake(cts.Token); // Start ReceiveLoop in the background. _receiveTask = ReceiveLoop(_cts.Token); diff --git a/Vpn/Utilities/RaiiSemaphoreSlim.cs b/Vpn/Utilities/RaiiSemaphoreSlim.cs index e38db6a..25f12bc 100644 --- a/Vpn/Utilities/RaiiSemaphoreSlim.cs +++ b/Vpn/Utilities/RaiiSemaphoreSlim.cs @@ -30,13 +30,13 @@ public IDisposable Lock() return new Locker(_semaphore); } - public async ValueTask LockAsync(CancellationToken ct = default) + public async Task LockAsync(CancellationToken ct = default) { await _semaphore.WaitAsync(ct); return new Locker(_semaphore); } - public async ValueTask LockAsync(TimeSpan timeout, CancellationToken ct = default) + public async Task LockAsync(TimeSpan timeout, CancellationToken ct = default) { if (!await _semaphore.WaitAsync(timeout, ct)) return null; return new Locker(_semaphore); @@ -44,16 +44,16 @@ public async ValueTask LockAsync(CancellationToken ct = default) private class Locker : IDisposable { - private readonly SemaphoreSlim _semaphore1; + private readonly SemaphoreSlim _semaphore; public Locker(SemaphoreSlim semaphore) { - _semaphore1 = semaphore; + _semaphore = semaphore; } public void Dispose() { - _semaphore1.Release(); + _semaphore.Release(); GC.SuppressFinalize(this); } } diff --git a/Vpn/Utilities/ServerVersionUtilities.cs b/Vpn/Utilities/ServerVersionUtilities.cs new file mode 100644 index 0000000..88bca69 --- /dev/null +++ b/Vpn/Utilities/ServerVersionUtilities.cs @@ -0,0 +1,45 @@ +using Semver; + +namespace Coder.Desktop.Vpn.Utilities; + +public class ServerVersion +{ + public required string RawString { get; set; } + public required SemVersion SemVersion { get; set; } +} + +public static class ServerVersionUtilities +{ + // The -0 allows pre-release versions. + private static readonly SemVersionRange ServerVersionRange = SemVersionRange.Parse(">= 2.20.0-0", + SemVersionRangeOptions.IncludeAllPrerelease | SemVersionRangeOptions.AllowV | + SemVersionRangeOptions.AllowMetadata); + + /// + /// Attempts to parse and verify that the server version is within the supported range. + /// + /// + /// The server version to check, optionally with a leading `v` or extra metadata/pre-release + /// tags + /// + /// The parsed server version + /// Could not parse version + /// The server version is not in range + public static ServerVersion ParseAndValidateServerVersion(string versionString) + { + if (string.IsNullOrWhiteSpace(versionString)) + throw new ArgumentException("Server version is empty", nameof(versionString)); + if (!SemVersion.TryParse(versionString, SemVersionStyles.AllowV, out var version)) + throw new ArgumentException($"Could not parse server version '{versionString}'", nameof(versionString)); + if (!version.Satisfies(ServerVersionRange)) + throw new ArgumentException( + $"Server version '{version}' is not within required server version range '{ServerVersionRange}'", + nameof(versionString)); + + return new ServerVersion + { + RawString = versionString, + SemVersion = version, + }; + } +} diff --git a/Vpn/Vpn.csproj b/Vpn/Vpn.csproj index e8016f3..76a72eb 100644 --- a/Vpn/Vpn.csproj +++ b/Vpn/Vpn.csproj @@ -3,7 +3,7 @@ Coder.Desktop.Vpn Coder.Desktop.Vpn - net8.0 + net8.0-windows enable enable true @@ -14,6 +14,8 @@ + + diff --git a/Vpn/packages.lock.json b/Vpn/packages.lock.json index c62e288..8876fe4 100644 --- a/Vpn/packages.lock.json +++ b/Vpn/packages.lock.json @@ -1,7 +1,26 @@ { "version": 1, "dependencies": { - "net8.0": { + "net8.0-windows7.0": { + "Microsoft.Extensions.Configuration": { + "type": "Direct", + "requested": "[9.0.1, )", + "resolved": "9.0.1", + "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Semver": { + "type": "Direct", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "5.0.1" + } + }, "System.IO.Pipelines": { "type": "Direct", "requested": "[9.0.1, )", @@ -13,6 +32,19 @@ "resolved": "3.29.3", "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw==" }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + }, "Coder.Desktop.Vpn.Proto": { "type": "Project", "dependencies": { diff --git a/scripts/Create-AppCastSigningKey.ps1 b/scripts/Create-AppCastSigningKey.ps1 new file mode 100644 index 0000000..209e226 --- /dev/null +++ b/scripts/Create-AppCastSigningKey.ps1 @@ -0,0 +1,27 @@ +# This is mostly just here for reference. +# +# Usage: Create-AppCastSigningKey.ps1 -outputKeyPath +param ( + [Parameter(Mandatory = $true)] + [string] $outputKeyPath +) + +$ErrorActionPreference = "Stop" + +& openssl.exe genpkey -algorithm ed25519 -out $outputKeyPath +if ($LASTEXITCODE -ne 0) { throw "Failed to generate ED25519 private key" } + +# Export the public key in DER format +$pubKeyDerPath = "$outputKeyPath.pub.der" +& openssl.exe pkey -in $outputKeyPath -pubout -outform DER -out $pubKeyDerPath +if ($LASTEXITCODE -ne 0) { throw "Failed to export ED25519 public key" } + +# Remove the DER header to get the actual key bytes +$pubBytes = [System.IO.File]::ReadAllBytes($pubKeyDerPath)[-32..-1] +Remove-Item $pubKeyDerPath + +# Base64 encode and print +Write-Output "NetSparkle formatted public key:" +Write-Output ([Convert]::ToBase64String($pubBytes)) +Write-Output "" +Write-Output "Private key written to $outputKeyPath" diff --git a/scripts/Get-Mutagen.ps1 b/scripts/Get-Mutagen.ps1 new file mode 100644 index 0000000..4de1143 --- /dev/null +++ b/scripts/Get-Mutagen.ps1 @@ -0,0 +1,46 @@ +# Usage: Get-Mutagen.ps1 -arch +param ( + [ValidateSet("x64", "arm64")] + [Parameter(Mandatory = $true)] + [string] $arch +) + +$ErrorActionPreference = "Stop" + +function Download-File([string] $url, [string] $outputPath, [string] $etagFile) { + Write-Host "Downloading '$url' to '$outputPath'" + # We use `curl.exe` here because `Invoke-WebRequest` is notoriously slow. + & curl.exe ` + --progress-bar ` + --show-error ` + --fail ` + --location ` + --etag-compare $etagFile ` + --etag-save $etagFile ` + --output $outputPath ` + $url + if ($LASTEXITCODE -ne 0) { throw "Failed to download $url" } + if (!(Test-Path $outputPath) -or (Get-Item $outputPath).Length -eq 0) { + throw "Failed to download '$url', output file '$outputPath' is missing or empty" + } +} + +$goArch = switch ($arch) { + "x64" { "amd64" } + "arm64" { "arm64" } + default { throw "Unsupported architecture: $arch" } +} + +# Download the mutagen binary from our bucket for this platform if we don't have +# it yet (or it's different). +$mutagenVersion = "v0.18.3" +$mutagenPath = Join-Path $PSScriptRoot "files\mutagen-windows-$($arch).exe" +$mutagenUrl = "https://storage.googleapis.com/coder-desktop/mutagen/$($mutagenVersion)/mutagen-windows-$($goArch).exe" +$mutagenEtagFile = $mutagenPath + ".etag" +Download-File $mutagenUrl $mutagenPath $mutagenEtagFile + +# Download mutagen agents tarball. +$mutagenAgentsPath = Join-Path $PSScriptRoot "files\mutagen-agents.tar.gz" +$mutagenAgentsUrl = "https://storage.googleapis.com/coder-desktop/mutagen/$($mutagenVersion)/mutagen-agents.tar.gz" +$mutagenAgentsEtagFile = $mutagenAgentsPath + ".etag" +Download-File $mutagenAgentsUrl $mutagenAgentsPath $mutagenAgentsEtagFile diff --git a/scripts/Get-WindowsAppSdk.ps1 b/scripts/Get-WindowsAppSdk.ps1 new file mode 100644 index 0000000..3db444f --- /dev/null +++ b/scripts/Get-WindowsAppSdk.ps1 @@ -0,0 +1,35 @@ +# Usage: Get-WindowsAppSdk.ps1 -arch +param ( + [ValidateSet("x64", "arm64")] + [Parameter(Mandatory = $true)] + [string] $arch +) + +$ErrorActionPreference = "Stop" + +function Download-File([string] $url, [string] $outputPath, [string] $etagFile) { + Write-Host "Downloading '$url' to '$outputPath'" + # We use `curl.exe` here because `Invoke-WebRequest` is notoriously slow. + & curl.exe ` + --progress-bar ` + --show-error ` + --fail ` + --location ` + --etag-compare $etagFile ` + --etag-save $etagFile ` + --output $outputPath ` + $url + if ($LASTEXITCODE -ne 0) { throw "Failed to download $url" } + if (!(Test-Path $outputPath) -or (Get-Item $outputPath).Length -eq 0) { + throw "Failed to download '$url', output file '$outputPath' is missing or empty" + } +} + +# Download the Windows App Sdk binary from Microsoft for this platform if we don't have +# it yet (or it's different). +$windowsAppSdkMajorVersion = "1.6" +$windowsAppSdkFullVersion = "1.6.250228001" +$windowsAppSdkPath = Join-Path $PSScriptRoot "files\windows-app-sdk-$($arch).exe" +$windowsAppSdkUri = "https://aka.ms/windowsappsdk/$($windowsAppSdkMajorVersion)/$($windowsAppSdkFullVersion)/windowsappruntimeinstall-$($arch).exe" +$windowsAppSdkEtagFile = $windowsAppSdkPath + ".etag" +Download-File $windowsAppSdkUri $windowsAppSdkPath $windowsAppSdkEtagFile diff --git a/scripts/Publish.ps1 b/scripts/Publish.ps1 index 08b30bd..6c0c101 100644 --- a/scripts/Publish.ps1 +++ b/scripts/Publish.ps1 @@ -113,16 +113,17 @@ if (Test-Path $buildPath) { New-Item -ItemType Directory -Path $buildPath -Force # Build in release mode -& dotnet.exe restore +& dotnet.exe restore /p:BuildWithNetFrameworkHostedCompiler=true if ($LASTEXITCODE -ne 0) { throw "Failed to dotnet restore" } $servicePublishDir = Join-Path $buildPath "service" -& dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a $arch -o $servicePublishDir +& dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a $arch -o $servicePublishDir /p:Version=$version if ($LASTEXITCODE -ne 0) { throw "Failed to build Vpn.Service" } # App needs to be built with msbuild $appPublishDir = Join-Path $buildPath "app" $msbuildBinary = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe if ($LASTEXITCODE -ne 0) { throw "Failed to find MSBuild" } -& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=$arch /p:OutputPath=$appPublishDir +if (-not (Test-Path $msbuildBinary)) { throw "Failed to find MSBuild at $msbuildBinary" } +& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=$arch /p:OutputPath=$appPublishDir /p:Version=$version if ($LASTEXITCODE -ne 0) { throw "Failed to build App" } # Find any files in the publish directory recursively that match any of our @@ -144,6 +145,18 @@ if ($null -eq $wintunDllSrc) { $wintunDllDest = Join-Path $vpnFilesPath "wintun.dll" Copy-Item $wintunDllSrc $wintunDllDest +$scriptRoot = Join-Path $repoRoot "scripts" +$getMutagen = Join-Path $scriptRoot "Get-Mutagen.ps1" +& $getMutagen -arch $arch + +$mutagenSrcPath = Join-Path $scriptRoot "files\mutagen-windows-$($arch).exe" +$mutagenDestPath = Join-Path $vpnFilesPath "mutagen.exe" +Copy-Item $mutagenSrcPath $mutagenDestPath + +$mutagenAgentsSrcPath = Join-Path $scriptRoot "files\mutagen-agents.tar.gz" +$mutagenAgentsDestPath = Join-Path $vpnFilesPath "mutagen-agents.tar.gz" +Copy-Item $mutagenAgentsSrcPath $mutagenAgentsDestPath + # Build the MSI installer & dotnet.exe run --project .\Installer\Installer.csproj -c Release -- ` build-msi ` @@ -162,6 +175,10 @@ Copy-Item $wintunDllSrc $wintunDllDest if ($LASTEXITCODE -ne 0) { throw "Failed to build MSI" } Add-CoderSignature $msiOutputPath +$getWindowsAppSdk = Join-Path $scriptRoot "Get-WindowsAppSdk.ps1" +& $getWindowsAppSdk -arch $arch +$windowsAppSdkPath = Join-Path $scriptRoot "files\windows-app-sdk-$($arch).exe" + # Build the bootstrapper & dotnet.exe run --project .\Installer\Installer.csproj -c Release -- ` build-bootstrapper ` @@ -171,6 +188,8 @@ Add-CoderSignature $msiOutputPath --output-path $outputPath ` --icon-file "App\coder.ico" ` --msi-path $msiOutputPath ` + --windows-app-sdk-path $windowsAppSdkPath ` + --theme-xml-path "scripts\files\RtfThemeLarge.xml" ` --logo-png "scripts\files\logo.png" if ($LASTEXITCODE -ne 0) { throw "Failed to build bootstrapper" } diff --git a/scripts/Update-AppCast.ps1 b/scripts/Update-AppCast.ps1 new file mode 100644 index 0000000..904dcdf --- /dev/null +++ b/scripts/Update-AppCast.ps1 @@ -0,0 +1,194 @@ +# Updates appcast.xml and appcast.xml.signature for a given release. +# +# Requires openssl.exe. You can install it via winget: +# winget install ShiningLight.OpenSSL.Light +# +# Usage: Update-AppCast.ps1 +# -tag +# -channel +# -x64Path +# -arm64Path +# -keyPath +# -inputAppCastPath +# -outputAppCastPath +# -outputAppCastSignaturePath +param ( + [Parameter(Mandatory = $true)] + [ValidatePattern("^v\d+\.\d+\.\d+$")] + [string] $tag, + + [Parameter(Mandatory = $true)] + [ValidateSet('stable', 'preview')] + [string] $channel, + + [Parameter(Mandatory = $false)] + [ValidatePattern("^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} \+00:00$")] + [string] $pubDate = (Get-Date).ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss +00:00"), + + [Parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ })] + [string] $x64Path, + + [Parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ })] + [string] $arm64Path, + + [Parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ })] + [string] $keyPath, + + [Parameter(Mandatory = $false)] + [ValidateScript({ Test-Path $_ })] + [string] $inputAppCastPath = "appcast.xml", + + [Parameter(Mandatory = $false)] + [string] $outputAppCastPath = "appcast.xml", + + [Parameter(Mandatory = $false)] + [string] $outputAppCastSignaturePath = "appcast.xml.signature" +) + +$ErrorActionPreference = "Stop" + +$repo = "coder/coder-desktop-windows" + +$version = $tag.Substring(1) # remove the v prefix + +function Get-Ed25519Signature { + param ( + [Parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ })] + [string] $path + ) + + # Use a temporary file. We can't just pipe directly because PowerShell + # operates with strings for third party commands. + $tempPath = Join-Path $env:TEMP "coder-desktop-temp.bin" + & openssl.exe pkeyutl -sign -inkey $keyPath -rawin -in $path -out $tempPath + if ($LASTEXITCODE -ne 0) { throw "Failed to sign file: $path" } + $signature = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($tempPath)) + Remove-Item -Force $tempPath + return $signature +} + +# Retrieve the release notes from the GitHub releases API +$releaseNotesMarkdown = & gh.exe release view $tag ` + --json body ` + --jq ".body" +if ($LASTEXITCODE -ne 0) { throw "Failed to retrieve release notes markdown" } +$releaseNotesMarkdown = $releaseNotesMarkdown -replace "`r`n", "`n" +$releaseNotesMarkdownPath = Join-Path $env:TEMP "coder-desktop-release-notes.md" +Set-Content -Path $releaseNotesMarkdownPath -Value $releaseNotesMarkdown -Encoding UTF8 + +Write-Output "---- Release Notes Markdown -----" +Get-Content $releaseNotesMarkdownPath +Write-Output "---- End of Release Notes Markdown ----" +Write-Output "" + +# Convert the release notes markdown to HTML using the GitHub API to match +# GitHub's formatting +$releaseNotesHtmlPath = Join-Path $env:TEMP "coder-desktop-release-notes.html" +& gh.exe api ` + --method POST ` + -H "Accept: application/vnd.github+json" ` + -H "X-GitHub-Api-Version: 2022-11-28" ` + /markdown ` + -F "text=@$releaseNotesMarkdownPath" ` + -F "mode=gfm" ` + -F "context=$repo" ` + > $releaseNotesHtmlPath +if ($LASTEXITCODE -ne 0) { throw "Failed to convert release notes markdown to HTML" } + +Write-Output "---- Release Notes HTML -----" +Get-Content $releaseNotesHtmlPath +Write-Output "---- End of Release Notes HTML ----" +Write-Output "" + +[xml] $appCast = Get-Content $inputAppCastPath + +# Set up namespace manager for sparkle: prefix +$nsManager = New-Object System.Xml.XmlNamespaceManager($appCast.NameTable) +$nsManager.AddNamespace("sparkle", "http://www.andymatuschak.org/xml-namespaces/sparkle") + +# Find the matching channel item +$channelItem = $appCast.SelectSingleNode("//item[sparkle:channel='$channel']", $nsManager) +if ($null -eq $channelItem) { + throw "Could not find channel item for channel: $channel" +} + +# Update the item properties +$channelItem.title = $tag +$channelItem.pubDate = $pubDate +$channelItem.SelectSingleNode("sparkle:version", $nsManager).InnerText = $version +$channelItem.SelectSingleNode("sparkle:shortVersionString", $nsManager).InnerText = $version +$channelItem.SelectSingleNode("sparkle:fullReleaseNotesLink", $nsManager).InnerText = "https://github.com/$repo/releases" + +# Set description with proper line breaks +$descriptionNode = $channelItem.SelectSingleNode("description") +$descriptionNode.InnerXml = "" # Clear existing content +$cdata = $appCast.CreateCDataSection([System.IO.File]::ReadAllText($releaseNotesHtmlPath)) +$descriptionNode.AppendChild($cdata) | Out-Null + +# Remove existing enclosures +$existingEnclosures = $channelItem.SelectNodes("enclosure") +foreach ($enclosure in $existingEnclosures) { + $channelItem.RemoveChild($enclosure) | Out-Null +} + +# Add new enclosures +$enclosures = @( + @{ + path = $x64Path + os = "win-x64" + }, + @{ + path = $arm64Path + os = "win-arm64" + } +) +foreach ($enclosure in $enclosures) { + $fileName = Split-Path $enclosure.path -Leaf + $url = "https://github.com/$repo/releases/download/$tag/$fileName" + $fileSize = (Get-Item $enclosure.path).Length + $signature = Get-Ed25519Signature $enclosure.path + + $newEnclosure = $appCast.CreateElement("enclosure") + $newEnclosure.SetAttribute("url", $url) + $newEnclosure.SetAttribute("type", "application/x-msdos-program") + $newEnclosure.SetAttribute("length", $fileSize) + + # Set namespaced attributes + $sparkleNs = $nsManager.LookupNamespace("sparkle") + $attrs = @{ + "os" = $enclosure.os + "version" = $version + "shortVersionString" = $version + "criticalUpdate" = "false" + "edSignature" = $signature # NetSparkle prefers edSignature over signature + } + foreach ($key in $attrs.Keys) { + $attr = $appCast.CreateAttribute("sparkle", $key, $sparkleNs) + $attr.Value = $attrs[$key] + $newEnclosure.Attributes.Append($attr) | Out-Null + } + + $channelItem.AppendChild($newEnclosure) | Out-Null +} + +# Save the updated XML. Convert CRLF to LF since CRLF seems to break NetSparkle +$appCast.Save($outputAppCastPath) +$content = [System.IO.File]::ReadAllText($outputAppCastPath) +$content = $content -replace "`r`n", "`n" +[System.IO.File]::WriteAllText($outputAppCastPath, $content) + +Write-Output "---- Updated appcast -----" +Get-Content $outputAppCastPath +Write-Output "---- End of updated appcast ----" +Write-Output "" + +# Generate the signature for the appcast itself +$appCastSignature = Get-Ed25519Signature $outputAppCastPath +[System.IO.File]::WriteAllText($outputAppCastSignaturePath, $appCastSignature) +Write-Output "---- Updated appcast signature -----" +Get-Content $outputAppCastSignaturePath +Write-Output "---- End of updated appcast signature ----" diff --git a/scripts/files/.gitignore b/scripts/files/.gitignore new file mode 100644 index 0000000..9080d92 --- /dev/null +++ b/scripts/files/.gitignore @@ -0,0 +1,4 @@ +mutagen-*.tar.gz +mutagen-*.exe +*.etag +windows-app-sdk-*.exe \ No newline at end of file diff --git a/scripts/files/RtfThemeLarge.xml b/scripts/files/RtfThemeLarge.xml new file mode 100644 index 0000000..a704810 --- /dev/null +++ b/scripts/files/RtfThemeLarge.xml @@ -0,0 +1,152 @@ + + + + + + + Segoe UI + Segoe UI + Segoe UI + Segoe UI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #(loc.FailureHyperlinkLogText) + + + + + + +