diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 8c42c13..b5059b2 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -51,10 +51,15 @@ jobs:
cache-dependency-path: '**/packages.lock.json'
- name: dotnet restore
run: dotnet restore --locked-mode
- #- name: dotnet publish
- # run: dotnet publish --no-restore --configuration Release --output .\publish
- #- name: Upload artifact
- # uses: actions/upload-artifact@v4
- # with:
- # name: publish
- # path: .\publish\
+ # This doesn't call `dotnet publish` on the entire solution, just the
+ # projects we care about building. Doing a full publish includes test
+ # libraries and stuff which is pointless.
+ - name: dotnet publish Coder.Desktop.Vpn.Service
+ run: dotnet publish .\Vpn.Service\Vpn.Service.csproj --configuration Release --output .\publish\Vpn.Service
+ - name: dotnet publish Coder.Desktop.App
+ run: dotnet publish .\App\App.csproj --configuration Release --output .\publish\App
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: publish
+ path: .\publish\
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..b9810fd
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,61 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - '*'
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Get version from tag
+ id: version
+ shell: pwsh
+ run: |
+ $tag = $env:GITHUB_REF -replace 'refs/tags/',''
+ if ($tag -notmatch '^v\d+\.\d+\.\d+$') {
+ throw "Tag must be in format v1.2.3"
+ }
+ $version = $tag -replace '^v',''
+ $assemblyVersion = "$version.0"
+ echo "VERSION=$version" >> $env:GITHUB_OUTPUT
+ echo "ASSEMBLY_VERSION=$assemblyVersion" >> $env:GITHUB_OUTPUT
+
+ - name: Build and publish x64
+ run: |
+ dotnet publish src/App/App.csproj -c Release -r win-x64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/x64
+ dotnet publish src/Vpn.Service/Vpn.Service.csproj -c Release -r win-x64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/x64
+
+ - name: Build and publish arm64
+ run: |
+ dotnet publish src/App/App.csproj -c Release -r win-arm64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/arm64
+ dotnet publish src/Vpn.Service/Vpn.Service.csproj -c Release -r win-arm64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/arm64
+
+ - name: Create ZIP archives
+ shell: pwsh
+ run: |
+ Compress-Archive -Path "publish/x64/*" -DestinationPath "./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-x64.zip"
+ Compress-Archive -Path "publish/arm64/*" -DestinationPath "./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-arm64.zip"
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ files: |
+ ./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-x64.zip
+ ./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-arm64.zip
+ name: Release ${{ steps.version.outputs.VERSION }}
+ generate_release_notes: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 4ea0881..54c47a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -403,5 +403,7 @@ FodyWeavers.xsd
.idea/**/shelf
publish
-WindowsAppRuntimeInstall-x64.exe
+WindowsAppRuntimeInstall-*.exe
+windowsdesktop-runtime-*.exe
wintun.dll
+wintun-*.dll
diff --git a/App/App.csproj b/App/App.csproj
index f6e3c0d..c28256a 100644
--- a/App/App.csproj
+++ b/App/App.csproj
@@ -10,22 +10,42 @@
Properties\PublishProfiles\win-$(Platform).pubxml
true
enable
- false
+ true
None
true
preview
+
+ DISABLE_XAML_GENERATED_MAIN
+
+
+
+ true
+ CopyUsed
+ true
+ true
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 515d404..af4217e 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -1,5 +1,5 @@
using System;
-using System.Diagnostics;
+using System.Threading.Tasks;
using Coder.Desktop.App.Services;
using Coder.Desktop.App.ViewModels;
using Coder.Desktop.App.Views;
@@ -13,6 +13,8 @@ public partial class App : Application
{
private readonly IServiceProvider _services;
+ private bool _handleWindowClosed = true;
+
public App()
{
var services = new ServiceCollection();
@@ -36,18 +38,27 @@ public App()
_services = services.BuildServiceProvider();
-#if DEBUG
- UnhandledException += (_, e) => { Debug.WriteLine(e.Exception.ToString()); };
-#endif
-
InitializeComponent();
}
+ public async Task ExitApplication()
+ {
+ _handleWindowClosed = false;
+ Exit();
+ var rpcManager = _services.GetRequiredService();
+ // TODO: send a StopRequest if we're connected???
+ await rpcManager.DisposeAsync();
+ Environment.Exit(0);
+ }
+
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
var trayWindow = _services.GetRequiredService();
+
+ // Prevent the TrayWindow from closing, just hide it.
trayWindow.Closed += (sender, args) =>
{
+ if (!_handleWindowClosed) return;
args.Handled = true;
trayWindow.AppWindow.Hide();
};
diff --git a/App/Images/SplashScreen.scale-200.png b/App/Images/SplashScreen.scale-200.png
deleted file mode 100644
index 32f486a..0000000
Binary files a/App/Images/SplashScreen.scale-200.png and /dev/null differ
diff --git a/App/Images/Square150x150Logo.scale-200.png b/App/Images/Square150x150Logo.scale-200.png
deleted file mode 100644
index 53ee377..0000000
Binary files a/App/Images/Square150x150Logo.scale-200.png and /dev/null differ
diff --git a/App/Images/Square44x44Logo.scale-200.png b/App/Images/Square44x44Logo.scale-200.png
deleted file mode 100644
index f713bba..0000000
Binary files a/App/Images/Square44x44Logo.scale-200.png and /dev/null differ
diff --git a/App/Program.cs b/App/Program.cs
new file mode 100644
index 0000000..2918caa
--- /dev/null
+++ b/App/Program.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Threading;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.Windows.AppLifecycle;
+using WinRT;
+
+namespace Coder.Desktop.App;
+
+#if DISABLE_XAML_GENERATED_MAIN
+public static class Program
+{
+ private static App? app;
+#if DEBUG
+ [DllImport("kernel32.dll")]
+ private static extern bool AllocConsole();
+#endif
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, int type);
+
+ [STAThread]
+ private static void Main(string[] args)
+ {
+ try
+ {
+ ComWrappersSupport.InitializeComWrappers();
+ if (!CheckSingleInstance()) return;
+ Application.Start(p =>
+ {
+ var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
+ SynchronizationContext.SetSynchronizationContext(context);
+
+ app = new App();
+ app.UnhandledException += (_, e) =>
+ {
+ e.Handled = true;
+ ShowExceptionAndCrash(e.Exception);
+ };
+ });
+ }
+ catch (Exception e)
+ {
+ ShowExceptionAndCrash(e);
+ }
+ }
+
+ [STAThread]
+ private static bool CheckSingleInstance()
+ {
+#if !DEBUG
+ const string appInstanceName = "Coder.Desktop.App";
+#else
+ const string appInstanceName = "Coder.Desktop.App.Debug";
+#endif
+
+ var instance = AppInstance.FindOrRegisterForKey(appInstanceName);
+ return instance.IsCurrent;
+ }
+
+ [STAThread]
+ private static void ShowExceptionAndCrash(Exception e)
+ {
+ const string title = "Coder Desktop Fatal Error";
+ var message =
+ "Coder Desktop has encountered a fatal error and must exit.\n\n" +
+ e + "\n\n" +
+ Environment.StackTrace;
+ MessageBoxW(IntPtr.Zero, message, title, 0);
+
+ if (app != null)
+ app.Exit();
+
+ // This will log the exception to the Windows Event Log.
+#if DEBUG
+ // And, if in DEBUG mode, it will also log to the console window.
+ AllocConsole();
+#endif
+ Environment.FailFast("Coder Desktop has encountered a fatal error and must exit.", e);
+ }
+}
+#endif
diff --git a/App/Properties/PublishProfiles/win-arm64.pubxml b/App/Properties/PublishProfiles/win-arm64.pubxml
index 5b906a9..ac9753e 100644
--- a/App/Properties/PublishProfiles/win-arm64.pubxml
+++ b/App/Properties/PublishProfiles/win-arm64.pubxml
@@ -1,4 +1,4 @@
-
+
@@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
ARM64
win-arm64
bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
- true
- False
diff --git a/App/Properties/PublishProfiles/win-x64.pubxml b/App/Properties/PublishProfiles/win-x64.pubxml
index d6e3ca5..942523b 100644
--- a/App/Properties/PublishProfiles/win-x64.pubxml
+++ b/App/Properties/PublishProfiles/win-x64.pubxml
@@ -1,4 +1,4 @@
-
+
@@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
x64
win-x64
bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
- true
- False
diff --git a/App/Properties/PublishProfiles/win-x86.pubxml b/App/Properties/PublishProfiles/win-x86.pubxml
index 084c7fe..e763481 100644
--- a/App/Properties/PublishProfiles/win-x86.pubxml
+++ b/App/Properties/PublishProfiles/win-x86.pubxml
@@ -1,4 +1,4 @@
-
+
@@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
x86
win-x86
bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
- true
- False
diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs
index 05dceec..4e47f47 100644
--- a/App/Services/CredentialManager.cs
+++ b/App/Services/CredentialManager.cs
@@ -2,6 +2,7 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
+using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Coder.Desktop.App.Models;
@@ -10,6 +11,17 @@
namespace Coder.Desktop.App.Services;
+public class RawCredentials
+{
+ public required string CoderUrl { get; set; }
+ public required string ApiToken { get; set; }
+}
+
+[JsonSerializable(typeof(RawCredentials))]
+public partial class RawCredentialsJsonContext : JsonSerializerContext
+{
+}
+
public interface ICredentialManager
{
public event EventHandler CredentialsChanged;
@@ -123,7 +135,7 @@ private void UpdateState(CredentialModel newModel)
RawCredentials? credentials;
try
{
- credentials = JsonSerializer.Deserialize(raw);
+ credentials = JsonSerializer.Deserialize(raw, RawCredentialsJsonContext.Default.RawCredentials);
}
catch (JsonException)
{
@@ -138,16 +150,10 @@ private void UpdateState(CredentialModel newModel)
private static void WriteCredentials(RawCredentials credentials)
{
- var raw = JsonSerializer.Serialize(credentials);
+ var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials);
NativeApi.WriteCredentials(CredentialsTargetName, raw);
}
- private class RawCredentials
- {
- public required string CoderUrl { get; set; }
- public required string ApiToken { get; set; }
- }
-
private static class NativeApi
{
private const int CredentialTypeGeneric = 1;
diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs
index 07ae38e..a02347f 100644
--- a/App/Services/RpcController.cs
+++ b/App/Services/RpcController.cs
@@ -32,7 +32,7 @@ public VpnLifecycleException(string message) : base(message)
}
}
-public interface IRpcController
+public interface IRpcController : IAsyncDisposable
{
public event EventHandler StateChanged;
@@ -224,6 +224,13 @@ public async Task StopVpn(CancellationToken ct = default)
new InvalidOperationException($"Service reported failure: {reply.Stop.ErrorMessage}"));
}
+ public async ValueTask DisposeAsync()
+ {
+ if (_speaker != null)
+ await _speaker.DisposeAsync();
+ GC.SuppressFinalize(this);
+ }
+
private void MutateState(Action mutator)
{
RpcModel newState;
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
index 5fcd84e..c643d2f 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -3,6 +3,7 @@
using System.Linq;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
+using Coder.Desktop.Vpn.Proto;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Google.Protobuf;
@@ -15,6 +16,7 @@ namespace Coder.Desktop.App.ViewModels;
public partial class TrayWindowViewModel : ObservableObject
{
private const int MaxAgents = 5;
+ private const string DefaultDashboardUrl = "https://coder.com";
private readonly IRpcController _rpcController;
private readonly ICredentialManager _credentialManager;
@@ -24,9 +26,9 @@ public partial class TrayWindowViewModel : ObservableObject
[ObservableProperty]
public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
- // VpnSwitchOn needs to be its own property as it is a two-way binding
+ // This is a separate property because we need the switch to be 2-way.
[ObservableProperty]
- public partial bool VpnSwitchOn { get; set; } = false;
+ public partial bool VpnSwitchActive { get; set; } = false;
[ObservableProperty]
public partial string? VpnFailedMessage { get; set; } = null;
@@ -82,13 +84,26 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected)
{
VpnLifecycle = VpnLifecycle.Unknown;
- VpnSwitchOn = false;
+ VpnSwitchActive = false;
Agents = [];
return;
}
VpnLifecycle = rpcModel.VpnLifecycle;
- VpnSwitchOn = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
+ VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
+
+ // Get the current dashboard URL.
+ var credentialModel = _credentialManager.GetCredentials();
+ Uri? coderUri = null;
+ if (credentialModel.State == CredentialState.Valid && !string.IsNullOrWhiteSpace(credentialModel.CoderUrl))
+ try
+ {
+ coderUri = new Uri(credentialModel.CoderUrl, UriKind.Absolute);
+ }
+ catch
+ {
+ // Ignore
+ }
// Add every known agent.
HashSet workspacesWithAgents = [];
@@ -114,6 +129,8 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime());
workspacesWithAgents.Add(agent.WorkspaceId);
+ var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId);
+
agents.Add(new AgentViewModel
{
Hostname = fqdnPrefix,
@@ -121,26 +138,22 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
ConnectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5)
? AgentConnectionStatus.Green
: AgentConnectionStatus.Red,
- // TODO: we don't actually have any way of crafting a dashboard
- // URL without the owner's username
- DashboardUrl = "https://coder.com",
+ DashboardUrl = WorkspaceUri(coderUri, workspace?.Name),
});
}
- // For every workspace that doesn't have an agent, add a dummy agent.
- foreach (var workspace in rpcModel.Workspaces.Where(w => !workspacesWithAgents.Contains(w.Id)))
- {
+ // For every stopped workspace that doesn't have any agents, add a
+ // dummy agent row.
+ foreach (var workspace in rpcModel.Workspaces.Where(w =>
+ w.Status == Workspace.Types.Status.Stopped && !workspacesWithAgents.Contains(w.Id)))
agents.Add(new AgentViewModel
{
// We just assume that it's a single-agent workspace.
Hostname = workspace.Name,
HostnameSuffix = ".coder",
ConnectionStatus = AgentConnectionStatus.Gray,
- // TODO: we don't actually have any way of crafting a dashboard
- // URL without the owner's username
- DashboardUrl = "https://coder.com",
+ DashboardUrl = WorkspaceUri(coderUri, workspace.Name),
});
- }
// Sort by status green, red, gray, then by hostname.
agents.Sort((a, b) =>
@@ -154,18 +167,29 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
if (Agents.Count < MaxAgents) ShowAllAgents = false;
}
+ private string WorkspaceUri(Uri? baseUri, string? workspaceName)
+ {
+ if (baseUri == null) return DefaultDashboardUrl;
+ if (string.IsNullOrWhiteSpace(workspaceName)) return baseUri.ToString();
+ try
+ {
+ return new Uri(baseUri, $"/@me/{workspaceName}").ToString();
+ }
+ catch
+ {
+ return DefaultDashboardUrl;
+ }
+ }
+
private void UpdateFromCredentialsModel(CredentialModel credentialModel)
{
// HACK: the HyperlinkButton crashes the whole app if the initial URI
// or this URI is invalid. CredentialModel.CoderUrl should never be
// null while the Page is active as the Page is only displayed when
// CredentialModel.Status == Valid.
- DashboardUrl = credentialModel.CoderUrl ?? "https://coder.com";
+ DashboardUrl = credentialModel.CoderUrl ?? DefaultDashboardUrl;
}
- // VpnSwitch_Toggled is handled separately than just listening to the
- // property change as we need to be able to tell the difference between the
- // user toggling the switch and the switch being toggled by code.
public void VpnSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (sender is not ToggleSwitch toggleSwitch) return;
@@ -173,13 +197,17 @@ public void VpnSwitch_Toggled(object sender, RoutedEventArgs e)
VpnFailedMessage = "";
try
{
- if (toggleSwitch.IsOn)
+ // The start/stop methods will call back to update the state.
+ if (toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Stopped)
_rpcController.StartVpn();
- else
+ else if (!toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Started)
_rpcController.StopVpn();
+ else
+ toggleSwitch.IsOn = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
}
catch
{
+ // TODO: display error
VpnFailedMessage = e.ToString();
}
}
diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml
index a09efb8..93a1796 100644
--- a/App/Views/Pages/SignInTokenPage.xaml
+++ b/App/Views/Pages/SignInTokenPage.xaml
@@ -57,14 +57,13 @@
HorizontalAlignment="Right"
Padding="10" />
-
+ Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" />
-
+
+
@@ -56,7 +58,7 @@
Grid.Column="2"
OnContent=""
OffContent=""
- IsOn="{x:Bind ViewModel.VpnSwitchOn, Mode=TwoWay}"
+ IsOn="{x:Bind ViewModel.VpnSwitchActive, Mode=TwoWay}"
IsEnabled="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource NotConnectingBoolConverter}, Mode=OneWay}"
Toggled="{x:Bind ViewModel.VpnSwitch_Toggled}"
Margin="0,0,-110,0"
@@ -204,6 +206,7 @@
diff --git a/App/Views/SignInWindow.xaml.cs b/App/Views/SignInWindow.xaml.cs
index 5549e7c..771dda0 100644
--- a/App/Views/SignInWindow.xaml.cs
+++ b/App/Views/SignInWindow.xaml.cs
@@ -1,6 +1,7 @@
using Windows.Graphics;
using Coder.Desktop.App.ViewModels;
using Coder.Desktop.App.Views.Pages;
+using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
namespace Coder.Desktop.App.Views;
@@ -24,6 +25,7 @@ public SignInWindow(SignInViewModel viewModel)
NavigateToUrlPage();
ResizeWindow();
+ MoveWindowToCenterOfDisplay();
}
public void NavigateToTokenPage()
@@ -43,4 +45,13 @@ private void ResizeWindow()
var width = (int)(WIDTH * scale);
AppWindow.Resize(new SizeInt32(width, height));
}
+
+ private void MoveWindowToCenterOfDisplay()
+ {
+ var workArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Primary).WorkArea;
+ var x = (workArea.Width - AppWindow.Size.Width) / 2;
+ var y = (workArea.Height - AppWindow.Size.Height) / 2;
+ if (x < 0 || y < 0) return;
+ AppWindow.Move(new PointInt32(x, y));
+ }
}
diff --git a/App/Views/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs
index 224fae2..b528723 100644
--- a/App/Views/TrayWindow.xaml.cs
+++ b/App/Views/TrayWindow.xaml.cs
@@ -247,10 +247,11 @@ private void Tray_Open()
[RelayCommand]
private void Tray_Exit()
{
- Application.Current.Exit();
+ // It's fine that this happens in the background.
+ _ = ((App)Application.Current).ExitApplication();
}
- public class NativeApi
+ public static class NativeApi
{
[DllImport("dwmapi.dll")]
public static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
diff --git a/CoderSdk/CoderApiClient.cs b/CoderSdk/CoderApiClient.cs
index 34863f1..016998d 100644
--- a/CoderSdk/CoderApiClient.cs
+++ b/CoderSdk/CoderApiClient.cs
@@ -17,6 +17,12 @@ public override string ConvertName(string name)
}
}
+[JsonSerializable(typeof(BuildInfo))]
+[JsonSerializable(typeof(User))]
+public partial class CoderSdkJsonContext : JsonSerializerContext
+{
+}
+
///
/// Provides a limited selection of API methods for a Coder instance.
///
@@ -37,6 +43,7 @@ public CoderApiClient(Uri baseUrl)
_httpClient.BaseAddress = baseUrl;
_jsonOptions = new JsonSerializerOptions
{
+ TypeInfoResolver = CoderSdkJsonContext.Default,
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = new SnakeCaseNamingPolicy(),
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
@@ -54,8 +61,14 @@ public void SetSessionToken(string token)
_httpClient.DefaultRequestHeaders.Add("Coder-Session-Token", token);
}
- private async Task SendRequestAsync(HttpMethod method, string path,
- object? payload, CancellationToken ct = default)
+ private async Task SendRequestNoBodyAsync(HttpMethod method, string path,
+ CancellationToken ct = default)
+ {
+ return await SendRequestAsync