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);
+ }
+}
diff --git a/App.Shared/packages.lock.json b/App.Shared/packages.lock.json
new file mode 100644
index 0000000..bee1a53
--- /dev/null
+++ b/App.Shared/packages.lock.json
@@ -0,0 +1,220 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net8.0": {
+ "CommunityToolkit.Mvvm": {
+ "type": "Direct",
+ "requested": "[8.4.0, )",
+ "resolved": "8.4.0",
+ "contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw=="
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Direct",
+ "requested": "[9.0.9, )",
+ "resolved": "9.0.9",
+ "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA=="
+ },
+ "Microsoft.Extensions.Hosting.Abstractions": {
+ "type": "Direct",
+ "requested": "[9.0.9, )",
+ "resolved": "9.0.9",
+ "contentHash": "ORA4dICNz7cuwupPkjXpSuoiK6GMg0aygInBIQCCFEimwoHntRKdJqB59faxq2HHJuTPW3NsZm5EjN5P5Zh6nQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.9",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.9",
+ "Microsoft.Extensions.FileProviders.Abstractions": "9.0.9",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.9"
+ }
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "Direct",
+ "requested": "[9.0.9, )",
+ "resolved": "9.0.9",
+ "contentHash": "loxGGHE1FC2AefwPHzrjPq7X92LQm64qnU/whKfo6oWaceewPUVYQJBJs3S3E2qlWwnCpeZ+dGCPTX+5dgVAuQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9",
+ "Microsoft.Extensions.Primitives": "9.0.9"
+ }
+ },
+ "NetSparkleUpdater.SparkleUpdater": {
+ "type": "Direct",
+ "requested": "[3.0.3, )",
+ "resolved": "3.0.3",
+ "contentHash": "8bmcqEkBDIFLos49NgH4aWe0mK6t0GSxMzJZpXQbQNieDENCjEQ9jGmYS7Byyq/LL+XQ9CLz1nMq1nYrQe73jQ==",
+ "dependencies": {
+ "NetSparkleUpdater.Chaos.NaCl": "0.9.3"
+ }
+ },
+ "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"
+ }
+ },
+ "Google.Protobuf": {
+ "type": "Transitive",
+ "resolved": "3.29.3",
+ "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw=="
+ },
+ "Grpc.Core.Api": {
+ "type": "Transitive",
+ "resolved": "2.67.0",
+ "contentHash": "cL1/2f8kc8lsAGNdfCU25deedXVehhLA6GXKLLN4hAWx16XN7BmjYn3gFU+FBpir5yJynvDTHEypr3Tl0j7x/Q=="
+ },
+ "Grpc.Net.Client": {
+ "type": "Transitive",
+ "resolved": "2.67.0",
+ "contentHash": "ofTjJQfegWkVlk5R4k/LlwpcucpsBzntygd4iAeuKd/eLMkmBWoXN+xcjYJ5IibAahRpIJU461jABZvT6E9dwA==",
+ "dependencies": {
+ "Grpc.Net.Common": "2.67.0",
+ "Microsoft.Extensions.Logging.Abstractions": "6.0.0"
+ }
+ },
+ "Grpc.Net.Common": {
+ "type": "Transitive",
+ "resolved": "2.67.0",
+ "contentHash": "gazn1cD2Eol0/W5ZJRV4PYbNrxJ9oMs8pGYux5S9E4MymClvl7aqYSmpqgmWAUWvziRqK9K+yt3cjCMfQ3x/5A==",
+ "dependencies": {
+ "Grpc.Core.Api": "2.67.0"
+ }
+ },
+ "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.9",
+ "contentHash": "p5RKAY9POvs3axwA/AQRuJeM8AHuE8h4qbP1NxQeGm0ep46aXz1oCLAp/oOYxX1GsjStgdhHrN3XXLLXr0+b3w==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "9.0.9"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.Abstractions": {
+ "type": "Transitive",
+ "resolved": "9.0.9",
+ "contentHash": "YHGmxccrVZ2Ar3eI+/NdbOHkd1/HzrHvmQ5yBsp0Gl7jTyBe6qcXNYjUt9v9JIO+Z14la44+YYEe63JSqs1fYg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9",
+ "Microsoft.Extensions.Options": "9.0.9",
+ "System.Diagnostics.DiagnosticSource": "9.0.9"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Abstractions": {
+ "type": "Transitive",
+ "resolved": "9.0.9",
+ "contentHash": "M1ZhL9QkBQ/k6l/Wjgcli5zrV86HzytQ+gQiNtk9vs9Ge1fb17KKZil9T6jd15p2x/BGfXpup7Hg55CC0kkfig==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "9.0.9"
+ }
+ },
+ "Microsoft.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection": "9.0.0",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.0",
+ "Microsoft.Extensions.Options": "9.0.0"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "9.0.9",
+ "contentHash": "FEgpSF+Z9StMvrsSViaybOBwR0f0ZZxDm8xV5cSOFiXN/t+ys+rwAlTd/6yG7Ld1gfppgvLcMasZry3GsI9lGA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9",
+ "System.Diagnostics.DiagnosticSource": "9.0.9"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "9.0.9",
+ "contentHash": "z4pyMePOrl733ltTowbN565PxBw1oAr8IHmIXNDiDqd22nFpYltX9KhrNC/qBWAG1/Zx5MHX+cOYhWJQYCO/iw=="
+ },
+ "NetSparkleUpdater.Chaos.NaCl": {
+ "type": "Transitive",
+ "resolved": "0.9.3",
+ "contentHash": "Copo3+rYuRVOuc6fmzHwXwehDC8l8DQ3y2VRI/d0sQTwProL4QAjkxRPV0zr3XBz1A8ZODXATOXV0hJXc7YdKg=="
+ },
+ "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.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.9",
+ "contentHash": "8hy61dsFYYSDjT9iTAfygGMU3A0EAnG69x5FUXeKsCjMhBmtTBt4UMUEW3ipprFoorOW6Jw/7hDMjXtlrsOvVQ=="
+ },
+ "System.IO.Pipelines": {
+ "type": "Transitive",
+ "resolved": "9.0.1",
+ "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg=="
+ },
+ "Coder.Desktop.CoderSdk": {
+ "type": "Project"
+ },
+ "Coder.Desktop.MutagenSdk": {
+ "type": "Project",
+ "dependencies": {
+ "Google.Protobuf": "[3.29.3, )",
+ "Grpc.Net.Client": "[2.67.0, )"
+ }
+ },
+ "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/Coder.Desktop.Linux.slnf b/Coder.Desktop.Linux.slnf
new file mode 100644
index 0000000..623b10e
--- /dev/null
+++ b/Coder.Desktop.Linux.slnf
@@ -0,0 +1,21 @@
+{
+ "solution": {
+ "path": "Coder.Desktop.sln",
+ "projects": [
+ "App.Avalonia\\App.Avalonia.csproj",
+ "App.Linux\\App.Linux.csproj",
+ "App.Shared\\App.Shared.csproj",
+ "CoderSdk\\CoderSdk.csproj",
+ "MutagenSdk\\MutagenSdk.csproj",
+ "Tests.CoderSdk\\Tests.CoderSdk.csproj",
+ "Tests.Vpn.Proto\\Tests.Vpn.Proto.csproj",
+ "Tests.Vpn.Service\\Tests.Vpn.Service.csproj",
+ "Tests.Vpn\\Tests.Vpn.csproj",
+ "Vpn.DebugClient\\Vpn.DebugClient.csproj",
+ "Vpn.Linux\\Vpn.Linux.csproj",
+ "Vpn.Proto\\Vpn.Proto.csproj",
+ "Vpn.Service\\Vpn.Service.csproj",
+ "Vpn\\Vpn.csproj"
+ ]
+ }
+}
diff --git a/Coder.Desktop.sln b/Coder.Desktop.sln
index d1f5ac6..d008f4e 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}") = "App.Avalonia", "App.Avalonia\App.Avalonia.csproj", "{A21B4D09-6D60-4A37-AFBE-1B419DD7568D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Shared", "App.Shared\App.Shared.csproj", "{5FD66ACE-230B-4B27-818C-6E4F8BE42D65}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Linux", "App.Linux\App.Linux.csproj", "{610AF553-2A68-4CF4-872E-70A7C978B203}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vpn.Linux", "Vpn.Linux\Vpn.Linux.csproj", "{BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}"
+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
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Debug|x64.Build.0 = Debug|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Debug|x86.Build.0 = Debug|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Release|ARM64.Build.0 = Release|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Release|x64.ActiveCfg = Release|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Release|x64.Build.0 = Release|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Release|x86.ActiveCfg = Release|Any CPU
+ {A21B4D09-6D60-4A37-AFBE-1B419DD7568D}.Release|x86.Build.0 = Release|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Debug|x64.Build.0 = Debug|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Debug|x86.Build.0 = Debug|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Release|ARM64.Build.0 = Release|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Release|x64.ActiveCfg = Release|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Release|x64.Build.0 = Release|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Release|x86.ActiveCfg = Release|Any CPU
+ {5FD66ACE-230B-4B27-818C-6E4F8BE42D65}.Release|x86.Build.0 = Release|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Debug|x64.Build.0 = Debug|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Debug|x86.Build.0 = Debug|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Release|Any CPU.Build.0 = Release|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Release|ARM64.Build.0 = Release|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Release|x64.ActiveCfg = Release|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Release|x64.Build.0 = Release|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Release|x86.ActiveCfg = Release|Any CPU
+ {610AF553-2A68-4CF4-872E-70A7C978B203}.Release|x86.Build.0 = Release|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Debug|x64.Build.0 = Debug|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Debug|x86.Build.0 = Debug|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Release|ARM64.Build.0 = Release|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Release|x64.ActiveCfg = Release|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Release|x64.Build.0 = Release|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Release|x86.ActiveCfg = Release|Any CPU
+ {BBFCCC50-FC3E-42AE-8069-5DBB68C3D46D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..efe3f87
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+
+ true
+
+
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/PKGBUILD b/Packaging.Linux/PKGBUILD
new file mode 100644
index 0000000..80fdb30
--- /dev/null
+++ b/Packaging.Linux/PKGBUILD
@@ -0,0 +1,73 @@
+pkgname=coder-desktop
+pkgver=0.1.0
+pkgrel=1
+pkgdesc="Coder Desktop VPN service and tray app for Linux"
+arch=('x86_64' 'aarch64')
+url='https://github.com/coder/coder-desktop-linux'
+license=('AGPL-3.0-only')
+depends=('dotnet-runtime-8.0' 'libnotify' 'libsecret' 'systemd')
+makedepends=('dotnet-sdk-8.0')
+optdepends=('freerdp: RDP client' 'remmina: Alternative RDP client')
+backup=('etc/coder-desktop/config.json')
+install='coder-desktop.install'
+source=('coder-desktop.service' 'coder-desktop.desktop')
+sha256sums=('SKIP' 'SKIP')
+
+build() {
+ export DOTNET_CLI_TELEMETRY_OPTOUT=1
+
+ local rid='linux-x64'
+ if [[ "${CARCH}" == 'aarch64' ]]; then
+ rid='linux-arm64'
+ fi
+
+ local repo_root="${startdir}/.."
+
+ dotnet publish "${repo_root}/App.Avalonia" \
+ -r "${rid}" \
+ -c Release \
+ -o "${srcdir}/build/app"
+
+ dotnet publish "${repo_root}/Vpn.Service" \
+ -r "${rid}" \
+ -c Release \
+ -o "${srcdir}/build/service"
+}
+
+package() {
+ local repo_root="${startdir}/.."
+
+ install -d "${pkgdir}/usr/lib/coder-desktop/app"
+ install -d "${pkgdir}/usr/lib/coder-desktop/service"
+ install -d "${pkgdir}/usr/bin"
+ install -d "${pkgdir}/usr/share/applications"
+ install -d "${pkgdir}/usr/share/pixmaps"
+ install -d "${pkgdir}/usr/lib/systemd/system"
+ install -d "${pkgdir}/etc/coder-desktop"
+
+ cp -a "${srcdir}/build/app/." "${pkgdir}/usr/lib/coder-desktop/app/"
+ cp -a "${srcdir}/build/service/." "${pkgdir}/usr/lib/coder-desktop/service/"
+
+ cat > "${pkgdir}/usr/bin/coder-desktop" <<'WRAPPER'
+#!/bin/bash
+exec "/usr/lib/coder-desktop/app/Coder Desktop" "$@"
+WRAPPER
+ chmod 755 "${pkgdir}/usr/bin/coder-desktop"
+
+ ln -sf "/usr/lib/coder-desktop/service/CoderVpnService" "${pkgdir}/usr/bin/coder-vpn-service"
+
+ install -m 644 "${srcdir}/coder-desktop.desktop" "${pkgdir}/usr/share/applications/coder-desktop.desktop"
+ install -m 644 "${srcdir}/coder-desktop.service" "${pkgdir}/usr/lib/systemd/system/coder-desktop.service"
+ install -m 644 "${repo_root}/App.Avalonia/Assets/coder.ico" "${pkgdir}/usr/share/pixmaps/coder-desktop.ico"
+
+ cat > "${pkgdir}/etc/coder-desktop/config.json" <<'CONFIG'
+{
+ "Manager": {
+ "ServiceRpcSocketPath": "/run/coder-desktop/vpn.sock",
+ "TunnelBinaryPath": "/usr/lib/coder-desktop/coder-vpn",
+ "TunnelBinarySignatureSigner": "",
+ "TunnelBinaryAllowVersionMismatch": false
+ }
+}
+CONFIG
+}
diff --git a/Packaging.Linux/README.md b/Packaging.Linux/README.md
new file mode 100644
index 0000000..9c7bfae
--- /dev/null
+++ b/Packaging.Linux/README.md
@@ -0,0 +1,70 @@
+# Linux Packaging
+
+## Build release artifacts locally
+
+From repo root:
+
+```bash
+# Build .deb, .rpm, and .tar.gz for amd64
+VERSION=1.0.0 ./Packaging.Linux/build-release-packages.sh amd64
+
+# Build .deb, .rpm, and .tar.gz for arm64
+VERSION=1.0.0 ./Packaging.Linux/build-release-packages.sh arm64
+
+# Build only one format
+VERSION=1.0.0 FORMATS=deb ./Packaging.Linux/build-release-packages.sh amd64
+```
+
+Artifacts are written to `./dist/` by default.
+
+Generated formats:
+
+- `coder-desktop__amd64.deb`
+- `coder-desktop__arm64.deb`
+- `coder-desktop--1.x86_64.rpm`
+- `coder-desktop--1.aarch64.rpm`
+- `coder-desktop__amd64.tar.gz`
+- `coder-desktop__arm64.tar.gz`
+
+## Build AUR source bundle
+
+```bash
+VERSION=1.0.0 ./Packaging.Linux/build-aur-source.sh
+```
+
+Produces:
+
+- `coder-desktop__aur.tar.gz`
+
+This archive contains `PKGBUILD`, `coder-desktop.install`, unit/desktop files,
+and can be used as a source bundle for AUR packaging.
+
+## Debian helper
+
+`build-deb.sh` is retained as a convenience wrapper:
+
+```bash
+VERSION=1.0.0 ./Packaging.Linux/build-deb.sh amd64
+```
+
+## Service behavior (root VPN service)
+
+Packages install a systemd unit (`coder-desktop.service`) that runs:
+
+- `User=root`
+- `ExecStart=/usr/bin/coder-vpn-service`
+
+Post-install scripts run `systemctl daemon-reload`, `enable`, and `start/restart`
+so the VPN service is active as root after installation (when systemd is
+available).
+
+## Package contents
+
+- `/usr/lib/coder-desktop/app/` — Avalonia desktop app binaries
+- `/usr/lib/coder-desktop/service/` — VPN service binaries
+- `/usr/bin/coder-desktop` — Desktop app launcher wrapper
+- `/usr/bin/coder-vpn-service` — Symlink to VPN service binary
+- `/usr/lib/systemd/system/coder-desktop.service` — systemd unit
+- `/etc/coder-desktop/config.json` — Default configuration
+- `/usr/share/applications/coder-desktop.desktop` — Desktop entry
+- `/usr/share/pixmaps/coder-desktop.ico` — App icon
diff --git a/Packaging.Linux/build-aur-source.sh b/Packaging.Linux/build-aur-source.sh
new file mode 100755
index 0000000..9332bc2
--- /dev/null
+++ b/Packaging.Linux/build-aur-source.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Build an AUR source archive containing PKGBUILD + install/unit/desktop assets.
+#
+# Usage:
+# VERSION=1.2.3 ./build-aur-source.sh
+
+VERSION="${VERSION:-0.1.0}"
+OUT_DIR="${OUT_DIR:-$(pwd)/dist}"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+WORK_DIR="$(mktemp -d)"
+AUR_DIR="$WORK_DIR/coder-desktop"
+
+cleanup() {
+ rm -rf "$WORK_DIR"
+}
+trap cleanup EXIT
+
+mkdir -p "$OUT_DIR"
+mkdir -p "$AUR_DIR"
+
+sed -E "s/^pkgver=.*/pkgver=${VERSION}/" "$SCRIPT_DIR/PKGBUILD" > "$AUR_DIR/PKGBUILD"
+cp "$SCRIPT_DIR/coder-desktop.install" "$AUR_DIR/"
+cp "$SCRIPT_DIR/coder-desktop.service" "$AUR_DIR/"
+cp "$SCRIPT_DIR/coder-desktop.desktop" "$AUR_DIR/"
+cp "$SCRIPT_DIR/README.md" "$AUR_DIR/README.md"
+
+ARCHIVE_NAME="coder-desktop_${VERSION}_aur.tar.gz"
+tar -C "$WORK_DIR" -czf "$OUT_DIR/$ARCHIVE_NAME" "coder-desktop"
+
+echo "AUR source archive created: $OUT_DIR/$ARCHIVE_NAME"
diff --git a/Packaging.Linux/build-deb.sh b/Packaging.Linux/build-deb.sh
new file mode 100755
index 0000000..a5fcc9d
--- /dev/null
+++ b/Packaging.Linux/build-deb.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Backwards-compatible wrapper around the multi-format release packaging script.
+# Usage: ./build-deb.sh [amd64|arm64]
+
+ARCH="${1:-amd64}"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(dirname "$SCRIPT_DIR")"
+
+FORMATS=deb OUT_DIR="$ROOT_DIR" "$SCRIPT_DIR/build-release-packages.sh" "$ARCH"
diff --git a/Packaging.Linux/build-release-packages.sh b/Packaging.Linux/build-release-packages.sh
new file mode 100755
index 0000000..4a16e05
--- /dev/null
+++ b/Packaging.Linux/build-release-packages.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Build Linux release packages from a single staged filesystem layout.
+#
+# Usage:
+# VERSION=1.2.3 ./build-release-packages.sh amd64
+# VERSION=1.2.3 FORMATS=deb,rpm,tar ./build-release-packages.sh arm64
+#
+# Supported architectures:
+# - amd64
+# - arm64
+#
+# Supported formats (FORMATS comma-separated):
+# - deb
+# - rpm
+# - tar
+
+ARCH="${1:-amd64}"
+VERSION="${VERSION:-0.1.0}"
+FORMATS="${FORMATS:-deb,rpm,tar}"
+OUT_DIR="${OUT_DIR:-$(pwd)/dist}"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(dirname "$SCRIPT_DIR")"
+BUILD_DIR="$(mktemp -d)"
+PKGROOT="$BUILD_DIR/pkgroot"
+STAGE_DIR="$PKGROOT/coder-desktop"
+
+cleanup() {
+ rm -rf "$BUILD_DIR"
+}
+trap cleanup EXIT
+
+has_format() {
+ local needle="$1"
+ [[ ",${FORMATS}," == *",${needle},"* ]]
+}
+
+case "$ARCH" in
+ amd64)
+ RID="linux-x64"
+ DEB_ARCH="amd64"
+ RPM_ARCH="x86_64"
+ ;;
+ arm64)
+ RID="linux-arm64"
+ DEB_ARCH="arm64"
+ RPM_ARCH="aarch64"
+ ;;
+ *)
+ echo "Unsupported architecture: $ARCH" >&2
+ exit 1
+ ;;
+esac
+
+mkdir -p "$OUT_DIR"
+
+echo "[release-packaging] Building Coder Desktop v${VERSION} (${ARCH})"
+
+echo "[release-packaging] Publishing App.Avalonia (${RID})"
+dotnet publish "$ROOT_DIR/App.Avalonia" \
+ -r "$RID" \
+ -c Release \
+ -o "$STAGE_DIR/usr/lib/coder-desktop/app"
+
+echo "[release-packaging] Publishing Vpn.Service (${RID})"
+dotnet publish "$ROOT_DIR/Vpn.Service" \
+ -r "$RID" \
+ -c Release \
+ -o "$STAGE_DIR/usr/lib/coder-desktop/service"
+
+mkdir -p "$STAGE_DIR/usr/bin"
+mkdir -p "$STAGE_DIR/usr/share/applications"
+mkdir -p "$STAGE_DIR/usr/share/pixmaps"
+mkdir -p "$STAGE_DIR/usr/lib/systemd/system"
+mkdir -p "$STAGE_DIR/etc/coder-desktop"
+
+cat > "$STAGE_DIR/usr/bin/coder-desktop" <<'WRAPPER'
+#!/usr/bin/env bash
+exec "/usr/lib/coder-desktop/app/Coder Desktop" "$@"
+WRAPPER
+chmod 755 "$STAGE_DIR/usr/bin/coder-desktop"
+ln -sf "/usr/lib/coder-desktop/service/CoderVpnService" "$STAGE_DIR/usr/bin/coder-vpn-service"
+
+cp "$SCRIPT_DIR/coder-desktop.service" "$STAGE_DIR/usr/lib/systemd/system/"
+cp "$SCRIPT_DIR/coder-desktop.desktop" "$STAGE_DIR/usr/share/applications/"
+cp "$ROOT_DIR/App.Avalonia/Assets/coder.ico" "$STAGE_DIR/usr/share/pixmaps/coder-desktop.ico"
+
+cat > "$STAGE_DIR/etc/coder-desktop/config.json" <<'CONFIG'
+{
+ "Manager": {
+ "ServiceRpcSocketPath": "/run/coder-desktop/vpn.sock",
+ "TunnelBinaryPath": "/usr/lib/coder-desktop/coder-vpn",
+ "TunnelBinarySignatureSigner": "",
+ "TunnelBinaryAllowVersionMismatch": false
+ }
+}
+CONFIG
+
+if has_format deb; then
+ echo "[release-packaging] Building .deb"
+ mkdir -p "$STAGE_DIR/DEBIAN"
+
+ cat > "$STAGE_DIR/DEBIAN/control" <
+Description: Coder Desktop - Connect to Coder workspaces
+ Provides a Linux tray app and root VPN service for connecting
+ to Coder workspaces.
+Depends: dotnet-runtime-8.0, libnotify-bin, libsecret-tools
+Recommends: freerdp2-x11 | remmina
+Section: net
+Priority: optional
+CONTROL
+
+ cp "$SCRIPT_DIR/postinst.sh" "$STAGE_DIR/DEBIAN/postinst"
+ cp "$SCRIPT_DIR/prerm.sh" "$STAGE_DIR/DEBIAN/prerm"
+ chmod 755 "$STAGE_DIR/DEBIAN/postinst" "$STAGE_DIR/DEBIAN/prerm"
+
+ dpkg-deb --build "$STAGE_DIR" "$OUT_DIR/coder-desktop_${VERSION}_${DEB_ARCH}.deb"
+fi
+
+if has_format rpm; then
+ echo "[release-packaging] Building .rpm"
+ if ! command -v fpm >/dev/null 2>&1; then
+ echo "fpm is required for rpm builds (gem install fpm)" >&2
+ exit 1
+ fi
+
+ fpm -s dir -t rpm \
+ -n coder-desktop \
+ -v "$VERSION" \
+ --iteration 1 \
+ --architecture "$RPM_ARCH" \
+ --license "AGPL-3.0-only" \
+ --url "https://github.com/coder/coder-desktop-linux" \
+ --maintainer "Coder Technologies Inc. " \
+ --description "Coder Desktop Linux tray app and root VPN service" \
+ --depends "dotnet-runtime-8.0" \
+ --depends "libnotify" \
+ --depends "libsecret" \
+ --after-install "$SCRIPT_DIR/postinst.sh" \
+ --before-remove "$SCRIPT_DIR/prerm.sh" \
+ --package "$OUT_DIR/coder-desktop-${VERSION}-1.${RPM_ARCH}.rpm" \
+ -C "$STAGE_DIR" \
+ .
+fi
+
+if has_format tar; then
+ echo "[release-packaging] Building .tar.gz"
+ TAR_ROOT="$BUILD_DIR/tar/coder-desktop-${VERSION}-${ARCH}"
+ mkdir -p "$TAR_ROOT"
+ cp -a "$STAGE_DIR/." "$TAR_ROOT/"
+ tar -C "$BUILD_DIR/tar" -czf "$OUT_DIR/coder-desktop_${VERSION}_${ARCH}.tar.gz" "coder-desktop-${VERSION}-${ARCH}"
+fi
+
+echo "[release-packaging] Output directory: $OUT_DIR"
+ls -lh "$OUT_DIR"
diff --git a/Packaging.Linux/coder-desktop.desktop b/Packaging.Linux/coder-desktop.desktop
new file mode 100644
index 0000000..2388487
--- /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=/usr/share/pixmaps/coder-desktop.ico
+Categories=Network;Development;
+MimeType=x-scheme-handler/coder;
+StartupNotify=true
diff --git a/Packaging.Linux/coder-desktop.install b/Packaging.Linux/coder-desktop.install
new file mode 100644
index 0000000..36ae59b
--- /dev/null
+++ b/Packaging.Linux/coder-desktop.install
@@ -0,0 +1,17 @@
+post_install() {
+ systemctl daemon-reload >/dev/null 2>&1 || true
+ systemctl enable --now coder-desktop.service >/dev/null 2>&1 || true
+}
+
+post_upgrade() {
+ post_install
+}
+
+pre_remove() {
+ systemctl stop coder-desktop.service >/dev/null 2>&1 || true
+ systemctl disable coder-desktop.service >/dev/null 2>&1 || true
+}
+
+post_remove() {
+ systemctl daemon-reload >/dev/null 2>&1 || true
+}
diff --git a/Packaging.Linux/coder-desktop.service b/Packaging.Linux/coder-desktop.service
new file mode 100644
index 0000000..426fd31
--- /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/bin/coder-vpn-service
+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..48c0339
--- /dev/null
+++ b/Packaging.Linux/postinst.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+set -e
+
+# Service runs as root (see coder-desktop.service User=root).
+if command -v systemctl >/dev/null 2>&1; then
+ systemctl daemon-reload || true
+ systemctl enable coder-desktop.service || true
+ systemctl restart coder-desktop.service || systemctl start coder-desktop.service || true
+fi
+
+# Refresh desktop entry cache where available.
+if command -v update-desktop-database >/dev/null 2>&1; 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..2e33ec9
--- /dev/null
+++ b/Packaging.Linux/prerm.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -e
+
+if command -v systemctl >/dev/null 2>&1; then
+ systemctl stop coder-desktop.service || true
+ systemctl disable coder-desktop.service || true
+fi
diff --git a/README.md b/README.md
index 963be46..cd75b3b 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Coder Desktop for Windows
+# Coder Desktop for Windows and Linux
Coder Desktop allows you to work on your Coder workspaces as though they're
on your local network, with no port-forwarding required. It provides seamless
@@ -9,11 +9,15 @@ remote directories.
Learn more about Coder Desktop in the
[official documentation](https://coder.com/docs/user-guides/desktop).
-This repo contains the C# source code for Coder Desktop for Windows. You can
-download the latest version from the GitHub releases.
+This repo contains the C# source code for Coder Desktop. The Windows app uses
+WinUI 3 (`App/`), and the Linux app uses Avalonia (`App.Avalonia/`). The VPN
+service, RPC protocol, and SDK projects are shared between both platforms.
+You can download the latest version from the GitHub releases.
### Contributing
+#### Windows
+
You will need:
- Visual Studio 2022
@@ -25,6 +29,24 @@ You will need:
It's also recommended to use JetBrains Rider (or VS + ReSharper) for a better
experience.
+#### Linux
+
+You will need the .NET 8 SDK. Build and test with the Linux solution filter:
+
+```bash
+dotnet restore Coder.Desktop.Linux.slnf
+dotnet build Coder.Desktop.Linux.slnf
+```
+
+To run the app and service locally:
+
+```bash
+./scripts/run-linux-dev.sh --show --sudo-service
+```
+
+Linux packages (`.deb`, `.rpm`, `.tar.gz`) are built with the scripts in
+`Packaging.Linux/`.
+
### License
The Coder Desktop for Windows source is licensed under the GNU Affero General
diff --git a/Tests.CoderSdk/UuidTest.cs b/Tests.CoderSdk/UuidTest.cs
index b05960f..0a949b9 100644
--- a/Tests.CoderSdk/UuidTest.cs
+++ b/Tests.CoderSdk/UuidTest.cs
@@ -38,7 +38,7 @@ public void CapitalizedStringToUuid()
[Test(Description = "Invalid length")]
public void InvalidLength()
{
- var ex = Assert.Throws(() => _ = new Uuid([]));
+ var ex = Assert.Throws(() => _ = new Uuid(Array.Empty()));
Assert.That(ex.Message, Does.Contain("UUID must be 16 bytes, but was 0 bytes"));
ex = Assert.Throws(() => _ = new Uuid(UuidBytes.AsSpan(..^1)));
Assert.That(ex.Message, Does.Contain("UUID must be 16 bytes, but was 15 bytes"));
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..9b7037e 100644
--- a/Tests.Vpn.Service/Tests.Vpn.Service.csproj
+++ b/Tests.Vpn.Service/Tests.Vpn.Service.csproj
@@ -3,7 +3,7 @@
Coder.Desktop.Tests.Vpn.Service
Coder.Desktop.Tests.Vpn.Service
- net8.0-windows
+ net8.0;net8.0-windows
enable
enable
true
diff --git a/Tests.Vpn.Service/packages.lock.json b/Tests.Vpn.Service/packages.lock.json
index 08a9b56..fbabe60 100644
--- a/Tests.Vpn.Service/packages.lock.json
+++ b/Tests.Vpn.Service/packages.lock.json
@@ -1,6 +1,517 @@
{
"version": 1,
"dependencies": {
+ "net8.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.Systemd": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "XcG1R/q82nlX9wTfOcQvS7oJGm2Bd1uv1q+TUzwYbAZcEBgSEzq2DV4m7d/V9Sm/Jt59eVEHzu1F9sdf6qN+vQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Hosting": "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.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.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.Linux": {
+ "type": "Project",
+ "dependencies": {
+ "Coder.Desktop.Vpn": "[1.0.0, )"
+ }
+ },
+ "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, )",
+ "Coder.Desktop.Vpn.Linux": "[1.0.0, )",
+ "Microsoft.Extensions.Hosting": "[9.0.4, )",
+ "Microsoft.Extensions.Hosting.Systemd": "[9.0.4, )",
+ "Microsoft.Extensions.Options.DataAnnotations": "[9.0.4, )",
+ "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, )"
+ }
+ }
+ },
"net8.0-windows7.0": {
"coverlet.collector": {
"type": "Direct",
diff --git a/Tests.Vpn/SpeakerTest.cs b/Tests.Vpn/SpeakerTest.cs
index 51950f7..fc6c676 100644
--- a/Tests.Vpn/SpeakerTest.cs
+++ b/Tests.Vpn/SpeakerTest.cs
@@ -253,15 +253,29 @@ public async Task SendMessageWriteError(CancellationToken ct)
var (stream1, stream2) = BidirectionalPipe.NewInMemory();
var failStream = new FailableStream(stream1, null, null);
+ // Once we start shutting down, the peer's receive loop will see the
+ // pipe close and may report an error before its own disposal, so stop
+ // asserting on errors at that point.
+ var shuttingDown = false;
+
await using var speaker1 = new Speaker(failStream);
speaker1.Receive += msg => Assert.Fail($"speaker1 received message: {msg}");
- speaker1.Error += ex => Assert.Fail($"speaker1 error: {ex}");
+ speaker1.Error += ex =>
+ {
+ if (!Volatile.Read(ref shuttingDown)) Assert.Fail($"speaker1 error: {ex}");
+ };
await using var speaker2 = new Speaker(stream2);
speaker2.Receive += msg => Assert.Fail($"speaker2 received message: {msg}");
- speaker2.Error += ex => Assert.Fail($"speaker2 error: {ex}");
+ speaker2.Error += ex =>
+ {
+ if (!Volatile.Read(ref shuttingDown)) Assert.Fail($"speaker2 error: {ex}");
+ };
await Task.WhenAll(speaker1.StartAsync(ct), speaker2.StartAsync(ct));
var writeEx = new IOException("Test write error");
+ // The write failure can tear down the pipe, so stop asserting on
+ // error events from this point onwards.
+ Volatile.Write(ref shuttingDown, true);
failStream.SetWriteException(writeEx);
var gotEx = Assert.ThrowsAsync(() => speaker1.SendMessage(new ManagerMessage
diff --git a/Tests.Vpn/Tests.Vpn.csproj b/Tests.Vpn/Tests.Vpn.csproj
index 2b9e30f..2648388 100644
--- a/Tests.Vpn/Tests.Vpn.csproj
+++ b/Tests.Vpn/Tests.Vpn.csproj
@@ -3,7 +3,7 @@
Coder.Desktop.Tests.Vpn
Coder.Desktop.Tests.Vpn
- net8.0-windows
+ net8.0;net8.0-windows
enable
enable
true
diff --git a/Tests.Vpn/packages.lock.json b/Tests.Vpn/packages.lock.json
index 725c743..651dea8 100644
--- a/Tests.Vpn/packages.lock.json
+++ b/Tests.Vpn/packages.lock.json
@@ -1,6 +1,129 @@
{
"version": 1,
"dependencies": {
+ "net8.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, )"
+ }
+ }
+ },
"net8.0-windows7.0": {
"coverlet.collector": {
"type": "Direct",
diff --git a/Vpn.DebugClient/Program.cs b/Vpn.DebugClient/Program.cs
index 9facc85..cb761a4 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,25 @@ 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 if (OperatingSystem.IsLinux())
+ {
+ var transport = new UnixSocketClientTransport();
+ stream = transport.ConnectAsync(CancellationToken.None).GetAwaiter().GetResult();
+ }
+ else
+ {
+ throw new PlatformNotSupportedException();
+ }
+ 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..41808c5 100644
--- a/Vpn.DebugClient/Vpn.DebugClient.csproj
+++ b/Vpn.DebugClient/Vpn.DebugClient.csproj
@@ -4,7 +4,7 @@
Coder.Desktop.Vpn.DebugClient
Coder.Desktop.Vpn.DebugClient
Exe
- net8.0-windows
+ net8.0
enable
enable
true
@@ -12,6 +12,7 @@
+
diff --git a/Vpn.DebugClient/packages.lock.json b/Vpn.DebugClient/packages.lock.json
index 473422b..27818f8 100644
--- a/Vpn.DebugClient/packages.lock.json
+++ b/Vpn.DebugClient/packages.lock.json
@@ -1,7 +1,7 @@
{
"version": 1,
"dependencies": {
- "net8.0-windows7.0": {
+ "net8.0": {
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.29.3",
@@ -51,6 +51,12 @@
"System.IO.Pipelines": "[9.0.1, )"
}
},
+ "Coder.Desktop.Vpn.Linux": {
+ "type": "Project",
+ "dependencies": {
+ "Coder.Desktop.Vpn": "[1.0.0, )"
+ }
+ },
"Coder.Desktop.Vpn.Proto": {
"type": "Project",
"dependencies": {
diff --git a/Vpn.Linux/UnixSocketClientTransport.cs b/Vpn.Linux/UnixSocketClientTransport.cs
new file mode 100644
index 0000000..4f05f97
--- /dev/null
+++ b/Vpn.Linux/UnixSocketClientTransport.cs
@@ -0,0 +1,31 @@
+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 = UnixSocketServerTransport.DefaultSocketPath)
+ {
+ var envSocketPath = Environment.GetEnvironmentVariable("CODER_DESKTOP_RPC_SOCKET_PATH");
+ _socketPath = string.IsNullOrWhiteSpace(envSocketPath) ? socketPath : envSocketPath;
+ }
+
+ 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..cbd93b5
--- /dev/null
+++ b/Vpn.Linux/UnixSocketServerTransport.cs
@@ -0,0 +1,83 @@
+using System.Net.Sockets;
+using System.Runtime.Versioning;
+
+namespace Coder.Desktop.Vpn;
+
+[SupportedOSPlatform("linux")]
+public class UnixSocketServerTransport : IRpcServerTransport
+{
+ public const string DefaultSocketPath = "/run/coder-desktop/vpn.sock";
+
+ private readonly string _socketPath;
+ private Socket? _listener;
+
+ public UnixSocketServerTransport(string socketPath = DefaultSocketPath)
+ {
+ _socketPath = socketPath;
+ }
+
+ public async Task AcceptAsync(CancellationToken ct)
+ {
+ _listener ??= CreateListener();
+ var client = await _listener.AcceptAsync(ct);
+ return new NetworkStream(client, ownsSocket: true);
+ }
+
+ private Socket CreateListener()
+ {
+ // Only remove an existing path if it is a stale socket, never a
+ // regular file, since the path is configurable.
+ if (File.Exists(_socketPath))
+ {
+ if (new FileInfo(_socketPath).Length > 0)
+ throw new InvalidOperationException(
+ $"Refusing to replace existing non-socket file at RPC socket path '{_socketPath}'");
+ File.Delete(_socketPath);
+ }
+
+ var dir = Path.GetDirectoryName(_socketPath);
+ if (dir != null && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ try
+ {
+ listener.Bind(new UnixDomainSocketEndPoint(_socketPath));
+
+ // Allow all users to connect (equivalent to WorldSid on Windows).
+ File.SetUnixFileMode(_socketPath,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite |
+ UnixFileMode.GroupRead | UnixFileMode.GroupWrite |
+ UnixFileMode.OtherRead | UnixFileMode.OtherWrite);
+
+ listener.Listen(5);
+ return listener;
+ }
+ catch
+ {
+ listener.Dispose();
+ throw;
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ if (_listener != null)
+ {
+ _listener.Close();
+ _listener.Dispose();
+ _listener = null;
+
+ try
+ {
+ File.Delete(_socketPath);
+ }
+ catch
+ {
+ // Best effort cleanup of the socket file.
+ }
+ }
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/Vpn.Linux/Vpn.Linux.csproj b/Vpn.Linux/Vpn.Linux.csproj
new file mode 100644
index 0000000..a33c9bc
--- /dev/null
+++ b/Vpn.Linux/Vpn.Linux.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Coder.Desktop.Vpn.Linux
+ Coder.Desktop.Vpn
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
diff --git a/Vpn.Linux/packages.lock.json b/Vpn.Linux/packages.lock.json
new file mode 100644
index 0000000..5844675
--- /dev/null
+++ b/Vpn.Linux/packages.lock.json
@@ -0,0 +1,62 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net8.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
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..6e6d16d 100644
--- a/Vpn.Service/Manager.cs
+++ b/Vpn.Service/Manager.cs
@@ -160,6 +160,8 @@ private async ValueTask HandleClientMessageStart(ClientMessage me
await DownloadTunnelBinaryAsync(message.Start.CoderUrl, serverVersion.SemVersion, ct);
+ EnsureTunnelBinaryExecutable();
+
await BroadcastStartProgress(StartProgressStage.Finalizing, cancellationToken: ct);
await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMessage,
HandleTunnelRpcError,
@@ -186,7 +188,7 @@ await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMess
return new StartResponse
{
Success = false,
- ErrorMessage = e.ToString(),
+ ErrorMessage = BuildClientErrorMessage(e),
};
}
}
@@ -373,7 +375,7 @@ private static string SystemArchitecture()
{
Architecture.X64 => "amd64",
Architecture.Arm64 => "arm64",
- // We only support amd64 and arm64 on Windows currently.
+ // Coder only supports amd64 and arm64 currently.
_ => throw new PlatformNotSupportedException(
$"Unsupported architecture '{RuntimeInformation.ProcessArchitecture}'. Coder only supports amd64 and arm64."),
};
@@ -403,7 +405,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 +422,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 +438,16 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected
var validators = new CombinationDownloadValidator();
if (!string.IsNullOrEmpty(_config.TunnelBinarySignatureSigner))
{
+#if WINDOWS
_logger.LogDebug("Adding Authenticode signature validator for signer '{Signer}'",
_config.TunnelBinarySignatureSigner);
validators.Add(new AuthenticodeDownloadValidator(_config.TunnelBinarySignatureSigner));
+#else
+ // Fail fast rather than silently skipping a configured signature
+ // check in a build without Authenticode support.
+ throw new InvalidOperationException(
+ "TunnelBinarySignatureSigner is set, but Authenticode validation is only available in Windows builds");
+#endif
}
else
{
@@ -445,9 +456,17 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected
if (!_config.TunnelBinaryAllowVersionMismatch)
{
- _logger.LogDebug("Adding version validator for version '{ExpectedVersion}'", expectedVersion);
- validators.Add(new AssemblyVersionDownloadValidator((int)expectedVersion.Major, (int)expectedVersion.Minor,
- (int)expectedVersion.Patch));
+ if (OperatingSystem.IsWindows())
+ {
+ _logger.LogDebug("Adding version validator for version '{ExpectedVersion}'", expectedVersion);
+ validators.Add(new AssemblyVersionDownloadValidator((int)expectedVersion.Major, (int)expectedVersion.Minor,
+ (int)expectedVersion.Patch));
+ }
+ else
+ {
+ _logger.LogDebug(
+ "Skipping tunnel binary version validation: ProductVersion validation is only supported on Windows binaries");
+ }
}
else
{
@@ -500,6 +519,42 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected
_logger.LogInformation("Completed downloading VPN binary");
}
+ private static string BuildClientErrorMessage(Exception e)
+ {
+ var parts = new List();
+ Exception? cursor = e;
+ while (cursor != null)
+ {
+ if (!string.IsNullOrWhiteSpace(cursor.Message))
+ parts.Add(cursor.Message.Trim());
+ cursor = cursor.InnerException;
+ }
+
+ if (parts.Count == 0)
+ return "Unknown error";
+
+ return string.Join(" -> ", parts);
+ }
+
+ private void EnsureTunnelBinaryExecutable()
+ {
+ if (!OperatingSystem.IsLinux())
+ return;
+
+ if (!File.Exists(_config.TunnelBinaryPath))
+ throw new FileNotFoundException("Tunnel binary does not exist", _config.TunnelBinaryPath);
+
+ var mode = File.GetUnixFileMode(_config.TunnelBinaryPath);
+ var executeBits = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
+ if ((mode & executeBits) == executeBits)
+ return;
+
+ var updatedMode = mode | executeBits;
+ File.SetUnixFileMode(_config.TunnelBinaryPath, updatedMode);
+ _logger.LogDebug("Set executable mode on tunnel binary '{TunnelBinaryPath}' ({OldMode} -> {NewMode})",
+ _config.TunnelBinaryPath, mode, updatedMode);
+ }
+
private async Task BroadcastStartProgress(StartProgressStage stage, StartProgressDownloadProgress? downloadProgress = null, CancellationToken cancellationToken = default)
{
await FallibleBroadcast(new ServiceMessage
diff --git a/Vpn.Service/ManagerConfig.cs b/Vpn.Service/ManagerConfig.cs
index c60b1b8..218e036 100644
--- a/Vpn.Service/ManagerConfig.cs
+++ b/Vpn.Service/ManagerConfig.cs
@@ -2,21 +2,28 @@
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(AllowEmptyStrings = true)]
+ 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..cc3dff3 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,85 @@ 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())
+ {
+ // Only enable systemd integration when actually launched by systemd.
+ // Local/interactive runs on Linux should use the default console lifetime.
+ if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("NOTIFY_SOCKET")))
+ {
+ MainLogger.Information("Running as a systemd service");
+ builder.Services.AddSystemd();
+ }
+ else
+ {
+ MainLogger.Information("Running as a console application");
+ }
+
+ // Register Unix socket transport
+ builder.Services.AddSingleton(sp =>
+ {
+ var config = sp.GetRequiredService>().Value;
+ var socketPath = string.IsNullOrEmpty(config.ServiceRpcSocketPath)
+ ? UnixSocketServerTransport.DefaultSocketPath
+ : 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 +175,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/TunnelSupervisor.cs b/Vpn.Service/TunnelSupervisor.cs
index 7dd6738..4594854 100644
--- a/Vpn.Service/TunnelSupervisor.cs
+++ b/Vpn.Service/TunnelSupervisor.cs
@@ -64,6 +64,10 @@ public class TunnelSupervisor : ITunnelSupervisor
private Speaker? _speaker;
private Process? _subprocess;
+ private const int MaxRecentStderrLines = 6;
+ private readonly Queue _recentStderrLines = new();
+ private readonly object _recentStderrLock = new();
+
// ReSharper disable once ConvertToPrimaryConstructor
public TunnelSupervisor(ILogger logger)
{
@@ -83,6 +87,7 @@ public async Task StartAsync(string binPath,
try
{
await CleanupAsync(ct);
+ ClearRecentStderr();
_outPipe = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable);
_inPipe = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
@@ -108,7 +113,10 @@ public async Task StartAsync(string binPath,
_subprocess.ErrorDataReceived += (_, args) =>
{
if (!string.IsNullOrWhiteSpace(args.Data))
+ {
+ RecordStderrLine(args.Data);
_logger.LogInformation("stderr: {Data}", args.Data);
+ }
};
// Pass the other end of the pipes to the subprocess and dispose
@@ -142,7 +150,7 @@ public async Task StartAsync(string binPath,
}
catch (Exception e)
{
- throw new Exception("Failed to start RPC Speaker on pipes to subprocess", e);
+ throw CreateSpeakerStartupException(e);
}
}
catch (Exception e)
@@ -245,6 +253,70 @@ private async Task OnProcessExited(Task task)
}
}
+ private void ClearRecentStderr()
+ {
+ lock (_recentStderrLock)
+ {
+ _recentStderrLines.Clear();
+ }
+ }
+
+ private void RecordStderrLine(string line)
+ {
+ lock (_recentStderrLock)
+ {
+ _recentStderrLines.Enqueue(line.Trim());
+ while (_recentStderrLines.Count > MaxRecentStderrLines)
+ _recentStderrLines.Dequeue();
+ }
+ }
+
+ private string? GetRecentStderrSummary()
+ {
+ lock (_recentStderrLock)
+ {
+ if (_recentStderrLines.Count == 0)
+ return null;
+ return string.Join(" | ", _recentStderrLines);
+ }
+ }
+
+ private int? GetExitCodeIfExited()
+ {
+ try
+ {
+ if (_subprocess?.HasExited == true)
+ return _subprocess.ExitCode;
+ }
+ catch
+ {
+ // ignored (process may no longer be associated)
+ }
+
+ return null;
+ }
+
+ private Exception CreateSpeakerStartupException(Exception inner)
+ {
+ var recentStderr = GetRecentStderrSummary();
+ if (!string.IsNullOrWhiteSpace(recentStderr) &&
+ recentStderr.Contains("vpn-daemon subcommand is not supported on this platform",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return new PlatformNotSupportedException(
+ "The downloaded Coder CLI reports that vpn-daemon is not supported on this platform for this deployment build.",
+ inner);
+ }
+
+ var exitCode = GetExitCodeIfExited();
+ var exitCodeSuffix = exitCode != null ? $" (exit code {exitCode})" : string.Empty;
+ var stderrSuffix = !string.IsNullOrWhiteSpace(recentStderr)
+ ? $" Last stderr: {recentStderr}"
+ : string.Empty;
+
+ return new Exception($"Failed to start RPC Speaker on pipes to subprocess{exitCodeSuffix}.{stderrSuffix}", inner);
+ }
+
///
/// Cleans up the pipes and the subprocess if it's still running. This method must be called while holding the
/// semaphore.
diff --git a/Vpn.Service/Vpn.Service.csproj b/Vpn.Service/Vpn.Service.csproj
index aaed3cc..63961b2 100644
--- a/Vpn.Service/Vpn.Service.csproj
+++ b/Vpn.Service/Vpn.Service.csproj
@@ -3,11 +3,11 @@
Coder.Desktop.Vpn.Service
Exe
- net8.0-windows
+ net8.0;net8.0-windows
enable
enable
true
- 13
+ preview
CoderVpnService
coder.ico
@@ -26,9 +26,7 @@
-
-
@@ -36,6 +34,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Vpn.Service/packages.lock.json b/Vpn.Service/packages.lock.json
index 09c7b76..1d6b361 100644
--- a/Vpn.Service/packages.lock.json
+++ b/Vpn.Service/packages.lock.json
@@ -1,6 +1,443 @@
{
"version": 1,
"dependencies": {
+ "net8.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.Systemd": {
+ "type": "Direct",
+ "requested": "[9.0.4, )",
+ "resolved": "9.0.4",
+ "contentHash": "XcG1R/q82nlX9wTfOcQvS7oJGm2Bd1uv1q+TUzwYbAZcEBgSEzq2DV4m7d/V9Sm/Jt59eVEHzu1F9sdf6qN+vQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Hosting": "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"
+ }
+ },
+ "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.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.Linux": {
+ "type": "Project",
+ "dependencies": {
+ "Coder.Desktop.Vpn": "[1.0.0, )"
+ }
+ },
+ "Coder.Desktop.Vpn.Proto": {
+ "type": "Project",
+ "dependencies": {
+ "Google.Protobuf": "[3.29.3, )"
+ }
+ }
+ },
"net8.0-windows7.0": {
"Microsoft.Extensions.Hosting": {
"type": "Direct",
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/NamedPipeServerTransport.cs b/Vpn/NamedPipeServerTransport.cs
new file mode 100644
index 0000000..2840729
--- /dev/null
+++ b/Vpn/NamedPipeServerTransport.cs
@@ -0,0 +1,50 @@
+using System.IO.Pipes;
+using System.Runtime.Versioning;
+using System.Security.AccessControl;
+using System.Security.Principal;
+
+namespace Coder.Desktop.Vpn;
+
+[SupportedOSPlatform("windows")]
+public class NamedPipeServerTransport : IRpcServerTransport
+{
+ private readonly string _pipeName;
+
+ public NamedPipeServerTransport(string pipeName)
+ {
+ _pipeName = pipeName;
+ }
+
+ public async Task AcceptAsync(CancellationToken ct)
+ {
+ // 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.
+ var pipeServer = NamedPipeServerStreamAcl.Create(_pipeName, PipeDirection.InOut,
+ NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0,
+ 0, pipeSecurity);
+
+ try
+ {
+ await pipeServer.WaitForConnectionAsync(ct);
+ return pipeServer;
+ }
+ catch
+ {
+ await pipeServer.DisposeAsync();
+ throw;
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/Vpn/Vpn.csproj b/Vpn/Vpn.csproj
index 76a72eb..8e501b7 100644
--- a/Vpn/Vpn.csproj
+++ b/Vpn/Vpn.csproj
@@ -3,7 +3,7 @@
Coder.Desktop.Vpn
Coder.Desktop.Vpn
- net8.0-windows
+ net8.0;net8.0-windows
enable
enable
true
@@ -13,6 +13,12 @@
+
+
+
+
+
+
diff --git a/Vpn/packages.lock.json b/Vpn/packages.lock.json
index 8876fe4..0e5fb37 100644
--- a/Vpn/packages.lock.json
+++ b/Vpn/packages.lock.json
@@ -1,6 +1,57 @@
{
"version": 1,
"dependencies": {
+ "net8.0": {
+ "Microsoft.Extensions.Configuration": {
+ "type": "Direct",
+ "requested": "[9.0.1, )",
+ "resolved": "9.0.1",
+ "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.1",
+ "Microsoft.Extensions.Primitives": "9.0.1"
+ }
+ },
+ "Semver": {
+ "type": "Direct",
+ "requested": "[3.0.0, )",
+ "resolved": "3.0.0",
+ "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "5.0.1"
+ }
+ },
+ "System.IO.Pipelines": {
+ "type": "Direct",
+ "requested": "[9.0.1, )",
+ "resolved": "9.0.1",
+ "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg=="
+ },
+ "Google.Protobuf": {
+ "type": "Transitive",
+ "resolved": "3.29.3",
+ "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw=="
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "9.0.1",
+ "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "9.0.1"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "9.0.1",
+ "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g=="
+ },
+ "Coder.Desktop.Vpn.Proto": {
+ "type": "Project",
+ "dependencies": {
+ "Google.Protobuf": "[3.29.3, )"
+ }
+ }
+ },
"net8.0-windows7.0": {
"Microsoft.Extensions.Configuration": {
"type": "Direct",
diff --git a/scripts/run-linux-dev.sh b/scripts/run-linux-dev.sh
new file mode 100755
index 0000000..9dbefe9
--- /dev/null
+++ b/scripts/run-linux-dev.sh
@@ -0,0 +1,191 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+print_help() {
+ cat <<'HELP'
+Usage: scripts/run-linux-dev.sh [--no-build] [--sudo-service] [-- ]
+
+Builds and runs the Linux VPN service + Avalonia app together for local dev.
+
+Options:
+ --no-build Skip dotnet build before launch
+ --sudo-service Run Vpn.Service via sudo (recommended for real tunnel setup)
+ --help Show this help
+
+Any remaining arguments are passed through to the app executable.
+Examples:
+ scripts/run-linux-dev.sh
+ scripts/run-linux-dev.sh --no-build -- --minimized
+ scripts/run-linux-dev.sh --sudo-service
+HELP
+}
+
+build=1
+sudo_service=0
+app_args=()
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --no-build)
+ build=0
+ shift
+ ;;
+ --sudo-service)
+ sudo_service=1
+ shift
+ ;;
+ --help|-h)
+ print_help
+ exit 0
+ ;;
+ --)
+ shift
+ while [[ $# -gt 0 ]]; do
+ app_args+=("$1")
+ shift
+ done
+ ;;
+ *)
+ app_args+=("$1")
+ shift
+ ;;
+ esac
+done
+
+if [[ $sudo_service -eq 1 ]] && [[ ${EUID:-$(id -u)} -ne 0 ]]; then
+ if ! command -v sudo >/dev/null 2>&1; then
+ echo "[run-linux-dev] --sudo-service requested, but sudo is not installed"
+ exit 1
+ fi
+
+ echo "[run-linux-dev] Validating sudo access..."
+ sudo -v
+fi
+
+if [[ $build -eq 1 ]]; then
+ echo "[run-linux-dev] Building Vpn.Service..."
+ dotnet build "$ROOT_DIR/Vpn.Service" -c Release >/dev/null
+ echo "[run-linux-dev] Building App.Avalonia..."
+ dotnet build "$ROOT_DIR/App.Avalonia" -c Release >/dev/null
+fi
+
+runtime_base="${XDG_RUNTIME_DIR:-/tmp}"
+run_dir="$runtime_base/coder-desktop-dev"
+log_dir="$run_dir/logs"
+socket_path="$run_dir/vpn.sock"
+
+if [[ $sudo_service -eq 1 ]]; then
+ cache_dir="/tmp/coder-desktop-dev-root"
+else
+ cache_base="${XDG_CACHE_HOME:-$HOME/.cache}"
+ cache_dir="$cache_base/coder-desktop-dev"
+fi
+tunnel_binary_path="$cache_dir/coder-vpn"
+
+mkdir -p "$log_dir" "$cache_dir"
+rm -f "$socket_path"
+
+service_stdout_log="$log_dir/vpn-service.stdout.log"
+service_file_log="$log_dir/vpn-service.file.log"
+app_stdout_log="$log_dir/app.stdout.log"
+
+# For manual runs, show the tray window unless the caller explicitly requests
+# minimized/hidden startup behavior.
+start_mode_args=" ${app_args[*]} "
+if [[ "$start_mode_args" != *" --minimized "* ]] &&
+ [[ "$start_mode_args" != *" --start-hidden "* ]] &&
+ [[ "$start_mode_args" != *" --show "* ]]; then
+ app_args=(--show "${app_args[@]}")
+fi
+
+service_pid=""
+app_pid=""
+
+cleanup() {
+ if [[ -n "$app_pid" ]] && kill -0 "$app_pid" 2>/dev/null; then
+ kill "$app_pid" 2>/dev/null || true
+ wait "$app_pid" 2>/dev/null || true
+ fi
+
+ if [[ -n "$service_pid" ]] && kill -0 "$service_pid" 2>/dev/null; then
+ kill "$service_pid" 2>/dev/null || true
+ wait "$service_pid" 2>/dev/null || true
+ fi
+
+ if [[ $sudo_service -eq 1 ]] && command -v sudo >/dev/null 2>&1; then
+ # Best effort cleanup in case the sudo wrapper exited but the root service
+ # process remained alive.
+ sudo -n pkill -f "CoderVpnService.*Manager:ServiceRpcSocketPath=$socket_path" 2>/dev/null || true
+ fi
+}
+trap cleanup EXIT INT TERM
+
+if [[ $sudo_service -eq 1 ]]; then
+ echo "[run-linux-dev] Starting VPN service with sudo..."
+else
+ echo "[run-linux-dev] Starting VPN service..."
+fi
+
+if [[ $sudo_service -eq 1 ]] && [[ ${EUID:-$(id -u)} -ne 0 ]]; then
+ sudo --preserve-env=XDG_RUNTIME_DIR,XDG_CACHE_HOME,HOME \
+ "$ROOT_DIR/Vpn.Service/bin/Release/net8.0/CoderVpnService" \
+ "Manager:ServiceRpcSocketPath=$socket_path" \
+ "Manager:TunnelBinaryPath=$tunnel_binary_path" \
+ "Manager:TunnelBinaryAllowVersionMismatch=true" \
+ "Serilog:WriteTo:0:Args:path=$service_file_log" \
+ >"$service_stdout_log" 2>&1 &
+else
+ "$ROOT_DIR/Vpn.Service/bin/Release/net8.0/CoderVpnService" \
+ "Manager:ServiceRpcSocketPath=$socket_path" \
+ "Manager:TunnelBinaryPath=$tunnel_binary_path" \
+ "Manager:TunnelBinaryAllowVersionMismatch=true" \
+ "Serilog:WriteTo:0:Args:path=$service_file_log" \
+ >"$service_stdout_log" 2>&1 &
+fi
+service_pid="$!"
+
+for _ in {1..40}; do
+ if [[ -S "$socket_path" ]]; then
+ break
+ fi
+
+ if ! kill -0 "$service_pid" 2>/dev/null; then
+ echo "[run-linux-dev] VPN service exited before creating socket: $socket_path"
+ echo "[run-linux-dev] Last service output:"
+ tail -n 40 "$service_stdout_log" || true
+ exit 1
+ fi
+
+ sleep 0.25
+done
+
+if [[ ! -S "$socket_path" ]]; then
+ echo "[run-linux-dev] Timed out waiting for VPN socket: $socket_path"
+ echo "[run-linux-dev] Last service output:"
+ tail -n 40 "$service_stdout_log" || true
+ exit 1
+fi
+
+echo "[run-linux-dev] Starting Avalonia app..."
+CODER_DESKTOP_RPC_SOCKET_PATH="$socket_path" \
+"$ROOT_DIR/App.Avalonia/bin/Release/net8.0/Coder Desktop" "${app_args[@]}" \
+ >"$app_stdout_log" 2>&1 &
+app_pid="$!"
+
+echo "[run-linux-dev] Service PID: $service_pid"
+echo "[run-linux-dev] App PID: $app_pid"
+echo "[run-linux-dev] Socket: $socket_path"
+echo "[run-linux-dev] Tunnel bin: $tunnel_binary_path"
+if [[ $sudo_service -eq 1 ]]; then
+ echo "[run-linux-dev] Service mode: sudo"
+fi
+echo "[run-linux-dev] Logs:"
+echo " - $service_stdout_log"
+echo " - $service_file_log"
+echo " - $app_stdout_log"
+echo "[run-linux-dev] App writes bootstrap logs to ~/.local/state/coder-desktop/app-avalonia.log"
+
+echo "[run-linux-dev] Waiting for app to exit (Ctrl+C to stop both)..."
+wait "$app_pid"