diff --git a/.editorconfig b/.editorconfig index cd2fd68..4cfe237 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,7 +5,10 @@ tab_width = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 -end_of_line = crlf +# end_of_line is intentionally not set: the repository history contains a mix +# of LF and CRLF files, and newer dotnet format releases treat this rule as a +# hard error, turning every diff into whitespace churn. Match the existing +# line endings of the file you are editing. resharper_csharp_brace_style = next_line resharper_csharp_braces_for_foreach = not_required diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a21166a..a68321e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ on: jobs: fmt: runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -23,11 +23,11 @@ jobs: - name: dotnet restore run: dotnet restore --locked-mode /p:BuildWithNetFrameworkHostedCompiler=true - name: dotnet format - run: dotnet format --verify-no-changes --no-restore + run: dotnet format Coder.Desktop.sln --verify-no-changes --no-restore - test: + test-windows: runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -44,16 +44,45 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-windows retention-days: 1 path: | ./**/bin ./**/obj ./**/TestResults - build: + test-linux: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Setup dotnet + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + - name: dotnet restore + run: dotnet restore Coder.Desktop.Linux.slnf --locked-mode + - name: dotnet build + run: dotnet build Coder.Desktop.Linux.slnf -c Release --no-restore + - name: dotnet test + run: | + dotnet test Tests.CoderSdk/Tests.CoderSdk.csproj -c Release --no-build -f net8.0 + dotnet test Tests.Vpn.Proto/Tests.Vpn.Proto.csproj -c Release --no-build + dotnet test Tests.Vpn/Tests.Vpn.csproj -c Release --no-build -f net8.0 + dotnet test Tests.Vpn.Service/Tests.Vpn.Service.csproj -c Release --no-build -f net8.0 + - name: Upload TestResults + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-linux + retention-days: 1 + path: ./**/TestResults + + build-windows: runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -68,7 +97,7 @@ jobs: # projects we care about building. Doing a full publish includes test # libraries and stuff which is pointless. - name: dotnet publish Coder.Desktop.Vpn.Service - run: dotnet publish .\Vpn.Service\Vpn.Service.csproj --configuration Release --output .\publish\Vpn.Service + run: dotnet publish .\Vpn.Service\Vpn.Service.csproj --configuration Release -f net8.0-windows --output .\publish\Vpn.Service - name: dotnet publish Coder.Desktop.App run: dotnet publish .\App\App.csproj --configuration Release --output .\publish\App - name: Upload artifact @@ -76,3 +105,26 @@ jobs: with: name: publish path: .\publish\ + + build-linux: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Setup dotnet + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + - name: dotnet restore + run: dotnet restore Coder.Desktop.Linux.slnf --locked-mode + - name: dotnet publish Coder.Desktop.Vpn.Service + run: dotnet publish Vpn.Service/Vpn.Service.csproj --configuration Release -f net8.0 --output ./publish/Vpn.Service + - name: dotnet publish Coder.Desktop.App.Avalonia + run: dotnet publish App.Avalonia/App.Avalonia.csproj --configuration Release --output ./publish/App.Avalonia + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: publish-linux + path: ./publish/ 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 0000000..b80bdc2 Binary files /dev/null and b/App.Avalonia/Assets/coder.ico differ 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 @@ + + + + + + + + + + +