From bc443450e82f14126837c817c73b4f6e6b4b23f9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 06:51:30 +0000 Subject: [PATCH 01/13] Phase 0: Retarget Vpn/ to net8.0, create cross-platform infrastructure - Retarget Vpn/Vpn.csproj from net8.0-windows to net8.0 - Create IRpcTransport.cs in Vpn/ with IRpcServerTransport and IRpcClientTransport interfaces - Create Vpn.Windows/ project (net8.0-windows): - Move RegistryConfigurationSource.cs from Vpn/ - Add NamedPipeServerTransport and NamedPipeClientTransport - Create Vpn.Linux/ project (net8.0): - Add UnixSocketServerTransport and UnixSocketClientTransport - Create App.Windows/ project (net8.0-windows) placeholder - Create App.Linux/ project (net8.0) placeholder - Add all new projects to Coder.Desktop.sln All cross-platform projects build successfully on Linux: Vpn, Vpn.Linux, App.Linux, Vpn.Proto, CoderSdk, MutagenSdk --- App.Linux/App.Linux.csproj | 11 +++ App.Linux/Placeholder.cs | 3 + App.Windows/App.Windows.csproj | 11 +++ App.Windows/Placeholder.cs | 3 + Coder.Desktop.sln | 72 +++++++++++++++++++ Vpn.Linux/UnixSocketClientTransport.cs | 30 ++++++++ Vpn.Linux/UnixSocketServerTransport.cs | 62 ++++++++++++++++ Vpn.Linux/Vpn.Linux.csproj | 15 ++++ Vpn.Windows/NamedPipeClientTransport.cs | 29 ++++++++ Vpn.Windows/NamedPipeServerTransport.cs | 42 +++++++++++ .../RegistryConfigurationSource.cs | 0 Vpn.Windows/Vpn.Windows.csproj | 19 +++++ Vpn/IRpcTransport.cs | 20 ++++++ Vpn/Vpn.csproj | 4 +- Vpn/packages.lock.json | 2 +- 15 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 App.Linux/App.Linux.csproj create mode 100644 App.Linux/Placeholder.cs create mode 100644 App.Windows/App.Windows.csproj create mode 100644 App.Windows/Placeholder.cs 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.Windows/NamedPipeClientTransport.cs create mode 100644 Vpn.Windows/NamedPipeServerTransport.cs rename {Vpn => Vpn.Windows}/RegistryConfigurationSource.cs (100%) create mode 100644 Vpn.Windows/Vpn.Windows.csproj create mode 100644 Vpn/IRpcTransport.cs diff --git a/App.Linux/App.Linux.csproj b/App.Linux/App.Linux.csproj new file mode 100644 index 0000000..ea76020 --- /dev/null +++ b/App.Linux/App.Linux.csproj @@ -0,0 +1,11 @@ + + + + Coder.Desktop.App.Linux + Coder.Desktop.App + net8.0 + enable + enable + + + diff --git a/App.Linux/Placeholder.cs b/App.Linux/Placeholder.cs new file mode 100644 index 0000000..be85fd3 --- /dev/null +++ b/App.Linux/Placeholder.cs @@ -0,0 +1,3 @@ +// Linux-specific App implementations will be added here during Phase 2. +// This project is a placeholder for now. +namespace Coder.Desktop.App; diff --git a/App.Windows/App.Windows.csproj b/App.Windows/App.Windows.csproj new file mode 100644 index 0000000..65a6060 --- /dev/null +++ b/App.Windows/App.Windows.csproj @@ -0,0 +1,11 @@ + + + + Coder.Desktop.App.Windows + Coder.Desktop.App + net8.0-windows + enable + enable + + + diff --git a/App.Windows/Placeholder.cs b/App.Windows/Placeholder.cs new file mode 100644 index 0000000..4e834a0 --- /dev/null +++ b/App.Windows/Placeholder.cs @@ -0,0 +1,3 @@ +// Windows-specific App implementations will be moved here during Phase 2. +// This project is a placeholder for now. +namespace Coder.Desktop.App; diff --git a/Coder.Desktop.sln b/Coder.Desktop.sln index d1f5ac6..9f151f3 100644 --- a/Coder.Desktop.sln +++ b/Coder.Desktop.sln @@ -29,6 +29,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MutagenSdk", "MutagenSdk\Mu EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.CoderSdk", "Tests.CoderSdk\Tests.CoderSdk.csproj", "{2BDEA023-FE75-476F-81DE-8EF90806C27C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vpn.Windows", "Vpn.Windows\Vpn.Windows.csproj", "{9485496D-1CF9-4B1D-A994-AF8E8617504A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vpn.Linux", "Vpn.Linux\Vpn.Linux.csproj", "{447D594A-14AD-4B63-A188-00AE7B125ECB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Windows", "App.Windows\App.Windows.csproj", "{DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Linux", "App.Linux\App.Linux.csproj", "{E2605556-CD04-4FD9-9621-C7063F859AED}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -257,6 +265,70 @@ Global {2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|x64.Build.0 = Release|Any CPU {2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|x86.ActiveCfg = Release|Any CPU {2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|x86.Build.0 = Release|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Debug|ARM64.Build.0 = Debug|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Debug|x64.ActiveCfg = Debug|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Debug|x64.Build.0 = Debug|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Debug|x86.ActiveCfg = Debug|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Debug|x86.Build.0 = Debug|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Release|Any CPU.Build.0 = Release|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Release|ARM64.ActiveCfg = Release|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Release|ARM64.Build.0 = Release|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Release|x64.ActiveCfg = Release|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Release|x64.Build.0 = Release|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Release|x86.ActiveCfg = Release|Any CPU + {9485496D-1CF9-4B1D-A994-AF8E8617504A}.Release|x86.Build.0 = Release|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Debug|ARM64.Build.0 = Debug|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Debug|x64.ActiveCfg = Debug|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Debug|x64.Build.0 = Debug|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Debug|x86.ActiveCfg = Debug|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Debug|x86.Build.0 = Debug|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Release|Any CPU.Build.0 = Release|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Release|ARM64.ActiveCfg = Release|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Release|ARM64.Build.0 = Release|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Release|x64.ActiveCfg = Release|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Release|x64.Build.0 = Release|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Release|x86.ActiveCfg = Release|Any CPU + {447D594A-14AD-4B63-A188-00AE7B125ECB}.Release|x86.Build.0 = Release|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Debug|ARM64.Build.0 = Debug|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Debug|x64.ActiveCfg = Debug|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Debug|x64.Build.0 = Debug|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Debug|x86.ActiveCfg = Debug|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Debug|x86.Build.0 = Debug|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Release|Any CPU.Build.0 = Release|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Release|ARM64.ActiveCfg = Release|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Release|ARM64.Build.0 = Release|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Release|x64.ActiveCfg = Release|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Release|x64.Build.0 = Release|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Release|x86.ActiveCfg = Release|Any CPU + {DECAA70B-27E2-4CB0-8C7E-AC271E5446E5}.Release|x86.Build.0 = Release|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Debug|ARM64.Build.0 = Debug|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Debug|x64.ActiveCfg = Debug|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Debug|x64.Build.0 = Debug|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Debug|x86.ActiveCfg = Debug|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Debug|x86.Build.0 = Debug|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|Any CPU.Build.0 = Release|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|ARM64.ActiveCfg = Release|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|ARM64.Build.0 = Release|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|x64.ActiveCfg = Release|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|x64.Build.0 = Release|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|x86.ActiveCfg = Release|Any CPU + {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Vpn.Linux/UnixSocketClientTransport.cs b/Vpn.Linux/UnixSocketClientTransport.cs new file mode 100644 index 0000000..491e9b2 --- /dev/null +++ b/Vpn.Linux/UnixSocketClientTransport.cs @@ -0,0 +1,30 @@ +using System.Net.Sockets; +using System.Runtime.Versioning; + +namespace Coder.Desktop.Vpn; + +[SupportedOSPlatform("linux")] +public class UnixSocketClientTransport : IRpcClientTransport +{ + private readonly string _socketPath; + + public UnixSocketClientTransport(string socketPath = "/run/coder-desktop/vpn.sock") + { + _socketPath = socketPath; + } + + public async Task ConnectAsync(CancellationToken ct) + { + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(_socketPath), ct); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } +} diff --git a/Vpn.Linux/UnixSocketServerTransport.cs b/Vpn.Linux/UnixSocketServerTransport.cs new file mode 100644 index 0000000..e9fbf83 --- /dev/null +++ b/Vpn.Linux/UnixSocketServerTransport.cs @@ -0,0 +1,62 @@ +using System.Net.Sockets; +using System.Runtime.Versioning; + +namespace Coder.Desktop.Vpn; + +[SupportedOSPlatform("linux")] +public class UnixSocketServerTransport : IRpcServerTransport +{ + private readonly string _socketPath; + private Socket? _listener; + + public UnixSocketServerTransport(string socketPath = "/run/coder-desktop/vpn.sock") + { + _socketPath = socketPath; + } + + public async Task AcceptAsync(CancellationToken ct) + { + if (_listener == null) + { + // Clean up stale socket file + if (File.Exists(_socketPath)) + File.Delete(_socketPath); + + // Ensure parent directory exists + var dir = Path.GetDirectoryName(_socketPath); + if (dir != null && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + _listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + _listener.Bind(new UnixDomainSocketEndPoint(_socketPath)); + + // Set permissions so all users can connect (equivalent to WorldSid on Windows) + File.SetUnixFileMode(_socketPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite); + + _listener.Listen(5); + } + + var client = await _listener.AcceptAsync(ct); + return new NetworkStream(client, ownsSocket: true); + } + + public ValueTask DisposeAsync() + { + if (_listener != null) + { + _listener.Close(); + _listener.Dispose(); + _listener = null; + } + + if (File.Exists(_socketPath)) + { + try { File.Delete(_socketPath); } catch { /* best effort */ } + } + + return ValueTask.CompletedTask; + } +} diff --git a/Vpn.Linux/Vpn.Linux.csproj b/Vpn.Linux/Vpn.Linux.csproj new file mode 100644 index 0000000..dd18f39 --- /dev/null +++ b/Vpn.Linux/Vpn.Linux.csproj @@ -0,0 +1,15 @@ + + + + Coder.Desktop.Vpn.Linux + Coder.Desktop.Vpn + net8.0 + enable + enable + + + + + + + diff --git a/Vpn.Windows/NamedPipeClientTransport.cs b/Vpn.Windows/NamedPipeClientTransport.cs new file mode 100644 index 0000000..73a8040 --- /dev/null +++ b/Vpn.Windows/NamedPipeClientTransport.cs @@ -0,0 +1,29 @@ +using System.IO.Pipes; + +namespace Coder.Desktop.Vpn; + +public class NamedPipeClientTransport : IRpcClientTransport +{ + private readonly string _pipeName; + + public NamedPipeClientTransport(string pipeName) + { + _pipeName = pipeName; + } + + public async Task ConnectAsync(CancellationToken ct) + { + var pipe = new NamedPipeClientStream(".", _pipeName, + PipeDirection.InOut, PipeOptions.Asynchronous); + try + { + await pipe.ConnectAsync(ct); + return pipe; + } + catch + { + await pipe.DisposeAsync(); + throw; + } + } +} diff --git a/Vpn.Windows/NamedPipeServerTransport.cs b/Vpn.Windows/NamedPipeServerTransport.cs new file mode 100644 index 0000000..d8d3416 --- /dev/null +++ b/Vpn.Windows/NamedPipeServerTransport.cs @@ -0,0 +1,42 @@ +using System.IO.Pipes; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace Coder.Desktop.Vpn; + +public class NamedPipeServerTransport : IRpcServerTransport +{ + private readonly string _pipeName; + + public NamedPipeServerTransport(string pipeName) + { + _pipeName = pipeName; + } + + public async Task AcceptAsync(CancellationToken ct) + { + var pipeSecurity = new PipeSecurity(); + pipeSecurity.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + PipeAccessRights.FullControl, AccessControlType.Allow)); + var pipe = NamedPipeServerStreamAcl.Create( + _pipeName, PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0, 0, pipeSecurity); + try + { + await pipe.WaitForConnectionAsync(ct); + return pipe; + } + catch + { + await pipe.DisposeAsync(); + throw; + } + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/Vpn/RegistryConfigurationSource.cs b/Vpn.Windows/RegistryConfigurationSource.cs similarity index 100% rename from Vpn/RegistryConfigurationSource.cs rename to Vpn.Windows/RegistryConfigurationSource.cs diff --git a/Vpn.Windows/Vpn.Windows.csproj b/Vpn.Windows/Vpn.Windows.csproj new file mode 100644 index 0000000..0dc1066 --- /dev/null +++ b/Vpn.Windows/Vpn.Windows.csproj @@ -0,0 +1,19 @@ + + + + Coder.Desktop.Vpn.Windows + Coder.Desktop.Vpn + net8.0-windows + enable + enable + + + + + + + + + + + diff --git a/Vpn/IRpcTransport.cs b/Vpn/IRpcTransport.cs new file mode 100644 index 0000000..1b5243d --- /dev/null +++ b/Vpn/IRpcTransport.cs @@ -0,0 +1,20 @@ +namespace Coder.Desktop.Vpn; + +/// +/// Server-side transport for accepting RPC connections. +/// Windows: Named Pipes. Linux: Unix Domain Sockets. +/// +public interface IRpcServerTransport : IAsyncDisposable +{ + /// Accept a single client connection, returning a bidirectional stream. + Task AcceptAsync(CancellationToken ct); +} + +/// +/// Client-side transport for connecting to the RPC server. +/// +public interface IRpcClientTransport +{ + /// Connect to the RPC server, returning a bidirectional stream. + Task ConnectAsync(CancellationToken ct); +} diff --git a/Vpn/Vpn.csproj b/Vpn/Vpn.csproj index 76a72eb..68755f8 100644 --- a/Vpn/Vpn.csproj +++ b/Vpn/Vpn.csproj @@ -1,9 +1,9 @@ - + Coder.Desktop.Vpn Coder.Desktop.Vpn - net8.0-windows + net8.0 enable enable true diff --git a/Vpn/packages.lock.json b/Vpn/packages.lock.json index 8876fe4..8e56ce8 100644 --- a/Vpn/packages.lock.json +++ b/Vpn/packages.lock.json @@ -1,7 +1,7 @@ { "version": 1, "dependencies": { - "net8.0-windows7.0": { + "net8.0": { "Microsoft.Extensions.Configuration": { "type": "Direct", "requested": "[9.0.1, )", From 18618a9d682c0efaeab29039d30b3698ba37bf30 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 07:10:28 +0000 Subject: [PATCH 02/13] feat: make Vpn.Service cross-platform (Windows + Linux) Phase 1: Retarget Vpn.Service from net8.0-windows to net8.0 and add platform-conditional code for Windows/Linux support. Changes: - Vpn.Service.csproj: retarget to net8.0, add EnableWindowsTargeting, conditional Windows/Linux package references and project references, WINDOWS preprocessor constant on Windows builds - Program.cs: platform-conditional hosting (AddWindowsService vs AddSystemd), config (Registry vs JSON file), transport registration (NamedPipeServerTransport vs UnixSocketServerTransport), log paths - ManagerConfig.cs: add ServiceRpcSocketPath for Linux, platform- conditional defaults for TunnelBinaryPath and SignatureSigner - ManagerRpc.cs: replace direct named pipe code with IRpcServerTransport abstraction, inject transport via constructor - TelemetryEnricher.cs: cross-platform device ID (Windows Registry vs /etc/machine-id), platform-conditional DeviceOs string - Downloader.cs: wrap AuthenticodeDownloadValidator in #if WINDOWS - Manager.cs: platform-conditional binary download URL (coder-linux-{arch} vs coder-windows-{arch}.exe), guard Authenticode validator instantiation with #if WINDOWS - Delete packages.lock.json (RestorePackagesWithLockFile=false) --- Vpn.Service/Downloader.cs | 8 + Vpn.Service/Manager.cs | 21 +- Vpn.Service/ManagerConfig.cs | 19 +- Vpn.Service/ManagerRpc.cs | 84 ++---- Vpn.Service/Program.cs | 99 +++++-- Vpn.Service/TelemetryEnricher.cs | 61 +++-- Vpn.Service/Vpn.Service.csproj | 30 +- Vpn.Service/packages.lock.json | 452 ------------------------------- 8 files changed, 202 insertions(+), 572 deletions(-) delete mode 100644 Vpn.Service/packages.lock.json diff --git a/Vpn.Service/Downloader.cs b/Vpn.Service/Downloader.cs index c4a916f..5727e71 100644 --- a/Vpn.Service/Downloader.cs +++ b/Vpn.Service/Downloader.cs @@ -1,14 +1,20 @@ using System.Collections.Concurrent; using System.Diagnostics; +#if WINDOWS using System.Formats.Asn1; +#endif using System.Net; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Security.Cryptography; +#if WINDOWS using System.Security.Cryptography.X509Certificates; +#endif using Coder.Desktop.Vpn.Utilities; using Microsoft.Extensions.Logging; +#if WINDOWS using Microsoft.Security.Extensions; +#endif namespace Coder.Desktop.Vpn.Service; @@ -38,6 +44,7 @@ public Task ValidateAsync(string path, CancellationToken ct = default) } } +#if WINDOWS /// /// Ensures the downloaded binary is signed by the expected authenticode organization. /// @@ -180,6 +187,7 @@ private static void Assert(bool condition, string message) throw new Exception("Failed certificate parse assertion: " + message); } } +#endif public class AssemblyVersionDownloadValidator : IDownloadValidator { diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 027a882..7a273f3 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -403,7 +403,7 @@ private async ValueTask CheckServerVersionAndCredentials(string b } /// - /// Fetches the "/bin/coder-windows-{architecture}.exe" binary from the given base URL and writes it to the + /// Fetches the "/bin/coder-{os}-{architecture}" binary from the given base URL and writes it to the /// destination path after validating the signature and checksum. /// /// Server base URL to download the binary from @@ -420,7 +420,9 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected url = new Uri(baseUrl, UriKind.Absolute); if (url.PathAndQuery != "/") throw new ArgumentException("Base URL must not contain a path", nameof(baseUrl)); - url = new Uri(url, $"/bin/coder-windows-{architecture}.exe"); + var osName = OperatingSystem.IsWindows() ? "windows" : "linux"; + var extension = OperatingSystem.IsWindows() ? ".exe" : ""; + url = new Uri(url, $"/bin/coder-{osName}-{architecture}{extension}"); } catch (Exception e) { @@ -434,9 +436,18 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected var validators = new CombinationDownloadValidator(); if (!string.IsNullOrEmpty(_config.TunnelBinarySignatureSigner)) { - _logger.LogDebug("Adding Authenticode signature validator for signer '{Signer}'", - _config.TunnelBinarySignatureSigner); - validators.Add(new AuthenticodeDownloadValidator(_config.TunnelBinarySignatureSigner)); + if (OperatingSystem.IsWindows()) + { +#if WINDOWS + _logger.LogDebug("Adding Authenticode signature validator for signer '{Signer}'", + _config.TunnelBinarySignatureSigner); + validators.Add(new AuthenticodeDownloadValidator(_config.TunnelBinarySignatureSigner)); +#endif + } + else + { + _logger.LogDebug("Authenticode validation is only available on Windows, skipping"); + } } else { diff --git a/Vpn.Service/ManagerConfig.cs b/Vpn.Service/ManagerConfig.cs index c60b1b8..a078f9c 100644 --- a/Vpn.Service/ManagerConfig.cs +++ b/Vpn.Service/ManagerConfig.cs @@ -2,21 +2,26 @@ namespace Coder.Desktop.Vpn.Service; -// These values are the config option names used in the registry. Any option -// here can be configured with `(Debug)?Manager:OptionName` in the registry. -// -// They should not be changed without backwards compatibility considerations. -// If changed here, they should also be changed in the installer. public class ManagerConfig { [Required] [RegularExpression(@"^([a-zA-Z0-9_-]+\.)*[a-zA-Z0-9_-]+$")] public string ServiceRpcPipeName { get; set; } = "Coder.Desktop.Vpn"; - [Required] public string TunnelBinaryPath { get; set; } = @"C:\coder-vpn.exe"; + /// + /// Path to the Unix domain socket for RPC (Linux only). + /// If empty, defaults to /run/coder-desktop/vpn.sock. + /// + public string ServiceRpcSocketPath { get; set; } = ""; + + [Required] public string TunnelBinaryPath { get; set; } = OperatingSystem.IsWindows() + ? @"C:\coder-vpn.exe" + : "/usr/lib/coder-desktop/coder-vpn"; // If empty, signatures will not be verified. - [Required] public string TunnelBinarySignatureSigner { get; set; } = "Coder Technologies Inc."; + [Required] public string TunnelBinarySignatureSigner { get; set; } = OperatingSystem.IsWindows() + ? "Coder Technologies Inc." + : ""; // No Authenticode on Linux [Required] public bool TunnelBinaryAllowVersionMismatch { get; set; } = false; } diff --git a/Vpn.Service/ManagerRpc.cs b/Vpn.Service/ManagerRpc.cs index 4920570..36d3fe1 100644 --- a/Vpn.Service/ManagerRpc.cs +++ b/Vpn.Service/ManagerRpc.cs @@ -1,10 +1,7 @@ using System.Collections.Concurrent; -using System.IO.Pipes; -using System.Security.AccessControl; -using System.Security.Principal; +using Coder.Desktop.Vpn; using Coder.Desktop.Vpn.Proto; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Coder.Desktop.Vpn.Service; @@ -22,28 +19,26 @@ delegate Task OnReceiveHandler(ulong clientId, ReplyableRpcMessage -/// Provides a named pipe server for communication between multiple RpcRole.Client and RpcRole.Manager. +/// Provides an RPC server for communication between multiple clients and the Manager. +/// Uses IRpcServerTransport for platform-specific transport (Named Pipes on Windows, Unix Sockets on Linux). /// public class ManagerRpc : IManagerRpc { private readonly ConcurrentDictionary _activeClients = new(); - private readonly ManagerConfig _config; private readonly CancellationTokenSource _cts = new(); private readonly ILogger _logger; + private readonly IRpcServerTransport _transport; private ulong _lastClientId; - // ReSharper disable once ConvertToPrimaryConstructor - public ManagerRpc(IOptions config, ILogger logger) + public ManagerRpc(IRpcServerTransport transport, ILogger logger) { _logger = logger; - _config = config.Value; + _transport = transport; } public event IManagerRpc.OnReceiveHandler? OnReceive; @@ -60,6 +55,7 @@ public async ValueTask DisposeAsync() { } + await _transport.DisposeAsync(); _cts.Dispose(); GC.SuppressFinalize(this); } @@ -71,87 +67,61 @@ public async Task StopAsync(CancellationToken cancellationToken) } /// - /// Starts the named pipe server, listens for incoming connections and starts handling them asynchronously. + /// Starts the RPC server, listens for incoming connections and starts handling them asynchronously. /// public async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation(@"Starting continuous named pipe RPC server at \\.\pipe\{PipeName}", - _config.ServiceRpcPipeName); - - // Allow everyone to connect to the named pipe - var pipeSecurity = new PipeSecurity(); - pipeSecurity.AddAccessRule(new PipeAccessRule( - new SecurityIdentifier(WellKnownSidType.WorldSid, null), - PipeAccessRights.FullControl, - AccessControlType.Allow)); - - // Starting a named pipe server is not like a TCP server where you can - // continuously accept new connections. You need to recreate the server - // after accepting a connection in order to accept new connections. + _logger.LogInformation("Starting RPC server"); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _cts.Token); while (!linkedCts.IsCancellationRequested) { - var pipeServer = NamedPipeServerStreamAcl.Create(_config.ServiceRpcPipeName, PipeDirection.InOut, - NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0, - 0, pipeSecurity); - + Stream stream; try { - _logger.LogDebug("Waiting for new named pipe client connection"); - await pipeServer.WaitForConnectionAsync(linkedCts.Token); - - var clientId = Interlocked.Add(ref _lastClientId, 1); - _logger.LogInformation("Handling named pipe client connection for client {ClientId}", clientId); - var speaker = new Speaker(pipeServer); - var clientTask = HandleRpcClientAsync(clientId, speaker, linkedCts.Token); - _activeClients.TryAdd(clientId, new ManagerRpcClient(speaker, clientTask)); - _ = clientTask.ContinueWith(task => - { - if (task.IsFaulted) - _logger.LogWarning(task.Exception, "Client {ClientId} RPC task faulted", clientId); - _activeClients.TryRemove(clientId, out _); - }, CancellationToken.None); + _logger.LogDebug("Waiting for new client connection"); + stream = await _transport.AcceptAsync(linkedCts.Token); } catch (OperationCanceledException) { - await pipeServer.DisposeAsync(); throw; } catch (Exception e) { - _logger.LogWarning(e, "Failed to accept named pipe client"); - await pipeServer.DisposeAsync(); + _logger.LogWarning(e, "Failed to accept client connection"); + continue; } + + var clientId = Interlocked.Add(ref _lastClientId, 1); + _logger.LogInformation("Handling client connection for client {ClientId}", clientId); + var speaker = new Speaker(stream); + var clientTask = HandleRpcClientAsync(clientId, speaker, linkedCts.Token); + _activeClients.TryAdd(clientId, new ManagerRpcClient(speaker, clientTask)); + _ = clientTask.ContinueWith(task => + { + if (task.IsFaulted) + _logger.LogWarning(task.Exception, "Client {ClientId} RPC task faulted", clientId); + _activeClients.TryRemove(clientId, out _); + }, CancellationToken.None); } } public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct) { - // Sends messages to all clients simultaneously and waits for them all - // to send or fail/timeout. - // - // Looping over a ConcurrentDictionary is exception-safe, but any items - // added or removed during the loop may or may not be included. await Task.WhenAll(_activeClients.Select(async item => { try { - // Enforce upper bound in case a CT with a timeout wasn't - // supplied. using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(2)); await item.Value.Speaker.SendMessage(message, cts.Token); } catch (ObjectDisposedException) { - // The speaker was likely closed while we were iterating. } catch (Exception e) { _logger.LogWarning(e, "Failed to send message to client {ClientId}", item.Key); - // TODO: this should probably kill the client, but due to the - // async nature of the client handling, calling Dispose - // will not remove the client from the active clients list } })); } diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs index 094875d..0e4c724 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -1,7 +1,7 @@ +using Coder.Desktop.Vpn; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Win32; using Serilog; using ILogger = Serilog.ILogger; @@ -9,18 +9,11 @@ namespace Coder.Desktop.Vpn.Service; public static class Program { - // These values are the service name and the prefix on registry value names. - // They should not be changed without backwards compatibility - // considerations. If changed here, they should also be changed in the - // installer. #if !DEBUG private const string ServiceName = "Coder Desktop"; - private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\VpnService"; private const string DefaultLogLevel = "Information"; #else - // This value matches Create-Service.ps1. private const string ServiceName = "Coder Desktop (Debug)"; - private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugVpnService"; private const string DefaultLogLevel = "Debug"; #endif @@ -30,7 +23,6 @@ public static class Program public static async Task Main(string[] args) { - // This logger will only be used until we load our full logging configuration and replace it. Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console() .CreateLogger(); MainLogger.Information("Application is starting"); @@ -59,12 +51,11 @@ private static async Task BuildAndRun(string[] args) // Configuration sources builder.Configuration.Sources.Clear(); AddDefaultConfig(configBuilder); - configBuilder.Add( - new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey)); + AddPlatformConfig(configBuilder); builder.Configuration.AddEnvironmentVariables("CODER_MANAGER_"); builder.Configuration.AddCommandLine(args); - // Options types (these get registered as IOptions singletons) + // Options types builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(ManagerConfigSection)) .ValidateDataAnnotations(); @@ -75,6 +66,9 @@ private static async Task BuildAndRun(string[] args) loggerConfig.ReadFrom.Configuration(builder.Configuration); }); + // Platform-specific services + RegisterPlatformServices(builder); + // Singletons builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -86,17 +80,6 @@ private static async Task BuildAndRun(string[] args) builder.Services.AddHostedService(); builder.Services.AddHostedService(); - // Either run as a Windows service or a console application - if (!Environment.UserInteractive) - { - MainLogger.Information("Running as a windows service"); - builder.Services.AddWindowsService(options => { options.ServiceName = ServiceName; }); - } - else - { - MainLogger.Information("Running as a console application"); - } - var host = builder.Build(); Log.Logger = (ILogger)host.Services.GetService(typeof(ILogger))!; MainLogger.Information("Application is starting"); @@ -104,8 +87,76 @@ private static async Task BuildAndRun(string[] args) await host.RunAsync(); } + private static void AddPlatformConfig(IConfigurationBuilder builder) + { +#if WINDOWS + if (OperatingSystem.IsWindows()) + { +#if !DEBUG + const string configSubKey = @"SOFTWARE\Coder Desktop\VpnService"; +#else + const string configSubKey = @"SOFTWARE\Coder Desktop\DebugVpnService"; +#endif + builder.Add(new RegistryConfigurationSource( + Microsoft.Win32.Registry.LocalMachine, configSubKey)); + } +#else + if (OperatingSystem.IsLinux()) + { + builder.AddJsonFile("/etc/coder-desktop/config.json", optional: true, reloadOnChange: false); + } +#endif + } + + private static void RegisterPlatformServices(HostApplicationBuilder builder) + { +#if WINDOWS + if (OperatingSystem.IsWindows()) + { + if (!Environment.UserInteractive) + { + MainLogger.Information("Running as a Windows service"); + builder.Services.AddWindowsService(options => { options.ServiceName = ServiceName; }); + } + else + { + MainLogger.Information("Running as a console application"); + } + + // Register Windows named pipe transport + builder.Services.AddSingleton(sp => + { + var config = sp.GetRequiredService>().Value; + return new NamedPipeServerTransport(config.ServiceRpcPipeName); + }); + } +#else +#pragma warning disable CA1416 // Platform compatibility - guarded by OperatingSystem.IsLinux() at runtime + if (OperatingSystem.IsLinux()) + { + MainLogger.Information("Running as a systemd service"); + builder.Services.AddSystemd(); + + // Register Unix socket transport + builder.Services.AddSingleton(sp => + { + var config = sp.GetRequiredService>().Value; + var socketPath = string.IsNullOrEmpty(config.ServiceRpcSocketPath) + ? "/run/coder-desktop/vpn.sock" + : config.ServiceRpcSocketPath; + return new UnixSocketServerTransport(socketPath); + }); + } +#pragma warning restore CA1416 +#endif + } + private static void AddDefaultConfig(IConfigurationBuilder builder) { + var logPath = OperatingSystem.IsWindows() + ? @"C:\coder-desktop-service.log" + : "/var/log/coder-desktop-service.log"; + builder.AddInMemoryCollection(new Dictionary { ["Serilog:Using:0"] = "Serilog.Sinks.File", @@ -115,7 +166,7 @@ private static void AddDefaultConfig(IConfigurationBuilder builder) ["Serilog:Enrich:0"] = "FromLogContext", ["Serilog:WriteTo:0:Name"] = "File", - ["Serilog:WriteTo:0:Args:path"] = @"C:\coder-desktop-service.log", + ["Serilog:WriteTo:0:Args:path"] = logPath, ["Serilog:WriteTo:0:Args:outputTemplate"] = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", ["Serilog:WriteTo:0:Args:rollingInterval"] = "Day", diff --git a/Vpn.Service/TelemetryEnricher.cs b/Vpn.Service/TelemetryEnricher.cs index 2169334..5c1bfe9 100644 --- a/Vpn.Service/TelemetryEnricher.cs +++ b/Vpn.Service/TelemetryEnricher.cs @@ -1,15 +1,11 @@ using System.Reflection; +using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using Coder.Desktop.Vpn.Proto; -using Microsoft.Win32; namespace Coder.Desktop.Vpn.Service; -// -// ITelemetryEnricher contains methods for enriching messages with telemetry -// information -// public interface ITelemetryEnricher { public StartRequest EnrichStartRequest(StartRequest original); @@ -24,28 +20,53 @@ public TelemetryEnricher() { var assembly = Assembly.GetExecutingAssembly(); _version = assembly.GetName().Version?.ToString(); - - using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SQMClient"); - if (key != null) - { - // this is the "Device ID" shown in settings. I don't think it's personally - // identifiable, but let's hash it just to be sure. - var deviceID = key.GetValue("MachineId") as string; - if (!string.IsNullOrEmpty(deviceID)) - { - var idBytes = Encoding.UTF8.GetBytes(deviceID); - var hash = SHA256.HashData(idBytes); - _deviceID = Convert.ToBase64String(hash); - } - } + _deviceID = GetDeviceId(); } public StartRequest EnrichStartRequest(StartRequest original) { var req = original.Clone(); - req.DeviceOs = "Windows"; + req.DeviceOs = OperatingSystem.IsWindows() ? "Windows" : "Linux"; if (_version != null) req.CoderDesktopVersion = _version; if (_deviceID != null) req.DeviceId = _deviceID; return req; } + + private static string? GetDeviceId() + { + try + { + string? rawId = null; + + if (OperatingSystem.IsWindows()) + { + rawId = GetWindowsDeviceId(); + } + else if (OperatingSystem.IsLinux()) + { + // /etc/machine-id is standard on systemd systems + const string machineIdPath = "/etc/machine-id"; + if (File.Exists(machineIdPath)) + rawId = File.ReadAllText(machineIdPath).Trim(); + } + + if (string.IsNullOrEmpty(rawId)) + return null; + + var idBytes = Encoding.UTF8.GetBytes(rawId); + var hash = SHA256.HashData(idBytes); + return Convert.ToBase64String(hash); + } + catch + { + return null; + } + } + + [SupportedOSPlatform("windows")] + private static string? GetWindowsDeviceId() + { + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SQMClient"); + return key?.GetValue("MachineId") as string; + } } diff --git a/Vpn.Service/Vpn.Service.csproj b/Vpn.Service/Vpn.Service.csproj index aaed3cc..d70d145 100644 --- a/Vpn.Service/Vpn.Service.csproj +++ b/Vpn.Service/Vpn.Service.csproj @@ -1,13 +1,14 @@ - + Coder.Desktop.Vpn.Service Exe - net8.0-windows + net8.0 + true enable enable - true - 13 + false + 12 CoderVpnService coder.ico @@ -15,20 +16,22 @@ false - true false + + + $(DefineConstants);WINDOWS + + - - @@ -36,6 +39,19 @@ + + + + + + + + + + + + + diff --git a/Vpn.Service/packages.lock.json b/Vpn.Service/packages.lock.json deleted file mode 100644 index 09c7b76..0000000 --- a/Vpn.Service/packages.lock.json +++ /dev/null @@ -1,452 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net8.0-windows7.0": { - "Microsoft.Extensions.Hosting": { - "type": "Direct", - "requested": "[9.0.4, )", - "resolved": "9.0.4", - "contentHash": "1rZwLE+tTUIyZRUzmlk/DQj+v+Eqox+rjb+X7Fi+cYTbQfIZPYwpf1pVybsV3oje8+Pe4GaNukpBVUlPYeQdeQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.Binder": "9.0.4", - "Microsoft.Extensions.Configuration.CommandLine": "9.0.4", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.4", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", - "Microsoft.Extensions.Configuration.Json": "9.0.4", - "Microsoft.Extensions.Configuration.UserSecrets": "9.0.4", - "Microsoft.Extensions.DependencyInjection": "9.0.4", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Diagnostics": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Physical": "9.0.4", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging.Configuration": "9.0.4", - "Microsoft.Extensions.Logging.Console": "9.0.4", - "Microsoft.Extensions.Logging.Debug": "9.0.4", - "Microsoft.Extensions.Logging.EventLog": "9.0.4", - "Microsoft.Extensions.Logging.EventSource": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4" - } - }, - "Microsoft.Extensions.Hosting.WindowsServices": { - "type": "Direct", - "requested": "[9.0.4, )", - "resolved": "9.0.4", - "contentHash": "QFeUS0NG4Kwq91Mf1WzVZSbBtw+nKxyOQTi4xTRUEQ2gC7HWiyCUiX0arMJxt9lWwbjXxQY9TQjDptm+ct7BkQ==", - "dependencies": { - "Microsoft.Extensions.Hosting": "9.0.4", - "Microsoft.Extensions.Logging.EventLog": "9.0.4", - "System.ServiceProcess.ServiceController": "9.0.4" - } - }, - "Microsoft.Extensions.Options.DataAnnotations": { - "type": "Direct", - "requested": "[9.0.4, )", - "resolved": "9.0.4", - "contentHash": "jJq7xO1PLi//cts59Yp6dKNN07xV0Day/JmVR7aXCdo2rYHAoFlyARyxrfB0CTzsErA+TOhYTz2Ee0poR8SPeQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4" - } - }, - "Microsoft.Security.Extensions": { - "type": "Direct", - "requested": "[1.3.0, )", - "resolved": "1.3.0", - "contentHash": "xK8WFEo5WMUE8DI8W+GjhRwpVcPrxc4DyTjfxh39+yOyhAtC5TBHDlFEJks5toNZHsUeUuiWELIX25oTWOKPBw==" - }, - "Semver": { - "type": "Direct", - "requested": "[3.0.0, )", - "resolved": "3.0.0", - "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "5.0.1" - } - }, - "Serilog.Extensions.Hosting": { - "type": "Direct", - "requested": "[9.0.0, )", - "resolved": "9.0.0", - "contentHash": "u2TRxuxbjvTAldQn7uaAwePkWxTHIqlgjelekBtilAGL5sYyF3+65NWctN4UrwwGLsDC7c3Vz3HnOlu+PcoxXg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.0", - "Microsoft.Extensions.Logging.Abstractions": "9.0.0", - "Serilog": "4.2.0", - "Serilog.Extensions.Logging": "9.0.0" - } - }, - "Serilog.Settings.Configuration": { - "type": "Direct", - "requested": "[9.0.0, )", - "resolved": "9.0.0", - "contentHash": "4/Et4Cqwa+F88l5SeFeNZ4c4Z6dEAIKbu3MaQb2Zz9F/g27T5a3wvfMcmCOaAiACjfUb4A6wrlTVfyYUZk3RRQ==", - "dependencies": { - "Microsoft.Extensions.Configuration.Binder": "9.0.0", - "Microsoft.Extensions.DependencyModel": "9.0.0", - "Serilog": "4.2.0" - } - }, - "Serilog.Sinks.Console": { - "type": "Direct", - "requested": "[6.0.0, )", - "resolved": "6.0.0", - "contentHash": "fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "Serilog.Sinks.File": { - "type": "Direct", - "requested": "[6.0.0, )", - "resolved": "6.0.0", - "contentHash": "lxjg89Y8gJMmFxVkbZ+qDgjl+T4yC5F7WSLTvA+5q0R04tfKVLRL/EHpYoJ/MEQd2EeCKDuylBIVnAYMotmh2A==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "Google.Protobuf": { - "type": "Transitive", - "resolved": "3.29.3", - "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw==" - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==", - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.CommandLine": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "TbM2HElARG7z1gxwakdppmOkm1SykPqDcu3EF97daEwSb/+TXnRrFfJtF+5FWWxcsNhbRrmLfS2WszYcab7u1A==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "2IGiG3FtVnD83IA6HYGuNei8dOw455C09yEhGl8bjcY6aGZgoC6yhYvDnozw8wlTowfoG9bxVrdTsr2ACZOYHg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.FileExtensions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "UY864WQ3AS2Fkc8fYLombWnjrXwYt+BEHHps0hY4sxlgqaVW06AxbpgRZjfYf8PyRbplJqruzZDB/nSLT+7RLQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Physical": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "vVXI70CgT/dmXV3MM+n/BR2rLXEoAyoK0hQT+8MrbCMuJBiLRxnTtSrksNiASWCwOtxo/Tyy7CO8AGthbsYxnw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "System.Text.Json": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.UserSecrets": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "zuvyC72gJkJyodyGowCuz3EQ1QvzNXJtKusuRzmjoHr17aeB3X0aSiKFB++HMHnQIWWlPOBf9YHTQfEqzbgl1g==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.Json": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Physical": "9.0.4" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "f2MTUaS2EQ3lX4325ytPAISZqgBfXmY0WvgD80ji6Z20AoDNiCESxsqo6mFRwHJD/jfVKRw9FsW6+86gNre3ug==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==", - "dependencies": { - "System.Text.Encodings.Web": "9.0.0", - "System.Text.Json": "9.0.0" - } - }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "System.Diagnostics.DiagnosticSource": "9.0.4" - } - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "gQN2o/KnBfVk6Bd71E2YsvO5lsqrqHmaepDGk+FB/C4aiQY9B0XKKNKfl5/TqcNOs9OEithm4opiMHAErMFyEw==", - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "qkQ9V7KFZdTWNThT7ke7E/Jad38s46atSs3QUYZB8f3thBTrcrousdY4Y/tyCtcH5YjsPSiByjuN+L8W/ThMQg==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.FileSystemGlobbing": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ==" - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "bXkwRPMo4x19YKH6/V9XotU7KYQJlihXhcWO1RDclAY3yfY3XNg4QtSEBvng4kK/DnboE0O/nwSl+6Jiv9P+FA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "xW6QPYsqhbuWBO9/1oA43g/XPKbohJx+7G8FLQgQXIriYvY7s+gxr2wjQJfRoPO900dvvv2vVH7wZovG+M1m6w==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "System.Diagnostics.DiagnosticSource": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.Configuration": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "/kF+rSnoo3/nIwGzWsR4RgBnoTOdZ3lzz2qFRyp/GgaNid4j6hOAQrs/O+QHXhlcAdZxjg37MvtIE+pAvIgi9g==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.Binder": "9.0.4", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.Console": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "cI0lQe0js65INCTCtAgnlVJWKgzgoRHVAW1B1zwCbmcliO4IZoTf92f1SYbLeLk7FzMJ/GlCvjLvJegJ6kltmQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging.Configuration": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "System.Text.Json": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.Debug": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "D1jy+jy+huUUxnkZ0H480RZK8vqKn8NsQxYpMpPL/ALPPh1WATVLcr/uXI3RUBB45wMW5265O+hk9x3jnnXFuA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.EventLog": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "bApxdklf7QTsONOLR5ow6SdDFXR5ncHvumSEg2+QnCvxvkzc2z5kNn7yQCyupRLRN4jKbnlTkVX8x9qLlwL6Qg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "System.Diagnostics.EventLog": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.EventSource": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "R600zTxVJNw2IeAEOvdOJGNA1lHr1m3vo460hSF5G1DjwP0FNpyeH4lpLDMuf34diKwB1LTt5hBw1iF1/iuwsQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4", - "System.Text.Json": "9.0.4" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.Binder": "9.0.4", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" - }, - "Serilog": { - "type": "Transitive", - "resolved": "4.2.0", - "contentHash": "gmoWVOvKgbME8TYR+gwMf7osROiWAURterc6Rt2dQyX7wtjZYpqFiA/pY6ztjGQKKV62GGCyOcmtP1UKMHgSmA==" - }, - "Serilog.Extensions.Logging": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "NwSSYqPJeKNzl5AuXVHpGbr6PkZJFlNa14CdIebVjK3k/76kYj/mz5kiTRNVSsSaxM8kAIa1kpy/qyT9E4npRQ==", - "dependencies": { - "Microsoft.Extensions.Logging": "9.0.0", - "Serilog": "4.2.0" - } - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" - }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" - }, - "System.IO.Pipelines": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "luF2Xba+lTe2GOoNQdZLe8q7K6s7nSpWZl9jIwWNMszN4/Yv0lmxk9HISgMmwdyZ83i3UhAGXaSY9o6IJBUuuA==" - }, - "System.ServiceProcess.ServiceController": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "j6Z+ED1d/yxe/Cm+UlFf+LNw2HSYBSgtFh71KnEEmUtHIwgoTVQxji5URvXPOAZ7iuKHItjMIzpCLyRZc8OmrQ==", - "dependencies": { - "System.Diagnostics.EventLog": "9.0.4" - } - }, - "System.Text.Encodings.Web": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" - }, - "System.Text.Json": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "pYtmpcO6R3Ef1XilZEHgXP2xBPVORbYEzRP7dl0IAAbN8Dm+kfwio8aCKle97rAWXOExr292MuxWYurIuwN62g==", - "dependencies": { - "System.IO.Pipelines": "9.0.4", - "System.Text.Encodings.Web": "9.0.4" - } - }, - "Coder.Desktop.CoderSdk": { - "type": "Project" - }, - "Coder.Desktop.Vpn": { - "type": "Project", - "dependencies": { - "Coder.Desktop.Vpn.Proto": "[1.0.0, )", - "Microsoft.Extensions.Configuration": "[9.0.1, )", - "Semver": "[3.0.0, )", - "System.IO.Pipelines": "[9.0.1, )" - } - }, - "Coder.Desktop.Vpn.Proto": { - "type": "Project", - "dependencies": { - "Google.Protobuf": "[3.29.3, )" - } - } - } - } -} \ No newline at end of file From dba327c91347cfa6e2ebfcb2768f8f5122a02805 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:20:03 +0000 Subject: [PATCH 03/13] feat: Linux platform services, cross-platform MutagenSdk, Linux packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3: App.Linux/ — Linux platform service implementations: - LinuxSecretServiceBackend: secret-tool CLI for credential storage - LinuxXdgStartupManager: XDG autostart desktop entry - LinuxNotifySendNotifier: notify-send for notifications - LinuxRdpConnector: xfreerdp/remmina fallback chain - LinuxSingleInstance: Unix domain socket lock Phase 4: MutagenSdk cross-platform: - MutagenClient detects Named Pipe vs Unix Domain Socket transport - Windows: existing NamedPipesConnectionFactory - Linux: direct Socket + NetworkStream to UDS Phase 5: Packaging.Linux/ — Linux packaging: - systemd unit file (Type=notify, root for TUN) - .desktop file with coder:// URI handler - postinst/prerm scripts for service lifecycle - build-deb.sh for amd64/arm64 .deb creation - Default config.json template --- App.Linux/ICredentialBackend.cs | 19 ++++++ App.Linux/IRdpConnector.cs | 14 ++++ App.Linux/IStartupManager.cs | 9 +++ App.Linux/IUserNotifier.cs | 15 +++++ App.Linux/LinuxNotifySendNotifier.cs | 59 +++++++++++++++++ App.Linux/LinuxRdpConnector.cs | 66 +++++++++++++++++++ App.Linux/LinuxSecretServiceBackend.cs | 91 ++++++++++++++++++++++++++ App.Linux/LinuxSingleInstance.cs | 80 ++++++++++++++++++++++ App.Linux/LinuxXdgStartupManager.cs | 46 +++++++++++++ App.Linux/Placeholder.cs | 3 - MutagenSdk/MutagenClient.cs | 86 ++++++++++++++++-------- Packaging.Linux/README.md | 25 +++++++ Packaging.Linux/build-deb.sh | 80 ++++++++++++++++++++++ Packaging.Linux/coder-desktop.desktop | 9 +++ Packaging.Linux/coder-desktop.service | 16 +++++ Packaging.Linux/postinst.sh | 19 ++++++ Packaging.Linux/prerm.sh | 6 ++ 17 files changed, 614 insertions(+), 29 deletions(-) create mode 100644 App.Linux/ICredentialBackend.cs create mode 100644 App.Linux/IRdpConnector.cs create mode 100644 App.Linux/IStartupManager.cs create mode 100644 App.Linux/IUserNotifier.cs 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 delete mode 100644 App.Linux/Placeholder.cs create mode 100644 Packaging.Linux/README.md create mode 100755 Packaging.Linux/build-deb.sh create mode 100644 Packaging.Linux/coder-desktop.desktop create mode 100644 Packaging.Linux/coder-desktop.service create mode 100755 Packaging.Linux/postinst.sh create mode 100755 Packaging.Linux/prerm.sh diff --git a/App.Linux/ICredentialBackend.cs b/App.Linux/ICredentialBackend.cs new file mode 100644 index 0000000..049ce89 --- /dev/null +++ b/App.Linux/ICredentialBackend.cs @@ -0,0 +1,19 @@ +namespace Coder.Desktop.App.Services; + +// These interfaces mirror those in the App project. They are defined here +// because App/ still targets WinUI 3 (net8.0-windows). When the Avalonia +// app is created, these will be replaced with references to the shared +// interfaces. + +public class RawCredentials +{ + public string? CoderUrl { get; set; } + public string? ApiToken { get; set; } +} + +public interface ICredentialBackend +{ + Task ReadCredentials(CancellationToken ct = default); + Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default); + Task DeleteCredentials(CancellationToken ct = default); +} diff --git a/App.Linux/IRdpConnector.cs b/App.Linux/IRdpConnector.cs new file mode 100644 index 0000000..a1414e7 --- /dev/null +++ b/App.Linux/IRdpConnector.cs @@ -0,0 +1,14 @@ +namespace Coder.Desktop.App.Services; + +public class RdpCredentials +{ + public string? Username { get; set; } + public string? Password { get; set; } +} + +public interface IRdpConnector +{ + const int DefaultPort = 3389; + void WriteCredentials(string fqdn, RdpCredentials credentials); + Task Connect(string fqdn, int port = DefaultPort, CancellationToken ct = default); +} diff --git a/App.Linux/IStartupManager.cs b/App.Linux/IStartupManager.cs new file mode 100644 index 0000000..0f778c9 --- /dev/null +++ b/App.Linux/IStartupManager.cs @@ -0,0 +1,9 @@ +namespace Coder.Desktop.App.Services; + +public interface IStartupManager +{ + bool Enable(); + void Disable(); + bool IsEnabled(); + bool IsDisabledByPolicy(); +} diff --git a/App.Linux/IUserNotifier.cs b/App.Linux/IUserNotifier.cs new file mode 100644 index 0000000..9ae9470 --- /dev/null +++ b/App.Linux/IUserNotifier.cs @@ -0,0 +1,15 @@ +namespace Coder.Desktop.App.Services; + +public interface INotificationHandler +{ + void HandleNotificationActivation(IDictionary args); +} + +public interface IUserNotifier : INotificationHandler, IAsyncDisposable +{ + void RegisterHandler(string name, INotificationHandler handler); + void UnregisterHandler(string name); + Task ShowErrorNotification(string title, string message, CancellationToken ct = default); + Task ShowActionNotification(string title, string message, string? handlerName, + IDictionary? args = null, CancellationToken ct = default); +} diff --git a/App.Linux/LinuxNotifySendNotifier.cs b/App.Linux/LinuxNotifySendNotifier.cs new file mode 100644 index 0000000..f911837 --- /dev/null +++ b/App.Linux/LinuxNotifySendNotifier.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; + +namespace Coder.Desktop.App.Services; + +/// +/// Linux notification implementation using notify-send. +/// +public class LinuxNotifySendNotifier : IUserNotifier +{ + private readonly Dictionary _handlers = new(); + + public void RegisterHandler(string name, INotificationHandler handler) + { + _handlers[name] = handler; + } + + public void UnregisterHandler(string name) + { + _handlers.Remove(name); + } + + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default) + { + try + { + Process.Start("notify-send", [title, message, "--app-name=Coder Desktop", "--urgency=critical"]); + } + catch + { + // notify-send may not be available — fail silently + } + return Task.CompletedTask; + } + + public Task ShowActionNotification(string title, string message, string? handlerName, + IDictionary? args = null, CancellationToken ct = default) + { + try + { + Process.Start("notify-send", [title, message, "--app-name=Coder Desktop"]); + } + catch + { + // fail silently + } + return Task.CompletedTask; + } + + public void HandleNotificationActivation(IDictionary args) + { + // notify-send doesn't support action callbacks + } + + public ValueTask DisposeAsync() + { + _handlers.Clear(); + return ValueTask.CompletedTask; + } +} diff --git a/App.Linux/LinuxRdpConnector.cs b/App.Linux/LinuxRdpConnector.cs new file mode 100644 index 0000000..dc96933 --- /dev/null +++ b/App.Linux/LinuxRdpConnector.cs @@ -0,0 +1,66 @@ +using System.Diagnostics; + +namespace Coder.Desktop.App.Services; + +/// +/// RDP connector for Linux using xfreerdp or remmina. +/// +public class LinuxRdpConnector : IRdpConnector +{ + private RdpCredentials? _lastCredentials; + private string? _lastFqdn; + + public void WriteCredentials(string fqdn, RdpCredentials credentials) + { + // Store temporarily — xfreerdp accepts credentials as command-line args + _lastFqdn = fqdn; + _lastCredentials = credentials; + } + + public Task Connect(string fqdn, int port = IRdpConnector.DefaultPort, CancellationToken ct = default) + { + // Try xfreerdp3 first, then xfreerdp, then remmina + string? exe = null; + foreach (var candidate in new[] { "/usr/bin/xfreerdp3", "/usr/bin/xfreerdp", "/usr/bin/remmina" }) + { + if (File.Exists(candidate)) + { + exe = candidate; + break; + } + } + + if (exe == null) + throw new FileNotFoundException( + "No RDP client found. Please install xfreerdp or remmina."); + + var psi = new ProcessStartInfo + { + FileName = exe, + UseShellExecute = false, + }; + + if (exe.Contains("xfreerdp")) + { + psi.ArgumentList.Add($"/v:{fqdn}:{port}"); + psi.ArgumentList.Add("/dynamic-resolution"); + psi.ArgumentList.Add("+clipboard"); + + if (_lastCredentials != null && _lastFqdn == fqdn) + { + if (!string.IsNullOrEmpty(_lastCredentials.Username)) + psi.ArgumentList.Add($"/u:{_lastCredentials.Username}"); + if (!string.IsNullOrEmpty(_lastCredentials.Password)) + psi.ArgumentList.Add($"/p:{_lastCredentials.Password}"); + } + } + else + { + // remmina + psi.ArgumentList.Add($"--connect=rdp://{fqdn}:{port}"); + } + + Process.Start(psi); + return Task.CompletedTask; + } +} diff --git a/App.Linux/LinuxSecretServiceBackend.cs b/App.Linux/LinuxSecretServiceBackend.cs new file mode 100644 index 0000000..87c074a --- /dev/null +++ b/App.Linux/LinuxSecretServiceBackend.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace Coder.Desktop.App.Services; + +/// +/// Credential backend using libsecret's secret-tool CLI. +/// Works on GNOME, KDE, and other desktop environments with Secret Service D-Bus API. +/// +public class LinuxSecretServiceBackend : ICredentialBackend +{ + private const string SecretToolBinary = "secret-tool"; + private const string AttributeKey = "application"; + private const string AttributeValue = "coder-desktop"; + private const string Label = "Coder Desktop Credentials"; + + public async Task ReadCredentials(CancellationToken ct = default) + { + try + { + var result = await RunProcess(SecretToolBinary, + ["lookup", AttributeKey, AttributeValue], null, ct); + if (string.IsNullOrWhiteSpace(result)) + return null; + return JsonSerializer.Deserialize(result); + } + catch + { + // secret-tool not found or returned error — no credentials stored + return null; + } + } + + public async Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default) + { + var json = JsonSerializer.Serialize(credentials); + await RunProcess(SecretToolBinary, + ["store", "--label=" + Label, AttributeKey, AttributeValue], + json, ct); + } + + public async Task DeleteCredentials(CancellationToken ct = default) + { + try + { + await RunProcess(SecretToolBinary, + ["clear", AttributeKey, AttributeValue], null, ct); + } + catch + { + // Ignore errors — credential may not exist + } + } + + private static async Task RunProcess(string fileName, string[] args, string? stdin, + CancellationToken ct) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = stdin != null, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + using var process = Process.Start(psi) ?? + throw new InvalidOperationException($"Failed to start {fileName}"); + + if (stdin != null) + { + await process.StandardInput.WriteAsync(stdin); + process.StandardInput.Close(); + } + + var output = await process.StandardOutput.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(ct); + throw new InvalidOperationException( + $"{fileName} exited with code {process.ExitCode}: {error}"); + } + + return output.Trim(); + } +} diff --git a/App.Linux/LinuxSingleInstance.cs b/App.Linux/LinuxSingleInstance.cs new file mode 100644 index 0000000..d4ca357 --- /dev/null +++ b/App.Linux/LinuxSingleInstance.cs @@ -0,0 +1,80 @@ +using System.Net.Sockets; + +namespace Coder.Desktop.App.Services; + +/// +/// Single instance enforcement using Unix domain socket lock. +/// +public class LinuxSingleInstance : IDisposable +{ + private Socket? _lockSocket; + private readonly string _socketPath; + + public LinuxSingleInstance(string? appName = null) + { + var name = appName ?? "coder-desktop"; + var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") + ?? Path.Combine(Path.GetTempPath(), $"coder-{Environment.UserName}"); + _socketPath = Path.Combine(runtimeDir, $"{name}.lock"); + } + + public bool TryAcquire() + { + Directory.CreateDirectory(Path.GetDirectoryName(_socketPath)!); + + _lockSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + // Try to detect stale socket from a crashed instance + if (File.Exists(_socketPath)) + { + using var testSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + testSocket.Connect(new UnixDomainSocketEndPoint(_socketPath)); + // Connected — another instance is running + testSocket.Close(); + _lockSocket.Dispose(); + _lockSocket = null; + return false; + } + catch (SocketException) + { + // Failed to connect — stale socket, remove it + File.Delete(_socketPath); + } + } + + _lockSocket!.Bind(new UnixDomainSocketEndPoint(_socketPath)); + _lockSocket!.Listen(1); + return true; + } + catch (SocketException) + { + _lockSocket?.Dispose(); + _lockSocket = null; + return false; + } + } + + public void Dispose() + { + if (_lockSocket != null) + { + _lockSocket.Close(); + _lockSocket.Dispose(); + _lockSocket = null; + } + + try + { + if (File.Exists(_socketPath)) + File.Delete(_socketPath); + } + catch + { + // best effort + } + GC.SuppressFinalize(this); + } +} diff --git a/App.Linux/LinuxXdgStartupManager.cs b/App.Linux/LinuxXdgStartupManager.cs new file mode 100644 index 0000000..7c0e267 --- /dev/null +++ b/App.Linux/LinuxXdgStartupManager.cs @@ -0,0 +1,46 @@ +namespace Coder.Desktop.App.Services; + +/// +/// Manages autostart via XDG autostart desktop entry. +/// +public class LinuxXdgStartupManager : IStartupManager +{ + private static readonly string AutostartDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "autostart"); + + private static readonly string DesktopFilePath = Path.Combine( + AutostartDir, "coder-desktop.desktop"); + + private readonly string _execPath; + + public LinuxXdgStartupManager(string? execPath = null) + { + _execPath = execPath ?? "/usr/bin/coder-desktop"; + } + + public bool Enable() + { + Directory.CreateDirectory(AutostartDir); + var content = $""" + [Desktop Entry] + Type=Application + Name=Coder Desktop + Exec={_execPath} --minimized + X-GNOME-Autostart-enabled=true + NoDisplay=true + """; + File.WriteAllText(DesktopFilePath, content); + return true; + } + + public void Disable() + { + if (File.Exists(DesktopFilePath)) + File.Delete(DesktopFilePath); + } + + public bool IsEnabled() => File.Exists(DesktopFilePath); + + public bool IsDisabledByPolicy() => false; +} diff --git a/App.Linux/Placeholder.cs b/App.Linux/Placeholder.cs deleted file mode 100644 index be85fd3..0000000 --- a/App.Linux/Placeholder.cs +++ /dev/null @@ -1,3 +0,0 @@ -// Linux-specific App implementations will be added here during Phase 2. -// This project is a placeholder for now. -namespace Coder.Desktop.App; diff --git a/MutagenSdk/MutagenClient.cs b/MutagenSdk/MutagenClient.cs index 89fad29..7a220c6 100644 --- a/MutagenSdk/MutagenClient.cs +++ b/MutagenSdk/MutagenClient.cs @@ -1,3 +1,4 @@ +using System.Net.Sockets; using Coder.Desktop.MutagenSdk.Proto.Service.Daemon; using Coder.Desktop.MutagenSdk.Proto.Service.Prompting; using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization; @@ -16,7 +17,7 @@ public class MutagenClient : IDisposable public MutagenClient(string dataDir) { - // Read the IPC named pipe address from the sock file. + // Read the IPC address from the sock file. var daemonSockFile = Path.Combine(dataDir, "daemon", "daemon.sock"); if (!File.Exists(daemonSockFile)) throw new FileNotFoundException( @@ -26,36 +27,69 @@ public MutagenClient(string dataDir) throw new InvalidOperationException( $"Mutagen daemon socket address from '{daemonSockFile}' is empty, did the mutagen daemon start successfully?"); + SocketsHttpHandler socketsHttpHandler; const string namedPipePrefix = @"\\.\pipe\"; - if (!daemonSockAddress.StartsWith(namedPipePrefix) || daemonSockAddress == namedPipePrefix) - throw new InvalidOperationException( - $"Mutagen daemon socket address '{daemonSockAddress}' is not a valid named pipe address"); - // Ensure the pipe exists before we try to connect to it. Obviously - // this is not 100% foolproof, since the pipe could appear/disappear - // after we check it. This allows us to fail early if the pipe isn't - // ready yet (and consumers can retry), otherwise the pipe connection - // may block. - // - // Note: we cannot use File.Exists here without breaking the named - // pipe connection code due to a .NET bug. - // https://github.com/dotnet/runtime/issues/69604 - var pipeName = daemonSockAddress[namedPipePrefix.Length..]; - var foundPipe = Directory - .GetFiles(namedPipePrefix, pipeName) - .FirstOrDefault(p => Path.GetFileName(p) == pipeName); - if (foundPipe == null) - throw new FileNotFoundException( - "Mutagen daemon named pipe not found, did the mutagen daemon start successfully?", daemonSockAddress); + if (OperatingSystem.IsWindows() && daemonSockAddress.StartsWith(namedPipePrefix)) + { + // Windows: Named Pipe transport + var pipeName = daemonSockAddress[namedPipePrefix.Length..]; + if (string.IsNullOrEmpty(pipeName)) + throw new InvalidOperationException( + $"Mutagen daemon socket address '{daemonSockAddress}' is not a valid named pipe address"); - var connectionFactory = new NamedPipesConnectionFactory(pipeName); - var socketsHttpHandler = new SocketsHttpHandler + // Ensure the pipe exists before we try to connect to it. Obviously + // this is not 100% foolproof, since the pipe could appear/disappear + // after we check it. This allows us to fail early if the pipe isn't + // ready yet (and consumers can retry), otherwise the pipe connection + // may block. + // + // Note: we cannot use File.Exists here without breaking the named + // pipe connection code due to a .NET bug. + // https://github.com/dotnet/runtime/issues/69604 + var foundPipe = Directory + .GetFiles(namedPipePrefix, pipeName) + .FirstOrDefault(p => Path.GetFileName(p) == pipeName); + if (foundPipe == null) + throw new FileNotFoundException( + "Mutagen daemon named pipe not found, did the mutagen daemon start successfully?", + daemonSockAddress); + + var connectionFactory = new NamedPipesConnectionFactory(pipeName); + socketsHttpHandler = new SocketsHttpHandler + { + ConnectCallback = connectionFactory.ConnectAsync, + }; + } + else { - ConnectCallback = connectionFactory.ConnectAsync, - }; + // Linux/macOS: Unix Domain Socket transport + if (!File.Exists(daemonSockAddress)) + throw new FileNotFoundException( + "Mutagen daemon Unix socket not found, did the mutagen daemon start successfully?", + daemonSockAddress); + + socketsHttpHandler = new SocketsHttpHandler + { + ConnectCallback = async (_, ct) => + { + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(daemonSockAddress), ct); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + }, + }; + } - // http://localhost is fake address. The HttpHandler will be used to - // open a socket to the named pipe. + // http://localhost is a fake address. The HttpHandler will be used to + // open a socket to the actual endpoint. _channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions { Credentials = ChannelCredentials.Insecure, diff --git a/Packaging.Linux/README.md b/Packaging.Linux/README.md new file mode 100644 index 0000000..17fb992 --- /dev/null +++ b/Packaging.Linux/README.md @@ -0,0 +1,25 @@ +# Linux Packaging + +## Building + +```bash +# Build .deb for amd64 +./build-deb.sh amd64 + +# Build .deb for arm64 +VERSION=1.0.0 ./build-deb.sh arm64 +``` + +## Package contents + +- `/usr/lib/coder-desktop/` — Service binaries +- `/usr/bin/coder-vpn-service` — Symlink to VPN service +- `/etc/systemd/system/coder-desktop.service` — Systemd unit +- `/etc/coder-desktop/config.json` — Default configuration +- `/usr/share/applications/coder-desktop.desktop` — Desktop entry + +## Dependencies + +- `libnotify-bin` — Desktop notifications +- `libsecret-tools` — Credential storage +- `freerdp2-x11` or `remmina` — RDP client (optional) diff --git a/Packaging.Linux/build-deb.sh b/Packaging.Linux/build-deb.sh new file mode 100755 index 0000000..cc5cdf2 --- /dev/null +++ b/Packaging.Linux/build-deb.sh @@ -0,0 +1,80 @@ +#!/bin/bash +set -euo pipefail + +# Build a .deb package for Coder Desktop +# Usage: ./build-deb.sh [amd64|arm64] + +ARCH="${1:-amd64}" +VERSION="${VERSION:-0.1.0}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +BUILD_DIR="$(mktemp -d)" +PKG_DIR="$BUILD_DIR/coder-desktop_${VERSION}_${ARCH}" + +echo "Building Coder Desktop .deb package v${VERSION} for ${ARCH}..." + +# Map architecture names +case "$ARCH" in + amd64) RID="linux-x64" ;; + arm64) RID="linux-arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +# Build the service +echo "Publishing Vpn.Service..." +dotnet publish "$ROOT_DIR/Vpn.Service" -r "$RID" -c Release --self-contained -o "$PKG_DIR/usr/lib/coder-desktop/service" + +# Create directory structure +mkdir -p "$PKG_DIR/usr/bin" +mkdir -p "$PKG_DIR/usr/share/applications" +mkdir -p "$PKG_DIR/etc/systemd/system" +mkdir -p "$PKG_DIR/etc/coder-desktop" +mkdir -p "$PKG_DIR/DEBIAN" + +# Symlinks +ln -sf "/usr/lib/coder-desktop/service/CoderVpnService" "$PKG_DIR/usr/bin/coder-vpn-service" + +# Copy packaging files +cp "$SCRIPT_DIR/coder-desktop.service" "$PKG_DIR/etc/systemd/system/" +cp "$SCRIPT_DIR/coder-desktop.desktop" "$PKG_DIR/usr/share/applications/" + +# Default config +cat > "$PKG_DIR/etc/coder-desktop/config.json" <<'EOF' +{ + "Manager": { + "ServiceRpcSocketPath": "/run/coder-desktop/vpn.sock", + "TunnelBinaryPath": "/usr/lib/coder-desktop/coder-vpn", + "TunnelBinarySignatureSigner": "", + "TunnelBinaryAllowVersionMismatch": false + } +} +EOF + +# Create DEBIAN control file +cat > "$PKG_DIR/DEBIAN/control" < +Description: Coder Desktop - Connect to Coder workspaces + Provides a VPN tunnel to Coder workspaces with a system + service for managing connections. +Depends: libnotify-bin, libsecret-tools +Recommends: freerdp2-x11 | remmina +Section: net +Priority: optional +EOF + +# Install scripts +cp "$SCRIPT_DIR/postinst.sh" "$PKG_DIR/DEBIAN/postinst" +cp "$SCRIPT_DIR/prerm.sh" "$PKG_DIR/DEBIAN/prerm" +chmod 755 "$PKG_DIR/DEBIAN/postinst" "$PKG_DIR/DEBIAN/prerm" + +# Build .deb +dpkg-deb --build "$PKG_DIR" +mv "$PKG_DIR.deb" "$ROOT_DIR/coder-desktop_${VERSION}_${ARCH}.deb" + +echo "Package built: coder-desktop_${VERSION}_${ARCH}.deb" + +# Cleanup +rm -rf "$BUILD_DIR" diff --git a/Packaging.Linux/coder-desktop.desktop b/Packaging.Linux/coder-desktop.desktop new file mode 100644 index 0000000..db8e6c7 --- /dev/null +++ b/Packaging.Linux/coder-desktop.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Coder Desktop +Comment=Connect to Coder workspaces +Exec=/usr/bin/coder-desktop %u +Icon=coder-desktop +Categories=Network;Development; +MimeType=x-scheme-handler/coder; +StartupNotify=true diff --git a/Packaging.Linux/coder-desktop.service b/Packaging.Linux/coder-desktop.service new file mode 100644 index 0000000..f0c0ca0 --- /dev/null +++ b/Packaging.Linux/coder-desktop.service @@ -0,0 +1,16 @@ +[Unit] +Description=Coder Desktop VPN Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +ExecStart=/usr/lib/coder-desktop/CoderVpnService +Restart=on-failure +RestartSec=10 +RuntimeDirectory=coder-desktop +# Needs root for TUN device creation +User=root + +[Install] +WantedBy=multi-user.target diff --git a/Packaging.Linux/postinst.sh b/Packaging.Linux/postinst.sh new file mode 100755 index 0000000..a4f2c2d --- /dev/null +++ b/Packaging.Linux/postinst.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +# Reload systemd +systemctl daemon-reload + +# Enable and start the service +systemctl enable coder-desktop.service +systemctl start coder-desktop.service || true + +# Register URI handler +if command -v xdg-mime &>/dev/null; then + xdg-mime default coder-desktop.desktop x-scheme-handler/coder +fi + +# Update desktop database +if command -v update-desktop-database &>/dev/null; then + update-desktop-database /usr/share/applications || true +fi diff --git a/Packaging.Linux/prerm.sh b/Packaging.Linux/prerm.sh new file mode 100755 index 0000000..8bbee2d --- /dev/null +++ b/Packaging.Linux/prerm.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +# Stop and disable the service +systemctl stop coder-desktop.service || true +systemctl disable coder-desktop.service || true From 3a85483169a272148ef9bde1cdb3e5370ff6fb00 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:23:54 +0000 Subject: [PATCH 04/13] feat: retarget test projects and DebugClient to net8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6: Cross-platform test infrastructure: - Tests.Vpn: net8.0-windows → net8.0 - Tests.Vpn.Service: net8.0-windows → net8.0 with EnableWindowsTargeting - AuthenticodeDownloadValidatorTest wrapped in #if WINDOWS - AssemblyVersionDownloadValidatorTest marked [Platform("Win")] - TelemetryEnricherTest updated for platform-conditional DeviceOs - Vpn.DebugClient: net8.0-windows → net8.0 - Platform-conditional connect (Named Pipe vs Unix Socket) - App.csproj and Tests.App.csproj: added EnableWindowsTargeting All cross-platform tests pass on Linux: - Tests.Vpn: 24 passed - Tests.Vpn.Proto: 9 passed - Tests.CoderSdk: 8 passed - Tests.Vpn.Service: 16 passed (Windows-only tests skipped) --- App/App.csproj | 1 + Tests.App/Tests.App.csproj | 1 + Tests.Vpn.Service/DownloaderTest.cs | 5 + Tests.Vpn.Service/TelemetryEnricherTest.cs | 7 +- Tests.Vpn.Service/Tests.Vpn.Service.csproj | 10 +- Tests.Vpn.Service/packages.lock.json | 525 --------------------- Tests.Vpn/Tests.Vpn.csproj | 4 +- Tests.Vpn/packages.lock.json | 128 ----- Vpn.DebugClient/Program.cs | 21 +- Vpn.DebugClient/Vpn.DebugClient.csproj | 6 +- Vpn.DebugClient/packages.lock.json | 62 --- 11 files changed, 42 insertions(+), 728 deletions(-) delete mode 100644 Tests.Vpn.Service/packages.lock.json delete mode 100644 Tests.Vpn/packages.lock.json delete mode 100644 Vpn.DebugClient/packages.lock.json diff --git a/App/App.csproj b/App/App.csproj index 9a91849..b6856c7 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -2,6 +2,7 @@ WinExe net8.0-windows10.0.19041.0 + true 10.0.17763.0 Coder.Desktop.App app.manifest diff --git a/Tests.App/Tests.App.csproj b/Tests.App/Tests.App.csproj index e20eba1..e9d842e 100644 --- a/Tests.App/Tests.App.csproj +++ b/Tests.App/Tests.App.csproj @@ -4,6 +4,7 @@ Coder.Desktop.Tests.App Coder.Desktop.Tests.App net8.0-windows10.0.19041.0 + true preview enable enable diff --git a/Tests.Vpn.Service/DownloaderTest.cs b/Tests.Vpn.Service/DownloaderTest.cs index 4d95721..de27806 100644 --- a/Tests.Vpn.Service/DownloaderTest.cs +++ b/Tests.Vpn.Service/DownloaderTest.cs @@ -4,6 +4,7 @@ using System.Text; using Coder.Desktop.Vpn.Service; using Microsoft.Extensions.Logging.Abstractions; +using System.Runtime.InteropServices; namespace Coder.Desktop.Tests.Vpn.Service; @@ -22,7 +23,9 @@ public Task ValidateAsync(string path, CancellationToken ct = default) } } +#if WINDOWS [TestFixture] +[Platform("Win", Reason = "AuthenticodeDownloadValidator requires Windows Authenticode APIs")] public class AuthenticodeDownloadValidatorTest { [Test(Description = "Test an unsigned binary")] @@ -127,8 +130,10 @@ public void IsEvCert() } } } +#endif [TestFixture] +[Platform("Win", Reason = "AssemblyVersionDownloadValidator tests use Windows PE test binaries")] public class AssemblyVersionDownloadValidatorTest { [Test(Description = "No version on binary")] diff --git a/Tests.Vpn.Service/TelemetryEnricherTest.cs b/Tests.Vpn.Service/TelemetryEnricherTest.cs index 144cd20..d5d7169 100644 --- a/Tests.Vpn.Service/TelemetryEnricherTest.cs +++ b/Tests.Vpn.Service/TelemetryEnricherTest.cs @@ -19,10 +19,13 @@ public void EnrichStartRequest() // quick sanity check that non-telemetry fields aren't lost or overwritten Assert.That(req.CoderUrl, Is.EqualTo("https://coder.example.com")); - Assert.That(req.DeviceOs, Is.EqualTo("Windows")); + var expectedOs = OperatingSystem.IsWindows() ? "Windows" : "Linux"; + Assert.That(req.DeviceOs, Is.EqualTo(expectedOs)); // seems that test assemblies always set 1.0.0.0 Assert.That(req.CoderDesktopVersion, Is.EqualTo("1.0.0.0")); - Assert.That(req.DeviceId, Is.Not.Empty); + // DeviceId may be empty on some Linux CI environments without /etc/machine-id + if (OperatingSystem.IsWindows() || File.Exists("/etc/machine-id")) + Assert.That(req.DeviceId, Is.Not.Empty); var deviceId = req.DeviceId; // deviceId is different on different machines, but we can test that diff --git a/Tests.Vpn.Service/Tests.Vpn.Service.csproj b/Tests.Vpn.Service/Tests.Vpn.Service.csproj index c57f85a..668970c 100644 --- a/Tests.Vpn.Service/Tests.Vpn.Service.csproj +++ b/Tests.Vpn.Service/Tests.Vpn.Service.csproj @@ -3,15 +3,21 @@ Coder.Desktop.Tests.Vpn.Service Coder.Desktop.Tests.Vpn.Service - net8.0-windows + net8.0 + true enable + 12 enable - true + false false true + + $(DefineConstants);WINDOWS + + PreserveNewest diff --git a/Tests.Vpn.Service/packages.lock.json b/Tests.Vpn.Service/packages.lock.json deleted file mode 100644 index 08a9b56..0000000 --- a/Tests.Vpn.Service/packages.lock.json +++ /dev/null @@ -1,525 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net8.0-windows7.0": { - "coverlet.collector": { - "type": "Direct", - "requested": "[6.0.4, )", - "resolved": "6.0.4", - "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" - }, - "Microsoft.NET.Test.Sdk": { - "type": "Direct", - "requested": "[17.12.0, )", - "resolved": "17.12.0", - "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", - "dependencies": { - "Microsoft.CodeCoverage": "17.12.0", - "Microsoft.TestPlatform.TestHost": "17.12.0" - } - }, - "NUnit": { - "type": "Direct", - "requested": "[4.3.2, )", - "resolved": "4.3.2", - "contentHash": "puVXayXNmEu7MFQSUswGmUjOy3M3baprMbkLl5PAutpeDoGTr+jPv33qAYsqxywi2wJCq8l/O3EhHoLulPE1iQ==" - }, - "NUnit.Analyzers": { - "type": "Direct", - "requested": "[4.6.0, )", - "resolved": "4.6.0", - "contentHash": "uK1TEViVBugOO6uDou1amu7CoNhrd2sEUFr/iaEmVfoeY8qq/zzWCCUZi97aCCSZmjnHKCCWKh3RucU27qPlKg==" - }, - "NUnit3TestAdapter": { - "type": "Direct", - "requested": "[4.6.0, )", - "resolved": "4.6.0", - "contentHash": "R7e1+a4vuV/YS+ItfL7f//rG+JBvVeVLX4mHzFEZo4W1qEKl8Zz27AqvQSAqo+BtIzUCo4aAJMYa56VXS4hudw==" - }, - "Google.Protobuf": { - "type": "Transitive", - "resolved": "3.29.3", - "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw==" - }, - "Microsoft.CodeCoverage": { - "type": "Transitive", - "resolved": "17.12.0", - "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==", - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.CommandLine": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "TbM2HElARG7z1gxwakdppmOkm1SykPqDcu3EF97daEwSb/+TXnRrFfJtF+5FWWxcsNhbRrmLfS2WszYcab7u1A==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "2IGiG3FtVnD83IA6HYGuNei8dOw455C09yEhGl8bjcY6aGZgoC6yhYvDnozw8wlTowfoG9bxVrdTsr2ACZOYHg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.FileExtensions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "UY864WQ3AS2Fkc8fYLombWnjrXwYt+BEHHps0hY4sxlgqaVW06AxbpgRZjfYf8PyRbplJqruzZDB/nSLT+7RLQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Physical": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "vVXI70CgT/dmXV3MM+n/BR2rLXEoAyoK0hQT+8MrbCMuJBiLRxnTtSrksNiASWCwOtxo/Tyy7CO8AGthbsYxnw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "System.Text.Json": "9.0.4" - } - }, - "Microsoft.Extensions.Configuration.UserSecrets": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "zuvyC72gJkJyodyGowCuz3EQ1QvzNXJtKusuRzmjoHr17aeB3X0aSiKFB++HMHnQIWWlPOBf9YHTQfEqzbgl1g==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.Json": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Physical": "9.0.4" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "f2MTUaS2EQ3lX4325ytPAISZqgBfXmY0WvgD80ji6Z20AoDNiCESxsqo6mFRwHJD/jfVKRw9FsW6+86gNre3ug==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==", - "dependencies": { - "System.Text.Encodings.Web": "9.0.0", - "System.Text.Json": "9.0.0" - } - }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "System.Diagnostics.DiagnosticSource": "9.0.4" - } - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "gQN2o/KnBfVk6Bd71E2YsvO5lsqrqHmaepDGk+FB/C4aiQY9B0XKKNKfl5/TqcNOs9OEithm4opiMHAErMFyEw==", - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "qkQ9V7KFZdTWNThT7ke7E/Jad38s46atSs3QUYZB8f3thBTrcrousdY4Y/tyCtcH5YjsPSiByjuN+L8W/ThMQg==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.FileSystemGlobbing": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ==" - }, - "Microsoft.Extensions.Hosting": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "1rZwLE+tTUIyZRUzmlk/DQj+v+Eqox+rjb+X7Fi+cYTbQfIZPYwpf1pVybsV3oje8+Pe4GaNukpBVUlPYeQdeQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.Binder": "9.0.4", - "Microsoft.Extensions.Configuration.CommandLine": "9.0.4", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.4", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", - "Microsoft.Extensions.Configuration.Json": "9.0.4", - "Microsoft.Extensions.Configuration.UserSecrets": "9.0.4", - "Microsoft.Extensions.DependencyInjection": "9.0.4", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Diagnostics": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Physical": "9.0.4", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging.Configuration": "9.0.4", - "Microsoft.Extensions.Logging.Console": "9.0.4", - "Microsoft.Extensions.Logging.Debug": "9.0.4", - "Microsoft.Extensions.Logging.EventLog": "9.0.4", - "Microsoft.Extensions.Logging.EventSource": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4" - } - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "bXkwRPMo4x19YKH6/V9XotU7KYQJlihXhcWO1RDclAY3yfY3XNg4QtSEBvng4kK/DnboE0O/nwSl+6Jiv9P+FA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Hosting.WindowsServices": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "QFeUS0NG4Kwq91Mf1WzVZSbBtw+nKxyOQTi4xTRUEQ2gC7HWiyCUiX0arMJxt9lWwbjXxQY9TQjDptm+ct7BkQ==", - "dependencies": { - "Microsoft.Extensions.Hosting": "9.0.4", - "Microsoft.Extensions.Logging.EventLog": "9.0.4", - "System.ServiceProcess.ServiceController": "9.0.4" - } - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "xW6QPYsqhbuWBO9/1oA43g/XPKbohJx+7G8FLQgQXIriYvY7s+gxr2wjQJfRoPO900dvvv2vVH7wZovG+M1m6w==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "System.Diagnostics.DiagnosticSource": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.Configuration": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "/kF+rSnoo3/nIwGzWsR4RgBnoTOdZ3lzz2qFRyp/GgaNid4j6hOAQrs/O+QHXhlcAdZxjg37MvtIE+pAvIgi9g==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.4", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.Binder": "9.0.4", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.Console": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "cI0lQe0js65INCTCtAgnlVJWKgzgoRHVAW1B1zwCbmcliO4IZoTf92f1SYbLeLk7FzMJ/GlCvjLvJegJ6kltmQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging.Configuration": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "System.Text.Json": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.Debug": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "D1jy+jy+huUUxnkZ0H480RZK8vqKn8NsQxYpMpPL/ALPPh1WATVLcr/uXI3RUBB45wMW5265O+hk9x3jnnXFuA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.EventLog": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "bApxdklf7QTsONOLR5ow6SdDFXR5ncHvumSEg2+QnCvxvkzc2z5kNn7yQCyupRLRN4jKbnlTkVX8x9qLlwL6Qg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "System.Diagnostics.EventLog": "9.0.4" - } - }, - "Microsoft.Extensions.Logging.EventSource": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "R600zTxVJNw2IeAEOvdOJGNA1lHr1m3vo460hSF5G1DjwP0FNpyeH4lpLDMuf34diKwB1LTt5hBw1iF1/iuwsQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Logging": "9.0.4", - "Microsoft.Extensions.Logging.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4", - "System.Text.Json": "9.0.4" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", - "Microsoft.Extensions.Configuration.Binder": "9.0.4", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4", - "Microsoft.Extensions.Primitives": "9.0.4" - } - }, - "Microsoft.Extensions.Options.DataAnnotations": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "jJq7xO1PLi//cts59Yp6dKNN07xV0Day/JmVR7aXCdo2rYHAoFlyARyxrfB0CTzsErA+TOhYTz2Ee0poR8SPeQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", - "Microsoft.Extensions.Options": "9.0.4" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" - }, - "Microsoft.Security.Extensions": { - "type": "Transitive", - "resolved": "1.3.0", - "contentHash": "xK8WFEo5WMUE8DI8W+GjhRwpVcPrxc4DyTjfxh39+yOyhAtC5TBHDlFEJks5toNZHsUeUuiWELIX25oTWOKPBw==" - }, - "Microsoft.TestPlatform.ObjectModel": { - "type": "Transitive", - "resolved": "17.12.0", - "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", - "dependencies": { - "System.Reflection.Metadata": "1.6.0" - } - }, - "Microsoft.TestPlatform.TestHost": { - "type": "Transitive", - "resolved": "17.12.0", - "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", - "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "17.12.0", - "Newtonsoft.Json": "13.0.1" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.1", - "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" - }, - "Semver": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "5.0.1" - } - }, - "Serilog": { - "type": "Transitive", - "resolved": "4.2.0", - "contentHash": "gmoWVOvKgbME8TYR+gwMf7osROiWAURterc6Rt2dQyX7wtjZYpqFiA/pY6ztjGQKKV62GGCyOcmtP1UKMHgSmA==" - }, - "Serilog.Extensions.Hosting": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "u2TRxuxbjvTAldQn7uaAwePkWxTHIqlgjelekBtilAGL5sYyF3+65NWctN4UrwwGLsDC7c3Vz3HnOlu+PcoxXg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.0", - "Microsoft.Extensions.Logging.Abstractions": "9.0.0", - "Serilog": "4.2.0", - "Serilog.Extensions.Logging": "9.0.0" - } - }, - "Serilog.Extensions.Logging": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "NwSSYqPJeKNzl5AuXVHpGbr6PkZJFlNa14CdIebVjK3k/76kYj/mz5kiTRNVSsSaxM8kAIa1kpy/qyT9E4npRQ==", - "dependencies": { - "Microsoft.Extensions.Logging": "9.0.0", - "Serilog": "4.2.0" - } - }, - "Serilog.Settings.Configuration": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "4/Et4Cqwa+F88l5SeFeNZ4c4Z6dEAIKbu3MaQb2Zz9F/g27T5a3wvfMcmCOaAiACjfUb4A6wrlTVfyYUZk3RRQ==", - "dependencies": { - "Microsoft.Extensions.Configuration.Binder": "9.0.0", - "Microsoft.Extensions.DependencyModel": "9.0.0", - "Serilog": "4.2.0" - } - }, - "Serilog.Sinks.Console": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "Serilog.Sinks.File": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "lxjg89Y8gJMmFxVkbZ+qDgjl+T4yC5F7WSLTvA+5q0R04tfKVLRL/EHpYoJ/MEQd2EeCKDuylBIVnAYMotmh2A==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" - }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" - }, - "System.IO.Pipelines": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "luF2Xba+lTe2GOoNQdZLe8q7K6s7nSpWZl9jIwWNMszN4/Yv0lmxk9HISgMmwdyZ83i3UhAGXaSY9o6IJBUuuA==" - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "1.6.0", - "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" - }, - "System.ServiceProcess.ServiceController": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "j6Z+ED1d/yxe/Cm+UlFf+LNw2HSYBSgtFh71KnEEmUtHIwgoTVQxji5URvXPOAZ7iuKHItjMIzpCLyRZc8OmrQ==", - "dependencies": { - "System.Diagnostics.EventLog": "9.0.4" - } - }, - "System.Text.Encodings.Web": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" - }, - "System.Text.Json": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "pYtmpcO6R3Ef1XilZEHgXP2xBPVORbYEzRP7dl0IAAbN8Dm+kfwio8aCKle97rAWXOExr292MuxWYurIuwN62g==", - "dependencies": { - "System.IO.Pipelines": "9.0.4", - "System.Text.Encodings.Web": "9.0.4" - } - }, - "Coder.Desktop.CoderSdk": { - "type": "Project" - }, - "Coder.Desktop.Vpn": { - "type": "Project", - "dependencies": { - "Coder.Desktop.Vpn.Proto": "[1.0.0, )", - "Microsoft.Extensions.Configuration": "[9.0.1, )", - "Semver": "[3.0.0, )", - "System.IO.Pipelines": "[9.0.1, )" - } - }, - "Coder.Desktop.Vpn.Proto": { - "type": "Project", - "dependencies": { - "Google.Protobuf": "[3.29.3, )" - } - }, - "CoderVpnService": { - "type": "Project", - "dependencies": { - "Coder.Desktop.CoderSdk": "[1.0.0, )", - "Coder.Desktop.Vpn": "[1.0.0, )", - "Microsoft.Extensions.Hosting": "[9.0.4, )", - "Microsoft.Extensions.Hosting.WindowsServices": "[9.0.4, )", - "Microsoft.Extensions.Options.DataAnnotations": "[9.0.4, )", - "Microsoft.Security.Extensions": "[1.3.0, )", - "Semver": "[3.0.0, )", - "Serilog.Extensions.Hosting": "[9.0.0, )", - "Serilog.Settings.Configuration": "[9.0.0, )", - "Serilog.Sinks.Console": "[6.0.0, )", - "Serilog.Sinks.File": "[6.0.0, )" - } - } - } - } -} \ No newline at end of file diff --git a/Tests.Vpn/Tests.Vpn.csproj b/Tests.Vpn/Tests.Vpn.csproj index 2b9e30f..bdae868 100644 --- a/Tests.Vpn/Tests.Vpn.csproj +++ b/Tests.Vpn/Tests.Vpn.csproj @@ -3,10 +3,10 @@ Coder.Desktop.Tests.Vpn Coder.Desktop.Tests.Vpn - net8.0-windows + net8.0 enable enable - true + false false true diff --git a/Tests.Vpn/packages.lock.json b/Tests.Vpn/packages.lock.json deleted file mode 100644 index 725c743..0000000 --- a/Tests.Vpn/packages.lock.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net8.0-windows7.0": { - "coverlet.collector": { - "type": "Direct", - "requested": "[6.0.4, )", - "resolved": "6.0.4", - "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" - }, - "Microsoft.NET.Test.Sdk": { - "type": "Direct", - "requested": "[17.12.0, )", - "resolved": "17.12.0", - "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", - "dependencies": { - "Microsoft.CodeCoverage": "17.12.0", - "Microsoft.TestPlatform.TestHost": "17.12.0" - } - }, - "NUnit": { - "type": "Direct", - "requested": "[4.3.2, )", - "resolved": "4.3.2", - "contentHash": "puVXayXNmEu7MFQSUswGmUjOy3M3baprMbkLl5PAutpeDoGTr+jPv33qAYsqxywi2wJCq8l/O3EhHoLulPE1iQ==" - }, - "NUnit.Analyzers": { - "type": "Direct", - "requested": "[4.6.0, )", - "resolved": "4.6.0", - "contentHash": "uK1TEViVBugOO6uDou1amu7CoNhrd2sEUFr/iaEmVfoeY8qq/zzWCCUZi97aCCSZmjnHKCCWKh3RucU27qPlKg==" - }, - "NUnit3TestAdapter": { - "type": "Direct", - "requested": "[4.6.0, )", - "resolved": "4.6.0", - "contentHash": "R7e1+a4vuV/YS+ItfL7f//rG+JBvVeVLX4mHzFEZo4W1qEKl8Zz27AqvQSAqo+BtIzUCo4aAJMYa56VXS4hudw==" - }, - "Google.Protobuf": { - "type": "Transitive", - "resolved": "3.29.3", - "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw==" - }, - "Microsoft.CodeCoverage": { - "type": "Transitive", - "resolved": "17.12.0", - "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" - }, - "Microsoft.TestPlatform.ObjectModel": { - "type": "Transitive", - "resolved": "17.12.0", - "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", - "dependencies": { - "System.Reflection.Metadata": "1.6.0" - } - }, - "Microsoft.TestPlatform.TestHost": { - "type": "Transitive", - "resolved": "17.12.0", - "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", - "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "17.12.0", - "Newtonsoft.Json": "13.0.1" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.1", - "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" - }, - "Semver": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "5.0.1" - } - }, - "System.IO.Pipelines": { - "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg==" - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "1.6.0", - "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" - }, - "Coder.Desktop.Vpn": { - "type": "Project", - "dependencies": { - "Coder.Desktop.Vpn.Proto": "[1.0.0, )", - "Microsoft.Extensions.Configuration": "[9.0.1, )", - "Semver": "[3.0.0, )", - "System.IO.Pipelines": "[9.0.1, )" - } - }, - "Coder.Desktop.Vpn.Proto": { - "type": "Project", - "dependencies": { - "Google.Protobuf": "[3.29.3, )" - } - } - } - } -} \ No newline at end of file diff --git a/Vpn.DebugClient/Program.cs b/Vpn.DebugClient/Program.cs index 9facc85..fbeeedc 100644 --- a/Vpn.DebugClient/Program.cs +++ b/Vpn.DebugClient/Program.cs @@ -1,4 +1,5 @@ using System.IO.Pipes; +using System.Net.Sockets; using Coder.Desktop.Vpn.Proto; namespace Coder.Desktop.Vpn.DebugClient; @@ -54,11 +55,23 @@ public static void Main() private static void Connect() { - var client = new NamedPipeClientStream(".", "Coder.Desktop.Vpn", PipeDirection.InOut, PipeOptions.Asynchronous); - client.Connect(); - Console.WriteLine("Connected to named pipe."); + Stream stream; + if (OperatingSystem.IsWindows()) + { + var client = new NamedPipeClientStream(".", "Coder.Desktop.Vpn", PipeDirection.InOut, PipeOptions.Asynchronous); + client.Connect(); + stream = client; + } + else + { + var socketPath = "/run/coder-desktop/vpn.sock"; + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + socket.Connect(new UnixDomainSocketEndPoint(socketPath)); + stream = new NetworkStream(socket, ownsSocket: true); + } + Console.WriteLine("Connected to RPC server."); - _speaker = new Speaker(client); + _speaker = new Speaker(stream); _speaker.Receive += message => { Console.WriteLine($"Received({message.Message.MsgCase}: {message.Message}"); }; _speaker.Error += exception => { diff --git a/Vpn.DebugClient/Vpn.DebugClient.csproj b/Vpn.DebugClient/Vpn.DebugClient.csproj index 0eda43d..bd7bedf 100644 --- a/Vpn.DebugClient/Vpn.DebugClient.csproj +++ b/Vpn.DebugClient/Vpn.DebugClient.csproj @@ -1,13 +1,13 @@ - + Coder.Desktop.Vpn.DebugClient Coder.Desktop.Vpn.DebugClient Exe - net8.0-windows + net8.0 enable enable - true + false diff --git a/Vpn.DebugClient/packages.lock.json b/Vpn.DebugClient/packages.lock.json deleted file mode 100644 index 473422b..0000000 --- a/Vpn.DebugClient/packages.lock.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net8.0-windows7.0": { - "Google.Protobuf": { - "type": "Transitive", - "resolved": "3.29.3", - "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw==" - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" - }, - "Semver": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "5.0.1" - } - }, - "System.IO.Pipelines": { - "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg==" - }, - "Coder.Desktop.Vpn": { - "type": "Project", - "dependencies": { - "Coder.Desktop.Vpn.Proto": "[1.0.0, )", - "Microsoft.Extensions.Configuration": "[9.0.1, )", - "Semver": "[3.0.0, )", - "System.IO.Pipelines": "[9.0.1, )" - } - }, - "Coder.Desktop.Vpn.Proto": { - "type": "Project", - "dependencies": { - "Google.Protobuf": "[3.29.3, )" - } - } - } - } -} \ No newline at end of file From 928d4882485a5de7907ff506fa7895f861833770 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:54:02 +0000 Subject: [PATCH 05/13] feat: create App.Shared/ with interfaces, models, and new abstractions Phase 2.0 (partial): Foundation for Avalonia UI migration. App.Shared/ (net8.0) contains: - Models: all 5 model files (CredentialModel, RpcModel, Settings, SyncSessionControllerStateModel, SyncSessionModel) - Service interfaces: IRpcController, ICredentialManager, ICredentialBackend, IUserNotifier, INotificationHandler, IStartupManager, IRdpConnector, IHostnameSuffixGetter, ISyncSessionController, IUpdateController, ISettingsManager, IUriHandler - New abstractions: IDispatcher (replaces DispatcherQueue), IClipboardService (replaces Windows Clipboard), ILauncherService (replaces Windows.System.Launcher), IWindowService (abstracts window creation for ViewModels) - Utils: ModelUpdate, FriendlyByteConverter (static utility) Builds with 0 errors on Linux. Next: copy cross-platform service implementations and ViewModels. --- App.Shared/App.Shared.csproj | 28 ++ 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/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/Utils/FriendlyByteConverter.cs | 19 ++ App.Shared/Utils/ModelUpdate.cs | 105 ++++++ Coder.Desktop.sln | 18 ++ 23 files changed, 1062 insertions(+) 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/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/Utils/FriendlyByteConverter.cs create mode 100644 App.Shared/Utils/ModelUpdate.cs diff --git a/App.Shared/App.Shared.csproj b/App.Shared/App.Shared.csproj new file mode 100644 index 0000000..4fcfb5c --- /dev/null +++ b/App.Shared/App.Shared.csproj @@ -0,0 +1,28 @@ + + + + Coder.Desktop.App.Shared + Coder.Desktop.App + net8.0 + enable + enable + preview + + + + + + + + + + + + + + + + + + + diff --git a/App.Shared/Models/CredentialModel.cs b/App.Shared/Models/CredentialModel.cs new file mode 100644 index 0000000..b38bbba --- /dev/null +++ b/App.Shared/Models/CredentialModel.cs @@ -0,0 +1,47 @@ +using System; +using Coder.Desktop.CoderSdk.Coder; + +namespace Coder.Desktop.App.Models; + +public enum CredentialState +{ + // Unknown means "we haven't checked yet" + Unknown, + + // Invalid means "we checked and there's either no saved credentials, or they are not valid" + Invalid, + + // Valid means "we checked and there are saved credentials, and they are valid" + Valid, +} + +public class CredentialModel : ICoderApiClientCredentialProvider +{ + public CredentialState State { get; init; } = CredentialState.Unknown; + + public Uri? CoderUrl { get; init; } + public string? ApiToken { get; init; } + + public string? Username { get; init; } + + public CredentialModel Clone() + { + return new CredentialModel + { + State = State, + CoderUrl = CoderUrl, + ApiToken = ApiToken, + Username = Username, + }; + } + + public CoderApiClientCredential? GetCoderApiClientCredential() + { + if (State != CredentialState.Valid) return null; + return new CoderApiClientCredential + { + ApiToken = ApiToken!, + CoderUrl = CoderUrl!, + }; + } +} diff --git a/App.Shared/Models/RpcModel.cs b/App.Shared/Models/RpcModel.cs new file mode 100644 index 0000000..e7b51b2 --- /dev/null +++ b/App.Shared/Models/RpcModel.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Coder.Desktop.App.Converters; +using Coder.Desktop.Vpn.Proto; + +namespace Coder.Desktop.App.Models; + +public enum RpcLifecycle +{ + Disconnected, + Connecting, + Connected, +} + +public enum VpnLifecycle +{ + Unknown, + Stopped, + Starting, + Started, + Stopping, +} + +public enum VpnStartupStage +{ + Unknown, + Initializing, + Downloading, + Finalizing, +} + +public class VpnDownloadProgress +{ + public ulong BytesWritten { get; set; } = 0; + public ulong? BytesTotal { get; set; } = null; // null means unknown total size + + public double Progress + { + get + { + if (BytesTotal is > 0) + { + return (double)BytesWritten / BytesTotal.Value; + } + return 0.0; + } + } + + public override string ToString() + { + // TODO: it would be nice if the two suffixes could match + var s = FriendlyByteConverter.FriendlyBytes(BytesWritten); + if (BytesTotal != null) + s += $" of {FriendlyByteConverter.FriendlyBytes(BytesTotal.Value)}"; + else + s += " of unknown"; + if (BytesTotal != null) + s += $" ({Progress:0%})"; + return s; + } + + public VpnDownloadProgress Clone() + { + return new VpnDownloadProgress + { + BytesWritten = BytesWritten, + BytesTotal = BytesTotal, + }; + } + + public static VpnDownloadProgress FromProto(StartProgressDownloadProgress proto) + { + return new VpnDownloadProgress + { + BytesWritten = proto.BytesWritten, + BytesTotal = proto.HasBytesTotal ? proto.BytesTotal : null, + }; + } +} + +public class VpnStartupProgress +{ + public const string DefaultStartProgressMessage = "Starting Coder Connect..."; + + // Scale the download progress to an overall progress value between these + // numbers. + private const double DownloadProgressMin = 0.05; + private const double DownloadProgressMax = 0.80; + + public VpnStartupStage Stage { get; init; } = VpnStartupStage.Unknown; + public VpnDownloadProgress? DownloadProgress { get; init; } = null; + + // 0.0 to 1.0 + public double Progress + { + get + { + switch (Stage) + { + case VpnStartupStage.Unknown: + case VpnStartupStage.Initializing: + return 0.0; + case VpnStartupStage.Downloading: + var progress = DownloadProgress?.Progress ?? 0.0; + return DownloadProgressMin + (DownloadProgressMax - DownloadProgressMin) * progress; + case VpnStartupStage.Finalizing: + return DownloadProgressMax; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + public override string ToString() + { + switch (Stage) + { + case VpnStartupStage.Unknown: + case VpnStartupStage.Initializing: + return DefaultStartProgressMessage; + case VpnStartupStage.Downloading: + var s = "Downloading Coder Connect binary..."; + if (DownloadProgress is not null) + { + s += "\n" + DownloadProgress; + } + + return s; + case VpnStartupStage.Finalizing: + return "Finalizing Coder Connect startup..."; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public VpnStartupProgress Clone() + { + return new VpnStartupProgress + { + Stage = Stage, + DownloadProgress = DownloadProgress?.Clone(), + }; + } + + public static VpnStartupProgress FromProto(StartProgress proto) + { + return new VpnStartupProgress + { + Stage = proto.Stage switch + { + StartProgressStage.Initializing => VpnStartupStage.Initializing, + StartProgressStage.Downloading => VpnStartupStage.Downloading, + StartProgressStage.Finalizing => VpnStartupStage.Finalizing, + _ => VpnStartupStage.Unknown, + }, + DownloadProgress = proto.Stage is StartProgressStage.Downloading ? + VpnDownloadProgress.FromProto(proto.DownloadProgress) : + null, + }; + } +} + +public class RpcModel +{ + public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected; + + private VpnLifecycle _vpnLifecycle; + public VpnLifecycle VpnLifecycle + { + get => _vpnLifecycle; + set + { + if (_vpnLifecycle != value && value == VpnLifecycle.Starting) + // Reset the startup progress when the VPN lifecycle changes to + // Starting. + _vpnStartupProgress = null; + _vpnLifecycle = value; + } + } + + // Nullable because it is only set when the VpnLifecycle is Starting + private VpnStartupProgress? _vpnStartupProgress; + public VpnStartupProgress? VpnStartupProgress + { + get => VpnLifecycle is VpnLifecycle.Starting ? _vpnStartupProgress ?? new VpnStartupProgress() : null; + set => _vpnStartupProgress = value; + } + + public IReadOnlyList Workspaces { get; set; } = []; + + public IReadOnlyList Agents { get; set; } = []; + + public RpcModel Clone() + { + return new RpcModel + { + RpcLifecycle = RpcLifecycle, + VpnLifecycle = VpnLifecycle, + VpnStartupProgress = VpnStartupProgress?.Clone(), + Workspaces = Workspaces, + Agents = Agents, + }; + } +} diff --git a/App.Shared/Models/Settings.cs b/App.Shared/Models/Settings.cs new file mode 100644 index 0000000..ec4c61b --- /dev/null +++ b/App.Shared/Models/Settings.cs @@ -0,0 +1,62 @@ +namespace Coder.Desktop.App.Models; + +public interface ISettings : ICloneable +{ + /// + /// FileName where the settings are stored. + /// + static abstract string SettingsFileName { get; } + + /// + /// Gets the version of the settings schema. + /// + int Version { get; } +} + +public interface ICloneable +{ + /// + /// Creates a deep copy of the settings object. + /// + /// A new instance of the settings object with the same values. + T Clone(); +} + +/// +/// CoderConnect settings class that holds the settings for the CoderConnect feature. +/// +public class CoderConnectSettings : ISettings +{ + public static string SettingsFileName { get; } = "coder-connect-settings.json"; + public int Version { get; set; } + /// + /// When this is true, CoderConnect will automatically connect to the Coder VPN when the application starts. + /// + public bool ConnectOnLaunch { get; set; } + + /// + /// CoderConnect current settings version. Increment this when the settings schema changes. + /// In future iterations we will be able to handle migrations when the user has + /// an older version. + /// + private const int VERSION = 1; + + public CoderConnectSettings() + { + Version = VERSION; + + ConnectOnLaunch = false; + } + + public CoderConnectSettings(int? version, bool connectOnLaunch) + { + Version = version ?? VERSION; + + ConnectOnLaunch = connectOnLaunch; + } + + public CoderConnectSettings Clone() + { + return new CoderConnectSettings(Version, ConnectOnLaunch); + } +} diff --git a/App.Shared/Models/SyncSessionControllerStateModel.cs b/App.Shared/Models/SyncSessionControllerStateModel.cs new file mode 100644 index 0000000..524a858 --- /dev/null +++ b/App.Shared/Models/SyncSessionControllerStateModel.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace Coder.Desktop.App.Models; + +public enum SyncSessionControllerLifecycle +{ + // Uninitialized means that the daemon has not been started yet. This can + // be resolved by calling RefreshState (or any other RPC method + // successfully). + Uninitialized, + + // Stopped means that the daemon is not running. This could be because: + // - It was never started (pre-Initialize) + // - It was stopped due to no sync sessions (post-Initialize, post-operation) + // - The last start attempt failed (DaemonError will be set) + // - The last daemon process crashed (DaemonError will be set) + Stopped, + + // Running is the normal state where the daemon is running and managing + // sync sessions. This is only set after a successful start (including + // being able to connect to the daemon). + Running, +} + +public class SyncSessionControllerStateModel +{ + public SyncSessionControllerLifecycle Lifecycle { get; init; } = SyncSessionControllerLifecycle.Stopped; + + /// + /// May be set when Lifecycle is Stopped to signify that the daemon failed + /// to start or unexpectedly crashed. + /// + public string? DaemonError { get; init; } + + public required string DaemonLogFilePath { get; init; } + + /// + /// This contains the last known state of all sync sessions. Sync sessions + /// are periodically refreshed if the daemon is running. This list is + /// sorted by creation time. + /// + public IReadOnlyList SyncSessions { get; init; } = []; +} diff --git a/App.Shared/Models/SyncSessionModel.cs b/App.Shared/Models/SyncSessionModel.cs new file mode 100644 index 0000000..46137f5 --- /dev/null +++ b/App.Shared/Models/SyncSessionModel.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Coder.Desktop.App.Converters; +using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core; +using Coder.Desktop.MutagenSdk.Proto.Url; + +namespace Coder.Desktop.App.Models; + +// This is a much slimmer enum than the original enum from Mutagen and only +// contains the overarching states that we care about from a code perspective. +// We still store the original state in the model for rendering purposes. +public enum SyncSessionStatusCategory +{ + Unknown, + Paused, + + // Halted is a combination of Error and Paused. If the session + // automatically pauses due to a safety check, we want to show it as an + // error, but also show that it can be resumed. + Halted, + Error, + + // If there are any conflicts, the state will be set to Conflicts, + // overriding Working and Ok. + Conflicts, + Working, + Ok, +} + +public sealed class SyncSessionModelEndpointSize +{ + public ulong SizeBytes { get; init; } + public ulong FileCount { get; init; } + public ulong DirCount { get; init; } + public ulong SymlinkCount { get; init; } + + public string Description(string linePrefix = "") + { + var str = + $"{linePrefix}{FriendlyByteConverter.FriendlyBytes(SizeBytes)}\n" + + $"{linePrefix}{FileCount:N0} files\n" + + $"{linePrefix}{DirCount:N0} directories"; + if (SymlinkCount > 0) str += $"\n{linePrefix} {SymlinkCount:N0} symlinks"; + + return str; + } +} + +public class SyncSessionModel +{ + public readonly string Identifier; + public readonly DateTime CreatedAt; + + public readonly string AlphaName; + public readonly string AlphaPath; + public readonly string BetaName; + public readonly string BetaPath; + + public readonly SyncSessionStatusCategory StatusCategory; + public readonly string StatusString; + public readonly string StatusDescription; + + public readonly SyncSessionModelEndpointSize AlphaSize; + public readonly SyncSessionModelEndpointSize BetaSize; + + public readonly IReadOnlyList Conflicts; // Conflict descriptions + public readonly ulong OmittedConflicts; + public readonly IReadOnlyList Errors; + + // If Paused is true, the session can be resumed. If false, the session can + // be paused. + public bool Paused => StatusCategory is SyncSessionStatusCategory.Paused or SyncSessionStatusCategory.Halted; + + public string StatusDetails + { + get + { + var str = StatusString; + if (StatusCategory.ToString() != StatusString) str += $" ({StatusCategory})"; + str += $"\n\n{StatusDescription}"; + foreach (var err in Errors) str += $"\n\n-----\n\n{err}"; + foreach (var conflict in Conflicts) str += $"\n\n-----\n\n{conflict}"; + if (OmittedConflicts > 0) str += $"\n\n-----\n\n{OmittedConflicts:N0} conflicts omitted"; + return str; + } + } + + public string SizeDetails + { + get + { + var str = "Alpha:\n" + AlphaSize.Description(" ") + "\n\n" + + "Remote:\n" + BetaSize.Description(" "); + return str; + } + } + + public SyncSessionModel(State state) + { + Identifier = state.Session.Identifier; + CreatedAt = state.Session.CreationTime.ToDateTime(); + + (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha); + (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta); + + switch (state.Status) + { + case Status.Disconnected: + StatusCategory = SyncSessionStatusCategory.Error; + StatusString = "Disconnected"; + StatusDescription = + "The session is unpaused but not currently connected or connecting to either endpoint."; + break; + case Status.HaltedOnRootEmptied: + StatusCategory = SyncSessionStatusCategory.Halted; + StatusString = "Halted on root emptied"; + StatusDescription = "The session is halted due to the root emptying safety check."; + break; + case Status.HaltedOnRootDeletion: + StatusCategory = SyncSessionStatusCategory.Halted; + StatusString = "Halted on root deletion"; + StatusDescription = "The session is halted due to the root deletion safety check."; + break; + case Status.HaltedOnRootTypeChange: + StatusCategory = SyncSessionStatusCategory.Halted; + StatusString = "Halted on root type change"; + StatusDescription = "The session is halted due to the root type change safety check."; + break; + case Status.ConnectingAlpha: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Connecting (alpha)"; + StatusDescription = "The session is attempting to connect to the alpha endpoint."; + break; + case Status.ConnectingBeta: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Connecting (beta)"; + StatusDescription = "The session is attempting to connect to the beta endpoint."; + break; + case Status.Watching: + StatusCategory = SyncSessionStatusCategory.Ok; + StatusString = "Watching"; + StatusDescription = "The session is watching for filesystem changes."; + break; + case Status.Scanning: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Scanning"; + StatusDescription = "The session is scanning the filesystem on each endpoint."; + break; + case Status.WaitingForRescan: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Waiting for rescan"; + StatusDescription = + "The session is waiting to retry scanning after an error during the previous scanning operation."; + break; + case Status.Reconciling: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Reconciling"; + StatusDescription = "The session is performing reconciliation."; + break; + case Status.StagingAlpha: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Staging (alpha)"; + StatusDescription = "The session is staging files on alpha."; + break; + case Status.StagingBeta: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Staging (beta)"; + StatusDescription = "The session is staging files on beta."; + break; + case Status.Transitioning: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Transitioning"; + StatusDescription = "The session is performing transition operations on each endpoint."; + break; + case Status.Saving: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Saving"; + StatusDescription = "The session is recording synchronization history to disk."; + break; + default: + StatusCategory = SyncSessionStatusCategory.Unknown; + StatusString = state.Status.ToString(); + StatusDescription = "Unknown status message."; + break; + } + + // If the session is paused, override all other statuses except Halted. + if (state.Session.Paused && StatusCategory is not SyncSessionStatusCategory.Halted) + { + StatusCategory = SyncSessionStatusCategory.Paused; + StatusString = "Paused"; + StatusDescription = "The session is paused."; + } + + // If there are any conflicts, override Working and Ok. + if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts) + { + StatusCategory = SyncSessionStatusCategory.Conflicts; + StatusString = "Conflicts"; + StatusDescription = "The session has conflicts that need to be resolved."; + } + + Conflicts = state.Conflicts.Select(ConflictToString).ToList(); + OmittedConflicts = state.ExcludedConflicts; + + AlphaSize = new SyncSessionModelEndpointSize + { + SizeBytes = state.AlphaState.TotalFileSize, + FileCount = state.AlphaState.Files, + DirCount = state.AlphaState.Directories, + SymlinkCount = state.AlphaState.SymbolicLinks, + }; + BetaSize = new SyncSessionModelEndpointSize + { + SizeBytes = state.BetaState.TotalFileSize, + FileCount = state.BetaState.Files, + DirCount = state.BetaState.Directories, + SymlinkCount = state.BetaState.SymbolicLinks, + }; + + List errors = []; + if (!string.IsNullOrWhiteSpace(state.LastError)) errors.Add($"Last error:\n {state.LastError}"); + // TODO: scan problems + transition problems + omissions should probably be fields + foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Alpha scan problem: {scanProblem}"); + if (state.AlphaState.ExcludedScanProblems > 0) + errors.Add($"Alpha scan problems omitted: {state.AlphaState.ExcludedScanProblems}"); + foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Beta scan problem: {scanProblem}"); + if (state.BetaState.ExcludedScanProblems > 0) + errors.Add($"Beta scan problems omitted: {state.BetaState.ExcludedScanProblems}"); + foreach (var transitionProblem in state.AlphaState.TransitionProblems) + errors.Add($"Alpha transition problem: {transitionProblem}"); + if (state.AlphaState.ExcludedTransitionProblems > 0) + errors.Add($"Alpha transition problems omitted: {state.AlphaState.ExcludedTransitionProblems}"); + foreach (var transitionProblem in state.AlphaState.TransitionProblems) + errors.Add($"Beta transition problem: {transitionProblem}"); + if (state.BetaState.ExcludedTransitionProblems > 0) + errors.Add($"Beta transition problems omitted: {state.BetaState.ExcludedTransitionProblems}"); + Errors = errors; + } + + private static (string, string) NameAndPathFromUrl(URL url) + { + var name = "Local"; + var path = !string.IsNullOrWhiteSpace(url.Path) ? url.Path : "Unknown"; + + if (url.Protocol is not Protocol.Local) + name = !string.IsNullOrWhiteSpace(url.Host) ? url.Host : "Unknown"; + if (string.IsNullOrWhiteSpace(url.Host)) name = url.Host; + + return (name, path); + } + + private static string ConflictToString(Conflict conflict) + { + string? friendlyProblem = null; + if (conflict.AlphaChanges.Count == 1 && conflict.BetaChanges.Count == 1 && + conflict.AlphaChanges[0].Old == null && + conflict.BetaChanges[0].Old == null && + conflict.AlphaChanges[0].New != null && + conflict.BetaChanges[0].New != null) + friendlyProblem = + "An entry was created on both endpoints and they do not match. You can resolve this conflict by deleting one of the entries on either side."; + + var str = $"Conflict at path '{conflict.Root}':"; + foreach (var change in conflict.AlphaChanges) + str += $"\n (alpha) {ChangeToString(change)}"; + foreach (var change in conflict.BetaChanges) + str += $"\n (beta) {ChangeToString(change)}"; + if (friendlyProblem != null) + str += $"\n\n{friendlyProblem}"; + + return str; + } + + private static string ChangeToString(Change change) + { + return $"{change.Path} ({EntryToString(change.Old)} -> {EntryToString(change.New)})"; + } + + private static string EntryToString(Entry? entry) + { + if (entry == null) return ""; + var str = entry.Kind.ToString(); + switch (entry.Kind) + { + case EntryKind.Directory: + str += $" ({entry.Contents.Count} entries)"; + break; + case EntryKind.File: + var digest = BitConverter.ToString(entry.Digest.ToByteArray()).Replace("-", "").ToLower(); + str += $" ({digest}, executable: {entry.Executable})"; + break; + case EntryKind.SymbolicLink: + str += $" (target: {entry.Target})"; + break; + case EntryKind.Problematic: + str += $" ({entry.Problem})"; + break; + } + + return str; + } +} diff --git a/App.Shared/Services/IClipboardService.cs b/App.Shared/Services/IClipboardService.cs new file mode 100644 index 0000000..e78acaa --- /dev/null +++ b/App.Shared/Services/IClipboardService.cs @@ -0,0 +1,9 @@ +namespace Coder.Desktop.App.Services; + +/// +/// Abstracts clipboard access. Replaces Windows.ApplicationModel.DataTransfer.Clipboard. +/// +public interface IClipboardService +{ + Task SetTextAsync(string text); +} diff --git a/App.Shared/Services/ICredentialManager.cs b/App.Shared/Services/ICredentialManager.cs new file mode 100644 index 0000000..34f018d --- /dev/null +++ b/App.Shared/Services/ICredentialManager.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using Coder.Desktop.App.Models; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; + +namespace Coder.Desktop.App.Services; + +public class RawCredentials +{ + public required string CoderUrl { get; set; } + public required string ApiToken { get; set; } +} + +[JsonSerializable(typeof(RawCredentials))] +public partial class RawCredentialsJsonContext : JsonSerializerContext; + +public interface ICredentialManager : ICoderApiClientCredentialProvider +{ + event EventHandler CredentialsChanged; + CredentialModel GetCachedCredentials(); + Task GetSignInUri(); + Task LoadCredentials(CancellationToken ct = default); + Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default); + Task ClearCredentials(CancellationToken ct = default); +} + +public interface ICredentialBackend +{ + Task ReadCredentials(CancellationToken ct = default); + Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default); + Task DeleteCredentials(CancellationToken ct = default); +} diff --git a/App.Shared/Services/IDispatcher.cs b/App.Shared/Services/IDispatcher.cs new file mode 100644 index 0000000..57a4dea --- /dev/null +++ b/App.Shared/Services/IDispatcher.cs @@ -0,0 +1,13 @@ +namespace Coder.Desktop.App.Services; + +/// +/// Abstracts UI thread dispatching. Replaces WinUI's DispatcherQueue. +/// +public interface IDispatcher +{ + /// Whether the calling thread is the UI thread. + bool CheckAccess(); + + /// Post an action to run on the UI thread. + void Post(Action action); +} diff --git a/App.Shared/Services/IHostnameSuffixGetter.cs b/App.Shared/Services/IHostnameSuffixGetter.cs new file mode 100644 index 0000000..a816e8f --- /dev/null +++ b/App.Shared/Services/IHostnameSuffixGetter.cs @@ -0,0 +1,7 @@ +namespace Coder.Desktop.App.Services; + +public interface IHostnameSuffixGetter +{ + event EventHandler SuffixChanged; + string GetCachedSuffix(); +} diff --git a/App.Shared/Services/ILauncherService.cs b/App.Shared/Services/ILauncherService.cs new file mode 100644 index 0000000..b925d66 --- /dev/null +++ b/App.Shared/Services/ILauncherService.cs @@ -0,0 +1,9 @@ +namespace Coder.Desktop.App.Services; + +/// +/// Abstracts launching URIs/URLs. Replaces Windows.System.Launcher. +/// +public interface ILauncherService +{ + Task LaunchUriAsync(Uri uri); +} diff --git a/App.Shared/Services/IRdpConnector.cs b/App.Shared/Services/IRdpConnector.cs new file mode 100644 index 0000000..ddc22f7 --- /dev/null +++ b/App.Shared/Services/IRdpConnector.cs @@ -0,0 +1,14 @@ +namespace Coder.Desktop.App.Services; + +public struct RdpCredentials(string username, string password) +{ + public readonly string Username = username; + public readonly string Password = password; +} + +public interface IRdpConnector +{ + const int DefaultPort = 3389; + void WriteCredentials(string fqdn, RdpCredentials credentials); + Task Connect(string fqdn, int port = DefaultPort, CancellationToken ct = default); +} diff --git a/App.Shared/Services/IRpcController.cs b/App.Shared/Services/IRpcController.cs new file mode 100644 index 0000000..fa6802d --- /dev/null +++ b/App.Shared/Services/IRpcController.cs @@ -0,0 +1,24 @@ +using Coder.Desktop.App.Models; + +namespace Coder.Desktop.App.Services; + +public class RpcOperationException : Exception +{ + public RpcOperationException(string message, Exception innerException) : base(message, innerException) { } + public RpcOperationException(string message) : base(message) { } +} + +public class VpnLifecycleException : Exception +{ + public VpnLifecycleException(string message, Exception innerException) : base(message, innerException) { } + public VpnLifecycleException(string message) : base(message) { } +} + +public interface IRpcController : IAsyncDisposable +{ + event EventHandler StateChanged; + RpcModel GetState(); + Task Reconnect(CancellationToken ct = default); + Task StartVpn(CancellationToken ct = default); + Task StopVpn(CancellationToken ct = default); +} diff --git a/App.Shared/Services/ISettingsManager.cs b/App.Shared/Services/ISettingsManager.cs new file mode 100644 index 0000000..d803d78 --- /dev/null +++ b/App.Shared/Services/ISettingsManager.cs @@ -0,0 +1,9 @@ +using Coder.Desktop.App.Models; + +namespace Coder.Desktop.App.Services; + +public interface ISettingsManager where T : ISettings, new() +{ + Task Read(CancellationToken ct = default); + Task Write(T settings, CancellationToken ct = default); +} diff --git a/App.Shared/Services/IStartupManager.cs b/App.Shared/Services/IStartupManager.cs new file mode 100644 index 0000000..0f778c9 --- /dev/null +++ b/App.Shared/Services/IStartupManager.cs @@ -0,0 +1,9 @@ +namespace Coder.Desktop.App.Services; + +public interface IStartupManager +{ + bool Enable(); + void Disable(); + bool IsEnabled(); + bool IsDisabledByPolicy(); +} diff --git a/App.Shared/Services/ISyncSessionController.cs b/App.Shared/Services/ISyncSessionController.cs new file mode 100644 index 0000000..bfdbe4f --- /dev/null +++ b/App.Shared/Services/ISyncSessionController.cs @@ -0,0 +1,61 @@ +using Coder.Desktop.App.Models; +using Coder.Desktop.MutagenSdk.Proto.Url; +using MutagenProtocol = Coder.Desktop.MutagenSdk.Proto.Url.Protocol; + +namespace Coder.Desktop.App.Services; + +public class CreateSyncSessionRequest +{ + public required Endpoint Alpha { get; init; } + public required Endpoint Beta { get; init; } + + public class Endpoint + { + public enum ProtocolKind + { + Local, + Ssh, + } + + public required ProtocolKind Protocol { get; init; } + public string User { get; init; } = ""; + public string Host { get; init; } = ""; + public uint Port { get; init; } = 0; + public string Path { get; init; } = ""; + + public URL MutagenUrl + { + get + { + var protocol = Protocol switch + { + ProtocolKind.Local => MutagenProtocol.Local, + ProtocolKind.Ssh => MutagenProtocol.Ssh, + _ => throw new ArgumentException($"Invalid protocol '{Protocol}'", nameof(Protocol)), + }; + + return new URL + { + Kind = Kind.Synchronization, + Protocol = protocol, + User = User, + Host = Host, + Port = Port, + Path = Path, + }; + } + } + } +} + +public interface ISyncSessionController : IAsyncDisposable +{ + event EventHandler StateChanged; + SyncSessionControllerStateModel GetState(); + Task RefreshState(CancellationToken ct = default); + Task CreateSyncSession(CreateSyncSessionRequest req, Action progressCallback, + CancellationToken ct = default); + Task PauseSyncSession(string identifier, CancellationToken ct = default); + Task ResumeSyncSession(string identifier, CancellationToken ct = default); + Task TerminateSyncSession(string identifier, CancellationToken ct = default); +} diff --git a/App.Shared/Services/IUpdateController.cs b/App.Shared/Services/IUpdateController.cs new file mode 100644 index 0000000..1f0c112 --- /dev/null +++ b/App.Shared/Services/IUpdateController.cs @@ -0,0 +1,6 @@ +namespace Coder.Desktop.App.Services; + +public interface IUpdateController : IAsyncDisposable +{ + Task CheckForUpdatesNow(); +} diff --git a/App.Shared/Services/IUriHandler.cs b/App.Shared/Services/IUriHandler.cs new file mode 100644 index 0000000..7fb6d53 --- /dev/null +++ b/App.Shared/Services/IUriHandler.cs @@ -0,0 +1,6 @@ +namespace Coder.Desktop.App.Services; + +public interface IUriHandler +{ + Task HandleUri(Uri uri, CancellationToken ct = default); +} diff --git a/App.Shared/Services/IUserNotifier.cs b/App.Shared/Services/IUserNotifier.cs new file mode 100644 index 0000000..b4316b2 --- /dev/null +++ b/App.Shared/Services/IUserNotifier.cs @@ -0,0 +1,19 @@ +namespace Coder.Desktop.App.Services; + +public interface INotificationHandler +{ + void HandleNotificationActivation(IDictionary args); +} + +public interface IDefaultNotificationHandler : INotificationHandler +{ +} + +public interface IUserNotifier : INotificationHandler, IAsyncDisposable +{ + void RegisterHandler(string name, INotificationHandler handler); + void UnregisterHandler(string name); + Task ShowErrorNotification(string title, string message, CancellationToken ct = default); + Task ShowActionNotification(string title, string message, string? handlerName, + IDictionary? args = null, CancellationToken ct = default); +} diff --git a/App.Shared/Services/IWindowService.cs b/App.Shared/Services/IWindowService.cs new file mode 100644 index 0000000..72e9f54 --- /dev/null +++ b/App.Shared/Services/IWindowService.cs @@ -0,0 +1,12 @@ +namespace Coder.Desktop.App.Services; + +/// +/// Abstracts window creation so ViewModels don't depend on UI framework types. +/// +public interface IWindowService +{ + void ShowSignInWindow(); + void ShowSettingsWindow(); + void ShowFileSyncListWindow(); + void ShowMessageWindow(string title, string message, string windowTitle); +} diff --git a/App.Shared/Utils/FriendlyByteConverter.cs b/App.Shared/Utils/FriendlyByteConverter.cs new file mode 100644 index 0000000..20a05ef --- /dev/null +++ b/App.Shared/Utils/FriendlyByteConverter.cs @@ -0,0 +1,19 @@ +namespace Coder.Desktop.App.Converters; + +/// +/// Utility for human-readable byte size formatting. +/// +public static class FriendlyByteConverter +{ + private static readonly string[] Suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + + public static string FriendlyBytes(ulong bytes) + { + if (bytes == 0) + return $"0 {Suffixes[0]}"; + + var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024))); + var num = Math.Round(bytes / Math.Pow(1024, place), 1); + return $"{num} {Suffixes[place]}"; + } +} diff --git a/App.Shared/Utils/ModelUpdate.cs b/App.Shared/Utils/ModelUpdate.cs new file mode 100644 index 0000000..de8b2b6 --- /dev/null +++ b/App.Shared/Utils/ModelUpdate.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Coder.Desktop.App.Utils; + +public interface IModelUpdateable +{ + /// + /// Applies changes from obj to `this` if they represent the same + /// object based on some identifier like an ID or fixed name. + /// + /// + /// True if the two objects represent the same item and the changes + /// were applied. + /// + public bool TryApplyChanges(T obj); +} + +/// +/// A static utility class providing methods for applying model updates +/// with as little UI updates as possible. +/// The main goal of the utilities in this class is to prevent redraws in +/// ItemsRepeater items when nothing has changed. +/// +public static class ModelUpdate +{ + /// + /// Takes all items in `update` and either applies them to existing + /// items in `target`, or adds them to `target` if there are no + /// matching items. + /// Any items in `target` that don't have a corresponding item in + /// `update` will be removed from `target`. + /// Items are inserted in their correct sort position according to + /// `sorter`. It's assumed that the target list is already sorted by + /// `sorter`. + /// + /// Target list to be updated + /// Incoming list to apply to `target` + /// + /// Comparison to use for sorting. Note that the sort order does not + /// need to be the ID/name field used in the IModelUpdateable + /// implementation, and can be by any order. + /// New items will be sorted after existing items. + /// + public static void ApplyLists(IList target, IEnumerable update, Comparison sorter) + where T : IModelUpdateable + { + var newItems = update.ToList(); + + // Update and remove existing items. We use index-based for loops here + // because we remove items, and removing items while using the list as + // an IEnumerable will throw an exception. + for (var i = 0; i < target.Count; i++) + { + // Even though we're removing items before a "break", we still use + // index-based for loops here to avoid exceptions. + for (var j = 0; j < newItems.Count; j++) + { + if (!target[i].TryApplyChanges(newItems[j])) continue; + + // Prevent it from being added below, or checked again. We + // don't need to decrement `j` here because we're breaking + // out of this inner loop. + newItems.RemoveAt(j); + goto OuterLoopEnd; // continue outer loop + } + + // A merge couldn't occur, so we need to remove the old item and + // decrement `i` for the next iteration. + target.RemoveAt(i); + i--; + + // Rider fights `dotnet format` about whether there should be a + // space before the semicolon or not. +#pragma warning disable format + OuterLoopEnd: ; +#pragma warning restore format + } + + // Add any items that were missing into their correct sorted place. + // It's assumed the list is already sorted. + foreach (var newItem in newItems) + { + for (var i = 0; i < target.Count; i++) + // If the new item sorts before the current item, insert it + // after. + if (sorter(newItem, target[i]) < 0) + { + target.Insert(i, newItem); + goto OuterLoopEnd; + } + + // Handle the case where target is empty or the new item is + // equal to or after every other item. + target.Add(newItem); + + // Rider fights `dotnet format` about whether there should be a + // space before the semicolon or not. +#pragma warning disable format + OuterLoopEnd: ; +#pragma warning restore format + } + } +} diff --git a/Coder.Desktop.sln b/Coder.Desktop.sln index 9f151f3..e13b27b 100644 --- a/Coder.Desktop.sln +++ b/Coder.Desktop.sln @@ -37,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Windows", "App.Windows\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Linux", "App.Linux\App.Linux.csproj", "{E2605556-CD04-4FD9-9621-C7063F859AED}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Shared", "App.Shared\App.Shared.csproj", "{8ECA503C-B827-4665-BE11-9CFF33B75BA0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -329,6 +331,22 @@ Global {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|x64.Build.0 = Release|Any CPU {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|x86.ActiveCfg = Release|Any CPU {E2605556-CD04-4FD9-9621-C7063F859AED}.Release|x86.Build.0 = Release|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Debug|ARM64.Build.0 = Debug|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Debug|x64.ActiveCfg = Debug|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Debug|x64.Build.0 = Debug|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Debug|x86.ActiveCfg = Debug|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Debug|x86.Build.0 = Debug|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|Any CPU.Build.0 = Release|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|ARM64.ActiveCfg = Release|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|ARM64.Build.0 = Release|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|x64.ActiveCfg = Release|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|x64.Build.0 = Release|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|x86.ActiveCfg = Release|Any CPU + {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 45a2c28361f78b1d6c3628d27a84488812839077 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:55:59 +0000 Subject: [PATCH 06/13] feat: add cross-platform service implementations to App.Shared/ Copy and adapt service implementations from App/: - CredentialManager.cs (without WindowsCredentialBackend) - RpcController.cs (refactored: IRpcClientTransport replaces NamedPipeClientStream) - HostnameSuffixGetter.cs (as-is, cross-platform) - SettingsManager.cs (as-is, JSON file storage) - UriHandler.cs (as-is, business logic only) RpcController now injects IRpcClientTransport for platform-agnostic RPC connections (Named Pipes on Windows, Unix Sockets on Linux). Builds with 0 errors on Linux. --- App.Shared/Services/CredentialManager.cs | 261 ++++++++++++++++++ App.Shared/Services/HostnameSuffixGetter.cs | 137 ++++++++++ App.Shared/Services/RpcController.cs | 285 ++++++++++++++++++++ App.Shared/Services/SettingsManager.cs | 138 ++++++++++ App.Shared/Services/UriHandler.cs | 180 +++++++++++++ 5 files changed, 1001 insertions(+) create mode 100644 App.Shared/Services/CredentialManager.cs create mode 100644 App.Shared/Services/HostnameSuffixGetter.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 diff --git a/App.Shared/Services/CredentialManager.cs b/App.Shared/Services/CredentialManager.cs new file mode 100644 index 0000000..11842aa --- /dev/null +++ b/App.Shared/Services/CredentialManager.cs @@ -0,0 +1,261 @@ +using System.Text.Json; +using Coder.Desktop.App.Models; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn.Utilities; + +namespace Coder.Desktop.App.Services; + +/// +/// Implements ICredentialManager using an ICredentialBackend to store +/// credentials. +/// +public class CredentialManager : ICredentialManager +{ + private readonly ICredentialBackend Backend; + private readonly ICoderApiClientFactory CoderApiClientFactory; + + // _opLock is held for the full duration of SetCredentials, and partially + // during LoadCredentials. _opLock protects _inFlightLoad, _loadCts, and + // writes to _latestCredentials. + private readonly RaiiSemaphoreSlim _opLock = new(1, 1); + + // _inFlightLoad and _loadCts are set at the beginning of a LoadCredentials + // call. + private Task? _inFlightLoad; + private CancellationTokenSource? _loadCts; + + // Reading and writing a reference in C# is always atomic, so this doesn't + // need to be protected on reads with a lock in GetCachedCredentials. + // + // The volatile keyword disables optimizations on reads/writes which helps + // other threads see the new value quickly (no guarantee that it's + // immediate). + private volatile CredentialModel? _latestCredentials; + + public CredentialManager(ICredentialBackend backend, ICoderApiClientFactory coderApiClientFactory) + { + Backend = backend; + CoderApiClientFactory = coderApiClientFactory; + } + + public event EventHandler? CredentialsChanged; + + public CredentialModel GetCachedCredentials() + { + // No lock required to read the reference. + var latestCreds = _latestCredentials; + // No clone needed as the model is immutable. + if (latestCreds != null) return latestCreds; + + return new CredentialModel + { + State = CredentialState.Unknown, + }; + } + + // Implements ICoderApiClientCredentialProvider + public CoderApiClientCredential? GetCoderApiClientCredential() + { + var latestCreds = _latestCredentials; + if (latestCreds is not { State: CredentialState.Valid } || latestCreds.CoderUrl is null) + return null; + + return new CoderApiClientCredential + { + CoderUrl = latestCreds.CoderUrl, + ApiToken = latestCreds.ApiToken ?? "", + }; + } + + public async Task GetSignInUri() + { + try + { + var raw = await Backend.ReadCredentials(); + if (raw is not null && !string.IsNullOrWhiteSpace(raw.CoderUrl)) return raw.CoderUrl; + } + catch + { + // ignored + } + + return null; + } + + // LoadCredentials may be preempted by SetCredentials. + public Task LoadCredentials(CancellationToken ct = default) + { + // This function is not `async` because we may return an existing task. + // However, we still want to acquire the lock with the + // CancellationToken so it can be canceled if needed. + using var _ = _opLock.LockAsync(ct).Result; + + // If we already have a cached value, return it. + var latestCreds = _latestCredentials; + if (latestCreds != null) return Task.FromResult(latestCreds); + + // If we are already loading, return the existing task. + if (_inFlightLoad != null) return _inFlightLoad; + + // Otherwise, kick off a new load. + // Note: subsequent loads returned from above will ignore the passed in + // CancellationToken. We set a maximum timeout of 15 seconds anyway. + _loadCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _loadCts.CancelAfter(TimeSpan.FromSeconds(15)); + _inFlightLoad = LoadCredentialsInner(_loadCts.Token); + return _inFlightLoad; + } + + public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct) + { + using var _ = await _opLock.LockAsync(ct); + + // If there's an ongoing load, cancel it. + if (_loadCts != null) + { + await _loadCts.CancelAsync(); + _loadCts.Dispose(); + _loadCts = null; + _inFlightLoad = null; + } + + if (string.IsNullOrWhiteSpace(coderUrl)) throw new ArgumentException("Coder URL is required", nameof(coderUrl)); + coderUrl = coderUrl.Trim(); + if (coderUrl.Length > 128) throw new ArgumentException("Coder URL is too long", nameof(coderUrl)); + if (!Uri.TryCreate(coderUrl, UriKind.Absolute, out var uri)) + throw new ArgumentException($"Coder URL '{coderUrl}' is not a valid URL", nameof(coderUrl)); + if (uri.Scheme != "http" && uri.Scheme != "https") + throw new ArgumentException("Coder URL must be HTTP or HTTPS", nameof(coderUrl)); + if (uri.PathAndQuery != "/") throw new ArgumentException("Coder URL must be the root URL", nameof(coderUrl)); + if (string.IsNullOrWhiteSpace(apiToken)) throw new ArgumentException("API token is required", nameof(apiToken)); + apiToken = apiToken.Trim(); + + var raw = new RawCredentials + { + CoderUrl = coderUrl, + ApiToken = apiToken, + }; + var populateCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + populateCts.CancelAfter(TimeSpan.FromSeconds(15)); + var model = await PopulateModel(raw, populateCts.Token); + await Backend.WriteCredentials(raw, ct); + UpdateState(model); + } + + public async Task ClearCredentials(CancellationToken ct = default) + { + using var _ = await _opLock.LockAsync(ct); + await Backend.DeleteCredentials(ct); + UpdateState(new CredentialModel + { + State = CredentialState.Invalid, + }); + } + + private async Task LoadCredentialsInner(CancellationToken ct) + { + CredentialModel model; + try + { + var raw = await Backend.ReadCredentials(ct); + model = await PopulateModel(raw, ct); + } + catch + { + // This catch will be hit if a SetCredentials operation started, or + // if the read/populate failed for some other reason (e.g. HTTP + // timeout). + // + // We don't need to clear the credentials here, the app will think + // they're unset and any subsequent SetCredentials call after the + // user signs in again will overwrite the old invalid ones. + model = new CredentialModel + { + State = CredentialState.Invalid, + }; + } + + // Grab the lock again so we can update the state. Don't use the CT + // here as it may have already been canceled. + using (await _opLock.LockAsync(TimeSpan.FromSeconds(5), CancellationToken.None)) + { + // Prevent new LoadCredentials calls from returning this task. + if (_loadCts != null) + { + _loadCts.Dispose(); + _loadCts = null; + _inFlightLoad = null; + } + + // If we were canceled but made it this far, try to return the + // latest credentials instead. + if (ct.IsCancellationRequested) + { + var latestCreds = _latestCredentials; + if (latestCreds is not null) return latestCreds; + } + + UpdateState(model); + ct.ThrowIfCancellationRequested(); + return model; + } + } + + private async Task PopulateModel(RawCredentials? credentials, CancellationToken ct) + { + if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) || + string.IsNullOrWhiteSpace(credentials.ApiToken)) + return new CredentialModel + { + State = CredentialState.Invalid, + }; + + if (!Uri.TryCreate(credentials.CoderUrl, UriKind.Absolute, out var uri)) + return new CredentialModel + { + State = CredentialState.Invalid, + }; + + BuildInfo buildInfo; + User me; + try + { + var sdkClient = CoderApiClientFactory.Create(credentials.CoderUrl); + // BuildInfo does not require authentication. + buildInfo = await sdkClient.GetBuildInfo(ct); + sdkClient.SetSessionToken(credentials.ApiToken); + me = await sdkClient.GetUser(User.Me, ct); + } + catch (CoderApiHttpException) + { + throw; + } + catch (Exception e) + { + throw new InvalidOperationException("Could not connect to or verify Coder server", e); + } + + ServerVersionUtilities.ParseAndValidateServerVersion(buildInfo.Version); + if (string.IsNullOrWhiteSpace(me.Username)) + throw new InvalidOperationException("Could not retrieve user information, username is empty"); + + return new CredentialModel + { + State = CredentialState.Valid, + CoderUrl = uri, + ApiToken = credentials.ApiToken, + Username = me.Username, + }; + } + + // Lock must be held when calling this function. + private void UpdateState(CredentialModel newModel) + { + _latestCredentials = newModel; + // Since the event handlers could block (or call back the + // CredentialManager and deadlock), we run these in a new task. + if (CredentialsChanged == null) return; + Task.Run(() => { CredentialsChanged?.Invoke(this, newModel); }); + } +} diff --git a/App.Shared/Services/HostnameSuffixGetter.cs b/App.Shared/Services/HostnameSuffixGetter.cs new file mode 100644 index 0000000..9e87c49 --- /dev/null +++ b/App.Shared/Services/HostnameSuffixGetter.cs @@ -0,0 +1,137 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn.Utilities; +using Microsoft.Extensions.Logging; + +namespace Coder.Desktop.App.Services; + +public class HostnameSuffixGetter : IHostnameSuffixGetter +{ + private const string DefaultSuffix = ".coder"; + + private readonly ICredentialManager _credentialManager; + private readonly ICoderApiClientFactory _clientFactory; + private readonly ILogger _logger; + + // _lock protects all private (non-readonly) values + private readonly RaiiSemaphoreSlim _lock = new(1, 1); + private string _domainSuffix = DefaultSuffix; + private bool _dirty = false; + private bool _getInProgress = false; + private CredentialModel _credentialModel = new() { State = CredentialState.Invalid }; + + public event EventHandler? SuffixChanged; + + public HostnameSuffixGetter(ICredentialManager credentialManager, ICoderApiClientFactory apiClientFactory, + ILogger logger) + { + _credentialManager = credentialManager; + _clientFactory = apiClientFactory; + _logger = logger; + credentialManager.CredentialsChanged += HandleCredentialsChanged; + HandleCredentialsChanged(this, _credentialManager.GetCachedCredentials()); + } + + ~HostnameSuffixGetter() + { + _credentialManager.CredentialsChanged -= HandleCredentialsChanged; + } + + private void HandleCredentialsChanged(object? sender, CredentialModel credentials) + { + using var _ = _lock.Lock(); + _logger.LogDebug("credentials updated with state {state}", credentials.State); + _credentialModel = credentials; + if (credentials.State != CredentialState.Valid) return; + + _dirty = true; + if (!_getInProgress) + { + _getInProgress = true; + Task.Run(Refresh).ContinueWith(MaybeRefreshAgain); + } + } + + private async Task Refresh() + { + _logger.LogDebug("refreshing domain suffix"); + CredentialModel credentials; + using (_ = await _lock.LockAsync()) + { + credentials = _credentialModel; + if (credentials.State != CredentialState.Valid) + { + _logger.LogDebug("abandoning refresh because credentials are now invalid"); + return; + } + + _dirty = false; + } + + var client = _clientFactory.Create(credentials); + using var timeoutSrc = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var connInfo = await client.GetAgentConnectionInfoGeneric(timeoutSrc.Token); + + // older versions of Coder might not set this + var suffix = string.IsNullOrEmpty(connInfo.HostnameSuffix) + ? DefaultSuffix + // and, it doesn't include the leading dot. + : "." + connInfo.HostnameSuffix; + + var changed = false; + using (_ = await _lock.LockAsync(CancellationToken.None)) + { + if (_domainSuffix != suffix) changed = true; + _domainSuffix = suffix; + } + + if (changed) + { + _logger.LogInformation("got new domain suffix '{suffix}'", suffix); + // grab a local copy of the EventHandler to avoid TOCTOU race on the `?.` null-check + var del = SuffixChanged; + del?.Invoke(this, suffix); + } + else + { + _logger.LogDebug("domain suffix unchanged '{suffix}'", suffix); + } + } + + private async Task MaybeRefreshAgain(Task prev) + { + if (prev.IsFaulted) + { + _logger.LogError(prev.Exception, "failed to query domain suffix"); + // back off here before retrying. We're just going to use a fixed, long + // delay since this just affects UI stuff; we're not in a huge rush as + // long as we eventually get the right value. + await Task.Delay(TimeSpan.FromSeconds(10)); + } + + using var l = await _lock.LockAsync(CancellationToken.None); + if ((_dirty || prev.IsFaulted) && _credentialModel.State == CredentialState.Valid) + { + // we still have valid credentials and we're either dirty or the last Get failed. + _logger.LogDebug("retrying domain suffix query"); + _ = Task.Run(Refresh).ContinueWith(MaybeRefreshAgain); + return; + } + + // Getting here means either the credentials are not valid or we don't need to + // refresh anyway. + // The next time we get new, valid credentials, HandleCredentialsChanged will kick off + // a new Refresh + _getInProgress = false; + return; + } + + public string GetCachedSuffix() + { + using var _ = _lock.Lock(); + return _domainSuffix; + } +} diff --git a/App.Shared/Services/RpcController.cs b/App.Shared/Services/RpcController.cs new file mode 100644 index 0000000..3d68f54 --- /dev/null +++ b/App.Shared/Services/RpcController.cs @@ -0,0 +1,285 @@ +using System.Diagnostics; +using Coder.Desktop.App.Models; +using Coder.Desktop.Vpn; +using Coder.Desktop.Vpn.Proto; +using Coder.Desktop.Vpn.Utilities; + +namespace Coder.Desktop.App.Services; + +public class RpcController : IRpcController +{ + private readonly ICredentialManager _credentialManager; + private readonly IRpcClientTransport _transport; + + private readonly RaiiSemaphoreSlim _operationLock = new(1, 1); + private Speaker? _speaker; + + private readonly RaiiSemaphoreSlim _stateLock = new(1, 1); + private readonly RpcModel _state = new(); + + public RpcController(ICredentialManager credentialManager, IRpcClientTransport transport) + { + _credentialManager = credentialManager; + _transport = transport; + } + + public event EventHandler? StateChanged; + + public RpcModel GetState() + { + using var _ = _stateLock.Lock(); + return _state.Clone(); + } + + public async Task Reconnect(CancellationToken ct = default) + { + using var _ = await AcquireOperationLockNowAsync(); + MutateState(state => + { + state.RpcLifecycle = RpcLifecycle.Connecting; + state.VpnLifecycle = VpnLifecycle.Stopped; + state.Workspaces = []; + state.Agents = []; + }); + + if (_speaker != null) + try + { + await DisposeSpeaker(); + } + catch (Exception e) + { + // TODO: log/notify? + Debug.WriteLine($"Error disposing existing Speaker: {e}"); + } + + try + { + var stream = await _transport.ConnectAsync(ct); + _speaker = new Speaker(stream); + _speaker.Receive += SpeakerOnReceive; + _speaker.Error += SpeakerOnError; + await _speaker.StartAsync(ct); + } + catch (Exception e) + { + MutateState(state => + { + state.RpcLifecycle = RpcLifecycle.Disconnected; + state.VpnLifecycle = VpnLifecycle.Unknown; + state.Workspaces = []; + state.Agents = []; + }); + throw new RpcOperationException("Failed to reconnect to the RPC server", e); + } + + MutateState(state => + { + state.RpcLifecycle = RpcLifecycle.Connected; + state.VpnLifecycle = VpnLifecycle.Unknown; + state.Workspaces = []; + state.Agents = []; + }); + + var statusReply = await _speaker.SendRequestAwaitReply(new ClientMessage + { + Status = new StatusRequest(), + }, ct); + if (statusReply.MsgCase != ServiceMessage.MsgOneofCase.Status) + throw new VpnLifecycleException( + $"Failed to get VPN status. Unexpected reply message type: {statusReply.MsgCase}"); + ApplyStatusUpdate(statusReply.Status); + } + + public async Task StartVpn(CancellationToken ct = default) + { + using var _ = await AcquireOperationLockNowAsync(); + AssertRpcConnected(); + + var credentials = _credentialManager.GetCachedCredentials(); + if (credentials.State != CredentialState.Valid) + throw new RpcOperationException( + $"Cannot start VPN without valid credentials, current state: {credentials.State}"); + + MutateState(state => + { + state.VpnLifecycle = VpnLifecycle.Starting; + }); + + ServiceMessage reply; + try + { + reply = await _speaker!.SendRequestAwaitReply(new ClientMessage + { + Start = new StartRequest + { + CoderUrl = credentials.CoderUrl?.ToString(), + ApiToken = credentials.ApiToken, + }, + }, ct); + } + catch (Exception e) + { + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; }); + throw new RpcOperationException("Failed to send start command to service", e); + } + + if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start) + { + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; }); + throw new VpnLifecycleException($"Failed to start VPN. Unexpected reply message type: {reply.MsgCase}"); + } + + if (!reply.Start.Success) + { + // We use Stopped instead of Unknown here as it's usually the case + // that a failed start got cleaned up successfully. + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; }); + throw new VpnLifecycleException( + $"Failed to start VPN. Service reported failure: {reply.Start.ErrorMessage}"); + } + + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Started; }); + } + + public async Task StopVpn(CancellationToken ct = default) + { + using var _ = await AcquireOperationLockNowAsync(); + AssertRpcConnected(); + + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopping; }); + + ServiceMessage reply; + try + { + reply = await _speaker!.SendRequestAwaitReply(new ClientMessage + { + Stop = new StopRequest(), + }, ct); + } + catch (Exception e) + { + throw new RpcOperationException("Failed to send stop command to service", e); + } + finally + { + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; }); + } + + if (reply.MsgCase != ServiceMessage.MsgOneofCase.Stop) + { + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; }); + throw new VpnLifecycleException($"Failed to stop VPN. Unexpected reply message type: {reply.MsgCase}"); + } + + if (!reply.Stop.Success) + { + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; }); + throw new VpnLifecycleException($"Failed to stop VPN. Service reported failure: {reply.Stop.ErrorMessage}"); + } + + MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; }); + } + + public async ValueTask DisposeAsync() + { + if (_speaker != null) + await _speaker.DisposeAsync(); + GC.SuppressFinalize(this); + } + + private void MutateState(Action mutator) + { + RpcModel newState; + using (_stateLock.Lock()) + { + mutator(_state); + newState = _state.Clone(); + } + + StateChanged?.Invoke(this, newState); + } + + private async Task AcquireOperationLockNowAsync() + { + var locker = await _operationLock.LockAsync(TimeSpan.Zero); + if (locker == null) + throw new InvalidOperationException("Cannot perform operation while another operation is in progress"); + return locker; + } + + private void ApplyStatusUpdate(Status status) + { + MutateState(state => + { + state.VpnLifecycle = status.Lifecycle switch + { + Status.Types.Lifecycle.Unknown => VpnLifecycle.Unknown, + Status.Types.Lifecycle.Starting => VpnLifecycle.Starting, + Status.Types.Lifecycle.Started => VpnLifecycle.Started, + Status.Types.Lifecycle.Stopping => VpnLifecycle.Stopping, + Status.Types.Lifecycle.Stopped => VpnLifecycle.Stopped, + _ => VpnLifecycle.Stopped, + }; + state.Workspaces = status.PeerUpdate.UpsertedWorkspaces; + state.Agents = status.PeerUpdate.UpsertedAgents; + }); + } + + private void ApplyStartProgressUpdate(StartProgress message) + { + MutateState(state => + { + // The model itself will ignore this value if we're not in the + // starting state. + state.VpnStartupProgress = VpnStartupProgress.FromProto(message); + }); + } + + private void SpeakerOnReceive(ReplyableRpcMessage message) + { + switch (message.Message.MsgCase) + { + case ServiceMessage.MsgOneofCase.Start: + case ServiceMessage.MsgOneofCase.Stop: + case ServiceMessage.MsgOneofCase.Status: + ApplyStatusUpdate(message.Message.Status); + break; + case ServiceMessage.MsgOneofCase.StartProgress: + ApplyStartProgressUpdate(message.Message.StartProgress); + break; + case ServiceMessage.MsgOneofCase.None: + default: + // TODO: log unexpected message + break; + } + } + + private async Task DisposeSpeaker() + { + if (_speaker == null) return; + _speaker.Receive -= SpeakerOnReceive; + _speaker.Error -= SpeakerOnError; + await _speaker.DisposeAsync(); + _speaker = null; + } + + private void SpeakerOnError(Exception e) + { + Debug.WriteLine($"Error: {e}"); + try + { + using var _ = Reconnect(CancellationToken.None); + } + catch + { + // best effort to immediately reconnect + } + } + + private void AssertRpcConnected() + { + if (_speaker == null) + throw new InvalidOperationException("Not connected to the RPC server"); + } +} diff --git a/App.Shared/Services/SettingsManager.cs b/App.Shared/Services/SettingsManager.cs new file mode 100644 index 0000000..d51844b --- /dev/null +++ b/App.Shared/Services/SettingsManager.cs @@ -0,0 +1,138 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Models; + +namespace Coder.Desktop.App.Services; + +public static class SettingsManagerUtils +{ + private const string AppName = "CoderDesktop"; + + /// + /// Generates the settings directory path and ensures it exists. + /// + /// Custom settings root, defaults to AppData/Local + public static string AppSettingsDirectory(string? settingsFilePath = null) + { + if (settingsFilePath is null) + { + settingsFilePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + } + else if (!Path.IsPathRooted(settingsFilePath)) + { + throw new ArgumentException("settingsFilePath must be an absolute path if provided", nameof(settingsFilePath)); + } + + var folder = Path.Combine( + settingsFilePath, + AppName); + + Directory.CreateDirectory(folder); + return folder; + } +} + +/// +/// Implementation of that persists settings to +/// a JSON file located in the user's local application data folder. +/// +public sealed class SettingsManager : ISettingsManager where T : ISettings, new() +{ + + private readonly string _settingsFilePath; + private readonly string _fileName; + + private T? _cachedSettings; + + private readonly SemaphoreSlim _gate = new(1, 1); + private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(3); + + /// + /// For unit‑tests you can pass an absolute path that already exists. + /// Otherwise the settings file will be created in the user's local application data folder. + /// + public SettingsManager(string? settingsFilePath = null) + { + var folder = SettingsManagerUtils.AppSettingsDirectory(settingsFilePath); + + _fileName = T.SettingsFileName; + _settingsFilePath = Path.Combine(folder, _fileName); + } + + public async Task Read(CancellationToken ct = default) + { + if (_cachedSettings is not null) + { + // return cached settings if available + return _cachedSettings.Clone(); + } + + // try to get the lock with short timeout + if (!await _gate.WaitAsync(LockTimeout, ct).ConfigureAwait(false)) + throw new InvalidOperationException( + $"Could not acquire the settings lock within {LockTimeout.TotalSeconds} s."); + + try + { + if (!File.Exists(_settingsFilePath)) + return new(); + + var json = await File.ReadAllTextAsync(_settingsFilePath, ct) + .ConfigureAwait(false); + + // deserialize; fall back to default(T) if empty or malformed + var result = JsonSerializer.Deserialize(json)!; + _cachedSettings = result; + return _cachedSettings.Clone(); // return a fresh instance of the settings + } + catch (OperationCanceledException) + { + throw; // propagate caller-requested cancellation + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to read settings from {_settingsFilePath}. " + + "The file may be corrupted, malformed or locked.", ex); + } + finally + { + _gate.Release(); + } + } + + public async Task Write(T settings, CancellationToken ct = default) + { + // try to get the lock with short timeout + if (!await _gate.WaitAsync(LockTimeout, ct).ConfigureAwait(false)) + throw new InvalidOperationException( + $"Could not acquire the settings lock within {LockTimeout.TotalSeconds} s."); + + try + { + // overwrite the settings file with the new settings + var json = JsonSerializer.Serialize( + settings, new JsonSerializerOptions() { WriteIndented = true }); + _cachedSettings = settings; // cache the settings + await File.WriteAllTextAsync(_settingsFilePath, json, ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // let callers observe cancellation + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to persist settings to {_settingsFilePath}. " + + "The file may be corrupted, malformed or locked.", ex); + } + finally + { + _gate.Release(); + } + } +} diff --git a/App.Shared/Services/UriHandler.cs b/App.Shared/Services/UriHandler.cs new file mode 100644 index 0000000..9571d9e --- /dev/null +++ b/App.Shared/Services/UriHandler.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Specialized; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Coder.Desktop.App.Models; +using Coder.Desktop.Vpn.Proto; +using Microsoft.Extensions.Logging; + + +namespace Coder.Desktop.App.Services; + +public class UriHandler( + ILogger logger, + IRpcController rpcController, + IUserNotifier userNotifier, + IRdpConnector rdpConnector, + ICredentialManager credentialManager) : IUriHandler +{ + private const string OpenWorkspacePrefix = "/v0/open/ws/"; + + internal class UriException : Exception + { + internal readonly string Title; + internal readonly string Detail; + + internal UriException(string title, string detail) : base($"{title}: {detail}") + { + Title = title; + Detail = detail; + } + } + + public async Task HandleUri(Uri uri, CancellationToken ct = default) + { + try + { + await HandleUriThrowingErrors(uri, ct); + } + catch (UriException e) + { + await userNotifier.ShowErrorNotification(e.Title, e.Detail, ct); + } + } + + private async Task HandleUriThrowingErrors(Uri uri, CancellationToken ct = default) + { + if (uri.AbsolutePath.StartsWith(OpenWorkspacePrefix)) + { + await HandleOpenWorkspaceApp(uri, ct); + return; + } + + logger.LogWarning("unhandled URI path {path}", uri.AbsolutePath); + throw new UriException("URI handling error", + $"URI with path '{uri.AbsolutePath}' is unsupported or malformed"); + } + + public async Task HandleOpenWorkspaceApp(Uri uri, CancellationToken ct = default) + { + const string errTitle = "Open Workspace Application Error"; + CheckAuthority(uri, errTitle); + + var subpath = uri.AbsolutePath[OpenWorkspacePrefix.Length..]; + var components = subpath.Split("/"); + if (components.Length != 4 || components[1] != "agent") + { + logger.LogWarning("unsupported open workspace app format in URI '{path}'", uri.AbsolutePath); + throw new UriException(errTitle, $"Failed to open '{uri.AbsolutePath}' because the format is unsupported."); + } + + var workspaceName = components[0]; + var agentName = components[2]; + var appName = components[3]; + + var state = rpcController.GetState(); + if (state.VpnLifecycle != VpnLifecycle.Started) + { + logger.LogDebug("got URI to open workspace '{workspace}', but Coder Connect is not started", workspaceName); + throw new UriException(errTitle, + $"Failed to open application on '{workspaceName}' because Coder Connect is not started."); + } + + var workspace = state.Workspaces.FirstOrDefault(w => w.Name == workspaceName); + if (workspace == null) + { + logger.LogDebug("got URI to open workspace '{workspace}', but the workspace doesn't exist", workspaceName); + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}' because it doesn't exist"); + } + + var agent = state.Agents.FirstOrDefault(a => a.WorkspaceId == workspace.Id && a.Name == agentName); + if (agent == null) + { + logger.LogDebug( + "got URI to open workspace/agent '{workspaceName}/{agentName}', but the agent doesn't exist", + workspaceName, agentName); + // If the workspace isn't running, that is almost certainly why we can't find the agent, so report that + // to the user. + if (workspace.Status != Workspace.Types.Status.Running) + { + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}', because the workspace is not running."); + } + + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}', because agent '{agentName}' doesn't exist."); + } + + if (appName != "rdp") + { + logger.LogWarning("unsupported agent application type {app}", appName); + throw new UriException(errTitle, + $"Failed to open agent in URI '{uri.AbsolutePath}' because application '{appName}' is unsupported"); + } + + await OpenRDP(agent.Fqdn.First(), uri.Query, ct); + } + + private void CheckAuthority(Uri uri, string errTitle) + { + if (string.IsNullOrEmpty(uri.Authority)) + { + logger.LogWarning("cannot open workspace app without a URI authority on path '{path}'", uri.AbsolutePath); + throw new UriException(errTitle, + $"Failed to open '{uri.AbsolutePath}' because no Coder server was given in the URI"); + } + + var credentialModel = credentialManager.GetCachedCredentials(); + if (credentialModel.State != CredentialState.Valid) + { + logger.LogWarning("cannot open workspace app because credentials are '{state}'", credentialModel.State); + throw new UriException(errTitle, + $"Failed to open '{uri.AbsolutePath}' because you are not signed in."); + } + + // here we assume that the URL is non-null since the credentials are marked valid. If not it's an internal error + // and the App will handle catching the exception and logging it. + var coderUri = credentialModel.CoderUrl!; + if (uri.Authority != coderUri.Authority) + { + logger.LogWarning( + "cannot open workspace app because it was for '{uri_authority}', be we are signed into '{signed_in_authority}'", + uri.Authority, coderUri.Authority); + throw new UriException(errTitle, + $"Failed to open workspace app because it was for '{uri.Authority}', be we are signed into '{coderUri.Authority}'"); + } + } + + public async Task OpenRDP(string domainName, string queryString, CancellationToken ct = default) + { + const string errTitle = "Workspace Remote Desktop Error"; + NameValueCollection query; + try + { + query = HttpUtility.ParseQueryString(queryString); + } + catch (Exception ex) + { + // unfortunately, we can't safely write they query string to logs because it might contain + // sensitive info like a password. This is also why we don't log the exception directly + var trace = new System.Diagnostics.StackTrace(ex, false); + logger.LogWarning("failed to parse open RDP query string: {classMethod}", + trace?.GetFrame(0)?.GetMethod()?.ReflectedType?.FullName); + throw new UriException(errTitle, + "Failed to open remote desktop on a workspace because the URI was malformed"); + } + + var username = query.Get("username"); + var password = query.Get("password"); + if (!string.IsNullOrEmpty(username)) + { + password ??= string.Empty; + rdpConnector.WriteCredentials(domainName, new RdpCredentials(username, password)); + } + + await rdpConnector.Connect(domainName, ct: ct); + } +} From c8af7a7a8292b021635eecf332f4942ae68550bd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:59:13 +0000 Subject: [PATCH 07/13] feat: add simple ViewModels to App.Shared/ Add SettingsViewModel and TrayWindowDisconnectedViewModel - the only ViewModels with zero WinUI dependencies. The remaining 10 ViewModels are deeply coupled to WinUI types (DispatcherQueue, ImageSource, FrameworkElement, ContentDialog, etc.) and will be adapted during the Avalonia UI conversion phase when the actual UI framework types are available to replace them with. This commit establishes the pattern: cross-platform ViewModels live in App.Shared/, WinUI-coupled code stays in App/ until Avalonia equivalents are ready. --- App.Shared/ViewModels/SettingsViewModel.cs | 81 +++++++++++++++++++ .../TrayWindowDisconnectedViewModel.cs | 44 ++++++++++ 2 files changed, 125 insertions(+) create mode 100644 App.Shared/ViewModels/SettingsViewModel.cs create mode 100644 App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs diff --git a/App.Shared/ViewModels/SettingsViewModel.cs b/App.Shared/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..721ea95 --- /dev/null +++ b/App.Shared/ViewModels/SettingsViewModel.cs @@ -0,0 +1,81 @@ +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using System; + +namespace Coder.Desktop.App.ViewModels; + +public partial class SettingsViewModel : ObservableObject +{ + private readonly ILogger _logger; + + [ObservableProperty] + public partial bool ConnectOnLaunch { get; set; } + + [ObservableProperty] + public partial bool StartOnLoginDisabled { get; set; } + + [ObservableProperty] + public partial bool StartOnLogin { get; set; } + + private ISettingsManager _connectSettingsManager; + private CoderConnectSettings _connectSettings = new CoderConnectSettings(); + private IStartupManager _startupManager; + + public SettingsViewModel(ILogger logger, ISettingsManager settingsManager, IStartupManager startupManager) + { + _connectSettingsManager = settingsManager; + _startupManager = startupManager; + _logger = logger; + _connectSettings = settingsManager.Read().GetAwaiter().GetResult(); + StartOnLogin = startupManager.IsEnabled(); + ConnectOnLaunch = _connectSettings.ConnectOnLaunch; + + // Various policies can disable the "Start on login" option. + // We disable the option in the UI if the policy is set. + StartOnLoginDisabled = _startupManager.IsDisabledByPolicy(); + + // Ensure the StartOnLogin property matches the current startup state. + if (StartOnLogin != _startupManager.IsEnabled()) + { + StartOnLogin = _startupManager.IsEnabled(); + } + } + + partial void OnConnectOnLaunchChanged(bool oldValue, bool newValue) + { + if (oldValue == newValue) + return; + try + { + _connectSettings.ConnectOnLaunch = ConnectOnLaunch; + _connectSettingsManager.Write(_connectSettings); + } + catch (Exception ex) + { + _logger.LogError($"Error saving Coder Connect settings: {ex.Message}"); + } + } + + partial void OnStartOnLoginChanged(bool oldValue, bool newValue) + { + if (oldValue == newValue) + return; + try + { + if (StartOnLogin) + { + _startupManager.Enable(); + } + else + { + _startupManager.Disable(); + } + } + catch (Exception ex) + { + _logger.LogError($"Error setting StartOnLogin in registry: {ex.Message}"); + } + } +} diff --git a/App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs b/App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs new file mode 100644 index 0000000..ce6582c --- /dev/null +++ b/App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs @@ -0,0 +1,44 @@ +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using System; +using System.Threading.Tasks; + +namespace Coder.Desktop.App.ViewModels; + +public partial class TrayWindowDisconnectedViewModel : ObservableObject +{ + private readonly IRpcController _rpcController; + + [ObservableProperty] public partial bool ReconnectButtonEnabled { get; set; } = true; + [ObservableProperty] public partial string ErrorMessage { get; set; } = string.Empty; + [ObservableProperty] public partial bool ReconnectFailed { get; set; } = false; + + public TrayWindowDisconnectedViewModel(IRpcController rpcController) + { + _rpcController = rpcController; + _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel); + } + + private void UpdateFromRpcModel(RpcModel rpcModel) + { + ReconnectButtonEnabled = rpcModel.RpcLifecycle != RpcLifecycle.Disconnected; + } + + [RelayCommand] + public async Task Reconnect() + { + try + { + ReconnectFailed = false; + ErrorMessage = string.Empty; + await _rpcController.Reconnect(); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + ReconnectFailed = true; + } + } +} From dae0110603be9e12a8c57bd7a0f85ed02a9aac68 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:32:14 +0000 Subject: [PATCH 08/13] App.Linux: use shared service interfaces --- App.Linux/App.Linux.csproj | 5 +++++ App.Linux/ICredentialBackend.cs | 19 ------------------- App.Linux/IRdpConnector.cs | 14 -------------- App.Linux/IStartupManager.cs | 9 --------- App.Linux/IUserNotifier.cs | 15 --------------- App.Linux/LinuxRdpConnector.cs | 11 ++++++----- 6 files changed, 11 insertions(+), 62 deletions(-) delete mode 100644 App.Linux/ICredentialBackend.cs delete mode 100644 App.Linux/IRdpConnector.cs delete mode 100644 App.Linux/IStartupManager.cs delete mode 100644 App.Linux/IUserNotifier.cs diff --git a/App.Linux/App.Linux.csproj b/App.Linux/App.Linux.csproj index ea76020..d92363b 100644 --- a/App.Linux/App.Linux.csproj +++ b/App.Linux/App.Linux.csproj @@ -8,4 +8,9 @@ enable + + + + + diff --git a/App.Linux/ICredentialBackend.cs b/App.Linux/ICredentialBackend.cs deleted file mode 100644 index 049ce89..0000000 --- a/App.Linux/ICredentialBackend.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Coder.Desktop.App.Services; - -// These interfaces mirror those in the App project. They are defined here -// because App/ still targets WinUI 3 (net8.0-windows). When the Avalonia -// app is created, these will be replaced with references to the shared -// interfaces. - -public class RawCredentials -{ - public string? CoderUrl { get; set; } - public string? ApiToken { get; set; } -} - -public interface ICredentialBackend -{ - Task ReadCredentials(CancellationToken ct = default); - Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default); - Task DeleteCredentials(CancellationToken ct = default); -} diff --git a/App.Linux/IRdpConnector.cs b/App.Linux/IRdpConnector.cs deleted file mode 100644 index a1414e7..0000000 --- a/App.Linux/IRdpConnector.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Coder.Desktop.App.Services; - -public class RdpCredentials -{ - public string? Username { get; set; } - public string? Password { get; set; } -} - -public interface IRdpConnector -{ - const int DefaultPort = 3389; - void WriteCredentials(string fqdn, RdpCredentials credentials); - Task Connect(string fqdn, int port = DefaultPort, CancellationToken ct = default); -} diff --git a/App.Linux/IStartupManager.cs b/App.Linux/IStartupManager.cs deleted file mode 100644 index 0f778c9..0000000 --- a/App.Linux/IStartupManager.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Coder.Desktop.App.Services; - -public interface IStartupManager -{ - bool Enable(); - void Disable(); - bool IsEnabled(); - bool IsDisabledByPolicy(); -} diff --git a/App.Linux/IUserNotifier.cs b/App.Linux/IUserNotifier.cs deleted file mode 100644 index 9ae9470..0000000 --- a/App.Linux/IUserNotifier.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Coder.Desktop.App.Services; - -public interface INotificationHandler -{ - void HandleNotificationActivation(IDictionary args); -} - -public interface IUserNotifier : INotificationHandler, IAsyncDisposable -{ - void RegisterHandler(string name, INotificationHandler handler); - void UnregisterHandler(string name); - Task ShowErrorNotification(string title, string message, CancellationToken ct = default); - Task ShowActionNotification(string title, string message, string? handlerName, - IDictionary? args = null, CancellationToken ct = default); -} diff --git a/App.Linux/LinuxRdpConnector.cs b/App.Linux/LinuxRdpConnector.cs index dc96933..3757eeb 100644 --- a/App.Linux/LinuxRdpConnector.cs +++ b/App.Linux/LinuxRdpConnector.cs @@ -46,12 +46,13 @@ public Task Connect(string fqdn, int port = IRdpConnector.DefaultPort, Cancellat psi.ArgumentList.Add("/dynamic-resolution"); psi.ArgumentList.Add("+clipboard"); - if (_lastCredentials != null && _lastFqdn == fqdn) + if (_lastCredentials.HasValue && _lastFqdn == fqdn) { - if (!string.IsNullOrEmpty(_lastCredentials.Username)) - psi.ArgumentList.Add($"/u:{_lastCredentials.Username}"); - if (!string.IsNullOrEmpty(_lastCredentials.Password)) - psi.ArgumentList.Add($"/p:{_lastCredentials.Password}"); + var lastCredentials = _lastCredentials.Value; + if (!string.IsNullOrEmpty(lastCredentials.Username)) + psi.ArgumentList.Add($"/u:{lastCredentials.Username}"); + if (!string.IsNullOrEmpty(lastCredentials.Password)) + psi.ArgumentList.Add($"/p:{lastCredentials.Password}"); } } else From eb03ed3330d5298fd62cc570f2a8e961f9a26a9c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:34:21 +0000 Subject: [PATCH 09/13] Add App.Avalonia scaffold --- App.Avalonia/App.Avalonia.csproj | 37 +++++++++++++++++++ App.Avalonia/App.axaml | 8 ++++ App.Avalonia/App.axaml.cs | 37 +++++++++++++++++++ App.Avalonia/Program.cs | 18 +++++++++ .../Services/AvaloniaClipboardService.cs | 19 ++++++++++ App.Avalonia/Services/AvaloniaDispatcher.cs | 17 +++++++++ .../Services/ProcessLauncherService.cs | 16 ++++++++ App.Avalonia/Views/MainWindow.axaml | 10 +++++ App.Avalonia/Views/MainWindow.axaml.cs | 11 ++++++ Coder.Desktop.sln | 18 +++++++++ 10 files changed, 191 insertions(+) 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/Program.cs create mode 100644 App.Avalonia/Services/AvaloniaClipboardService.cs create mode 100644 App.Avalonia/Services/AvaloniaDispatcher.cs create mode 100644 App.Avalonia/Services/ProcessLauncherService.cs create mode 100644 App.Avalonia/Views/MainWindow.axaml create mode 100644 App.Avalonia/Views/MainWindow.axaml.cs diff --git a/App.Avalonia/App.Avalonia.csproj b/App.Avalonia/App.Avalonia.csproj new file mode 100644 index 0000000..df5d1c6 --- /dev/null +++ b/App.Avalonia/App.Avalonia.csproj @@ -0,0 +1,37 @@ + + + WinExe + net8.0 + Coder.Desktop.App + Coder Desktop + enable + enable + preview + true + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.Avalonia/App.axaml b/App.Avalonia/App.axaml new file mode 100644 index 0000000..b144cfd --- /dev/null +++ b/App.Avalonia/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/App.Avalonia/App.axaml.cs b/App.Avalonia/App.axaml.cs new file mode 100644 index 0000000..66c8d3a --- /dev/null +++ b/App.Avalonia/App.axaml.cs @@ -0,0 +1,37 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace Coder.Desktop.App; + +public partial class App : Application +{ + private ServiceProvider? _services; + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + var services = new ServiceCollection(); + + // Register cross-platform services from App.Shared + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + _services = services.BuildServiceProvider(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/App.Avalonia/Program.cs b/App.Avalonia/Program.cs new file mode 100644 index 0000000..d006f7f --- /dev/null +++ b/App.Avalonia/Program.cs @@ -0,0 +1,18 @@ +using Avalonia; + +namespace Coder.Desktop.App; + +public static class Program +{ + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + 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..ebaa8f1 --- /dev/null +++ b/App.Avalonia/Services/AvaloniaDispatcher.cs @@ -0,0 +1,17 @@ +using Avalonia.Threading; +using IDispatcher = Coder.Desktop.App.Services.IDispatcher; + +namespace Coder.Desktop.App; + +public class AvaloniaDispatcher : IDispatcher +{ + public bool CheckAccess() => Dispatcher.UIThread.CheckAccess(); + + public void Post(Action action) + { + if (Dispatcher.UIThread.CheckAccess()) + action(); + else + Dispatcher.UIThread.Post(action); + } +} 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/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/Coder.Desktop.sln b/Coder.Desktop.sln index e13b27b..11a5526 100644 --- a/Coder.Desktop.sln +++ b/Coder.Desktop.sln @@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Linux", "App.Linux\App. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Shared", "App.Shared\App.Shared.csproj", "{8ECA503C-B827-4665-BE11-9CFF33B75BA0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Avalonia", "App.Avalonia\App.Avalonia.csproj", "{AC71930B-17DB-47F1-9880-2FB60C292C15}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -347,6 +349,22 @@ Global {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|x64.Build.0 = Release|Any CPU {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|x86.ActiveCfg = Release|Any CPU {8ECA503C-B827-4665-BE11-9CFF33B75BA0}.Release|x86.Build.0 = Release|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Debug|ARM64.Build.0 = Debug|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Debug|x64.ActiveCfg = Debug|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Debug|x64.Build.0 = Debug|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Debug|x86.ActiveCfg = Debug|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Debug|x86.Build.0 = Debug|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Release|Any CPU.Build.0 = Release|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Release|ARM64.ActiveCfg = Release|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Release|ARM64.Build.0 = Release|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Release|x64.ActiveCfg = Release|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Release|x64.Build.0 = Release|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Release|x86.ActiveCfg = Release|Any CPU + {AC71930B-17DB-47F1-9880-2FB60C292C15}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From b32c12161c37875f54ad977bc7081ca27507cf3b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:55:06 +0000 Subject: [PATCH 10/13] App.Avalonia: add converters and custom controls --- App.Avalonia/App.Avalonia.csproj | 2 +- App.Avalonia/Controls/ExpandChevron.axaml | 27 ++++ App.Avalonia/Controls/ExpandChevron.axaml.cs | 48 ++++++ App.Avalonia/Controls/ExpandContent.axaml | 28 ++++ App.Avalonia/Controls/ExpandContent.axaml.cs | 103 ++++++++++++ App.Avalonia/Controls/HorizontalRule.axaml | 9 ++ App.Avalonia/Controls/HorizontalRule.axaml.cs | 11 ++ .../Converters/BoolToObjectConverter.cs | 22 +++ .../Converters/DependencyObjectSelector.cs | 152 ++++++++++++++++++ .../Converters/FriendlyByteConverter.cs | 30 ++++ .../Converters/InverseBoolConverter.cs | 18 +++ .../Converters/VpnLifecycleToBoolConverter.cs | 42 +++++ 12 files changed, 491 insertions(+), 1 deletion(-) 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/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 diff --git a/App.Avalonia/App.Avalonia.csproj b/App.Avalonia/App.Avalonia.csproj index df5d1c6..2cbd626 100644 --- a/App.Avalonia/App.Avalonia.csproj +++ b/App.Avalonia/App.Avalonia.csproj @@ -32,6 +32,6 @@ - + diff --git a/App.Avalonia/Controls/ExpandChevron.axaml b/App.Avalonia/Controls/ExpandChevron.axaml new file mode 100644 index 0000000..6d7f51d --- /dev/null +++ b/App.Avalonia/Controls/ExpandChevron.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/App.Avalonia/Controls/ExpandChevron.axaml.cs b/App.Avalonia/Controls/ExpandChevron.axaml.cs new file mode 100644 index 0000000..81976a4 --- /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 +{ + public static readonly StyledProperty IsOpenProperty = + AvaloniaProperty.Register(nameof(IsOpen)); + + static ExpandChevron() + { + IsOpenProperty.Changed.AddClassHandler((x, e) => + { + if (e.NewValue is bool isOpen) + { + x.UpdateChevronAngle(isOpen); + } + }); + } + + public bool IsOpen + { + get => GetValue(IsOpenProperty); + set => SetValue(IsOpenProperty, value); + } + + public ExpandChevron() + { + InitializeComponent(); + + UpdateChevronAngle(IsOpen); + } + + private void UpdateChevronAngle(bool isOpen) + { + if (ChevronIcon is null) + { + return; + } + + if (ChevronIcon.RenderTransform is RotateTransform rotate) + { + rotate.Angle = isOpen ? 90 : 0; + } + } +} 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..10f644a --- /dev/null +++ b/App.Avalonia/Controls/ExpandContent.axaml.cs @@ -0,0 +1,103 @@ +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; + + [Content] + public IList Children => CollapsiblePanel.Children; + + public bool IsOpen + { + get => GetValue(IsOpenProperty); + set => SetValue(IsOpenProperty, value); + } + + public ExpandContent() + { + InitializeComponent(); + + _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/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(); + } +} From ebc362b7589d85cbb220edd6fb70a3b443c93af4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:06:38 +0000 Subject: [PATCH 11/13] Port remaining ViewModels to App.Shared for Avalonia --- App.Shared/ViewModels/AgentAppViewModel.cs | 165 ++++++ App.Shared/ViewModels/AgentViewModel.cs | 544 ++++++++++++++++++ .../ViewModels/DirectoryPickerViewModel.cs | 275 +++++++++ .../ViewModels/FileSyncListViewModel.cs | 540 +++++++++++++++++ App.Shared/ViewModels/SignInViewModel.cs | 194 +++++++ App.Shared/ViewModels/SyncSessionViewModel.cs | 37 ++ .../TrayWindowLoginRequiredViewModel.cs | 29 + App.Shared/ViewModels/TrayWindowViewModel.cs | 459 +++++++++++++++ .../UpdaterDownloadProgressViewModel.cs | 91 +++ .../UpdaterUpdateAvailableViewModel.cs | 205 +++++++ 10 files changed, 2539 insertions(+) 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/SignInViewModel.cs create mode 100644 App.Shared/ViewModels/SyncSessionViewModel.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 diff --git a/App.Shared/ViewModels/AgentAppViewModel.cs b/App.Shared/ViewModels/AgentAppViewModel.cs new file mode 100644 index 0000000..9a38dac --- /dev/null +++ b/App.Shared/ViewModels/AgentAppViewModel.cs @@ -0,0 +1,165 @@ +using System; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; +using Coder.Desktop.CoderSdk; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; + +namespace Coder.Desktop.App.ViewModels; + +public interface IAgentAppViewModelFactory +{ + public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl); +} + +public class AgentAppViewModelFactory( + ILogger childLogger, + ICredentialManager credentialManager, + ILauncherService launcherService, + IWindowService windowService) + : IAgentAppViewModelFactory +{ + public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl) + { + return new AgentAppViewModel(childLogger, credentialManager, launcherService, windowService) + { + Id = id, + Name = name, + AppUri = appUri, + IconUrl = iconUrl, + }; + } +} + +public partial class AgentAppViewModel : ObservableObject, IModelUpdateable +{ + private const string SessionTokenUriVar = "$SESSION_TOKEN"; + + private readonly ILogger _logger; + private readonly ICredentialManager _credentialManager; + private readonly ILauncherService _launcherService; + private readonly IWindowService _windowService; + + public required Uuid Id { get; init; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Details))] + public required partial string Name { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Details))] + public required partial Uri AppUri { get; set; } + + [ObservableProperty] + public partial Uri? IconUrl { get; set; } + + /// + /// UI framework-specific image type (Avalonia IImage, WinUI ImageSource, etc.). + /// + /// In App.Shared we keep this as . + /// + [ObservableProperty] + public partial object? IconImageSource { get; set; } + + [ObservableProperty] + public partial bool UseFallbackIcon { get; set; } = true; + + public string Details => + (string.IsNullOrWhiteSpace(Name) ? "(no name)" : Name) + ":\n\n" + AppUri; + + public AgentAppViewModel( + ILogger logger, + ICredentialManager credentialManager, + ILauncherService launcherService, + IWindowService windowService) + { + _logger = logger; + _credentialManager = credentialManager; + _launcherService = launcherService; + _windowService = windowService; + + // Apply the icon URL to the icon image source when it is updated. + IconImageSource = UpdateIcon(); + PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(IconUrl)) + IconImageSource = UpdateIcon(); + }; + } + + public bool TryApplyChanges(AgentAppViewModel obj) + { + if (Id != obj.Id) + return false; + + // To avoid spurious UI updates which cause flashing, don't actually + // write to values unless they've changed. + if (Name != obj.Name) + Name = obj.Name; + if (AppUri != obj.AppUri) + AppUri = obj.AppUri; + if (IconUrl != obj.IconUrl) + { + UseFallbackIcon = true; + IconUrl = obj.IconUrl; + } + + return true; + } + + private object? UpdateIcon() + { + if (IconUrl is null || (IconUrl.Scheme != "http" && IconUrl.Scheme != "https")) + { + UseFallbackIcon = true; + return null; + } + + // In App.Shared we don't construct UI-framework image types. + // The UI layer can decide how to load/render this URI (SVG/bitmap/etc). + UseFallbackIcon = true; + return IconUrl; + } + + public void OnImageOpened(object? sender, EventArgs e) + { + UseFallbackIcon = false; + } + + public void OnImageFailed(object? sender, EventArgs e) + { + UseFallbackIcon = true; + } + + [RelayCommand] + private async Task OpenApp(object? parameter) + { + try + { + var uri = AppUri; + + // http and https URLs should already be filtered out by + // AgentViewModel, but as a second line of defence don't do session + // token var replacement on those URLs. + if (uri.Scheme is not "http" and not "https") + { + var cred = _credentialManager.GetCachedCredentials(); + if (cred.State is CredentialState.Valid && cred.ApiToken is not null) + uri = new Uri(uri.ToString().Replace(SessionTokenUriVar, cred.ApiToken)); + } + + if (uri.ToString().Contains(SessionTokenUriVar)) + throw new Exception( + $"URI contains {SessionTokenUriVar} variable but could not be replaced (http and https URLs cannot contain {SessionTokenUriVar})"); + + await _launcherService.LaunchUriAsync(uri); + } + catch (Exception e) + { + _logger.LogWarning(e, "could not parse or launch app"); + _windowService.ShowMessageWindow("Could not open app", e.Message, "Coder Connect"); + } + } +} diff --git a/App.Shared/ViewModels/AgentViewModel.cs b/App.Shared/ViewModels/AgentViewModel.cs new file mode 100644 index 0000000..a1eed99 --- /dev/null +++ b/App.Shared/ViewModels/AgentViewModel.cs @@ -0,0 +1,544 @@ +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn.Proto; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Coder.Desktop.App.ViewModels; + +public interface IAgentViewModelFactory +{ + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, + string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, + string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency, DateTime? lastHandshake); + public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName); +} + +public class AgentViewModelFactory( + ILogger childLogger, + ICoderApiClientFactory coderApiClientFactory, + ICredentialManager credentialManager, + IAgentAppViewModelFactory agentAppViewModelFactory, + IDispatcher dispatcher, + IClipboardService clipboardService) : IAgentViewModelFactory +{ + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, + string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency, + DateTime? lastHandshake) + { + return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, + dispatcher, clipboardService, expanderHost, id) + { + ConfiguredFqdn = fullyQualifiedDomainName, + ConfiguredHostname = string.Empty, + ConfiguredHostnameSuffix = hostnameSuffix, + ConnectionStatus = connectionStatus, + DashboardBaseUrl = dashboardBaseUrl, + WorkspaceName = workspaceName, + DidP2p = didP2p, + PreferredDerp = preferredDerp, + Latency = latency, + PreferredDerpLatency = preferredDerpLatency, + LastHandshake = lastHandshake, + }; + } + + public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName) + { + return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, + dispatcher, clipboardService, expanderHost, id) + { + ConfiguredFqdn = string.Empty, + ConfiguredHostname = workspaceName, + ConfiguredHostnameSuffix = hostnameSuffix, + ConnectionStatus = connectionStatus, + DashboardBaseUrl = dashboardBaseUrl, + WorkspaceName = workspaceName, + }; + } +} + +public enum AgentConnectionStatus +{ + Healthy, + Connecting, + Unhealthy, + NoRecentHandshake, + Offline +} + +public static class AgentConnectionStatusExtensions +{ + public static string ToDisplayString(this AgentConnectionStatus status) => + status switch + { + AgentConnectionStatus.Healthy => "Healthy", + AgentConnectionStatus.Connecting => "Connecting", + AgentConnectionStatus.Unhealthy => "High latency", + AgentConnectionStatus.NoRecentHandshake => "No recent handshake", + AgentConnectionStatus.Offline => "Offline", + _ => status.ToString() + }; +} + +public partial class AgentViewModel : ObservableObject, IModelUpdateable +{ + private const string DefaultDashboardUrl = "https://coder.com"; + private const int MaxAppsPerRow = 6; + + // These are fake UUIDs, for UI purposes only. Display apps don't exist on + // the backend as real app resources and therefore don't have an ID. + private static readonly Uuid VscodeAppUuid = new("819828b1-5213-4c3d-855e-1b74db6ddd19"); + private static readonly Uuid VscodeInsidersAppUuid = new("becf1e10-5101-4940-a853-59af86468069"); + + private readonly ILogger _logger; + private readonly ICoderApiClientFactory _coderApiClientFactory; + private readonly ICredentialManager _credentialManager; + private readonly IAgentAppViewModelFactory _agentAppViewModelFactory; + private readonly IDispatcher _dispatcher; + private readonly IClipboardService _clipboardService; + + private readonly IAgentExpanderHost _expanderHost; + + // This isn't an ObservableProperty because the property itself never + // changes. We add an event listener for the collection changing in the + // constructor. + public readonly ObservableCollection Apps = []; + + public readonly Uuid Id; + + // This is set only for "dummy" agents that represent unstarted workspaces. If set, then ConfiguredFqdn + // should be empty, otherwise it will override this. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ViewableHostname))] + [NotifyPropertyChangedFor(nameof(ViewableHostnameSuffix))] + [NotifyPropertyChangedFor(nameof(FullyQualifiedDomainName))] + public required partial string ConfiguredHostname { get; set; } + + // This should be set if we have an FQDN from the VPN service, and overrides ConfiguredHostname if set. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ViewableHostname))] + [NotifyPropertyChangedFor(nameof(ViewableHostnameSuffix))] + [NotifyPropertyChangedFor(nameof(FullyQualifiedDomainName))] + public required partial string ConfiguredFqdn { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ViewableHostname))] + [NotifyPropertyChangedFor(nameof(ViewableHostnameSuffix))] + [NotifyPropertyChangedFor(nameof(FullyQualifiedDomainName))] + public required partial string ConfiguredHostnameSuffix { get; set; } // including leading dot + + + public string FullyQualifiedDomainName + { + get + { + if (!string.IsNullOrEmpty(ConfiguredFqdn)) return ConfiguredFqdn; + return ConfiguredHostname + ConfiguredHostnameSuffix; + } + } + + /// + /// ViewableHostname is the hostname portion of the fully qualified domain name (FQDN) specifically for + /// views that render it differently than the suffix. If the ConfiguredHostnameSuffix doesn't actually + /// match the FQDN, then this will be the entire FQDN, and ViewableHostnameSuffix will be empty. + /// + public string ViewableHostname => !FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix) + ? FullyQualifiedDomainName + : FullyQualifiedDomainName[0..^ConfiguredHostnameSuffix.Length]; + + /// + /// ViewableHostnameSuffix is the domain suffix portion (including leading dot) of the fully qualified + /// domain name (FQDN) specifically for views that render it differently from the rest of the FQDN. If + /// the ConfiguredHostnameSuffix doesn't actually match the FQDN, then this will be empty and the + /// ViewableHostname will contain the entire FQDN. + /// + public string ViewableHostnameSuffix => FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix) + ? ConfiguredHostnameSuffix + : string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public required partial AgentConnectionStatus ConnectionStatus { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DashboardUrl))] + public required partial Uri DashboardBaseUrl { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DashboardUrl))] + public required partial string? WorkspaceName { get; set; } + + [ObservableProperty] public partial bool IsExpanded { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public partial bool FetchingApps { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public partial bool AppFetchErrored { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial bool? DidP2p { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial string? PreferredDerp { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial TimeSpan? Latency { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial TimeSpan? PreferredDerpLatency { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial DateTime? LastHandshake { get; set; } = null; + + public string ConnectionTooltip + { + get + { + var description = new StringBuilder(); + var highLatencyWarning = ConnectionStatus == AgentConnectionStatus.Unhealthy ? $"({AgentConnectionStatus.Unhealthy.ToDisplayString()})" : ""; + + if (DidP2p != null && DidP2p.Value && Latency != null) + { + description.Append($""" + You're connected peer-to-peer. {highLatencyWarning} + + You ↔ {Latency.Value.Milliseconds} ms ↔ {WorkspaceName} + """ + ); + } + else if (Latency != null) + { + description.Append($""" + You're connected through a DERP relay. {highLatencyWarning} + We'll switch over to peer-to-peer when available. + + Total latency: {Latency.Value.Milliseconds} ms + """ + ); + + if (PreferredDerpLatency != null) + { + description.Append($"\nYou ↔ {PreferredDerp}: {PreferredDerpLatency.Value.Milliseconds} ms"); + + var derpToWorkspaceEstimatedLatency = Latency - PreferredDerpLatency; + + // Guard against negative values if the two readings were taken at different times + if (derpToWorkspaceEstimatedLatency > TimeSpan.Zero) + { + description.Append($"\n{PreferredDerp} ms ↔ {WorkspaceName}: {derpToWorkspaceEstimatedLatency.Value.Milliseconds} ms"); + } + } + } + else + { + description.Append(ConnectionStatus.ToDisplayString()); + } + if (LastHandshake != null) + description.Append($"\n\nLast handshake: {LastHandshake?.ToString()}"); + + return description.ToString().TrimEnd('\n', ' '); ; + } + } + + + // We only show 6 apps max, which fills the entire width of the tray + // window. + public IEnumerable VisibleApps => Apps.Count > MaxAppsPerRow ? Apps.Take(MaxAppsPerRow) : Apps; + + public bool ShowExpandAppsMessage => ExpandAppsMessage != null; + + public string? ExpandAppsMessage + { + get + { + if (ConnectionStatus == AgentConnectionStatus.Offline) + return "Your workspace is offline."; + if (FetchingApps && Apps.Count == 0) + // Don't show this message if we have any apps already. When + // they finish loading, we'll just update the screen with any + // changes. + return "Fetching workspace apps..."; + if (AppFetchErrored && Apps.Count == 0) + // There's very limited screen real estate here so we don't + // show the actual error message. + return "Could not fetch workspace apps."; + if (Apps.Count == 0) + return "No apps to show."; + return null; + } + } + + public string DashboardUrl + { + get + { + if (string.IsNullOrWhiteSpace(WorkspaceName)) return DashboardBaseUrl.ToString(); + try + { + return new Uri(DashboardBaseUrl, $"/@me/{WorkspaceName}").ToString(); + } + catch + { + return DefaultDashboardUrl; + } + } + } + + public AgentViewModel(ILogger logger, ICoderApiClientFactory coderApiClientFactory, + ICredentialManager credentialManager, IAgentAppViewModelFactory agentAppViewModelFactory, + IDispatcher dispatcher, IClipboardService clipboardService, + IAgentExpanderHost expanderHost, Uuid id) + { + _logger = logger; + _coderApiClientFactory = coderApiClientFactory; + _credentialManager = credentialManager; + _agentAppViewModelFactory = agentAppViewModelFactory; + _dispatcher = dispatcher; + _clipboardService = clipboardService; + _expanderHost = expanderHost; + + Id = id; + + PropertyChanging += (x, args) => + { + if (args.PropertyName == nameof(IsExpanded)) + { + var value = !IsExpanded; + if (value) + _expanderHost.HandleAgentExpanded(Id, value); + } + }; + + PropertyChanged += (x, args) => + { + if (args.PropertyName == nameof(IsExpanded)) + { + // Every time the drawer is expanded, re-fetch all apps. + if (IsExpanded && !FetchingApps) + FetchApps(); + } + }; + + // Since the property value itself never changes, we add event + // listeners for the underlying collection changing instead. + Apps.CollectionChanged += (_, _) => + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleApps))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowExpandAppsMessage))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ExpandAppsMessage))); + }; + } + + public bool TryApplyChanges(AgentViewModel model) + { + if (Id != model.Id) return false; + + // To avoid spurious UI updates which cause flashing, don't actually + // write to values unless they've changed. + if (ConfiguredFqdn != model.ConfiguredFqdn) + ConfiguredFqdn = model.ConfiguredFqdn; + if (ConfiguredHostname != model.ConfiguredHostname) + ConfiguredHostname = model.ConfiguredHostname; + if (ConfiguredHostnameSuffix != model.ConfiguredHostnameSuffix) + ConfiguredHostnameSuffix = model.ConfiguredHostnameSuffix; + if (ConnectionStatus != model.ConnectionStatus) + ConnectionStatus = model.ConnectionStatus; + if (DashboardBaseUrl != model.DashboardBaseUrl) + DashboardBaseUrl = model.DashboardBaseUrl; + if (WorkspaceName != model.WorkspaceName) + WorkspaceName = model.WorkspaceName; + if (DidP2p != model.DidP2p) + DidP2p = model.DidP2p; + if (PreferredDerp != model.PreferredDerp) + PreferredDerp = model.PreferredDerp; + if (Latency != model.Latency) + Latency = model.Latency; + if (PreferredDerpLatency != model.PreferredDerpLatency) + PreferredDerpLatency = model.PreferredDerpLatency; + if (LastHandshake != model.LastHandshake) + LastHandshake = model.LastHandshake; + + // Apps are not set externally. + + return true; + } + + [RelayCommand] + private void ToggleExpanded() + { + SetExpanded(!IsExpanded); + } + + public void SetExpanded(bool expanded) + { + if (IsExpanded == expanded) return; + // This will bubble up to the TrayWindowViewModel because of the + // PropertyChanged handler. + IsExpanded = expanded; + } + + partial void OnConnectionStatusChanged(AgentConnectionStatus oldValue, AgentConnectionStatus newValue) + { + if (IsExpanded && newValue is not AgentConnectionStatus.Offline) FetchApps(); + } + + private void FetchApps() + { + if (FetchingApps) return; + FetchingApps = true; + + // If the workspace is off, then there's no agent and there's no apps. + if (ConnectionStatus == AgentConnectionStatus.Offline) + { + FetchingApps = false; + Apps.Clear(); + return; + } + + // API client creation could fail, which would leave FetchingApps true. + ICoderApiClient client; + try + { + client = _coderApiClientFactory.Create(_credentialManager); + } + catch + { + FetchingApps = false; + throw; + } + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + client.GetWorkspaceAgent(Id.ToString(), cts.Token).ContinueWith(t => + { + cts.Dispose(); + ContinueFetchApps(t); + }, CancellationToken.None); + } + + private void ContinueFetchApps(Task task) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => ContinueFetchApps(task)); + return; + } + + FetchingApps = false; + AppFetchErrored = !task.IsCompletedSuccessfully; + if (!task.IsCompletedSuccessfully) + { + _logger.LogWarning(task.Exception, "Could not fetch workspace agent"); + return; + } + + var workspaceAgent = task.Result; + var apps = new List(); + foreach (var app in workspaceAgent.Apps) + { + if (!app.External || !string.IsNullOrEmpty(app.Command)) continue; + + if (!Uri.TryCreate(app.Url, UriKind.Absolute, out var appUri)) + { + _logger.LogWarning("Could not parse app URI '{Url}' for '{DisplayName}', app will not appear in list", + app.Url, + app.DisplayName); + continue; + } + + // HTTP or HTTPS external apps are usually things like + // wikis/documentation, which clutters up the app. + if (appUri.Scheme is "http" or "https") + continue; + + // Icon parse failures are not fatal, we will just use the fallback + // icon. + _ = Uri.TryCreate(DashboardBaseUrl, app.Icon, out var iconUrl); + + apps.Add(_agentAppViewModelFactory.Create(app.Id, app.DisplayName, appUri, iconUrl)); + } + + foreach (var displayApp in workspaceAgent.DisplayApps) + { + if (displayApp is not WorkspaceAgent.DisplayAppVscode and not WorkspaceAgent.DisplayAppVscodeInsiders) + continue; + + var id = VscodeAppUuid; + var displayName = "VS Code"; + var icon = "/icon/code.svg"; + var scheme = "vscode"; + if (displayApp is WorkspaceAgent.DisplayAppVscodeInsiders) + { + id = VscodeInsidersAppUuid; + displayName = "VS Code Insiders"; + icon = "/icon/code-insiders.svg"; + scheme = "vscode-insiders"; + } + + Uri appUri; + try + { + appUri = new UriBuilder + { + Scheme = scheme, + Host = "vscode-remote", + Path = $"/ssh-remote+{FullyQualifiedDomainName}/{workspaceAgent.ExpandedDirectory}", + }.Uri; + } + catch (Exception e) + { + _logger.LogWarning(e, + "Could not craft app URI for display app {displayApp}, app will not appear in list", + displayApp); + continue; + } + + // Icon parse failures are not fatal, we will just use the fallback + // icon. + _ = Uri.TryCreate(DashboardBaseUrl, icon, out var iconUrl); + + apps.Add(_agentAppViewModelFactory.Create(id, displayName, appUri, iconUrl)); + } + + // Sort by name. + ModelUpdate.ApplyLists(Apps, apps, (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + } + + [RelayCommand] + private async Task CopyHostname() + { + await _clipboardService.SetTextAsync(FullyQualifiedDomainName); + + // TODO: Avalonia - surface non-intrusive "copied" feedback (e.g. toast). + } +} diff --git a/App.Shared/ViewModels/DirectoryPickerViewModel.cs b/App.Shared/ViewModels/DirectoryPickerViewModel.cs new file mode 100644 index 0000000..cc1080f --- /dev/null +++ b/App.Shared/ViewModels/DirectoryPickerViewModel.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Services; +using Coder.Desktop.CoderSdk.Agent; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Coder.Desktop.App.ViewModels; + +public class DirectoryPickerBreadcrumb +{ + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + + public required string Name { get; init; } + + public required IReadOnlyList AbsolutePathSegments { get; init; } + + // HACK: we need to know which one is first so we don't prepend an arrow + // icon. You can't get the index of the current ItemsRepeater item in XAML. + public required bool IsFirst { get; init; } +} + +public enum DirectoryPickerItemKind +{ + ParentDirectory, // aka. ".." + Directory, + File, // includes everything else +} + +public class DirectoryPickerItem +{ + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + + public required DirectoryPickerItemKind Kind { get; init; } + public required string Name { get; init; } + public required IReadOnlyList AbsolutePathSegments { get; init; } + + public bool Selectable => Kind is DirectoryPickerItemKind.ParentDirectory or DirectoryPickerItemKind.Directory; +} + +public partial class DirectoryPickerViewModel : ObservableObject +{ + // PathSelected will be called ONCE when the user either cancels or selects + // a directory. If the user cancelled, the path will be null. + public event EventHandler? PathSelected; + + /// + /// Raised when the view should close itself. + /// + public event EventHandler? CloseRequested; + + private const int RequestTimeoutMilliseconds = 15_000; + + private readonly IAgentApiClient _client; + private readonly IDispatcher _dispatcher; + private readonly IWindowService _windowService; + + public readonly string AgentFqdn; + + // The initial loading screen is differentiated from subsequent loading + // screens because: + // 1. We don't want to show a broken state while the page is loading. + // 2. An error dialog allows the user to get to a broken state with no + // breadcrumbs, no items, etc. with no chance to reload. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial bool InitialLoading { get; set; } = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowErrorScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial string? InitialLoadError { get; set; } = null; + + [ObservableProperty] + public partial bool NavigatingLoading { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSelectable))] + public partial string CurrentDirectory { get; set; } = ""; + + [ObservableProperty] + public partial IReadOnlyList Breadcrumbs { get; set; } = []; + + [ObservableProperty] + public partial IReadOnlyList Items { get; set; } = []; + + public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading; + public bool ShowErrorScreen => InitialLoadError != null; + public bool ShowListScreen => InitialLoadError == null && !InitialLoading; + + // The "root" directory on Windows isn't a real thing, but in our model + // it's a drive listing. We don't allow users to select the fake drive + // listing directory. + // + // On Linux, this will never be empty since the highest you can go is "/". + public bool IsSelectable => CurrentDirectory != ""; + + public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, IDispatcher dispatcher, + IWindowService windowService, string agentFqdn) + { + _client = clientFactory.Create(agentFqdn); + _dispatcher = dispatcher; + _windowService = windowService; + AgentFqdn = agentFqdn; + } + + public void Initialize() + { + if (!_dispatcher.CheckAccess()) + throw new InvalidOperationException("Initialize must be called from the UI thread"); + + InitialLoading = true; + InitialLoadError = null; + + // Initial load is in the home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Home, []).ContinueWith(ContinueInitialLoad); + } + + [RelayCommand] + private void RetryLoad() + { + InitialLoading = true; + InitialLoadError = null; + + // Subsequent loads after the initial failure are always in the root + // directory in case there's a permanent issue preventing listing the + // home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Root, []).ContinueWith(ContinueInitialLoad); + } + + private async Task BackgroundLoad(ListDirectoryRelativity relativity, List path) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + return await _client.ListDirectory(new ListDirectoryRequest + { + Path = path, + Relativity = relativity, + }, cts.Token); + } + + private void ContinueInitialLoad(Task task) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => ContinueInitialLoad(task)); + return; + } + + if (task.IsCompletedSuccessfully) + { + ProcessResponse(task.Result); + return; + } + + InitialLoadError = "Could not list home directory in workspace: "; + if (task.IsCanceled) + InitialLoadError += new TaskCanceledException(); + else if (task.IsFaulted) + InitialLoadError += task.Exception; + else + InitialLoadError += "no successful result or error"; + + InitialLoading = false; + } + + [RelayCommand] + public async Task ListPath(IReadOnlyList path) + { + if (NavigatingLoading) + return; + + NavigatingLoading = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(RequestTimeoutMilliseconds)); + try + { + var res = await _client.ListDirectory(new ListDirectoryRequest + { + Path = path.ToList(), + Relativity = ListDirectoryRelativity.Root, + }, cts.Token); + + ProcessResponse(res); + } + catch (Exception e) + { + // Subsequent listing errors are just shown as dialog boxes. + _windowService.ShowMessageWindow("Failed to list remote directory", e.ToString(), "Coder Connect"); + } + finally + { + NavigatingLoading = false; + } + } + + [RelayCommand] + public void Cancel() + { + PathSelected?.Invoke(this, null); + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + [RelayCommand] + public void Select() + { + if (CurrentDirectory == "") + return; + + PathSelected?.Invoke(this, CurrentDirectory); + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + private void ProcessResponse(ListDirectoryResponse res) + { + InitialLoading = false; + InitialLoadError = null; + NavigatingLoading = false; + + var breadcrumbs = new List(res.AbsolutePath.Count + 1) + { + new() + { + Name = "🖥️", + AbsolutePathSegments = [], + IsFirst = true, + ViewModel = this, + }, + }; + + for (var i = 0; i < res.AbsolutePath.Count; i++) + breadcrumbs.Add(new DirectoryPickerBreadcrumb + { + Name = res.AbsolutePath[i], + AbsolutePathSegments = res.AbsolutePath[..(i + 1)], + IsFirst = false, + ViewModel = this, + }); + + var items = new List(res.Contents.Count + 1); + if (res.AbsolutePath.Count != 0) + items.Add(new DirectoryPickerItem + { + Kind = DirectoryPickerItemKind.ParentDirectory, + Name = "..", + AbsolutePathSegments = res.AbsolutePath[..^1], + ViewModel = this, + }); + + foreach (var item in res.Contents) + { + if (item.Name.StartsWith(".")) + continue; + + items.Add(new DirectoryPickerItem + { + Kind = item.IsDir ? DirectoryPickerItemKind.Directory : DirectoryPickerItemKind.File, + Name = item.Name, + AbsolutePathSegments = res.AbsolutePath.Append(item.Name).ToList(), + ViewModel = this, + }); + } + + CurrentDirectory = res.AbsolutePathString; + Breadcrumbs = breadcrumbs; + Items = items; + } +} diff --git a/App.Shared/ViewModels/FileSyncListViewModel.cs b/App.Shared/ViewModels/FileSyncListViewModel.cs new file mode 100644 index 0000000..6fd36cb --- /dev/null +++ b/App.Shared/ViewModels/FileSyncListViewModel.cs @@ -0,0 +1,540 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.CoderSdk.Agent; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Coder.Desktop.App.ViewModels; + +public partial class FileSyncListViewModel : ObservableObject +{ + private readonly ISyncSessionController _syncSessionController; + private readonly IRpcController _rpcController; + private readonly ICredentialManager _credentialManager; + private readonly IAgentApiClientFactory _agentApiClientFactory; + private readonly IDispatcher _dispatcher; + private readonly IWindowService _windowService; + + /// + /// View model for the remote directory picker dialog (if open). + /// + /// Replaces the WinUI DirectoryPickerWindow. + /// + [ObservableProperty] + public partial DirectoryPickerViewModel? RemotePathPickerViewModel { get; set; } = null; + + /// + /// UI hook for opening a local folder picker. + /// + /// The WinUI implementation used Windows.Storage.Pickers.FolderPicker. + /// + public Func>? LocalFolderPicker { get; set; } + + /// + /// Optional UI hook for confirming termination of a sync session. + /// + /// If not set, termination will proceed without confirmation. + /// + public Func>? ConfirmTerminateSessionAsync { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUnavailable))] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowError))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial string? UnavailableMessage { get; set; } = null; + + // Initially we use the current cached state, the loading screen is only + // shown when the user clicks "Reload" on the error screen. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial bool Loading { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowError))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial string? Error { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanOpenLocalPath))] + [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial bool OperationInProgress { get; set; } = false; + + [ObservableProperty] public partial IReadOnlyList Sessions { get; set; } = []; + + [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionLocalPath { get; set; } = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(CanOpenLocalPath))] + public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] + public partial IReadOnlyList AvailableHosts { get; set; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial string? NewSessionRemoteHost { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionRemotePath { get; set; } = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial bool NewSessionRemotePathDialogOpen { get; set; } = false; + + public bool CanOpenLocalPath => !NewSessionLocalPathDialogOpen && !OperationInProgress; + + public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0 && !OperationInProgress; + + public bool NewSessionRemotePathDialogEnabled => + !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen && !OperationInProgress; + + [ObservableProperty] public partial string NewSessionStatus { get; set; } = ""; + + public bool NewSessionCreateEnabled + { + get + { + if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false; + if (NewSessionLocalPathDialogOpen) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; + if (NewSessionRemotePathDialogOpen) return false; + return true; + } + } + + // TODO: this could definitely be improved + public bool ShowUnavailable => UnavailableMessage != null; + public bool ShowLoading => Loading && UnavailableMessage == null && Error == null; + public bool ShowError => UnavailableMessage == null && Error != null; + public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null; + + public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController, + ICredentialManager credentialManager, IAgentApiClientFactory agentApiClientFactory, + IDispatcher dispatcher, IWindowService windowService) + { + _syncSessionController = syncSessionController; + _rpcController = rpcController; + _credentialManager = credentialManager; + _agentApiClientFactory = agentApiClientFactory; + _dispatcher = dispatcher; + _windowService = windowService; + + _rpcController.StateChanged += RpcControllerStateChanged; + _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; + _syncSessionController.StateChanged += SyncSessionStateChanged; + + var rpcModel = _rpcController.GetState(); + var credentialModel = _credentialManager.GetCachedCredentials(); + var syncSessionState = _syncSessionController.GetState(); + + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => + { + UpdateSyncSessionState(syncSessionState); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); + }); + return; + } + + UpdateSyncSessionState(syncSessionState); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); + } + + public void Dispose() + { + CloseRemotePathPicker(); + + _rpcController.StateChanged -= RpcControllerStateChanged; + _credentialManager.CredentialsChanged -= CredentialManagerCredentialsChanged; + _syncSessionController.StateChanged -= SyncSessionStateChanged; + } + + private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => RpcControllerStateChanged(sender, rpcModel)); + return; + } + + var credentialModel = _credentialManager.GetCachedCredentials(); + var syncSessionState = _syncSessionController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); + } + + private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => CredentialManagerCredentialsChanged(sender, credentialModel)); + return; + } + + var rpcModel = _rpcController.GetState(); + var syncSessionState = _syncSessionController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel, syncSessionState); + } + + private void SyncSessionStateChanged(object? sender, SyncSessionControllerStateModel syncSessionState) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => SyncSessionStateChanged(sender, syncSessionState)); + return; + } + + UpdateSyncSessionState(syncSessionState); + } + + private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel, SyncSessionControllerStateModel syncSessionState) + { + var oldMessage = UnavailableMessage; + if (rpcModel.RpcLifecycle != RpcLifecycle.Connected) + { + UnavailableMessage = + "Disconnected from the Windows service. Please see the tray window for more information."; + } + else if (credentialModel.State != CredentialState.Valid) + { + UnavailableMessage = "Please sign in to access file sync."; + } + else if (rpcModel.VpnLifecycle != VpnLifecycle.Started) + { + UnavailableMessage = "Please start Coder Connect from the tray window to access file sync."; + } + else if (syncSessionState.Lifecycle == SyncSessionControllerLifecycle.Uninitialized) + { + UnavailableMessage = "Sync session controller is not initialized. Please wait..."; + } + else + { + UnavailableMessage = null; + // Reload if we transitioned from unavailable to available. + if (oldMessage != null) ReloadSessions(); + } + + // When transitioning from available to unavailable: + if (oldMessage == null && UnavailableMessage != null) + ClearNewForm(); + } + + private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState) + { + // This should never happen. + if (syncSessionState == null) + return; + if (syncSessionState.Lifecycle == SyncSessionControllerLifecycle.Uninitialized) + { + MaybeSetUnavailableMessage(_rpcController.GetState(), _credentialManager.GetCachedCredentials(), syncSessionState); + } + Error = syncSessionState.DaemonError; + Sessions = syncSessionState.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList(); + } + + private void ClearNewForm() + { + CreatingNewSession = false; + NewSessionLocalPath = ""; + NewSessionRemoteHost = ""; + NewSessionRemotePath = ""; + NewSessionStatus = ""; + CloseRemotePathPicker(); + } + + [RelayCommand] + private void ReloadSessions() + { + Loading = true; + Error = null; + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + _syncSessionController.RefreshState(cts.Token).ContinueWith(HandleRefresh, CancellationToken.None); + } + + private void HandleRefresh(Task t) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => HandleRefresh(t)); + return; + } + + if (t.IsCompletedSuccessfully) + { + Sessions = t.Result.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList(); + Loading = false; + Error = t.Result.DaemonError; + return; + } + + Error = "Could not list sync sessions: "; + if (t.IsCanceled) Error += new TaskCanceledException(); + else if (t.IsFaulted) Error += t.Exception; + else Error += "no successful result or error"; + Loading = false; + } + + // Overriding AvailableHosts seems to make the ComboBox clear its value, so + // we only do this while the create form is not open. + // Must be called in UI thread. + private void SetAvailableHostsFromRpcModel(RpcModel rpcModel) + { + var hosts = new List(rpcModel.Agents.Count); + // Agents will only contain started agents. + foreach (var agent in rpcModel.Agents) + { + var fqdn = agent.Fqdn + .Select(a => a.Trim('.')) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b); + if (string.IsNullOrWhiteSpace(fqdn)) + continue; + hosts.Add(fqdn); + } + + NewSessionRemoteHost = null; + AvailableHosts = hosts; + } + + [RelayCommand] + private void StartCreatingNewSession() + { + ClearNewForm(); + // Ensure we have a fresh hosts list before we open the form. We don't + // bind directly to the list on RPC state updates as updating the list + // while in use seems to break it. + SetAvailableHostsFromRpcModel(_rpcController.GetState()); + CreatingNewSession = true; + } + + [RelayCommand] + public async Task OpenLocalPathSelectDialog() + { + if (!CanOpenLocalPath) + return; + + if (LocalFolderPicker is null) + { + _windowService.ShowMessageWindow( + "Local folder picker unavailable", + "The UI did not provide a local folder picker implementation.", + "Coder Connect"); + return; + } + + NewSessionLocalPathDialogOpen = true; + try + { + var path = await LocalFolderPicker(); + if (string.IsNullOrWhiteSpace(path)) + return; + + NewSessionLocalPath = path; + } + catch + { + // ignored + } + finally + { + NewSessionLocalPathDialogOpen = false; + } + } + + [RelayCommand] + public void OpenRemotePathSelectDialog() + { + if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) + return; + + // The UI is responsible for rendering the picker when this ViewModel is set. + if (RemotePathPickerViewModel is not null) + return; + + NewSessionRemotePathDialogOpen = true; + + var pickerViewModel = new DirectoryPickerViewModel(_agentApiClientFactory, _dispatcher, _windowService, NewSessionRemoteHost); + pickerViewModel.PathSelected += OnRemotePathSelected; + pickerViewModel.CloseRequested += OnRemotePathPickerCloseRequested; + + RemotePathPickerViewModel = pickerViewModel; + pickerViewModel.Initialize(); + } + + private void OnRemotePathSelected(object? sender, string? path) + { + if (path != null) + NewSessionRemotePath = path; + + CloseRemotePathPicker(sender as DirectoryPickerViewModel); + } + + private void OnRemotePathPickerCloseRequested(object? sender, EventArgs e) + { + CloseRemotePathPicker(sender as DirectoryPickerViewModel); + } + + private void CloseRemotePathPicker(DirectoryPickerViewModel? pickerViewModel = null) + { + pickerViewModel ??= RemotePathPickerViewModel; + if (pickerViewModel is not null) + { + pickerViewModel.PathSelected -= OnRemotePathSelected; + pickerViewModel.CloseRequested -= OnRemotePathPickerCloseRequested; + } + + RemotePathPickerViewModel = null; + NewSessionRemotePathDialogOpen = false; + } + + [RelayCommand] + private void CancelNewSession() + { + ClearNewForm(); + } + + private void OnCreateSessionProgress(string message) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => OnCreateSessionProgress(message)); + return; + } + + NewSessionStatus = message; + } + + [RelayCommand] + private async Task ConfirmNewSession() + { + if (OperationInProgress || !NewSessionCreateEnabled) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + try + { + // The controller will send us a state changed event. + await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = NewSessionLocalPath, + }, + Beta = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh, + Host = NewSessionRemoteHost!, + Path = NewSessionRemotePath, + }, + }, OnCreateSessionProgress, cts.Token); + + ClearNewForm(); + } + catch (Exception e) + { + _windowService.ShowMessageWindow("Failed to create sync session", e.ToString(), "Coder Connect"); + } + finally + { + OperationInProgress = false; + NewSessionStatus = ""; + } + } + + public async Task PauseOrResumeSession(string identifier) + { + if (OperationInProgress) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var actionString = "resume/pause"; + try + { + if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session) + throw new InvalidOperationException("Session not found"); + + // The controller will send us a state changed event. + if (session.Model.Paused) + { + actionString = "resume"; + await _syncSessionController.ResumeSyncSession(session.Model.Identifier, cts.Token); + } + else + { + actionString = "pause"; + await _syncSessionController.PauseSyncSession(session.Model.Identifier, cts.Token); + } + } + catch (Exception e) + { + _windowService.ShowMessageWindow( + $"Failed to {actionString} sync session", + $"Identifier: {identifier}\n{e}", + "Coder Connect"); + } + finally + { + OperationInProgress = false; + } + } + + public async Task TerminateSession(string identifier) + { + if (OperationInProgress) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + try + { + if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session) + throw new InvalidOperationException("Session not found"); + + var shouldTerminate = true; + if (ConfirmTerminateSessionAsync is not null) + shouldTerminate = await ConfirmTerminateSessionAsync(identifier); + + // TODO: Avalonia - add confirmation dialog in UI if needed. + if (!shouldTerminate) + return; + + // The controller will send us a state changed event. + await _syncSessionController.TerminateSyncSession(session.Model.Identifier, cts.Token); + } + catch (Exception e) + { + _windowService.ShowMessageWindow( + "Failed to terminate sync session", + $"Identifier: {identifier}\n{e}", + "Coder Connect"); + } + finally + { + OperationInProgress = false; + } + } +} diff --git a/App.Shared/ViewModels/SignInViewModel.cs b/App.Shared/ViewModels/SignInViewModel.cs new file mode 100644 index 0000000..4e4805c --- /dev/null +++ b/App.Shared/ViewModels/SignInViewModel.cs @@ -0,0 +1,194 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Coder.Desktop.App.ViewModels; + +public enum SignInStage +{ + Url, + Token, +} + +/// +/// The View Model backing the sign in window and all its associated pages. +/// +public partial class SignInViewModel : ObservableObject +{ + private readonly ICredentialManager _credentialManager; + private readonly IDispatcher _dispatcher; + private readonly IWindowService _windowService; + + /// + /// Raised when the view should close itself (e.g. after successful sign-in). + /// + public event EventHandler? CloseRequested; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CoderUrlError))] + [NotifyPropertyChangedFor(nameof(GenTokenUrl))] + public partial string CoderUrl { get; set; } = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CoderUrlError))] + public partial bool CoderUrlTouched { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ApiTokenError))] + public partial string ApiToken { get; set; } = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ApiTokenError))] + public partial bool ApiTokenTouched { get; set; } = false; + + [ObservableProperty] + public partial bool SignInLoading { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsUrlStage))] + [NotifyPropertyChangedFor(nameof(IsTokenStage))] + public partial SignInStage Stage { get; set; } = SignInStage.Url; + + public bool IsUrlStage => Stage == SignInStage.Url; + public bool IsTokenStage => Stage == SignInStage.Token; + + public string? CoderUrlError => CoderUrlTouched ? _coderUrlError : null; + + private string? _coderUrlError + { + get + { + if (!Uri.TryCreate(CoderUrl, UriKind.Absolute, out var uri)) + return "Invalid URL"; + if (uri.Scheme is not "http" and not "https") + return "Must be a HTTP or HTTPS URL"; + if (uri.PathAndQuery != "/") + return "Must be a root URL with no path or query"; + return null; + } + } + + public string? ApiTokenError => ApiTokenTouched ? _apiTokenError : null; + + private string? _apiTokenError => string.IsNullOrWhiteSpace(ApiToken) ? "Invalid token" : null; + + public Uri GenTokenUrl + { + get + { + // In case somehow the URL is invalid, just default to coder.com. + try + { + var baseUri = new Uri(CoderUrl.Trim()); + return new Uri(baseUri, "/cli-auth"); + } + catch + { + return new Uri("https://coder.com"); + } + } + } + + public SignInViewModel(ICredentialManager credentialManager, IDispatcher dispatcher, IWindowService windowService) + { + _credentialManager = credentialManager; + _dispatcher = dispatcher; + _windowService = windowService; + + // Load the previously used Coder URL in the background. + _ = LoadExistingCoderUrl(); + } + + public void CoderUrl_Loaded(object? sender, EventArgs e) + { + _ = LoadExistingCoderUrl(); + } + + private async Task LoadExistingCoderUrl() + { + try + { + var url = await _credentialManager.GetSignInUri(); + if (string.IsNullOrWhiteSpace(url)) + return; + + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => ApplyLoadedCoderUrl(url)); + return; + } + + ApplyLoadedCoderUrl(url); + } + catch + { + // ignored + } + } + + private void ApplyLoadedCoderUrl(string url) + { + if (CoderUrlTouched) + return; + + CoderUrl = url; + CoderUrlTouched = true; + } + + public void CoderUrl_FocusLost(object? sender, EventArgs e) + { + CoderUrlTouched = true; + } + + public void ApiToken_FocusLost(object? sender, EventArgs e) + { + ApiTokenTouched = true; + } + + [RelayCommand] + public void UrlPage_Next() + { + CoderUrlTouched = true; + if (_coderUrlError != null) + return; + + Stage = SignInStage.Token; + } + + [RelayCommand] + public void TokenPage_Back() + { + ApiToken = ""; + Stage = SignInStage.Url; + } + + [RelayCommand] + public async Task TokenPage_SignIn() + { + CoderUrlTouched = true; + ApiTokenTouched = true; + if (_coderUrlError != null || _apiTokenError != null) + return; + + try + { + SignInLoading = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await _credentialManager.SetCredentials(CoderUrl.Trim(), ApiToken.Trim(), cts.Token); + + CloseRequested?.Invoke(this, EventArgs.Empty); + } + catch (Exception e) + { + _windowService.ShowMessageWindow("Failed to sign in", e.ToString(), "Coder Connect"); + } + finally + { + SignInLoading = false; + } + } +} diff --git a/App.Shared/ViewModels/SyncSessionViewModel.cs b/App.Shared/ViewModels/SyncSessionViewModel.cs new file mode 100644 index 0000000..2c11006 --- /dev/null +++ b/App.Shared/ViewModels/SyncSessionViewModel.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Coder.Desktop.App.ViewModels; + +public partial class SyncSessionViewModel : ObservableObject +{ + public SyncSessionModel Model { get; } + + private FileSyncListViewModel Parent { get; } + + public string Icon => Model.Paused ? "\uE768" : "\uE769"; + + // Tooltip text for views to bind to (replaces WinUI ToolTipService hacks). + public string StatusToolTip => Model.StatusDetails; + public string SizeToolTip => Model.SizeDetails; + + public SyncSessionViewModel(FileSyncListViewModel parent, SyncSessionModel model) + { + Parent = parent; + Model = model; + } + + [RelayCommand] + public async Task PauseOrResumeSession() + { + await Parent.PauseOrResumeSession(Model.Identifier); + } + + [RelayCommand] + public async Task TerminateSession() + { + await Parent.TerminateSession(Model.Identifier); + } +} diff --git a/App.Shared/ViewModels/TrayWindowLoginRequiredViewModel.cs b/App.Shared/ViewModels/TrayWindowLoginRequiredViewModel.cs new file mode 100644 index 0000000..8a72e32 --- /dev/null +++ b/App.Shared/ViewModels/TrayWindowLoginRequiredViewModel.cs @@ -0,0 +1,29 @@ +using Coder.Desktop.App.Services; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Hosting; + +namespace Coder.Desktop.App.ViewModels; + +public partial class TrayWindowLoginRequiredViewModel +{ + private readonly IWindowService _windowService; + private readonly IHostApplicationLifetime _applicationLifetime; + + public TrayWindowLoginRequiredViewModel(IWindowService windowService, IHostApplicationLifetime applicationLifetime) + { + _windowService = windowService; + _applicationLifetime = applicationLifetime; + } + + [RelayCommand] + public void Login() + { + _windowService.ShowSignInWindow(); + } + + [RelayCommand] + public void Exit() + { + _applicationLifetime.StopApplication(); + } +} diff --git a/App.Shared/ViewModels/TrayWindowViewModel.cs b/App.Shared/ViewModels/TrayWindowViewModel.cs new file mode 100644 index 0000000..1a2fa56 --- /dev/null +++ b/App.Shared/ViewModels/TrayWindowViewModel.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.Vpn.Proto; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Google.Protobuf; +using Microsoft.Extensions.Hosting; + +namespace Coder.Desktop.App.ViewModels; + +public interface IAgentExpanderHost +{ + public void HandleAgentExpanded(Uuid id, bool expanded); +} + +public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost +{ + private const int MaxAgents = 5; + private const string DefaultDashboardUrl = "https://coder.com"; + private readonly TimeSpan HealthyPingThreshold = TimeSpan.FromMilliseconds(150); + + private readonly IRpcController _rpcController; + private readonly ICredentialManager _credentialManager; + private readonly IAgentViewModelFactory _agentViewModelFactory; + private readonly IHostnameSuffixGetter _hostnameSuffixGetter; + private readonly IDispatcher _dispatcher; + private readonly IWindowService _windowService; + private readonly IHostApplicationLifetime _applicationLifetime; + + private bool _settingVpnSwitchState; + + // When we transition from 0 online workspaces to >0 online workspaces, the + // first agent will be expanded. This bool tracks whether this has occurred + // yet (or if the user has expanded something themselves). + private bool _hasExpandedAgent; + + // This isn't an ObservableProperty because the property itself never + // changes. We add an event listener for the collection changing in the + // constructor. + public readonly ObservableCollection Agents = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowEnableSection))] + [NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))] + [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] + [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] + [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] + public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; + + // This is a separate property because we need the switch to be 2-way. + [ObservableProperty] public partial bool VpnSwitchActive { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowEnableSection))] + [NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))] + [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] + [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] + [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] + [NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))] + [NotifyPropertyChangedFor(nameof(ShowFailedSection))] + public partial string? VpnFailedMessage { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VpnStartProgressIsIndeterminate))] + [NotifyPropertyChangedFor(nameof(VpnStartProgressValueOrDefault))] + public partial int? VpnStartProgressValue { get; set; } = null; + + public int VpnStartProgressValueOrDefault => VpnStartProgressValue ?? 0; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VpnStartProgressMessageOrDefault))] + public partial string? VpnStartProgressMessage { get; set; } = null; + + public string VpnStartProgressMessageOrDefault => + string.IsNullOrEmpty(VpnStartProgressMessage) ? VpnStartupProgress.DefaultStartProgressMessage : VpnStartProgressMessage; + + public bool VpnStartProgressIsIndeterminate => VpnStartProgressValueOrDefault == 0; + + public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Starting and not VpnLifecycle.Started; + + public bool ShowVpnStartProgressSection => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Starting; + + public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started; + + public bool ShowNoAgentsSection => + VpnFailedMessage is null && Agents.Count == 0 && VpnLifecycle is VpnLifecycle.Started; + + public bool ShowAgentsSection => + VpnFailedMessage is null && Agents.Count > 0 && VpnLifecycle is VpnLifecycle.Started; + + public bool ShowFailedSection => VpnFailedMessage is not null; + + public bool ShowAgentOverflowButton => VpnFailedMessage is null && Agents.Count > MaxAgents; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VisibleAgents))] + public partial bool ShowAllAgents { get; set; } = false; + + public IEnumerable VisibleAgents => ShowAllAgents ? Agents : Agents.Take(MaxAgents); + + [ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl; + + public TrayWindowViewModel( + IRpcController rpcController, + ICredentialManager credentialManager, + IAgentViewModelFactory agentViewModelFactory, + IHostnameSuffixGetter hostnameSuffixGetter, + IDispatcher dispatcher, + IWindowService windowService, + IHostApplicationLifetime applicationLifetime) + { + _rpcController = rpcController; + _credentialManager = credentialManager; + _agentViewModelFactory = agentViewModelFactory; + _hostnameSuffixGetter = hostnameSuffixGetter; + _dispatcher = dispatcher; + _windowService = windowService; + _applicationLifetime = applicationLifetime; + + // Since the property value itself never changes, we add event + // listeners for the underlying collection changing instead. + Agents.CollectionChanged += (_, _) => + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleAgents))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowNoAgentsSection))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentsSection))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentOverflowButton))); + }; + + _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel); + _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialModel(credentialModel); + _hostnameSuffixGetter.SuffixChanged += (_, suffix) => HandleHostnameSuffixChanged(suffix); + + UpdateFromRpcModel(_rpcController.GetState()); + UpdateFromCredentialModel(_credentialManager.GetCachedCredentials()); + HandleHostnameSuffixChanged(_hostnameSuffixGetter.GetCachedSuffix()); + } + + // Implements IAgentExpanderHost + public void HandleAgentExpanded(Uuid id, bool expanded) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => HandleAgentExpanded(id, expanded)); + return; + } + + if (!expanded) return; + _hasExpandedAgent = true; + // Collapse every other agent. + foreach (var otherAgent in Agents.Where(a => a.Id != id && a.IsExpanded == true)) + otherAgent.SetExpanded(false); + } + + private void UpdateFromRpcModel(RpcModel rpcModel) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => UpdateFromRpcModel(rpcModel)); + return; + } + + // As a failsafe, if RPC is disconnected (or we're not signed in) we + // disable the switch. The Window should not show the current Page if + // the RPC is disconnected. + var credentialModel = _credentialManager.GetCachedCredentials(); + if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected || credentialModel.State is not CredentialState.Valid || + credentialModel.CoderUrl == null) + { + VpnLifecycle = VpnLifecycle.Unknown; + SetVpnSwitchFromLifecycle(); + Agents.Clear(); + return; + } + + VpnLifecycle = rpcModel.VpnLifecycle; + SetVpnSwitchFromLifecycle(); + + // VpnStartupProgress is only set when the VPN is starting. + if (rpcModel.VpnLifecycle is VpnLifecycle.Starting && rpcModel.VpnStartupProgress != null) + { + // Convert 0.00-1.00 to 0-100. + var progress = (int)(rpcModel.VpnStartupProgress.Progress * 100); + VpnStartProgressValue = Math.Clamp(progress, 0, 100); + VpnStartProgressMessage = rpcModel.VpnStartupProgress.ToString(); + } + else + { + VpnStartProgressValue = null; + VpnStartProgressMessage = null; + } + + // Add every known agent. + HashSet workspacesWithAgents = []; + List agents = []; + foreach (var agent in rpcModel.Agents) + { + if (!Uuid.TryFrom(agent.Id.Span, out var uuid)) + continue; + + // Find the FQDN with the least amount of dots and split it into + // prefix and suffix. + var fqdn = agent.Fqdn + .Select(a => a.Trim('.')) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b); + if (string.IsNullOrWhiteSpace(fqdn)) + continue; + + var connectionStatus = AgentConnectionStatus.Healthy; + + if (agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default && agent.LastHandshake.ToDateTime() < DateTime.UtcNow) + { + // For compatibility with older deployments, we assume that if the + // last ping is null, the agent is healthy. + var isLatencyAcceptable = agent.LastPing == null || agent.LastPing.Latency.ToTimeSpan() < HealthyPingThreshold; + + var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); + + if (lastHandshakeAgo > TimeSpan.FromMinutes(5)) + connectionStatus = AgentConnectionStatus.NoRecentHandshake; + else if (!isLatencyAcceptable) + connectionStatus = AgentConnectionStatus.Unhealthy; + } + else + { + // If the last handshake is not correct (null, default or in the future), + // we assume the agent is connecting (yellow status icon). + connectionStatus = AgentConnectionStatus.Connecting; + } + + workspacesWithAgents.Add(agent.WorkspaceId); + var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId); + + agents.Add(_agentViewModelFactory.Create( + this, + uuid, + fqdn, + _hostnameSuffixGetter.GetCachedSuffix(), + connectionStatus, + credentialModel.CoderUrl, + workspace?.Name, + agent.LastPing?.DidP2P, + agent.LastPing?.PreferredDerp, + agent.LastPing?.Latency?.ToTimeSpan(), + agent.LastPing?.PreferredDerpLatency?.ToTimeSpan(), + agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default ? agent.LastHandshake?.ToDateTime() : null)); + } + + // For every stopped workspace that doesn't have any agents, add a + // dummy agent row. + foreach (var workspace in rpcModel.Workspaces.Where(w => + ShouldShowDummy(w) && !workspacesWithAgents.Contains(w.Id))) + { + if (!Uuid.TryFrom(workspace.Id.Span, out var uuid)) + continue; + + agents.Add(_agentViewModelFactory.CreateDummy( + this, + // Workspace ID is fine as a stand-in here, it shouldn't + // conflict with any agent IDs. + uuid, + _hostnameSuffixGetter.GetCachedSuffix(), + AgentConnectionStatus.Offline, + credentialModel.CoderUrl, + workspace.Name)); + } + + // Sort by status green, red, gray, then by hostname. + ModelUpdate.ApplyLists(Agents, agents, (a, b) => + { + if (a.ConnectionStatus != b.ConnectionStatus) + return a.ConnectionStatus.CompareTo(b.ConnectionStatus); + return string.Compare(a.FullyQualifiedDomainName, b.FullyQualifiedDomainName, StringComparison.Ordinal); + }); + + if (Agents.Count < MaxAgents) ShowAllAgents = false; + + var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Offline); + if (firstOnlineAgent is null) + _hasExpandedAgent = false; + if (!_hasExpandedAgent && firstOnlineAgent is not null) + { + firstOnlineAgent.SetExpanded(true); + _hasExpandedAgent = true; + } + } + + private void UpdateFromCredentialModel(CredentialModel credentialModel) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => UpdateFromCredentialModel(credentialModel)); + return; + } + + // CredentialModel updates trigger RpcStateModel updates first. This + // resolves an issue on startup where the window would be locked for 5 + // seconds, even if all startup preconditions have been met: + // + // 1. RPC state updates, but credentials are invalid so the window + // enters the invalid loading state to prevent interaction. + // 2. Credential model finally becomes valid after reaching out to the + // server to check credentials. + // 3. UpdateFromCredentialModel previously did not re-trigger RpcModel + // update. + // 4. Five seconds after step 1, a new RPC state update would come in + // and finally unlock the window. + // + // Calling UpdateFromRpcModel at step 3 resolves this issue. + UpdateFromRpcModel(_rpcController.GetState()); + + // HACK: the HyperlinkButton crashes the whole app if the initial URI + // or this URI is invalid. CredentialModel.CoderUrl should never be + // null while the Page is active as the Page is only displayed when + // CredentialModel.Status == Valid. + DashboardUrl = credentialModel.CoderUrl?.ToString() ?? DefaultDashboardUrl; + } + + private void HandleHostnameSuffixChanged(string suffix) + { + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => HandleHostnameSuffixChanged(suffix)); + return; + } + + foreach (var agent in Agents) + { + agent.ConfiguredHostnameSuffix = suffix; + } + } + + partial void OnVpnSwitchActiveChanged(bool oldValue, bool newValue) + { + if (_settingVpnSwitchState) + return; + + // Ensure we're on the UI thread. + if (!_dispatcher.CheckAccess()) + { + _dispatcher.Post(() => OnVpnSwitchActiveChanged(oldValue, newValue)); + return; + } + + VpnFailedMessage = null; + + // The start/stop methods will call back to update the state. + if (newValue && VpnLifecycle is VpnLifecycle.Stopped) + _ = StartVpn(); // in the background + else if (!newValue && VpnLifecycle is VpnLifecycle.Started) + _ = StopVpn(); // in the background + else + SetVpnSwitchFromLifecycle(); + } + + private void SetVpnSwitchFromLifecycle() + { + _settingVpnSwitchState = true; + try + { + VpnSwitchActive = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started; + } + finally + { + _settingVpnSwitchState = false; + } + } + + private async Task StartVpn() + { + try + { + await _rpcController.StartVpn(); + } + catch (Exception e) + { + VpnFailedMessage = "Failed to start CoderVPN: " + MaybeUnwrapTunnelError(e); + } + } + + private async Task StopVpn() + { + try + { + await _rpcController.StopVpn(); + } + catch (Exception e) + { + VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e); + } + } + + private static string MaybeUnwrapTunnelError(Exception e) + { + if (e is VpnLifecycleException vpnError) return vpnError.Message; + return e.ToString(); + } + + [RelayCommand] + private void ToggleShowAllAgents() + { + ShowAllAgents = !ShowAllAgents; + } + + [RelayCommand] + private void ShowFileSyncListWindow() + { + _windowService.ShowFileSyncListWindow(); + } + + [RelayCommand] + private void ShowSettingsWindow() + { + _windowService.ShowSettingsWindow(); + } + + [RelayCommand] + private async Task SignOut() + { + await _rpcController.StopVpn(); + await _credentialManager.ClearCredentials(); + } + + [RelayCommand] + public void Exit() + { + _applicationLifetime.StopApplication(); + } + + private static bool ShouldShowDummy(Workspace workspace) + { + switch (workspace.Status) + { + case Workspace.Types.Status.Unknown: + case Workspace.Types.Status.Pending: + case Workspace.Types.Status.Starting: + case Workspace.Types.Status.Stopping: + case Workspace.Types.Status.Stopped: + return true; + // TODO: should we include and show a different color than Offline for workspaces that are + // failed, canceled or deleting? + default: + return false; + } + } +} diff --git a/App.Shared/ViewModels/UpdaterDownloadProgressViewModel.cs b/App.Shared/ViewModels/UpdaterDownloadProgressViewModel.cs new file mode 100644 index 0000000..bdfb7e6 --- /dev/null +++ b/App.Shared/ViewModels/UpdaterDownloadProgressViewModel.cs @@ -0,0 +1,91 @@ +using System; +using Coder.Desktop.App.Converters; +using CommunityToolkit.Mvvm.ComponentModel; +using NetSparkleUpdater.Events; + +namespace Coder.Desktop.App.ViewModels; + +public partial class UpdaterDownloadProgressViewModel : ObservableObject +{ + // Partially implements IDownloadProgress + public event DownloadInstallEventHandler? DownloadProcessCompleted; + + [ObservableProperty] + public partial bool IsDownloading { get; set; } = false; + + [ObservableProperty] + public partial string DownloadingTitle { get; set; } = "Downloading..."; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DownloadProgressValue))] + [NotifyPropertyChangedFor(nameof(UserReadableDownloadProgress))] + public partial ulong DownloadedBytes { get; set; } = 0; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DownloadProgressValue))] + [NotifyPropertyChangedFor(nameof(DownloadProgressIndeterminate))] + [NotifyPropertyChangedFor(nameof(UserReadableDownloadProgress))] + public partial ulong TotalBytes { get; set; } = 0; // 0 means unknown + + public int DownloadProgressValue => (int)(TotalBytes > 0 ? DownloadedBytes * 100 / TotalBytes : 0); + + public bool DownloadProgressIndeterminate => TotalBytes == 0; + + public string UserReadableDownloadProgress + { + get + { + if (DownloadProgressValue == 100) + return "Download complete"; + + // TODO: FriendlyByteConverter should allow for matching suffixes + // on both + var str = FriendlyByteConverter.FriendlyBytes(DownloadedBytes) + " of "; + if (TotalBytes > 0) + str += FriendlyByteConverter.FriendlyBytes(TotalBytes); + else + str += "unknown"; + str += " downloaded"; + if (DownloadProgressValue > 0) + str += $" ({DownloadProgressValue}%)"; + return str; + } + } + + // TODO: is this even necessary? + [ObservableProperty] + public partial string ActionButtonTitle { get; set; } = "Cancel"; // Default action string from the built-in NetSparkle UI + + [ObservableProperty] + public partial bool IsActionButtonEnabled { get; set; } = true; + + public void SetFinishedDownloading(bool isDownloadedFileValid) + { + IsDownloading = false; + TotalBytes = DownloadedBytes; // In case the total bytes were unknown + if (isDownloadedFileValid) + { + DownloadingTitle = "Ready to install"; + ActionButtonTitle = "Install"; + } + + // We don't need to handle the error/invalid state here as the window + // will handle that for us by showing a MessageWindow. + } + + public void SetDownloadProgress(ulong bytesReceived, ulong totalBytesToReceive) + { + DownloadedBytes = bytesReceived; + TotalBytes = totalBytesToReceive; + } + + public void SetActionButtonEnabled(bool enabled) + { + IsActionButtonEnabled = enabled; + } + + public void ActionButton_Click(object? sender, EventArgs e) + { + DownloadProcessCompleted?.Invoke(this, new DownloadInstallEventArgs(!IsDownloading)); + } +} diff --git a/App.Shared/ViewModels/UpdaterUpdateAvailableViewModel.cs b/App.Shared/ViewModels/UpdaterUpdateAvailableViewModel.cs new file mode 100644 index 0000000..069d04f --- /dev/null +++ b/App.Shared/ViewModels/UpdaterUpdateAvailableViewModel.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using NetSparkleUpdater; +using NetSparkleUpdater.Enums; +using NetSparkleUpdater.Events; +using NetSparkleUpdater.Interfaces; + +namespace Coder.Desktop.App.ViewModels; + +public interface IUpdaterUpdateAvailableViewModelFactory +{ + public UpdaterUpdateAvailableViewModel Create(List updates, ISignatureVerifier? signatureVerifier, + string currentVersion, string appName, bool isUpdateAlreadyDownloaded); +} + +public class UpdaterUpdateAvailableViewModelFactory(ILogger childLogger) + : IUpdaterUpdateAvailableViewModelFactory +{ + public UpdaterUpdateAvailableViewModel Create(List updates, ISignatureVerifier? signatureVerifier, + string currentVersion, string appName, bool isUpdateAlreadyDownloaded) + { + return new UpdaterUpdateAvailableViewModel(childLogger, updates, signatureVerifier, currentVersion, appName, + isUpdateAlreadyDownloaded); + } +} + +public partial class UpdaterUpdateAvailableViewModel : ObservableObject +{ + private readonly ILogger _logger; + + // All the unchanging stuff we get from NetSparkle: + public readonly IReadOnlyList Updates; + public readonly ISignatureVerifier? SignatureVerifier; + public readonly string CurrentVersion; + public readonly string AppName; + public readonly bool IsUpdateAlreadyDownloaded; + + // Partial implementation of IUpdateAvailable: + public UpdateAvailableResult Result { get; set; } = UpdateAvailableResult.None; + + // We only show the first update. + public AppCastItem CurrentItem => Updates[0]; // always has at least one item + + public event UserRespondedToUpdate? UserResponded; + + // Other computed fields based on readonly data: + public bool MissingCriticalUpdate => Updates.Any(u => u.IsCriticalUpdate); + + [ObservableProperty] + public partial bool ReleaseNotesVisible { get; set; } = true; + + [ObservableProperty] + public partial bool RemindMeLaterButtonVisible { get; set; } = true; + + [ObservableProperty] + public partial bool SkipButtonVisible { get; set; } = true; + + /// + /// Whether the current app theme is dark. + /// + /// Replaces WinUI's Application.Current.RequestedTheme usage. + /// + [ObservableProperty] + public partial bool IsDarkTheme { get; set; } = false; + + public string MainText + { + get + { + var actionText = IsUpdateAlreadyDownloaded ? "install" : "download"; + return + $"{AppName} {CurrentItem.Version} is now available (you have {CurrentVersion}). Would you like to {actionText} it now?"; + } + } + + public UpdaterUpdateAvailableViewModel(ILogger logger, List updates, + ISignatureVerifier? signatureVerifier, string currentVersion, string appName, bool isUpdateAlreadyDownloaded) + { + if (updates.Count == 0) + throw new InvalidOperationException("No updates available, cannot create UpdaterUpdateAvailableViewModel"); + + _logger = logger; + Updates = updates; + SignatureVerifier = signatureVerifier; + CurrentVersion = currentVersion; + AppName = appName; + IsUpdateAlreadyDownloaded = isUpdateAlreadyDownloaded; + } + + public void HideReleaseNotes() + { + ReleaseNotesVisible = false; + } + + public void HideRemindMeLaterButton() + { + RemindMeLaterButtonVisible = false; + } + + public void HideSkipButton() + { + SkipButtonVisible = false; + } + + public Task ChangelogHtml(AppCastItem item) + { + const string htmlTemplate = @" + + + + + + + + + +
+ {{CONTENT}} +
+ + +"; + + const string githubMarkdownCssToken = "{{GITHUB_MARKDOWN_CSS}}"; + const string themeToken = "{{THEME}}"; + const string contentToken = "{{CONTENT}}"; + + // TODO: Avalonia - load and provide GitHub markdown CSS from UI layer. + var css = ""; + + // We store the changelog in the description field, rather than using + // the release notes URL to avoid extra requests. + var innerHtml = item.Description; + if (string.IsNullOrWhiteSpace(innerHtml)) + innerHtml = "

No release notes available.

"; + + // The theme doesn't automatically update. + var currentTheme = IsDarkTheme ? "dark" : "light"; + + var html = htmlTemplate + .Replace(githubMarkdownCssToken, css) + .Replace(themeToken, currentTheme) + .Replace(contentToken, innerHtml); + + return Task.FromResult(html); + } + + public Task Changelog_Loaded(object? sender, EventArgs e) + { + // TODO: Avalonia - implement WebView/Markdown rendering in UI layer. + // WinUI used WebView2 and configured navigation + data folder. + _logger.LogDebug("Changelog_Loaded is a no-op in App.Shared"); + return Task.CompletedTask; + } + + private void SendResponse(UpdateAvailableResult result) + { + Result = result; + UserResponded?.Invoke(this, new UpdateResponseEventArgs(result, CurrentItem)); + } + + public void SkipButton_Click() + { + if (!SkipButtonVisible || MissingCriticalUpdate) + return; + SendResponse(UpdateAvailableResult.SkipUpdate); + } + + public void RemindMeLaterButton_Click() + { + if (!RemindMeLaterButtonVisible || MissingCriticalUpdate) + return; + SendResponse(UpdateAvailableResult.RemindMeLater); + } + + public void InstallButton_Click() + { + SendResponse(UpdateAvailableResult.InstallUpdate); + } +} From cfac77541a96de860ab47ab711fe91116ebc9d10 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:35:41 +0000 Subject: [PATCH 12/13] Add Avalonia pages as AXAML UserControls --- .../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 | 179 +++++++++++++++ .../Views/Pages/TrayWindowMainPage.axaml.cs | 43 ++++ .../UpdaterDownloadProgressMainPage.axaml | 32 +++ .../UpdaterDownloadProgressMainPage.axaml.cs | 27 +++ .../UpdaterUpdateAvailableMainPage.axaml | 68 ++++++ .../UpdaterUpdateAvailableMainPage.axaml.cs | 36 +++ 22 files changed, 1230 insertions(+) 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/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 diff --git a/App.Avalonia/Views/Pages/DirectoryPickerMainPage.axaml b/App.Avalonia/Views/Pages/DirectoryPickerMainPage.axaml new file mode 100644 index 0000000..ca9e0fe --- /dev/null +++ b/App.Avalonia/Views/Pages/DirectoryPickerMainPage.axaml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + +