Skip to content

chore: various finish line tasks #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 20, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -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\
61 changes: 61 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -403,5 +403,7 @@ FodyWeavers.xsd
.idea/**/shelf

publish
WindowsAppRuntimeInstall-x64.exe
WindowsAppRuntimeInstall-*.exe
windowsdesktop-runtime-*.exe
wintun.dll
wintun-*.dll
32 changes: 26 additions & 6 deletions App/App.csproj
Original file line number Diff line number Diff line change
@@ -10,22 +10,42 @@
<PublishProfile>Properties\PublishProfiles\win-$(Platform).pubxml</PublishProfile>
<UseWinUI>true</UseWinUI>
<Nullable>enable</Nullable>
<EnableMsixTooling>false</EnableMsixTooling>
<EnableMsixTooling>true</EnableMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- To use CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute: -->
<LangVersion>preview</LangVersion>
<!-- We have our own implementation of main with exception handling -->
<DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>

<PropertyGroup Condition="$(Configuration) == 'Release'">
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>CopyUsed</TrimMode>
<PublishReadyToRun>true</PublishReadyToRun>
<SelfContained>true</SelfContained>
</PropertyGroup>

<ItemGroup>
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>

<ItemGroup>
<Content Include="Images\SplashScreen.scale-200.png" />
<Content Include="Images\Square150x150Logo.scale-200.png" />
<Content Include="Images\Square44x44Logo.scale-200.png" />
</ItemGroup>
<!--
Clean up unnecessary files (including .xbf XAML Binary Format files)
and (now) empty directories from target. The .xbf files are not
necessary as they are contained within resources.pri.
-->
<Target Name="CleanupTargetDir" AfterTargets="Build;_GenerateProjectPriFileCore" Condition="$(Configuration) == 'Release'">
<ItemGroup>
<FilesToDelete Include="$(TargetDir)**\*.xbf" />
<FilesToDelete Include="$(TargetDir)createdump.exe" />
<DirsToDelete Include="$(TargetDir)Controls" />
<DirsToDelete Include="$(TargetDir)Views" />
</ItemGroup>

<Delete Files="@(FilesToDelete)" />
<RemoveDir Directories="@(DirsToDelete)" />
</Target>

<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
21 changes: 16 additions & 5 deletions App/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -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<IRpcController>();
// TODO: send a StopRequest if we're connected???
await rpcManager.DisposeAsync();
Environment.Exit(0);
}

protected override void OnLaunched(LaunchActivatedEventArgs args)
{
var trayWindow = _services.GetRequiredService<TrayWindow>();

// Prevent the TrayWindow from closing, just hide it.
trayWindow.Closed += (sender, args) =>
{
if (!_handleWindowClosed) return;
args.Handled = true;
trayWindow.AppWindow.Hide();
};
Binary file removed App/Images/SplashScreen.scale-200.png
Binary file not shown.
Binary file removed App/Images/Square150x150Logo.scale-200.png
Binary file not shown.
Binary file removed App/Images/Square44x44Logo.scale-200.png
Binary file not shown.
83 changes: 83 additions & 0 deletions App/Program.cs
Original file line number Diff line number Diff line change
@@ -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
4 changes: 1 addition & 3 deletions App/Properties/PublishProfiles/win-arm64.pubxml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
@@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>ARM64</Platform>
<RuntimeIdentifier>win-arm64</RuntimeIdentifier>
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
<SelfContained>true</SelfContained>
<PublishSingleFile>False</PublishSingleFile>
</PropertyGroup>
</Project>
4 changes: 1 addition & 3 deletions App/Properties/PublishProfiles/win-x64.pubxml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
@@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>x64</Platform>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
<SelfContained>true</SelfContained>
<PublishSingleFile>False</PublishSingleFile>
</PropertyGroup>
</Project>
4 changes: 1 addition & 3 deletions App/Properties/PublishProfiles/win-x86.pubxml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
@@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>x86</Platform>
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
<SelfContained>true</SelfContained>
<PublishSingleFile>False</PublishSingleFile>
</PropertyGroup>
</Project>
22 changes: 14 additions & 8 deletions App/Services/CredentialManager.cs
Original file line number Diff line number Diff line change
@@ -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<CredentialModel> CredentialsChanged;
@@ -123,7 +135,7 @@ private void UpdateState(CredentialModel newModel)
RawCredentials? credentials;
try
{
credentials = JsonSerializer.Deserialize<RawCredentials>(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;
9 changes: 8 additions & 1 deletion App/Services/RpcController.cs
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ public VpnLifecycleException(string message) : base(message)
}
}

public interface IRpcController
public interface IRpcController : IAsyncDisposable
{
public event EventHandler<RpcModel> 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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why this is necessary.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't understand why the entire DisposeAsync implementation is necessary or why the GC.SuppressFinalize(this); is?

If you mean the whole implementation, we're calling it when the app closes to close the named pipe and remove the async receive tasks from the background.

If you mean the GC.SuppressFinalize(this) thing, it prevents the ~RpcController() finalizer method from being called. Since we're not using it and we don't need to clean up any unmanaged resources here, we just tell the GC to not bother calling it during a GC run later on.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I meant SuppressFinalize.

I don't see an explicit ~RpcController() method--I assume this is implicit? Is the need to suppress it required for correctness (i.e. it is not idempotent), or is suppressing it just a performance optimization?

I ask because I'm concerned that this is somewhat fragile. _speaker is the only resource that needs to be cleaned up today, but if we add a new resource and forget to include it in this method, presumably we'd leak it by supressing the implicit finalizer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly I don't know that much about how the C# GC works, so I'm not sure. From what I can tell in most cases where you don't explicitly write a finalizer it won't try to run it, so it's probably fine without it.

The main reason this is here is because the linter said we should include it even though we don't have a finalizer:

To prevent derived types with finalizers from having to reimplement IDisposable and to call it, unsealed types without finalizers should still call GC.SuppressFinalize.

So it seems like it's more about convention

}

private void MutateState(Action<RpcModel> mutator)
{
RpcModel newState;
68 changes: 48 additions & 20 deletions App/ViewModels/TrayWindowViewModel.cs
Original file line number Diff line number Diff line change
@@ -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<ByteString> workspacesWithAgents = [];
@@ -114,33 +129,31 @@ 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,
HostnameSuffix = fqdnSuffix,
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,32 +167,47 @@ 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;

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();
}
}
5 changes: 2 additions & 3 deletions App/Views/Pages/SignInTokenPage.xaml
Original file line number Diff line number Diff line change
@@ -57,14 +57,13 @@
HorizontalAlignment="Right"
Padding="10" />

<TextBox
<PasswordBox
Grid.Column="1"
Grid.Row="2"
HorizontalAlignment="Stretch"
PlaceholderText="Paste your token here"
LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}"
Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}"
InputScope="Password" />
Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" />

<TextBlock
Grid.Column="1"
7 changes: 5 additions & 2 deletions App/Views/Pages/TrayWindowMainPage.xaml
Original file line number Diff line number Diff line change
@@ -15,8 +15,10 @@
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<converters:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibilityConverter" />

<converters:VpnLifecycleToBoolConverter x:Key="ConnectingBoolConverter" Starting="true" Stopping="true" />
<converters:VpnLifecycleToBoolConverter x:Key="ConnectingBoolConverter" Unknown="true" Starting="true"
Stopping="true" />
<converters:VpnLifecycleToBoolConverter x:Key="NotConnectingBoolConverter" Started="true" Stopped="true" />
<converters:VpnLifecycleToBoolConverter x:Key="StoppedBoolConverter" Stopped="true" />
<converters:VpnLifecycleToVisibilityConverter x:Key="StartedVisibilityConverter" Started="true" />
<converters:VpnLifecycleToVisibilityConverter x:Key="NotStartedVisibilityConverter" Starting="true"
Stopping="true" Stopped="true" />
@@ -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 @@

<HyperlinkButton
Command="{x:Bind ViewModel.SignOutCommand, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource StoppedBoolConverter}, Mode=OneWay}"
Margin="-12,0"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left">
11 changes: 11 additions & 0 deletions App/Views/SignInWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
5 changes: 3 additions & 2 deletions App/Views/TrayWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -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);
19 changes: 16 additions & 3 deletions CoderSdk/CoderApiClient.cs
Original file line number Diff line number Diff line change
@@ -17,6 +17,12 @@ public override string ConvertName(string name)
}
}

[JsonSerializable(typeof(BuildInfo))]
[JsonSerializable(typeof(User))]
public partial class CoderSdkJsonContext : JsonSerializerContext
{
}

/// <summary>
/// Provides a limited selection of API methods for a Coder instance.
/// </summary>
@@ -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,16 +61,22 @@ public void SetSessionToken(string token)
_httpClient.DefaultRequestHeaders.Add("Coder-Session-Token", token);
}

private async Task<TResponse> SendRequestAsync<TResponse>(HttpMethod method, string path,
object? payload, CancellationToken ct = default)
private async Task<TResponse> SendRequestNoBodyAsync<TResponse>(HttpMethod method, string path,
CancellationToken ct = default)
{
return await SendRequestAsync<object, TResponse>(method, path, null, ct);
}

private async Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod method, string path,
TRequest? payload, CancellationToken ct = default)
{
try
{
var request = new HttpRequestMessage(method, path);

if (payload is not null)
{
var json = JsonSerializer.Serialize(payload, _jsonOptions);
var json = JsonSerializer.Serialize(payload, typeof(TRequest), _jsonOptions);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
}

2 changes: 1 addition & 1 deletion CoderSdk/Deployment.cs
Original file line number Diff line number Diff line change
@@ -17,6 +17,6 @@ public partial class CoderApiClient
{
public Task<BuildInfo> GetBuildInfo(CancellationToken ct = default)
{
return SendRequestAsync<BuildInfo>(HttpMethod.Get, "/api/v2/buildinfo", null, ct);
return SendRequestNoBodyAsync<BuildInfo>(HttpMethod.Get, "/api/v2/buildinfo", ct);
}
}
2 changes: 1 addition & 1 deletion CoderSdk/Users.cs
Original file line number Diff line number Diff line change
@@ -12,6 +12,6 @@ public partial class CoderApiClient
{
public Task<User> GetUser(string user, CancellationToken ct = default)
{
return SendRequestAsync<User>(HttpMethod.Get, $"/api/v2/users/{user}", null, ct);
return SendRequestNoBodyAsync<User>(HttpMethod.Get, $"/api/v2/users/{user}", ct);
}
}
60 changes: 49 additions & 11 deletions Publish-Alpha.ps1
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
# Usage: Publish-Alpha.ps1 [-arch <x64|arm64>]
param (
[ValidateSet("x64", "arm64")]
[string] $arch = "x64"
)

# CD to the directory of this PS script
Push-Location $PSScriptRoot

# Create a publish directory
$publishDir = Join-Path $PSScriptRoot "publish"
New-Item -ItemType Directory -Path "publish" -Force
$publishDir = Join-Path $PSScriptRoot "publish/$arch"
if (Test-Path $publishDir) {
# prompt the user to confirm the deletion
$confirm = Read-Host "The directory $publishDir already exists. Do you want to delete it? (y/n)"
@@ -17,39 +24,57 @@ New-Item -ItemType Directory -Path $publishDir

# Build in release mode
dotnet.exe clean
dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a x64 -o $publishDir\service
$servicePublishDir = Join-Path $publishDir "service"
dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a $arch -o $servicePublishDir
# App needs to be built with msbuild
$appPublishDir = Join-Path $publishDir "app"
$msbuildBinary = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe
& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=x64 /p:OutputPath=..\publish\app /p:GenerateAppxPackageOnBuild=true
& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=$arch /p:OutputPath=$appPublishDir

$scriptsDir = Join-Path $publishDir "scripts"
New-Item -ItemType Directory -Path $scriptsDir

# Download the 1.6.250108002 redistributable zip from here and drop the x64
# version in the root of the repo:
# Download 8.0.12 Desktop runtime from here for both amd64 and arm64:
# https://dotnet.microsoft.com/en-us/download/dotnet/8.0
$dotnetRuntimeInstaller = Join-Path $PSScriptRoot "windowsdesktop-runtime-8.0.12-win-$($arch).exe"
Copy-Item $dotnetRuntimeInstaller $scriptsDir

# Download the 1.6.250108002 redistributable zip from here and drop the executables
# in the root of the repo:
# https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/downloads
$windowsAppSdkInstaller = Join-Path $PSScriptRoot "WindowsAppRuntimeInstall-x64.exe"
$windowsAppSdkInstaller = Join-Path $PSScriptRoot "WindowsAppRuntimeInstall-$($arch).exe"
Copy-Item $windowsAppSdkInstaller $scriptsDir

# Acquire wintun.dll and put it in the root of the repo.
$wintunDll = Join-Path $PSScriptRoot "wintun.dll"
# Download wintun DLLs from https://www.wintun.net and place wintun-x64.dll and
# wintun-arm64.dll in the root of the repo.
$wintunDll = Join-Path $PSScriptRoot "wintun-$arch.dll"
Copy-Item $wintunDll $scriptsDir

# Add a PS1 script for installing the service
$installScript = Join-Path $scriptsDir "Install.ps1"
$installScriptContent = @"
try {
# Install .NET Desktop Runtime
`$dotNetInstallerPath = Join-Path `$PSScriptRoot "windowsdesktop-runtime-8.0.12-win-$($arch).exe"
Write-Host "Installing .NET Desktop Runtime from `$dotNetInstallerPath"
Start-Process `$dotNetInstallerPath -ArgumentList "/install /quiet /norestart" -Wait
# Install Windows App SDK
`$installerPath = Join-Path `$PSScriptRoot "WindowsAppRuntimeInstall-x64.exe"
Start-Process `$installerPath -ArgumentList "/silent" -Wait
Write-Host "Installing Windows App SDK from `$sdkInstallerPath"
`$sdkInstallerPath = Join-Path `$PSScriptRoot "WindowsAppRuntimeInstall-$($arch).exe"
Start-Process `$sdkInstallerPath -ArgumentList "--quiet" -Wait
# Install wintun.dll
`$wintunPath = Join-Path `$PSScriptRoot "wintun.dll"
Write-Host "Installing wintun.dll from `$wintunPath"
`$wintunPath = Join-Path `$PSScriptRoot "wintun-$($arch).dll"
Copy-Item `$wintunPath "C:\wintun.dll"
# Install and start the service
`$name = "Coder Desktop (Debug)"
`$binaryPath = Join-Path `$PSScriptRoot "..\service\Vpn.Service.exe" | Resolve-Path
Write-Host "Installing service"
New-Service -Name `$name -BinaryPathName `$binaryPath -StartupType Automatic
Write-Host "Starting service"
Start-Service -Name `$name
} catch {
Write-Host ""
@@ -76,10 +101,13 @@ $uninstallScriptContent = @"
try {
# Uninstall the service
`$name = "Coder Desktop (Debug)"
Write-Host "Stopping service"
Stop-Service -Name `$name
Write-Host "Deleting service"
sc.exe delete `$name
# Delete wintun.dll
Write-Host "Deleting wintun.dll"
Remove-Item "C:\wintun.dll"
# Maybe delete C:\coder-vpn.exe and C:\CoderDesktop.log
@@ -127,6 +155,11 @@ $readmeContent = @"
selecting "Exit".
2. Uninstall the service by double clicking `Uninstall.bat`.
## Troubleshooting
1. Try installing `scripts/windowsdesktop-runtime-8.0.12-win-$($arch).exe`.
2. Try installing `scripts/WindowsAppRuntimeInstall-$($arch).exe`.
3. Ensure `C:\wintun.dll` exists.
## Notes
- During install and uninstall a User Account Control popup will appear asking
for admin permissions. This is normal.
@@ -138,3 +171,8 @@ $readmeContent = @"
by double clicking `StartTrayApp.bat`.
"@
Set-Content -Path $readme -Value $readmeContent

# Zip everything in the publish directory and drop it into publish.
$zipFile = Join-Path $PSScriptRoot "publish\CoderDesktop-preview-$($arch).zip"
Remove-Item -Path $zipFile -ErrorAction SilentlyContinue
Compress-Archive -Path "$($publishDir)\*" -DestinationPath $zipFile
23 changes: 16 additions & 7 deletions Vpn.Service/Manager.cs
Original file line number Diff line number Diff line change
@@ -16,6 +16,12 @@ public enum TunnelStatus
Stopped,
}

public class ServerVersion
{
public required string String { get; set; }
public required SemVersion SemVersion { get; set; }
}

public interface IManager : IDisposable
{
public Task StopAsync(CancellationToken ct = default);
@@ -40,7 +46,7 @@ public class Manager : IManager
// TunnelSupervisor already has protections against concurrent operations,
// but all the other stuff before starting the tunnel does not.
private readonly RaiiSemaphoreSlim _tunnelOperationLock = new(1, 1);
private SemVersion? _lastServerVersion;
private ServerVersion? _lastServerVersion;
private StartRequest? _lastStartRequest;

private readonly RaiiSemaphoreSlim _statusLock = new(1, 1);
@@ -133,10 +139,9 @@ private async ValueTask<StartResponse> HandleClientMessageStart(ClientMessage me
try
{
var serverVersion =
await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken,
ct);
await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken, ct);
if (_status == TunnelStatus.Started && _lastStartRequest != null &&
_lastStartRequest.Equals(message.Start) && _lastServerVersion == serverVersion)
_lastStartRequest.Equals(message.Start) && _lastServerVersion?.String == serverVersion.String)
{
// The client is requesting to start an identical tunnel while
// we're already running it.
@@ -156,7 +161,7 @@ await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.Api
// Stop the tunnel if it's running so we don't have to worry about
// permissions issues when replacing the binary.
await _tunnelSupervisor.StopAsync(ct);
await DownloadTunnelBinaryAsync(message.Start.CoderUrl, serverVersion, ct);
await DownloadTunnelBinaryAsync(message.Start.CoderUrl, serverVersion.SemVersion, ct);
await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMessage,
HandleTunnelRpcError,
ct);
@@ -361,7 +366,7 @@ private static string SystemArchitecture()
/// <param name="ct">Cancellation token</param>
/// <returns>The server version</returns>
/// <exception cref="InvalidOperationException">The server version is not within the required range</exception>
private async ValueTask<SemVersion> CheckServerVersionAndCredentials(string baseUrl, string apiToken,
private async ValueTask<ServerVersion> CheckServerVersionAndCredentials(string baseUrl, string apiToken,
CancellationToken ct = default)
{
var client = new CoderApiClient(baseUrl, apiToken);
@@ -377,7 +382,11 @@ private async ValueTask<SemVersion> CheckServerVersionAndCredentials(string base
var user = await client.GetUser(User.Me, ct);
_logger.LogInformation("Authenticated to server as '{Username}'", user.Username);

return serverVersion;
return new ServerVersion
{
String = buildInfo.Version,
SemVersion = serverVersion,
};
}

/// <summary>
6 changes: 3 additions & 3 deletions Vpn.Service/ManagerConfig.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;

namespace Coder.Desktop.Vpn.Service;

[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")]
public class ManagerConfig
{
[Required]
[RegularExpression(@"^([a-zA-Z0-9_-]+\.)*[a-zA-Z0-9_-]+$")]
public string ServiceRpcPipeName { get; set; } = "Coder.Desktop.Vpn";

// TODO: pick a better default path
[Required]
public string TunnelBinaryPath { get; set; } = @"C:\coder-vpn.exe";

[Required]
public string LogFileLocation { get; set; } = @"C:\coder-desktop-service.log";
}
39 changes: 22 additions & 17 deletions Vpn.Service/Program.cs
Original file line number Diff line number Diff line change
@@ -8,29 +8,28 @@ namespace Coder.Desktop.Vpn.Service;

public static class Program
{
#if DEBUG
private const string ServiceName = "Coder Desktop (Debug)";
#if !DEBUG
private const string ServiceName = "Coder Desktop";
#else
const string ServiceName = "Coder Desktop";
private const string ServiceName = "Coder Desktop (Debug)";
#endif

private const string ConsoleOutputTemplate =
"[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}";

private const string FileOutputTemplate =
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}";

private static readonly ILogger MainLogger = Log.ForContext("SourceContext", "Coder.Desktop.Vpn.Service.Program");

private static LoggerConfiguration LogConfig = new LoggerConfiguration()
.Enrich.FromLogContext()
.MinimumLevel.Debug()
.WriteTo.Console(outputTemplate: ConsoleOutputTemplate);

public static async Task<int> Main(string[] args)
{
// Configure Serilog.
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
// TODO: configurable level
.MinimumLevel.Debug()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}")
// TODO: better location
.WriteTo.File(@"C:\CoderDesktop.log",
outputTemplate:
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}")
.CreateLogger();

Log.Logger = LogConfig.CreateLogger();
try
{
await BuildAndRun(args);
@@ -61,7 +60,13 @@ private static async Task BuildAndRun(string[] args)
// Options types (these get registered as IOptions<T> singletons)
builder.Services.AddOptions<ManagerConfig>()
.Bind(builder.Configuration.GetSection("Manager"))
.ValidateDataAnnotations();
.ValidateDataAnnotations()
.PostConfigure(config =>
{
LogConfig = LogConfig
.WriteTo.File(config.LogFileLocation, outputTemplate: FileOutputTemplate);
Log.Logger = LogConfig.CreateLogger();
});

// Logging
builder.Services.AddSerilog();