From 36d4bab527e1247a6b3e22ef57b7393ea0ab1b54 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:31:02 +0000 Subject: [PATCH 1/3] feat: import Avalonia UI, Linux service, and packaging from coder-desktop-linux --- .github/workflows/release-linux.yaml | 114 +++ App.Avalonia/App.Avalonia.csproj | 42 + App.Avalonia/App.axaml | 41 + App.Avalonia/App.axaml.cs | 317 ++++++++ App.Avalonia/Assets/coder.ico | Bin 0 -> 60215 bytes App.Avalonia/Controls/AgentAppIcon.axaml | 18 + App.Avalonia/Controls/AgentAppIcon.axaml.cs | 163 ++++ App.Avalonia/Controls/ExpandChevron.axaml | 14 + App.Avalonia/Controls/ExpandChevron.axaml.cs | 48 ++ App.Avalonia/Controls/ExpandContent.axaml | 28 + App.Avalonia/Controls/ExpandContent.axaml.cs | 128 +++ App.Avalonia/Controls/HorizontalRule.axaml | 9 + App.Avalonia/Controls/HorizontalRule.axaml.cs | 11 + .../AgentConnectionStatusToBrushConverter.cs | 35 + .../Converters/BoolToObjectConverter.cs | 22 + .../Converters/DependencyObjectSelector.cs | 152 ++++ .../Converters/FriendlyByteConverter.cs | 30 + .../Converters/InverseBoolConverter.cs | 18 + .../Converters/VpnLifecycleToBoolConverter.cs | 42 + .../Diagnostics/AppBootstrapLogger.cs | 82 ++ App.Avalonia/Program.cs | 43 + .../Services/AvaloniaClipboardService.cs | 19 + App.Avalonia/Services/AvaloniaDispatcher.cs | 19 + .../AvaloniaHostApplicationLifetime.cs | 64 ++ .../Services/AvaloniaWindowService.cs | 101 +++ .../Services/ProcessLauncherService.cs | 16 + .../UnavailableSyncSessionController.cs | 63 ++ App.Avalonia/ViewModels/TrayIconViewModel.cs | 17 + .../ViewModels/TrayWindowShellViewModel.cs | 24 + .../Views/DirectoryPickerWindow.axaml | 12 + .../Views/DirectoryPickerWindow.axaml.cs | 58 ++ App.Avalonia/Views/FileSyncListWindow.axaml | 11 + .../Views/FileSyncListWindow.axaml.cs | 119 +++ App.Avalonia/Views/MainWindow.axaml | 10 + App.Avalonia/Views/MainWindow.axaml.cs | 11 + App.Avalonia/Views/MessageWindow.axaml | 20 + App.Avalonia/Views/MessageWindow.axaml.cs | 36 + .../Views/Pages/DirectoryPickerMainPage.axaml | 139 ++++ .../Pages/DirectoryPickerMainPage.axaml.cs | 20 + .../Views/Pages/FileSyncListMainPage.axaml | 211 +++++ .../Views/Pages/FileSyncListMainPage.axaml.cs | 20 + .../Views/Pages/SettingsMainPage.axaml | 52 ++ .../Views/Pages/SettingsMainPage.axaml.cs | 20 + .../Views/Pages/SignInTokenPage.axaml | 72 ++ .../Views/Pages/SignInTokenPage.axaml.cs | 61 ++ App.Avalonia/Views/Pages/SignInUrlPage.axaml | 47 ++ .../Views/Pages/SignInUrlPage.axaml.cs | 46 ++ .../Pages/TrayWindowDisconnectedPage.axaml | 52 ++ .../Pages/TrayWindowDisconnectedPage.axaml.cs | 20 + .../Views/Pages/TrayWindowLoadingPage.axaml | 21 + .../Pages/TrayWindowLoadingPage.axaml.cs | 11 + .../Pages/TrayWindowLoginRequiredPage.axaml | 33 + .../TrayWindowLoginRequiredPage.axaml.cs | 20 + .../Views/Pages/TrayWindowMainPage.axaml | 354 +++++++++ .../Views/Pages/TrayWindowMainPage.axaml.cs | 60 ++ .../UpdaterCheckingForUpdatesMainPage.axaml | 10 + ...UpdaterCheckingForUpdatesMainPage.axaml.cs | 11 + .../UpdaterDownloadProgressMainPage.axaml | 32 + .../UpdaterDownloadProgressMainPage.axaml.cs | 27 + .../UpdaterUpdateAvailableMainPage.axaml | 68 ++ .../UpdaterUpdateAvailableMainPage.axaml.cs | 36 + App.Avalonia/Views/SettingsWindow.axaml | 11 + App.Avalonia/Views/SettingsWindow.axaml.cs | 11 + App.Avalonia/Views/SignInWindow.axaml | 11 + App.Avalonia/Views/SignInWindow.axaml.cs | 86 ++ App.Avalonia/Views/TrayWindow.axaml | 34 + App.Avalonia/Views/TrayWindow.axaml.cs | 280 +++++++ .../UpdaterCheckingForUpdatesWindow.axaml | 11 + .../UpdaterCheckingForUpdatesWindow.axaml.cs | 29 + .../Views/UpdaterDownloadProgressWindow.axaml | 11 + .../UpdaterDownloadProgressWindow.axaml.cs | 73 ++ .../Views/UpdaterUpdateAvailableWindow.axaml | 11 + .../UpdaterUpdateAvailableWindow.axaml.cs | 85 ++ App.Avalonia/packages.lock.json | 741 ++++++++++++++++++ App.Linux/App.Linux.csproj | 17 + App.Linux/LinuxNotifySendNotifier.cs | 59 ++ App.Linux/LinuxRdpConnector.cs | 67 ++ App.Linux/LinuxSecretServiceBackend.cs | 91 +++ App.Linux/LinuxSingleInstance.cs | 80 ++ App.Linux/LinuxXdgStartupManager.cs | 46 ++ App.Linux/packages.lock.json | 229 ++++++ App.Shared/App.Shared.csproj | 29 + App.Shared/Models/CredentialModel.cs | 47 ++ App.Shared/Models/RpcModel.cs | 205 +++++ App.Shared/Models/Settings.cs | 62 ++ .../Models/SyncSessionControllerStateModel.cs | 43 + App.Shared/Models/SyncSessionModel.cs | 305 +++++++ App.Shared/Services/CredentialManager.cs | 261 ++++++ App.Shared/Services/HostnameSuffixGetter.cs | 137 ++++ App.Shared/Services/IClipboardService.cs | 9 + App.Shared/Services/ICredentialManager.cs | 32 + App.Shared/Services/IDispatcher.cs | 13 + App.Shared/Services/IHostnameSuffixGetter.cs | 7 + App.Shared/Services/ILauncherService.cs | 9 + App.Shared/Services/IRdpConnector.cs | 14 + App.Shared/Services/IRpcController.cs | 24 + App.Shared/Services/ISettingsManager.cs | 9 + App.Shared/Services/IStartupManager.cs | 9 + App.Shared/Services/ISyncSessionController.cs | 61 ++ App.Shared/Services/IUpdateController.cs | 6 + App.Shared/Services/IUriHandler.cs | 6 + App.Shared/Services/IUserNotifier.cs | 19 + App.Shared/Services/IWindowService.cs | 12 + App.Shared/Services/RpcController.cs | 300 +++++++ App.Shared/Services/SettingsManager.cs | 138 ++++ App.Shared/Services/UriHandler.cs | 180 +++++ App.Shared/Utils/FriendlyByteConverter.cs | 19 + App.Shared/Utils/ModelUpdate.cs | 105 +++ App.Shared/ViewModels/AgentAppViewModel.cs | 165 ++++ App.Shared/ViewModels/AgentViewModel.cs | 545 +++++++++++++ .../ViewModels/DirectoryPickerViewModel.cs | 275 +++++++ .../ViewModels/FileSyncListViewModel.cs | 544 +++++++++++++ App.Shared/ViewModels/SettingsViewModel.cs | 81 ++ App.Shared/ViewModels/SignInViewModel.cs | 194 +++++ App.Shared/ViewModels/SyncSessionViewModel.cs | 37 + .../TrayWindowDisconnectedViewModel.cs | 49 ++ .../TrayWindowLoginRequiredViewModel.cs | 37 + App.Shared/ViewModels/TrayWindowViewModel.cs | 553 +++++++++++++ .../UpdaterDownloadProgressViewModel.cs | 91 +++ .../UpdaterUpdateAvailableViewModel.cs | 205 +++++ App.Shared/packages.lock.json | 220 ++++++ Packaging.Linux/PKGBUILD | 73 ++ Packaging.Linux/README.md | 70 ++ Packaging.Linux/build-aur-source.sh | 32 + Packaging.Linux/build-deb.sh | 11 + Packaging.Linux/build-release-packages.sh | 160 ++++ Packaging.Linux/coder-desktop.desktop | 9 + Packaging.Linux/coder-desktop.install | 17 + Packaging.Linux/coder-desktop.service | 16 + Packaging.Linux/postinst.sh | 14 + Packaging.Linux/prerm.sh | 7 + Vpn.Linux/UnixSocketClientTransport.cs | 31 + Vpn.Linux/UnixSocketServerTransport.cs | 83 ++ Vpn.Linux/Vpn.Linux.csproj | 16 + Vpn.Linux/packages.lock.json | 62 ++ Vpn/IRpcTransport.cs | 20 + scripts/run-linux-dev.sh | 191 +++++ 137 files changed, 11252 insertions(+) create mode 100644 .github/workflows/release-linux.yaml create mode 100644 App.Avalonia/App.Avalonia.csproj create mode 100644 App.Avalonia/App.axaml create mode 100644 App.Avalonia/App.axaml.cs create mode 100644 App.Avalonia/Assets/coder.ico create mode 100644 App.Avalonia/Controls/AgentAppIcon.axaml create mode 100644 App.Avalonia/Controls/AgentAppIcon.axaml.cs create mode 100644 App.Avalonia/Controls/ExpandChevron.axaml create mode 100644 App.Avalonia/Controls/ExpandChevron.axaml.cs create mode 100644 App.Avalonia/Controls/ExpandContent.axaml create mode 100644 App.Avalonia/Controls/ExpandContent.axaml.cs create mode 100644 App.Avalonia/Controls/HorizontalRule.axaml create mode 100644 App.Avalonia/Controls/HorizontalRule.axaml.cs create mode 100644 App.Avalonia/Converters/AgentConnectionStatusToBrushConverter.cs create mode 100644 App.Avalonia/Converters/BoolToObjectConverter.cs create mode 100644 App.Avalonia/Converters/DependencyObjectSelector.cs create mode 100644 App.Avalonia/Converters/FriendlyByteConverter.cs create mode 100644 App.Avalonia/Converters/InverseBoolConverter.cs create mode 100644 App.Avalonia/Converters/VpnLifecycleToBoolConverter.cs create mode 100644 App.Avalonia/Diagnostics/AppBootstrapLogger.cs create mode 100644 App.Avalonia/Program.cs create mode 100644 App.Avalonia/Services/AvaloniaClipboardService.cs create mode 100644 App.Avalonia/Services/AvaloniaDispatcher.cs create mode 100644 App.Avalonia/Services/AvaloniaHostApplicationLifetime.cs create mode 100644 App.Avalonia/Services/AvaloniaWindowService.cs create mode 100644 App.Avalonia/Services/ProcessLauncherService.cs create mode 100644 App.Avalonia/Services/UnavailableSyncSessionController.cs create mode 100644 App.Avalonia/ViewModels/TrayIconViewModel.cs create mode 100644 App.Avalonia/ViewModels/TrayWindowShellViewModel.cs create mode 100644 App.Avalonia/Views/DirectoryPickerWindow.axaml create mode 100644 App.Avalonia/Views/DirectoryPickerWindow.axaml.cs create mode 100644 App.Avalonia/Views/FileSyncListWindow.axaml create mode 100644 App.Avalonia/Views/FileSyncListWindow.axaml.cs create mode 100644 App.Avalonia/Views/MainWindow.axaml create mode 100644 App.Avalonia/Views/MainWindow.axaml.cs create mode 100644 App.Avalonia/Views/MessageWindow.axaml create mode 100644 App.Avalonia/Views/MessageWindow.axaml.cs create mode 100644 App.Avalonia/Views/Pages/DirectoryPickerMainPage.axaml create mode 100644 App.Avalonia/Views/Pages/DirectoryPickerMainPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/FileSyncListMainPage.axaml create mode 100644 App.Avalonia/Views/Pages/FileSyncListMainPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/SettingsMainPage.axaml create mode 100644 App.Avalonia/Views/Pages/SettingsMainPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/SignInTokenPage.axaml create mode 100644 App.Avalonia/Views/Pages/SignInTokenPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/SignInUrlPage.axaml create mode 100644 App.Avalonia/Views/Pages/SignInUrlPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/TrayWindowDisconnectedPage.axaml create mode 100644 App.Avalonia/Views/Pages/TrayWindowDisconnectedPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/TrayWindowLoadingPage.axaml create mode 100644 App.Avalonia/Views/Pages/TrayWindowLoadingPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/TrayWindowLoginRequiredPage.axaml create mode 100644 App.Avalonia/Views/Pages/TrayWindowLoginRequiredPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/TrayWindowMainPage.axaml create mode 100644 App.Avalonia/Views/Pages/TrayWindowMainPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/UpdaterCheckingForUpdatesMainPage.axaml create mode 100644 App.Avalonia/Views/Pages/UpdaterCheckingForUpdatesMainPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/UpdaterDownloadProgressMainPage.axaml create mode 100644 App.Avalonia/Views/Pages/UpdaterDownloadProgressMainPage.axaml.cs create mode 100644 App.Avalonia/Views/Pages/UpdaterUpdateAvailableMainPage.axaml create mode 100644 App.Avalonia/Views/Pages/UpdaterUpdateAvailableMainPage.axaml.cs create mode 100644 App.Avalonia/Views/SettingsWindow.axaml create mode 100644 App.Avalonia/Views/SettingsWindow.axaml.cs create mode 100644 App.Avalonia/Views/SignInWindow.axaml create mode 100644 App.Avalonia/Views/SignInWindow.axaml.cs create mode 100644 App.Avalonia/Views/TrayWindow.axaml create mode 100644 App.Avalonia/Views/TrayWindow.axaml.cs create mode 100644 App.Avalonia/Views/UpdaterCheckingForUpdatesWindow.axaml create mode 100644 App.Avalonia/Views/UpdaterCheckingForUpdatesWindow.axaml.cs create mode 100644 App.Avalonia/Views/UpdaterDownloadProgressWindow.axaml create mode 100644 App.Avalonia/Views/UpdaterDownloadProgressWindow.axaml.cs create mode 100644 App.Avalonia/Views/UpdaterUpdateAvailableWindow.axaml create mode 100644 App.Avalonia/Views/UpdaterUpdateAvailableWindow.axaml.cs create mode 100644 App.Avalonia/packages.lock.json create mode 100644 App.Linux/App.Linux.csproj create mode 100644 App.Linux/LinuxNotifySendNotifier.cs create mode 100644 App.Linux/LinuxRdpConnector.cs create mode 100644 App.Linux/LinuxSecretServiceBackend.cs create mode 100644 App.Linux/LinuxSingleInstance.cs create mode 100644 App.Linux/LinuxXdgStartupManager.cs create mode 100644 App.Linux/packages.lock.json create mode 100644 App.Shared/App.Shared.csproj create mode 100644 App.Shared/Models/CredentialModel.cs create mode 100644 App.Shared/Models/RpcModel.cs create mode 100644 App.Shared/Models/Settings.cs create mode 100644 App.Shared/Models/SyncSessionControllerStateModel.cs create mode 100644 App.Shared/Models/SyncSessionModel.cs create mode 100644 App.Shared/Services/CredentialManager.cs create mode 100644 App.Shared/Services/HostnameSuffixGetter.cs create mode 100644 App.Shared/Services/IClipboardService.cs create mode 100644 App.Shared/Services/ICredentialManager.cs create mode 100644 App.Shared/Services/IDispatcher.cs create mode 100644 App.Shared/Services/IHostnameSuffixGetter.cs create mode 100644 App.Shared/Services/ILauncherService.cs create mode 100644 App.Shared/Services/IRdpConnector.cs create mode 100644 App.Shared/Services/IRpcController.cs create mode 100644 App.Shared/Services/ISettingsManager.cs create mode 100644 App.Shared/Services/IStartupManager.cs create mode 100644 App.Shared/Services/ISyncSessionController.cs create mode 100644 App.Shared/Services/IUpdateController.cs create mode 100644 App.Shared/Services/IUriHandler.cs create mode 100644 App.Shared/Services/IUserNotifier.cs create mode 100644 App.Shared/Services/IWindowService.cs create mode 100644 App.Shared/Services/RpcController.cs create mode 100644 App.Shared/Services/SettingsManager.cs create mode 100644 App.Shared/Services/UriHandler.cs create mode 100644 App.Shared/Utils/FriendlyByteConverter.cs create mode 100644 App.Shared/Utils/ModelUpdate.cs create mode 100644 App.Shared/ViewModels/AgentAppViewModel.cs create mode 100644 App.Shared/ViewModels/AgentViewModel.cs create mode 100644 App.Shared/ViewModels/DirectoryPickerViewModel.cs create mode 100644 App.Shared/ViewModels/FileSyncListViewModel.cs create mode 100644 App.Shared/ViewModels/SettingsViewModel.cs create mode 100644 App.Shared/ViewModels/SignInViewModel.cs create mode 100644 App.Shared/ViewModels/SyncSessionViewModel.cs create mode 100644 App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs create mode 100644 App.Shared/ViewModels/TrayWindowLoginRequiredViewModel.cs create mode 100644 App.Shared/ViewModels/TrayWindowViewModel.cs create mode 100644 App.Shared/ViewModels/UpdaterDownloadProgressViewModel.cs create mode 100644 App.Shared/ViewModels/UpdaterUpdateAvailableViewModel.cs create mode 100644 App.Shared/packages.lock.json create mode 100644 Packaging.Linux/PKGBUILD create mode 100644 Packaging.Linux/README.md create mode 100755 Packaging.Linux/build-aur-source.sh create mode 100755 Packaging.Linux/build-deb.sh create mode 100755 Packaging.Linux/build-release-packages.sh create mode 100644 Packaging.Linux/coder-desktop.desktop create mode 100644 Packaging.Linux/coder-desktop.install create mode 100644 Packaging.Linux/coder-desktop.service create mode 100755 Packaging.Linux/postinst.sh create mode 100755 Packaging.Linux/prerm.sh create mode 100644 Vpn.Linux/UnixSocketClientTransport.cs create mode 100644 Vpn.Linux/UnixSocketServerTransport.cs create mode 100644 Vpn.Linux/Vpn.Linux.csproj create mode 100644 Vpn.Linux/packages.lock.json create mode 100644 Vpn/IRpcTransport.cs create mode 100755 scripts/run-linux-dev.sh diff --git a/.github/workflows/release-linux.yaml b/.github/workflows/release-linux.yaml new file mode 100644 index 0000000..958a967 --- /dev/null +++ b/.github/workflows/release-linux.yaml @@ -0,0 +1,114 @@ +name: release-linux + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g. 1.2.3)' + required: true + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.resolve_version.outputs.version }} + steps: + - name: Resolve version + id: resolve_version + shell: bash + run: | + set -euo pipefail + + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + version="${{ inputs.version }}" + else + version="${GITHUB_REF_NAME#v}" + fi + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+)*$ ]]; then + echo "Invalid version: $version" >&2 + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + + package: + runs-on: ubuntu-latest + needs: prepare + strategy: + fail-fast: false + matrix: + arch: [amd64, arm64] + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Install packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y ruby ruby-dev build-essential rpm dpkg-dev + sudo gem install --no-document fpm + + - name: Build release packages + env: + VERSION: ${{ needs.prepare.outputs.version }} + OUT_DIR: ${{ github.workspace }}/dist + run: | + chmod +x Packaging.Linux/*.sh + ./Packaging.Linux/build-release-packages.sh "${{ matrix.arch }}" + + - name: Upload package artifacts (${{ matrix.arch }}) + uses: actions/upload-artifact@v4 + with: + name: linux-packages-${{ matrix.arch }} + path: dist/* + + aur-source: + runs-on: ubuntu-latest + needs: prepare + steps: + - uses: actions/checkout@v4 + + - name: Build AUR source archive + env: + VERSION: ${{ needs.prepare.outputs.version }} + OUT_DIR: ${{ github.workspace }}/dist + run: | + chmod +x Packaging.Linux/*.sh + ./Packaging.Linux/build-aur-source.sh + + - name: Upload AUR source artifact + uses: actions/upload-artifact@v4 + with: + name: linux-packages-aur + path: dist/* + + release: + runs-on: ubuntu-latest + needs: [prepare, package, aur-source] + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + pattern: linux-packages-* + path: release-assets + merge-multiple: true + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + name: v${{ needs.prepare.outputs.version }} + generate_release_notes: true + files: | + release-assets/* diff --git a/App.Avalonia/App.Avalonia.csproj b/App.Avalonia/App.Avalonia.csproj new file mode 100644 index 0000000..874c9ec --- /dev/null +++ b/App.Avalonia/App.Avalonia.csproj @@ -0,0 +1,42 @@ + + + WinExe + net8.0 + Coder.Desktop.App + Coder Desktop + enable + enable + true + preview + + $(NoWarn);AVP1002 + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.Avalonia/App.axaml b/App.Avalonia/App.axaml new file mode 100644 index 0000000..c94c40e --- /dev/null +++ b/App.Avalonia/App.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.Avalonia/App.axaml.cs b/App.Avalonia/App.axaml.cs new file mode 100644 index 0000000..2d0d2a4 --- /dev/null +++ b/App.Avalonia/App.axaml.cs @@ -0,0 +1,317 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Coder.Desktop.App.Diagnostics; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.ViewModels; +using Coder.Desktop.App.Views; +using Coder.Desktop.CoderSdk.Agent; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Coder.Desktop.App; + +public partial class App : Application +{ + private ServiceProvider? _services; + private TrayWindow? _trayWindow; + private TrayIconViewModel? _trayIconViewModel; + private AvaloniaHostApplicationLifetime? _hostApplicationLifetime; + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + { + AppBootstrapLogger.Warn("Unsupported application lifetime (not desktop style)"); + base.OnFrameworkInitializationCompleted(); + return; + } + + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + var services = new ServiceCollection(); + + // App infrastructure + _hostApplicationLifetime = new AvaloniaHostApplicationLifetime(() => desktop.Shutdown()); + desktop.Exit += (_, _) => _hostApplicationLifetime.NotifyStopped(); + + services.AddSingleton(_hostApplicationLifetime); + + // Logging + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddSimpleConsole(options => + { + options.SingleLine = true; + options.TimestampFormat = "HH:mm:ss "; + }); + builder.SetMinimumLevel(LogLevel.Information); + }); + + // Core platform services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Credentials + RPC + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + if (!OperatingSystem.IsLinux()) + throw new PlatformNotSupportedException("App.Avalonia currently supports Linux RPC transport only."); + + services.AddSingleton(); + services.AddSingleton(); + + // File sync (placeholder backend for Avalonia Linux host) + services.AddSingleton(); + + // Settings and hostname metadata + services.AddSingleton, SettingsManager>(); + services.AddSingleton(); + + // View model factories + services.AddSingleton(); + services.AddSingleton(); + + // Tray and window view models + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddSingleton(); + + _services = services.BuildServiceProvider(); + + var args = desktop.Args ?? []; + var startMinimized = false; + foreach (var arg in args) + { + if (arg.Equals("--minimized", StringComparison.OrdinalIgnoreCase) || + arg.Equals("--start-hidden", StringComparison.OrdinalIgnoreCase)) + { + startMinimized = true; + break; + } + } + + _trayWindow = _services.GetRequiredService(); + + // Make TrayWindow the MainWindow so dialogs can use it as an owner. + desktop.MainWindow = _trayWindow; + + // If launched with --minimized (e.g. XDG autostart), keep startup + // behavior as tray-first. Otherwise, show the tray window so manual + // launches are visible even when tray icon integration is missing. + if (startMinimized) + { + AppBootstrapLogger.Info("Startup mode: minimized to tray (--minimized)"); + _trayWindow.RequestHideOnFirstOpen(); + } + else + { + AppBootstrapLogger.Info("Startup mode: showing tray window"); + } + + _trayIconViewModel = new TrayIconViewModel(ToggleTrayWindow, () => desktop.Shutdown()); + ConfigureTrayIcons(_trayIconViewModel); + + _ = InitializeServicesAsync(); + _hostApplicationLifetime.NotifyStarted(); + + base.OnFrameworkInitializationCompleted(); + } + + private async Task InitializeServicesAsync() + { + if (_services == null) + return; + + var credentialManager = _services.GetRequiredService(); + var rpcController = _services.GetRequiredService(); + var settingsManager = _services.GetRequiredService>(); + + AppBootstrapLogger.Info("Initializing credentials and RPC connection..."); + + var appStopping = _hostApplicationLifetime?.ApplicationStopping ?? CancellationToken.None; + using var credentialLoadCts = CancellationTokenSource.CreateLinkedTokenSource(appStopping); + credentialLoadCts.CancelAfter(TimeSpan.FromSeconds(15)); + + var loadCredentialsTask = credentialManager.LoadCredentials(credentialLoadCts.Token); + var reconnectTask = ReconnectWithStartupRetryAsync(rpcController, appStopping); + + try + { + await Task.WhenAll(loadCredentialsTask, reconnectTask); + AppBootstrapLogger.Info("Initial credential load and RPC reconnect completed"); + } + catch + { + if (loadCredentialsTask.IsFaulted) + AppBootstrapLogger.Error("Credential initialization failed", loadCredentialsTask.Exception?.GetBaseException()); + + // reconnectTask logs its own errors internally; just note if it threw here + if (reconnectTask.IsFaulted) + AppBootstrapLogger.Error("Startup reconnect failed unexpectedly", reconnectTask.Exception?.GetBaseException()); + } + + var reconnectSucceeded = reconnectTask is { IsCompletedSuccessfully: true, Result: true }; + + if (!reconnectSucceeded) + AppBootstrapLogger.Warn("Startup continuing in disconnected state after retry exhaustion"); + + try + { + await MaybeAutoStartVpnOnLaunchAsync(settingsManager, credentialManager, rpcController, reconnectSucceeded, appStopping); + } + catch (Exception ex) + { + AppBootstrapLogger.Error("ConnectOnLaunch failed", ex); + } + } + + private async Task ReconnectWithStartupRetryAsync(IRpcController rpcController, CancellationToken ct) + { + TimeSpan[] delays = + [ + TimeSpan.Zero, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(4), + TimeSpan.FromSeconds(8), + ]; + + Exception? lastError = null; + + for (var attempt = 0; attempt < delays.Length; attempt++) + { + if (attempt > 0) + await Task.Delay(delays[attempt], ct); + + try + { + await rpcController.Reconnect(ct); + AppBootstrapLogger.Info($"RPC reconnect succeeded on attempt {attempt + 1}/{delays.Length}"); + return true; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + lastError = ex; + AppBootstrapLogger.Warn($"RPC reconnect attempt {attempt + 1}/{delays.Length} failed: {ex.Message}"); + } + } + + AppBootstrapLogger.Error("RPC reconnect exhausted startup retries", lastError); + return false; + } + + private async Task MaybeAutoStartVpnOnLaunchAsync( + ISettingsManager settingsManager, + ICredentialManager credentialManager, + IRpcController rpcController, + bool reconnectSucceeded, + CancellationToken ct) + { + var settings = await settingsManager.Read(ct); + if (!settings.ConnectOnLaunch) + return; + + if (!reconnectSucceeded) + { + AppBootstrapLogger.Info("ConnectOnLaunch skipped because startup reconnect did not succeed"); + return; + } + + var creds = credentialManager.GetCachedCredentials(); + var rpc = rpcController.GetState(); + + if (creds.State != CredentialState.Valid || + rpc.RpcLifecycle != RpcLifecycle.Connected || + rpc.VpnLifecycle != VpnLifecycle.Stopped) + { + AppBootstrapLogger.Info($"ConnectOnLaunch skipped (cred={creds.State}, rpc={rpc.RpcLifecycle}, vpn={rpc.VpnLifecycle})"); + return; + } + + await rpcController.StartVpn(ct); + AppBootstrapLogger.Info("ConnectOnLaunch started VPN successfully"); + } + + private void ConfigureTrayIcons(TrayIconViewModel trayIconViewModel) + { + // The tray icons are defined in App.axaml via the TrayIcon.Icons attached property. + var icons = TrayIcon.GetIcons(this); + if (icons is null) + { + AppBootstrapLogger.Warn("Tray icon collection is null; tray menu may be unavailable"); + return; + } + + foreach (var trayIcon in icons) + { + // Ensure clicking the icon toggles the tray window. + trayIcon.Clicked -= TrayIconOnClicked; + trayIcon.Clicked += TrayIconOnClicked; + + // Keep the icon click behavior event-driven. Setting both Command and + // Clicked can cause duplicate toggles on some Linux tray implementations. + trayIcon.Command = null; + + if (trayIcon.Menu is NativeMenu menu) + { + foreach (var item in menu.Items) + { + if (item is not NativeMenuItem nativeItem) + continue; + + switch (nativeItem.Header?.ToString()) + { + case "Show": + nativeItem.Command = trayIconViewModel.ShowWindowCommand; + break; + case "Exit": + nativeItem.Command = trayIconViewModel.ExitCommand; + break; + } + } + } + } + } + + private void TrayIconOnClicked(object? sender, EventArgs e) + { + ToggleTrayWindow(); + } + + private void ToggleTrayWindow() + { + if (_trayWindow is null) + return; + + if (_trayWindow.IsVisible) + { + _trayWindow.Hide(); + return; + } + + _trayWindow.ShowNearSystemTray(); + } +} diff --git a/App.Avalonia/Assets/coder.ico b/App.Avalonia/Assets/coder.ico new file mode 100644 index 0000000000000000000000000000000000000000..b80bdc2651196582b234907a52f2a89f555b1097 GIT binary patch literal 60215 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 literal 0 HcmV?d00001 diff --git a/App.Avalonia/Controls/AgentAppIcon.axaml b/App.Avalonia/Controls/AgentAppIcon.axaml new file mode 100644 index 0000000..04fb55f --- /dev/null +++ b/App.Avalonia/Controls/AgentAppIcon.axaml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/App.Avalonia/Controls/AgentAppIcon.axaml.cs b/App.Avalonia/Controls/AgentAppIcon.axaml.cs new file mode 100644 index 0000000..a607faf --- /dev/null +++ b/App.Avalonia/Controls/AgentAppIcon.axaml.cs @@ -0,0 +1,163 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using SkiaSharp; +using Svg.Skia; + +namespace Coder.Desktop.App.Controls; + +public partial class AgentAppIcon : UserControl +{ + public static readonly StyledProperty IconUrlProperty = + AvaloniaProperty.Register(nameof(IconUrl)); + + private static readonly HttpClient IconHttpClient = new(); + + private CancellationTokenSource? _iconLoadCts; + + static AgentAppIcon() + { + IconUrlProperty.Changed.AddClassHandler((x, e) => + { + x.LoadIcon(e.NewValue is Uri uri ? uri : null); + }); + } + + public Uri? IconUrl + { + get => GetValue(IconUrlProperty); + set => SetValue(IconUrlProperty, value); + } + + public AgentAppIcon() + { + InitializeComponent(); + + DetachedFromVisualTree += (_, _) => + { + _iconLoadCts?.Cancel(); + _iconLoadCts?.Dispose(); + _iconLoadCts = null; + SetImageSource(null); + }; + + LoadIcon(IconUrl); + } + + private void LoadIcon(Uri? iconUrl) + { + _iconLoadCts?.Cancel(); + _iconLoadCts?.Dispose(); + _iconLoadCts = null; + + if (iconUrl is null || + (iconUrl.Scheme is not "http" and not "https")) + { + ShowFallback(); + return; + } + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + _iconLoadCts = cts; + + _ = LoadIconCore(iconUrl, cts.Token); + } + + private async Task LoadIconCore(Uri iconUrl, CancellationToken ct) + { + try + { + using var response = await IconHttpClient.GetAsync(iconUrl, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + var mediaType = response.Content.Headers.ContentType?.MediaType; + var iconBytes = await response.Content.ReadAsByteArrayAsync(ct); + + if (ct.IsCancellationRequested) + return; + + using var iconStream = new MemoryStream(iconBytes, writable: false); + var bitmap = ShouldDecodeAsSvg(iconUrl, mediaType, iconBytes) + ? DecodeSvgToBitmap(iconStream) + : new Bitmap(iconStream); + + await Dispatcher.UIThread.InvokeAsync(() => + { + if (ct.IsCancellationRequested) + { + bitmap.Dispose(); + return; + } + + SetImageSource(bitmap); + IconImage.IsVisible = true; + FallbackIcon.IsVisible = false; + }); + } + catch + { + if (!ct.IsCancellationRequested) + { + await Dispatcher.UIThread.InvokeAsync(ShowFallback); + } + } + } + + private static bool ShouldDecodeAsSvg(Uri iconUrl, string? mediaType, byte[] iconBytes) + { + if (!string.IsNullOrWhiteSpace(mediaType) && + mediaType.Contains("svg", StringComparison.OrdinalIgnoreCase)) + return true; + + if (iconUrl.AbsolutePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) + return true; + + if (iconBytes.Length == 0) + return false; + + var probeLen = Math.Min(iconBytes.Length, 256); + var prefix = Encoding.UTF8.GetString(iconBytes, 0, probeLen).TrimStart('\uFEFF', ' ', '\t', '\r', '\n'); + return prefix.IndexOf("= 0; + } + + private static Bitmap DecodeSvgToBitmap(Stream svgStream) + { + using var svg = new SKSvg(); + var picture = svg.Load(svgStream); + if (picture is null) + throw new InvalidOperationException("Could not parse SVG icon"); + + using var pngStream = new MemoryStream(); + if (!svg.Save(pngStream, SKColors.Transparent, SKEncodedImageFormat.Png, 100, 1f, 1f)) + throw new InvalidOperationException("Could not render SVG icon"); + + pngStream.Position = 0; + return new Bitmap(pngStream); + } + + private void ShowFallback() + { + SetImageSource(null); + IconImage.IsVisible = false; + FallbackIcon.IsVisible = true; + } + + private void SetImageSource(IImage? image) + { + if (ReferenceEquals(IconImage.Source, image)) + return; + + if (IconImage.Source is IDisposable disposable) + disposable.Dispose(); + + IconImage.Source = image; + } +} diff --git a/App.Avalonia/Controls/ExpandChevron.axaml b/App.Avalonia/Controls/ExpandChevron.axaml new file mode 100644 index 0000000..ba8202c --- /dev/null +++ b/App.Avalonia/Controls/ExpandChevron.axaml @@ -0,0 +1,14 @@ + + + + + + diff --git a/App.Avalonia/Controls/ExpandChevron.axaml.cs b/App.Avalonia/Controls/ExpandChevron.axaml.cs new file mode 100644 index 0000000..7dfd0b8 --- /dev/null +++ b/App.Avalonia/Controls/ExpandChevron.axaml.cs @@ -0,0 +1,48 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace Coder.Desktop.App.Controls; + +public partial class ExpandChevron : UserControl +{ + private static readonly Geometry RightChevron = Geometry.Parse("M 5,3 L 11,8 L 5,13 L 6.4,14.4 L 13,8 L 6.4,1.6 Z"); + private static readonly Geometry DownChevron = Geometry.Parse("M 3,5 L 8,11 L 13,5 L 14.4,6.4 L 8,13 L 1.6,6.4 Z"); + + public static readonly StyledProperty IsOpenProperty = + AvaloniaProperty.Register(nameof(IsOpen)); + + static ExpandChevron() + { + IsOpenProperty.Changed.AddClassHandler((x, e) => + { + if (e.NewValue is bool isOpen) + { + x.UpdateChevronGlyph(isOpen); + } + }); + } + + public bool IsOpen + { + get => GetValue(IsOpenProperty); + set => SetValue(IsOpenProperty, value); + } + + public ExpandChevron() + { + InitializeComponent(); + + UpdateChevronGlyph(IsOpen); + } + + private void UpdateChevronGlyph(bool isOpen) + { + if (ChevronIcon is null) + { + return; + } + + ChevronIcon.Data = isOpen ? DownChevron : RightChevron; + } +} diff --git a/App.Avalonia/Controls/ExpandContent.axaml b/App.Avalonia/Controls/ExpandContent.axaml new file mode 100644 index 0000000..b7fe4d0 --- /dev/null +++ b/App.Avalonia/Controls/ExpandContent.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.Avalonia/Controls/ExpandContent.axaml.cs b/App.Avalonia/Controls/ExpandContent.axaml.cs new file mode 100644 index 0000000..7c0d489 --- /dev/null +++ b/App.Avalonia/Controls/ExpandContent.axaml.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace Coder.Desktop.App.Controls; + +public partial class ExpandContent : UserControl +{ + public static readonly StyledProperty IsOpenProperty = + AvaloniaProperty.Register(nameof(IsOpen)); + + static ExpandContent() + { + IsOpenProperty.Changed.AddClassHandler((x, e) => + { + if (e.NewValue is bool isOpen) + { + x.SetOpenState(isOpen, immediate: false); + } + }); + } + + private static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(160); + + private CancellationTokenSource? _collapseCts; + + private TranslateTransform? _slideTransform; + + // During XAML population, this content property can be accessed before the + // named CollapsiblePanel field is assigned, so stage children here first. + private readonly List _deferredChildren = []; + + [Content] + public IList Children + { + get + { + if (CollapsiblePanel is null) + return _deferredChildren; + + if (_deferredChildren.Count > 0) + { + foreach (var child in _deferredChildren) + CollapsiblePanel.Children.Add(child); + + _deferredChildren.Clear(); + } + + return CollapsiblePanel.Children; + } + } + + public bool IsOpen + { + get => GetValue(IsOpenProperty); + set => SetValue(IsOpenProperty, value); + } + + public ExpandContent() + { + InitializeComponent(); + + // Flush any content that may have been staged before named controls + // were available. + _ = Children; + + _slideTransform = CollapsiblePanel.RenderTransform as TranslateTransform; + + SetOpenState(IsOpen, immediate: true); + } + + private void SetOpenState(bool isOpen, bool immediate) + { + if (CollapsiblePanel is null || _slideTransform is null) + { + return; + } + + if (isOpen) + { + _collapseCts?.Cancel(); + CollapsiblePanel.IsVisible = true; + + CollapsiblePanel.Opacity = 1; + CollapsiblePanel.MaxHeight = 10000; + _slideTransform.Y = 0; + return; + } + + CollapsiblePanel.Opacity = 0; + CollapsiblePanel.MaxHeight = 0; + _slideTransform.Y = -16; + + if (immediate) + { + CollapsiblePanel.IsVisible = false; + return; + } + + ScheduleHideAfterCollapse(); + } + + private async void ScheduleHideAfterCollapse() + { + _collapseCts?.Cancel(); + _collapseCts = new CancellationTokenSource(); + var token = _collapseCts.Token; + + try + { + await Task.Delay(TransitionDuration, token); + } + catch (TaskCanceledException) + { + return; + } + + if (!token.IsCancellationRequested && !IsOpen) + { + CollapsiblePanel.IsVisible = false; + } + } +} diff --git a/App.Avalonia/Controls/HorizontalRule.axaml b/App.Avalonia/Controls/HorizontalRule.axaml new file mode 100644 index 0000000..5f3009e --- /dev/null +++ b/App.Avalonia/Controls/HorizontalRule.axaml @@ -0,0 +1,9 @@ + + + + diff --git a/App.Avalonia/Controls/HorizontalRule.axaml.cs b/App.Avalonia/Controls/HorizontalRule.axaml.cs new file mode 100644 index 0000000..31c2a12 --- /dev/null +++ b/App.Avalonia/Controls/HorizontalRule.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Coder.Desktop.App.Controls; + +public partial class HorizontalRule : UserControl +{ + public HorizontalRule() + { + InitializeComponent(); + } +} diff --git a/App.Avalonia/Converters/AgentConnectionStatusToBrushConverter.cs b/App.Avalonia/Converters/AgentConnectionStatusToBrushConverter.cs new file mode 100644 index 0000000..a07a08d --- /dev/null +++ b/App.Avalonia/Converters/AgentConnectionStatusToBrushConverter.cs @@ -0,0 +1,35 @@ +using System; +using Avalonia.Data.Converters; +using Avalonia.Media; +using Coder.Desktop.App.ViewModels; + +namespace Coder.Desktop.App.Converters; + +public class AgentConnectionStatusToBrushConverter : IValueConverter +{ + private static readonly IBrush HealthyBrush = new SolidColorBrush(Color.Parse("#34C759")); + private static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#FFCC01")); + private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FF3B30")); + private static readonly IBrush OfflineBrush = new SolidColorBrush(Color.Parse("#8E8E93")); + + public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + if (value is not AgentConnectionStatus status) + return OfflineBrush; + + return status switch + { + AgentConnectionStatus.Healthy => HealthyBrush, + AgentConnectionStatus.Connecting => WarningBrush, + AgentConnectionStatus.Unhealthy => WarningBrush, + AgentConnectionStatus.NoRecentHandshake => ErrorBrush, + AgentConnectionStatus.Offline => OfflineBrush, + _ => OfflineBrush, + }; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/App.Avalonia/Converters/BoolToObjectConverter.cs b/App.Avalonia/Converters/BoolToObjectConverter.cs new file mode 100644 index 0000000..629562a --- /dev/null +++ b/App.Avalonia/Converters/BoolToObjectConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Coder.Desktop.App.Converters; + +public sealed class BoolToObjectConverter : IValueConverter +{ + public object? TrueValue { get; set; } = true; + + public object? FalseValue { get; set; } = true; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is true ? TrueValue : FalseValue; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/App.Avalonia/Converters/DependencyObjectSelector.cs b/App.Avalonia/Converters/DependencyObjectSelector.cs new file mode 100644 index 0000000..4f44ce7 --- /dev/null +++ b/App.Avalonia/Converters/DependencyObjectSelector.cs @@ -0,0 +1,152 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Collections; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace Coder.Desktop.App.Converters; + +/// +/// An item in a . +/// +/// Key type. +/// Value type. +public class DependencyObjectSelectorItem : AvaloniaObject + where TK : IEquatable + where TV : class +{ + public static readonly StyledProperty KeyProperty = + AvaloniaProperty.Register, TK?>(nameof(Key)); + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register, TV?>(nameof(Value)); + + public TK? Key + { + get => GetValue(KeyProperty); + set => SetValue(KeyProperty, value); + } + + public TV? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } +} + +/// +/// Avalonia port of the WinUI DependencyObjectSelector. +/// +/// This is a simplified implementation intended for XAML resources where a view-model key +/// selects a value from a list. +/// +/// Key type. +/// Value type. +public class DependencyObjectSelector : AvaloniaObject + where TK : IEquatable + where TV : class +{ + public static readonly StyledProperty SelectedKeyProperty = + AvaloniaProperty.Register, TK?>(nameof(SelectedKey)); + + public static readonly DirectProperty, TV?> SelectedObjectProperty = + AvaloniaProperty.RegisterDirect, TV?>(nameof(SelectedObject), o => o.SelectedObject); + + private TV? _selectedObject; + private DependencyObjectSelectorItem? _selectedItem; + + public DependencyObjectSelector() + { + References = new AvaloniaList>(); + References.CollectionChanged += (_, __) => UpdateSelectedObject(); + + UpdateSelectedObject(); + } + + /// + /// The list of possible references. + /// + [Content] + public AvaloniaList> References { get; } + + /// + /// The key to select. + /// + public TK? SelectedKey + { + get => GetValue(SelectedKeyProperty); + set => SetValue(SelectedKeyProperty, value); + } + + /// + /// The selected value. + /// + public TV? SelectedObject + { + get => _selectedObject; + private set => SetAndRaise(SelectedObjectProperty, ref _selectedObject, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SelectedKeyProperty) + { + UpdateSelectedObject(); + } + } + + private void UpdateSelectedObject() + { + VerifyReferencesUniqueKeys(); + + 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 (!ReferenceEquals(item, _selectedItem)) + { + if (_selectedItem != null) + { + _selectedItem.PropertyChanged -= SelectedItem_OnPropertyChanged; + } + + _selectedItem = item; + + if (_selectedItem != null) + { + _selectedItem.PropertyChanged += SelectedItem_OnPropertyChanged; + } + } + + SelectedObject = item?.Value; + } + + private void SelectedItem_OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == DependencyObjectSelectorItem.ValueProperty) + { + SelectedObject = _selectedItem?.Value; + } + } + + private void VerifyReferencesUniqueKeys() + { + var keys = References.Select(i => i.Key).Distinct().ToArray(); + if (keys.Length != References.Count) + { + throw new ArgumentException("DependencyObjectSelector keys must be unique."); + } + } +} + +public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem; + +public sealed class StringToBrushSelector : DependencyObjectSelector; + +public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem; + +public sealed class StringToStringSelector : DependencyObjectSelector; diff --git a/App.Avalonia/Converters/FriendlyByteConverter.cs b/App.Avalonia/Converters/FriendlyByteConverter.cs new file mode 100644 index 0000000..9cef9e4 --- /dev/null +++ b/App.Avalonia/Converters/FriendlyByteConverter.cs @@ -0,0 +1,30 @@ +extern alias AppShared; + +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using SharedFriendlyByteConverter = AppShared::Coder.Desktop.App.Converters.FriendlyByteConverter; + +namespace Coder.Desktop.App.Converters; + +public sealed class FriendlyByteConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var bytes = value switch + { + int i => i < 0 ? 0ul : (ulong)i, + uint ui => ui, + long l => l < 0 ? 0ul : (ulong)l, + ulong ul => ul, + _ => 0ul, + }; + + return SharedFriendlyByteConverter.FriendlyBytes(bytes); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/App.Avalonia/Converters/InverseBoolConverter.cs b/App.Avalonia/Converters/InverseBoolConverter.cs new file mode 100644 index 0000000..3d85c8d --- /dev/null +++ b/App.Avalonia/Converters/InverseBoolConverter.cs @@ -0,0 +1,18 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Coder.Desktop.App.Converters; + +public sealed class InverseBoolConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/App.Avalonia/Converters/VpnLifecycleToBoolConverter.cs b/App.Avalonia/Converters/VpnLifecycleToBoolConverter.cs new file mode 100644 index 0000000..2925aea --- /dev/null +++ b/App.Avalonia/Converters/VpnLifecycleToBoolConverter.cs @@ -0,0 +1,42 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Coder.Desktop.App.Models; + +namespace Coder.Desktop.App.Converters; + +public sealed class VpnLifecycleToBoolConverter : IValueConverter +{ + public bool Unknown { get; set; } = false; + + public bool Starting { get; set; } = false; + + public bool Started { get; set; } = false; + + public bool Stopping { get; set; } = false; + + public bool Stopped { get; set; } = false; + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not VpnLifecycle lifecycle) + { + return Stopped; + } + + return lifecycle switch + { + VpnLifecycle.Unknown => Unknown, + VpnLifecycle.Starting => Starting, + VpnLifecycle.Started => Started, + VpnLifecycle.Stopping => Stopping, + VpnLifecycle.Stopped => Stopped, + _ => false, + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/App.Avalonia/Diagnostics/AppBootstrapLogger.cs b/App.Avalonia/Diagnostics/AppBootstrapLogger.cs new file mode 100644 index 0000000..76207d0 --- /dev/null +++ b/App.Avalonia/Diagnostics/AppBootstrapLogger.cs @@ -0,0 +1,82 @@ +using System.Text; + +namespace Coder.Desktop.App.Diagnostics; + +internal static class AppBootstrapLogger +{ + private static readonly object FileLock = new(); + + public static string LogFilePath { get; } = ResolveLogFilePath(); + + public static void Info(string message) + { + Write("INF", message, null); + } + + public static void Warn(string message) + { + Write("WRN", message, null); + } + + public static void Error(string message, Exception? exception) + { + Write("ERR", message, exception); + } + + private static void Write(string level, string message, Exception? exception) + { + var timestamp = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz"); + var line = $"[{timestamp}] [{level}] {message}"; + + try + { + Console.Error.WriteLine(line); + if (exception != null) + Console.Error.WriteLine(exception); + } + catch + { + // best effort + } + + try + { + var builder = new StringBuilder(); + builder.AppendLine(line); + if (exception != null) + builder.AppendLine(exception.ToString()); + + lock (FileLock) + { + File.AppendAllText(LogFilePath, builder.ToString()); + } + } + catch + { + // best effort + } + } + + private static string ResolveLogFilePath() + { + try + { + var stateHome = Environment.GetEnvironmentVariable("XDG_STATE_HOME"); + if (string.IsNullOrWhiteSpace(stateHome)) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + stateHome = string.IsNullOrWhiteSpace(home) + ? Path.GetTempPath() + : Path.Combine(home, ".local", "state"); + } + + var directory = Path.Combine(stateHome, "coder-desktop"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, "app-avalonia.log"); + } + catch + { + return Path.Combine(Path.GetTempPath(), "coder-desktop-app-avalonia.log"); + } + } +} diff --git a/App.Avalonia/Program.cs b/App.Avalonia/Program.cs new file mode 100644 index 0000000..1b4f79d --- /dev/null +++ b/App.Avalonia/Program.cs @@ -0,0 +1,43 @@ +using Avalonia; +using Coder.Desktop.App.Diagnostics; + +namespace Coder.Desktop.App; + +public static class Program +{ + [STAThread] + public static int Main(string[] args) + { + AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => + { + AppBootstrapLogger.Error("Unhandled exception", eventArgs.ExceptionObject as Exception); + }; + + TaskScheduler.UnobservedTaskException += (_, eventArgs) => + { + AppBootstrapLogger.Error("Unobserved task exception", eventArgs.Exception); + eventArgs.SetObserved(); + }; + + AppBootstrapLogger.Info($"Starting Coder Desktop (Avalonia) with args: {string.Join(" ", args)}"); + AppBootstrapLogger.Info($"Logs: {AppBootstrapLogger.LogFilePath}"); + + try + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + AppBootstrapLogger.Info("Coder Desktop exited normally"); + return 0; + } + catch (Exception ex) + { + AppBootstrapLogger.Error("Coder Desktop exited with fatal error", ex); + return 1; + } + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/App.Avalonia/Services/AvaloniaClipboardService.cs b/App.Avalonia/Services/AvaloniaClipboardService.cs new file mode 100644 index 0000000..7e30226 --- /dev/null +++ b/App.Avalonia/Services/AvaloniaClipboardService.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; +using Coder.Desktop.App.Services; + +namespace Coder.Desktop.App; + +public class AvaloniaClipboardService : IClipboardService +{ + public async Task SetTextAsync(string text) + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var clipboard = desktop.MainWindow?.Clipboard; + if (clipboard != null) + await clipboard.SetTextAsync(text); + } + } +} diff --git a/App.Avalonia/Services/AvaloniaDispatcher.cs b/App.Avalonia/Services/AvaloniaDispatcher.cs new file mode 100644 index 0000000..2221ecf --- /dev/null +++ b/App.Avalonia/Services/AvaloniaDispatcher.cs @@ -0,0 +1,19 @@ +using Avalonia.Threading; +using IDispatcher = Coder.Desktop.App.Services.IDispatcher; + +namespace Coder.Desktop.App; + +public class AvaloniaDispatcher : IDispatcher +{ + public bool CheckAccess() => Dispatcher.UIThread.CheckAccess(); + + /// + /// Always post to the dispatcher queue, even when already on the UI thread. + /// This avoids subtle reentrancy issues when event-handler chains call + /// MutateState → StateChanged → UpdateFromRpcModel inline. + /// + public void Post(Action action) + { + Dispatcher.UIThread.Post(action); + } +} diff --git a/App.Avalonia/Services/AvaloniaHostApplicationLifetime.cs b/App.Avalonia/Services/AvaloniaHostApplicationLifetime.cs new file mode 100644 index 0000000..b46930c --- /dev/null +++ b/App.Avalonia/Services/AvaloniaHostApplicationLifetime.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Hosting; + +namespace Coder.Desktop.App.Services; + +/// +/// Lightweight adapter for Avalonia apps +/// that are not running inside a generic host. +/// +public sealed class AvaloniaHostApplicationLifetime : IHostApplicationLifetime, IDisposable +{ + private readonly Action _stopApplication; + private readonly CancellationTokenSource _startedCts = new(); + private readonly CancellationTokenSource _stoppingCts = new(); + private readonly CancellationTokenSource _stoppedCts = new(); + + private int _stopRequested; + + public AvaloniaHostApplicationLifetime(Action stopApplication) + { + _stopApplication = stopApplication; + } + + public CancellationToken ApplicationStarted => _startedCts.Token; + public CancellationToken ApplicationStopping => _stoppingCts.Token; + public CancellationToken ApplicationStopped => _stoppedCts.Token; + + public void NotifyStarted() + { + if (!_startedCts.IsCancellationRequested) + _startedCts.Cancel(); + } + + public void NotifyStopped() + { + if (!_stoppedCts.IsCancellationRequested) + _stoppedCts.Cancel(); + } + + public void StopApplication() + { + if (Interlocked.Exchange(ref _stopRequested, 1) != 0) + return; + + if (!_stoppingCts.IsCancellationRequested) + _stoppingCts.Cancel(); + + try + { + _stopApplication(); + } + finally + { + if (!_stoppedCts.IsCancellationRequested) + _stoppedCts.Cancel(); + } + } + + public void Dispose() + { + _startedCts.Dispose(); + _stoppingCts.Dispose(); + _stoppedCts.Dispose(); + } +} diff --git a/App.Avalonia/Services/AvaloniaWindowService.cs b/App.Avalonia/Services/AvaloniaWindowService.cs new file mode 100644 index 0000000..d42c149 --- /dev/null +++ b/App.Avalonia/Services/AvaloniaWindowService.cs @@ -0,0 +1,101 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Coder.Desktop.App.Diagnostics; +using Coder.Desktop.App.ViewModels; +using Coder.Desktop.App.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace Coder.Desktop.App.Services; + +/// +/// Avalonia implementation of . +/// +public sealed class AvaloniaWindowService(IServiceProvider services) : IWindowService +{ + private SignInWindow? _signInWindow; + private SettingsWindow? _settingsWindow; + private FileSyncListWindow? _fileSyncListWindow; + + public void ShowSignInWindow() + { + if (_signInWindow is { } existing) + { + if (!existing.IsVisible) + existing.Show(); + existing.Activate(); + return; + } + + var vm = services.GetRequiredService(); + var window = new SignInWindow(vm); + window.Closed += (_, _) => _signInWindow = null; + _signInWindow = window; + ShowWindow(window, useOwner: false); + } + + public void ShowSettingsWindow() + { + if (_settingsWindow is { } existing) + { + if (!existing.IsVisible) + existing.Show(); + existing.Activate(); + return; + } + + var vm = services.GetRequiredService(); + var window = new SettingsWindow + { + DataContext = vm, + }; + window.Closed += (_, _) => _settingsWindow = null; + _settingsWindow = window; + ShowWindow(window, useOwner: false); + } + + public void ShowFileSyncListWindow() + { + if (_fileSyncListWindow is { } existing) + { + if (!existing.IsVisible) + existing.Show(); + existing.Activate(); + return; + } + + var vm = services.GetRequiredService(); + var window = new FileSyncListWindow(vm); + window.Closed += (_, _) => + { + vm.Dispose(); + _fileSyncListWindow = null; + }; + + _fileSyncListWindow = window; + ShowWindow(window, useOwner: false); + } + + public void ShowMessageWindow(string title, string message, string windowTitle) + { + var window = new MessageWindow(title, message, windowTitle); + ShowWindow(window, useOwner: false); + } + + private static void ShowWindow(Window window, bool useOwner) + { + var desktop = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + + if (desktop?.MainWindow is TrayWindow trayWindow && trayWindow != window && trayWindow.IsVisible) + trayWindow.Hide(); + + var owner = useOwner ? desktop?.MainWindow : null; + if (owner != null && owner != window && owner.IsVisible) + window.Show(owner); + else + window.Show(); + + window.Activate(); + AppBootstrapLogger.Info($"Opened window: {window.GetType().Name}"); + } +} diff --git a/App.Avalonia/Services/ProcessLauncherService.cs b/App.Avalonia/Services/ProcessLauncherService.cs new file mode 100644 index 0000000..c3475e5 --- /dev/null +++ b/App.Avalonia/Services/ProcessLauncherService.cs @@ -0,0 +1,16 @@ +using System.Diagnostics; +using Coder.Desktop.App.Services; + +namespace Coder.Desktop.App; + +public class ProcessLauncherService : ILauncherService +{ + public Task LaunchUriAsync(Uri uri) + { + Process.Start(new ProcessStartInfo(uri.ToString()) + { + UseShellExecute = true + }); + return Task.CompletedTask; + } +} diff --git a/App.Avalonia/Services/UnavailableSyncSessionController.cs b/App.Avalonia/Services/UnavailableSyncSessionController.cs new file mode 100644 index 0000000..9ac83de --- /dev/null +++ b/App.Avalonia/Services/UnavailableSyncSessionController.cs @@ -0,0 +1,63 @@ +using System; +using Coder.Desktop.App.Models; + +namespace Coder.Desktop.App.Services; + +/// +/// Placeholder file-sync controller used by the Linux Avalonia host until +/// Mutagen plumbing is wired for this platform. +/// +public sealed class UnavailableSyncSessionController : ISyncSessionController +{ + private static readonly SyncSessionControllerStateModel CurrentState = new() + { + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = "File Sync is not available in this Linux Avalonia host yet.", + DaemonLogFilePath = "N/A", + SyncSessions = [], + }; + + public event EventHandler? StateChanged; + + public SyncSessionControllerStateModel GetState() + { + return CurrentState; + } + + public Task RefreshState(CancellationToken ct = default) + { + StateChanged?.Invoke(this, CurrentState); + return Task.FromResult(CurrentState); + } + + public Task CreateSyncSession(CreateSyncSessionRequest req, Action progressCallback, + CancellationToken ct = default) + { + return Task.FromException(CreateUnavailableException()); + } + + public Task PauseSyncSession(string identifier, CancellationToken ct = default) + { + return Task.FromException(CreateUnavailableException()); + } + + public Task ResumeSyncSession(string identifier, CancellationToken ct = default) + { + return Task.FromException(CreateUnavailableException()); + } + + public Task TerminateSyncSession(string identifier, CancellationToken ct = default) + { + return Task.FromException(CreateUnavailableException()); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + private static Exception CreateUnavailableException() + { + return new PlatformNotSupportedException("File Sync is not available in this Linux Avalonia host yet."); + } +} diff --git a/App.Avalonia/ViewModels/TrayIconViewModel.cs b/App.Avalonia/ViewModels/TrayIconViewModel.cs new file mode 100644 index 0000000..b0f68db --- /dev/null +++ b/App.Avalonia/ViewModels/TrayIconViewModel.cs @@ -0,0 +1,17 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Coder.Desktop.App.ViewModels; + +public sealed class TrayIconViewModel : ObservableObject +{ + public IRelayCommand ShowWindowCommand { get; } + public IRelayCommand ExitCommand { get; } + + public TrayIconViewModel(Action showOrToggleWindow, Action exit) + { + ShowWindowCommand = new RelayCommand(showOrToggleWindow); + ExitCommand = new RelayCommand(exit); + } +} diff --git a/App.Avalonia/ViewModels/TrayWindowShellViewModel.cs b/App.Avalonia/ViewModels/TrayWindowShellViewModel.cs new file mode 100644 index 0000000..33c8def --- /dev/null +++ b/App.Avalonia/ViewModels/TrayWindowShellViewModel.cs @@ -0,0 +1,24 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Coder.Desktop.App.ViewModels; + +public enum TrayWindowShellPage +{ + Loading, + Disconnected, + LoginRequired, + Main, +} + +/// +/// A light-weight shell ViewModel for the TrayWindow. +/// +/// The WinUI implementation performed page switching in code-behind based on +/// RPC + credential state. For Avalonia we keep the shell state in a VM so the +/// view can swap page content via a ContentControl. +/// +public sealed partial class TrayWindowShellViewModel : ObservableObject +{ + [ObservableProperty] + private TrayWindowShellPage _page = TrayWindowShellPage.Loading; +} diff --git a/App.Avalonia/Views/DirectoryPickerWindow.axaml b/App.Avalonia/Views/DirectoryPickerWindow.axaml new file mode 100644 index 0000000..fefbbd8 --- /dev/null +++ b/App.Avalonia/Views/DirectoryPickerWindow.axaml @@ -0,0 +1,12 @@ + + + + diff --git a/App.Avalonia/Views/DirectoryPickerWindow.axaml.cs b/App.Avalonia/Views/DirectoryPickerWindow.axaml.cs new file mode 100644 index 0000000..a6e3d34 --- /dev/null +++ b/App.Avalonia/Views/DirectoryPickerWindow.axaml.cs @@ -0,0 +1,58 @@ +using System; +using Avalonia.Controls; +using Coder.Desktop.App.ViewModels; + +namespace Coder.Desktop.App.Views; + +public partial class DirectoryPickerWindow : Window +{ + private DirectoryPickerViewModel? _vm; + + public DirectoryPickerWindow() + { + InitializeComponent(); + + DataContextChanged += (_, _) => AttachViewModel(); + Closed += (_, _) => DetachViewModel(); + + AttachViewModel(); + } + + public DirectoryPickerWindow(DirectoryPickerViewModel vm) : this() + { + DataContext = vm; + } + + private void AttachViewModel() + { + DetachViewModel(); + + _vm = DataContext as DirectoryPickerViewModel; + if (_vm is null) + return; + + _vm.PathSelected += VmOnPathSelected; + _vm.CloseRequested += VmOnCloseRequested; + } + + private void DetachViewModel() + { + if (_vm is null) + return; + + _vm.PathSelected -= VmOnPathSelected; + _vm.CloseRequested -= VmOnCloseRequested; + _vm = null; + } + + private void VmOnCloseRequested(object? sender, EventArgs e) + { + Close(); + } + + private void VmOnPathSelected(object? sender, string? path) + { + // ShowDialog will return the value passed to Close(T). + Close(path); + } +} diff --git a/App.Avalonia/Views/FileSyncListWindow.axaml b/App.Avalonia/Views/FileSyncListWindow.axaml new file mode 100644 index 0000000..2012e15 --- /dev/null +++ b/App.Avalonia/Views/FileSyncListWindow.axaml @@ -0,0 +1,11 @@ + + + + diff --git a/App.Avalonia/Views/FileSyncListWindow.axaml.cs b/App.Avalonia/Views/FileSyncListWindow.axaml.cs new file mode 100644 index 0000000..1aba8cb --- /dev/null +++ b/App.Avalonia/Views/FileSyncListWindow.axaml.cs @@ -0,0 +1,119 @@ +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Coder.Desktop.App.ViewModels; + +namespace Coder.Desktop.App.Views; + +public partial class FileSyncListWindow : Window +{ + private FileSyncListViewModel? _vm; + private DirectoryPickerWindow? _remotePickerWindow; + + public FileSyncListWindow() + { + InitializeComponent(); + + DataContextChanged += (_, _) => AttachViewModel(); + Closed += (_, _) => + { + DetachViewModel(); + _remotePickerWindow?.Close(); + _remotePickerWindow = null; + }; + + AttachViewModel(); + } + + public FileSyncListWindow(FileSyncListViewModel vm) : this() + { + DataContext = vm; + } + + private void AttachViewModel() + { + DetachViewModel(); + + _vm = DataContext as FileSyncListViewModel; + if (_vm is null) + return; + + _vm.PropertyChanged += VmOnPropertyChanged; + _vm.LocalFolderPicker = PickLocalFolderAsync; + SyncRemotePickerWindow(); + } + + private void DetachViewModel() + { + if (_vm is null) + return; + + _vm.PropertyChanged -= VmOnPropertyChanged; + _vm.LocalFolderPicker = null; + _vm = null; + } + + private void VmOnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(FileSyncListViewModel.RemotePathPickerViewModel)) + SyncRemotePickerWindow(); + } + + private async void SyncRemotePickerWindow() + { + if (_vm?.RemotePathPickerViewModel is not { } pickerVm) + { + if (_remotePickerWindow is not null) + { + _remotePickerWindow.Close(); + _remotePickerWindow = null; + } + + return; + } + + if (_remotePickerWindow is not null) + return; + + var window = new DirectoryPickerWindow(pickerVm); + _remotePickerWindow = window; + + window.Closed += (_, _) => + { + if (ReferenceEquals(_remotePickerWindow, window)) + _remotePickerWindow = null; + }; + + try + { + await window.ShowDialog(this); + } + catch + { + // ignored + } + } + + private async Task PickLocalFolderAsync() + { + if (StorageProvider is not { CanPickFolder: true }) + return null; + + var results = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + AllowMultiple = false, + Title = "Select local folder", + }); + + if (results.Count == 0) + return null; + + var path = results[0].Path; + if (path.IsFile) + return path.LocalPath; + + return Uri.UnescapeDataString(path.AbsolutePath); + } +} diff --git a/App.Avalonia/Views/MainWindow.axaml b/App.Avalonia/Views/MainWindow.axaml new file mode 100644 index 0000000..1721bdd --- /dev/null +++ b/App.Avalonia/Views/MainWindow.axaml @@ -0,0 +1,10 @@ + + + diff --git a/App.Avalonia/Views/MainWindow.axaml.cs b/App.Avalonia/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..70fc59b --- /dev/null +++ b/App.Avalonia/Views/MainWindow.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Coder.Desktop.App.Views; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} diff --git a/App.Avalonia/Views/MessageWindow.axaml b/App.Avalonia/Views/MessageWindow.axaml new file mode 100644 index 0000000..af2506a --- /dev/null +++ b/App.Avalonia/Views/MessageWindow.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.Avalonia/Views/Pages/TrayWindowMainPage.axaml.cs b/App.Avalonia/Views/Pages/TrayWindowMainPage.axaml.cs new file mode 100644 index 0000000..94ca0c3 --- /dev/null +++ b/App.Avalonia/Views/Pages/TrayWindowMainPage.axaml.cs @@ -0,0 +1,60 @@ +using System; +using System.Diagnostics; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Coder.Desktop.App.ViewModels; + +namespace Coder.Desktop.App.Views.Pages; + +public partial class TrayWindowMainPage : UserControl +{ + public TrayWindowViewModel? ViewModel { get; private set; } + + public TrayWindowMainPage() + { + InitializeComponent(); + + DataContextChanged += (_, _) => + { + ViewModel = DataContext as TrayWindowViewModel; + }; + } + + public TrayWindowMainPage(TrayWindowViewModel viewModel) : this() + { + ViewModel = viewModel; + DataContext = viewModel; + } + + private void CreateWorkspaceButton_Click(object? sender, RoutedEventArgs e) + { + LaunchUrl(ViewModel?.DashboardUrl); + } + + private void OpenWorkspaceButton_Click(object? sender, RoutedEventArgs e) + { + if (sender is not Control { DataContext: AgentViewModel agentVm }) + return; + + LaunchUrl(agentVm.DashboardUrl); + } + + private static void LaunchUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + return; + + try + { + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true, + }); + } + catch + { + // ignored + } + } +} diff --git a/App.Avalonia/Views/Pages/UpdaterCheckingForUpdatesMainPage.axaml b/App.Avalonia/Views/Pages/UpdaterCheckingForUpdatesMainPage.axaml new file mode 100644 index 0000000..96d7502 --- /dev/null +++ b/App.Avalonia/Views/Pages/UpdaterCheckingForUpdatesMainPage.axaml @@ -0,0 +1,10 @@ + + + + + diff --git a/App.Avalonia/Views/Pages/UpdaterCheckingForUpdatesMainPage.axaml.cs b/App.Avalonia/Views/Pages/UpdaterCheckingForUpdatesMainPage.axaml.cs new file mode 100644 index 0000000..a9473e9 --- /dev/null +++ b/App.Avalonia/Views/Pages/UpdaterCheckingForUpdatesMainPage.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Coder.Desktop.App.Views.Pages; + +public partial class UpdaterCheckingForUpdatesMainPage : UserControl +{ + public UpdaterCheckingForUpdatesMainPage() + { + InitializeComponent(); + } +} diff --git a/App.Avalonia/Views/Pages/UpdaterDownloadProgressMainPage.axaml b/App.Avalonia/Views/Pages/UpdaterDownloadProgressMainPage.axaml new file mode 100644 index 0000000..f2bba18 --- /dev/null +++ b/App.Avalonia/Views/Pages/UpdaterDownloadProgressMainPage.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + +