Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c2791f5

Browse files
authoredFeb 12, 2025··
feat: add agent status to tray app (#21)
Closes #5
1 parent 641f1bc commit c2791f5

23 files changed

+724
-446
lines changed
 

‎.github/workflows/ci.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ jobs:
5151
cache-dependency-path: '**/packages.lock.json'
5252
- name: dotnet restore
5353
run: dotnet restore --locked-mode
54-
- name: dotnet publish
55-
run: dotnet publish --no-restore --configuration Release --output .\publish
56-
- name: Upload artifact
57-
uses: actions/upload-artifact@v4
58-
with:
59-
name: publish
60-
path: .\publish\
54+
#- name: dotnet publish
55+
# run: dotnet publish --no-restore --configuration Release --output .\publish
56+
#- name: Upload artifact
57+
# uses: actions/upload-artifact@v4
58+
# with:
59+
# name: publish
60+
# path: .\publish\

‎.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,3 +403,5 @@ FodyWeavers.xsd
403403
.idea/**/shelf
404404

405405
publish
406+
WindowsAppRuntimeInstall-x64.exe
407+
wintun.dll

‎App/App.csproj

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,13 @@
1010
<PublishProfile>Properties\PublishProfiles\win-$(Platform).pubxml</PublishProfile>
1111
<UseWinUI>true</UseWinUI>
1212
<Nullable>enable</Nullable>
13-
<EnableMsixTooling>true</EnableMsixTooling>
13+
<EnableMsixTooling>false</EnableMsixTooling>
14+
<WindowsPackageType>None</WindowsPackageType>
1415
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
1516
<!-- To use CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute: -->
1617
<LangVersion>preview</LangVersion>
1718
</PropertyGroup>
1819

19-
<ItemGroup>
20-
<AppxManifest Include="Package.appxmanifest">
21-
<SubType>Designer</SubType>
22-
</AppxManifest>
23-
</ItemGroup>
24-
2520
<ItemGroup>
2621
<Manifest Include="$(ApplicationManifest)" />
2722
</ItemGroup>
@@ -40,43 +35,12 @@
4035
</PackageReference>
4136
<PackageReference Include="H.NotifyIcon.WinUI" Version="2.2.0" />
4237
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.1" />
43-
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" />
4438
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250108002" />
4539
</ItemGroup>
4640

47-
<!--
48-
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
49-
Tools extension to be activated for this project even if the Windows App SDK Nuget
50-
package has not yet been restored.
51-
-->
52-
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
53-
<ProjectCapability Include="Msix" />
54-
</ItemGroup>
5541
<ItemGroup>
5642
<ProjectReference Include="..\CoderSdk\CoderSdk.csproj" />
5743
<ProjectReference Include="..\Vpn.Proto\Vpn.Proto.csproj" />
5844
<ProjectReference Include="..\Vpn\Vpn.csproj" />
5945
</ItemGroup>
60-
61-
<!--
62-
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
63-
Explorer "Package and Publish" context menu entry to be enabled for this project even if
64-
the Windows App SDK Nuget package has not yet been restored.
65-
-->
66-
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
67-
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
68-
</PropertyGroup>
69-
70-
<!-- Publish Properties -->
71-
<PropertyGroup>
72-
<!--
73-
This does not work in CI at the moment, so we need to set it to false
74-
Error: C:\Program Files\dotnet\sdk\9.0.102\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Publish.targets(400,5): error NETSDK1094: Unable to optimize assemblies for performance: a valid runtime package was not found. Either set the PublishReadyToRun property to false, or use a supported runtime identifier when publishing. When targeting .NET 6 or higher, make sure to restore packages with the PublishReadyToRun property set to true. [D:\a\coder-desktop-windows\coder-desktop-windows\App\App.csproj]
75-
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
76-
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
77-
-->
78-
<PublishReadyToRun>False</PublishReadyToRun>
79-
<PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed>
80-
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
81-
</PropertyGroup>
8246
</Project>

‎App/App.xaml.cs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ namespace Coder.Desktop.App;
1212
public partial class App : Application
1313
{
1414
private readonly IServiceProvider _services;
15-
private readonly bool _handleClosedEvents = true;
1615

1716
public App()
1817
{
@@ -49,12 +48,8 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
4948
var trayWindow = _services.GetRequiredService<TrayWindow>();
5049
trayWindow.Closed += (sender, args) =>
5150
{
52-
// TODO: wire up HandleClosedEvents properly
53-
if (_handleClosedEvents)
54-
{
55-
args.Handled = true;
56-
trayWindow.AppWindow.Hide();
57-
}
51+
args.Handled = true;
52+
trayWindow.AppWindow.Hide();
5853
};
5954
}
6055
}

‎App/Converters/VpnLifecycleToBoolConverter.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
namespace Coder.Desktop.App.Converters;
88

9+
[DependencyProperty<bool>("Unknown", DefaultValue = false)]
910
[DependencyProperty<bool>("Starting", DefaultValue = false)]
1011
[DependencyProperty<bool>("Started", DefaultValue = false)]
1112
[DependencyProperty<bool>("Stopping", DefaultValue = false)]
@@ -18,6 +19,7 @@ public object Convert(object value, Type targetType, object parameter, string la
1819

1920
return lifecycle switch
2021
{
22+
VpnLifecycle.Unknown => Unknown,
2123
VpnLifecycle.Starting => Starting,
2224
VpnLifecycle.Started => Started,
2325
VpnLifecycle.Stopping => Stopping,

‎App/Models/RpcModel.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System.Collections.Generic;
2+
using System.Linq;
3+
using Coder.Desktop.Vpn.Proto;
24

35
namespace Coder.Desktop.App.Models;
46

@@ -11,6 +13,7 @@ public enum RpcLifecycle
1113

1214
public enum VpnLifecycle
1315
{
16+
Unknown,
1417
Stopped,
1518
Starting,
1619
Started,
@@ -21,17 +24,20 @@ public class RpcModel
2124
{
2225
public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected;
2326

24-
public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Stopped;
27+
public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
2528

26-
public List<object> Agents { get; set; } = [];
29+
public List<Workspace> Workspaces { get; set; } = [];
30+
31+
public List<Agent> Agents { get; set; } = [];
2732

2833
public RpcModel Clone()
2934
{
3035
return new RpcModel
3136
{
3237
RpcLifecycle = RpcLifecycle,
3338
VpnLifecycle = VpnLifecycle,
34-
Agents = Agents,
39+
Workspaces = Workspaces.ToList(),
40+
Agents = Agents.ToList(),
3541
};
3642
}
3743
}

‎App/Properties/launchSettings.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
{
22
"profiles": {
3-
"App (Package)": {
4-
"commandName": "MsixPackage"
5-
},
63
"App (Unpackaged)": {
74
"commandName": "Project"
85
}

‎App/Services/CredentialManager.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,14 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT
6666

6767
try
6868
{
69+
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
70+
cts.CancelAfter(TimeSpan.FromSeconds(15));
6971
var sdkClient = new CoderApiClient(uri);
7072
sdkClient.SetSessionToken(apiToken);
7173
// TODO: we should probably perform a version check here too,
7274
// rather than letting the service do it on Start
73-
_ = await sdkClient.GetBuildInfo(ct);
74-
_ = await sdkClient.GetUser(User.Me, ct);
75+
_ = await sdkClient.GetBuildInfo(cts.Token);
76+
_ = await sdkClient.GetUser(User.Me, cts.Token);
7577
}
7678
catch (Exception e)
7779
{

‎App/Services/RpcController.cs

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ public async Task Reconnect(CancellationToken ct = default)
9696
{
9797
state.RpcLifecycle = RpcLifecycle.Connecting;
9898
state.VpnLifecycle = VpnLifecycle.Stopped;
99+
state.Workspaces.Clear();
99100
state.Agents.Clear();
100101
});
101102

@@ -125,7 +126,8 @@ public async Task Reconnect(CancellationToken ct = default)
125126
MutateState(state =>
126127
{
127128
state.RpcLifecycle = RpcLifecycle.Disconnected;
128-
state.VpnLifecycle = VpnLifecycle.Stopped;
129+
state.VpnLifecycle = VpnLifecycle.Unknown;
130+
state.Workspaces.Clear();
129131
state.Agents.Clear();
130132
});
131133
throw new RpcOperationException("Failed to reconnect to the RPC server", e);
@@ -134,10 +136,18 @@ public async Task Reconnect(CancellationToken ct = default)
134136
MutateState(state =>
135137
{
136138
state.RpcLifecycle = RpcLifecycle.Connected;
137-
// TODO: fetch current state
138-
state.VpnLifecycle = VpnLifecycle.Stopped;
139+
state.VpnLifecycle = VpnLifecycle.Unknown;
140+
state.Workspaces.Clear();
139141
state.Agents.Clear();
140142
});
143+
144+
var statusReply = await _speaker.SendRequestAwaitReply(new ClientMessage
145+
{
146+
Status = new StatusRequest(),
147+
}, ct);
148+
if (statusReply.MsgCase != ServiceMessage.MsgOneofCase.Status)
149+
throw new InvalidOperationException($"Unexpected reply message type: {statusReply.MsgCase}");
150+
ApplyStatusUpdate(statusReply.Status);
141151
}
142152

143153
public async Task StartVpn(CancellationToken ct = default)
@@ -234,9 +244,40 @@ private async Task<IDisposable> AcquireOperationLockNowAsync()
234244
return locker;
235245
}
236246

247+
private void ApplyStatusUpdate(Status status)
248+
{
249+
MutateState(state =>
250+
{
251+
state.VpnLifecycle = status.Lifecycle switch
252+
{
253+
Status.Types.Lifecycle.Unknown => VpnLifecycle.Unknown,
254+
Status.Types.Lifecycle.Starting => VpnLifecycle.Starting,
255+
Status.Types.Lifecycle.Started => VpnLifecycle.Started,
256+
Status.Types.Lifecycle.Stopping => VpnLifecycle.Stopping,
257+
Status.Types.Lifecycle.Stopped => VpnLifecycle.Stopped,
258+
_ => VpnLifecycle.Stopped,
259+
};
260+
state.Workspaces.Clear();
261+
state.Workspaces.AddRange(status.PeerUpdate.UpsertedWorkspaces);
262+
state.Agents.Clear();
263+
state.Agents.AddRange(status.PeerUpdate.UpsertedAgents);
264+
});
265+
}
266+
237267
private void SpeakerOnReceive(ReplyableRpcMessage<ClientMessage, ServiceMessage> message)
238268
{
239-
// TODO: this
269+
switch (message.Message.MsgCase)
270+
{
271+
case ServiceMessage.MsgOneofCase.Status:
272+
ApplyStatusUpdate(message.Message.Status);
273+
break;
274+
case ServiceMessage.MsgOneofCase.Start:
275+
case ServiceMessage.MsgOneofCase.Stop:
276+
case ServiceMessage.MsgOneofCase.None:
277+
default:
278+
// TODO: log unexpected message
279+
break;
280+
}
240281
}
241282

242283
private async Task DisposeSpeaker()
@@ -251,7 +292,14 @@ private async Task DisposeSpeaker()
251292
private void SpeakerOnError(Exception e)
252293
{
253294
Debug.WriteLine($"Error: {e}");
254-
Reconnect(CancellationToken.None).Wait();
295+
try
296+
{
297+
Reconnect(CancellationToken.None).Wait();
298+
}
299+
catch
300+
{
301+
// best effort to immediately reconnect
302+
}
255303
}
256304

257305
private void AssertRpcConnected()

‎App/ViewModels/TrayWindowViewModel.cs

Lines changed: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
using System;
12
using System.Collections.Generic;
2-
using System.Collections.ObjectModel;
33
using System.Linq;
44
using Coder.Desktop.App.Models;
55
using Coder.Desktop.App.Services;
66
using CommunityToolkit.Mvvm.ComponentModel;
77
using CommunityToolkit.Mvvm.Input;
8+
using Google.Protobuf;
9+
using Microsoft.UI.Dispatching;
810
using Microsoft.UI.Xaml;
911
using Microsoft.UI.Xaml.Controls;
1012

@@ -17,9 +19,10 @@ public partial class TrayWindowViewModel : ObservableObject
1719
private readonly IRpcController _rpcController;
1820
private readonly ICredentialManager _credentialManager;
1921

22+
private DispatcherQueue? _dispatcherQueue;
23+
2024
[ObservableProperty]
21-
public partial VpnLifecycle VpnLifecycle { get; set; } =
22-
VpnLifecycle.Stopping; // to prevent interaction until we get the real state
25+
public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
2326

2427
// VpnSwitchOn needs to be its own property as it is a two-way binding
2528
[ObservableProperty]
@@ -32,7 +35,7 @@ public partial class TrayWindowViewModel : ObservableObject
3235
[NotifyPropertyChangedFor(nameof(NoAgents))]
3336
[NotifyPropertyChangedFor(nameof(AgentOverflow))]
3437
[NotifyPropertyChangedFor(nameof(VisibleAgents))]
35-
public partial ObservableCollection<AgentViewModel> Agents { get; set; } = [];
38+
public partial List<AgentViewModel> Agents { get; set; } = [];
3639

3740
public bool NoAgents => Agents.Count == 0;
3841

@@ -51,6 +54,11 @@ public TrayWindowViewModel(IRpcController rpcController, ICredentialManager cred
5154
{
5255
_rpcController = rpcController;
5356
_credentialManager = credentialManager;
57+
}
58+
59+
public void Initialize(DispatcherQueue dispatcherQueue)
60+
{
61+
_dispatcherQueue = dispatcherQueue;
5462

5563
_rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel);
5664
UpdateFromRpcModel(_rpcController.GetState());
@@ -61,64 +69,87 @@ public TrayWindowViewModel(IRpcController rpcController, ICredentialManager cred
6169

6270
private void UpdateFromRpcModel(RpcModel rpcModel)
6371
{
72+
// Ensure we're on the UI thread.
73+
if (_dispatcherQueue == null) return;
74+
if (!_dispatcherQueue.HasThreadAccess)
75+
{
76+
_dispatcherQueue.TryEnqueue(() => UpdateFromRpcModel(rpcModel));
77+
return;
78+
}
79+
6480
// As a failsafe, if RPC is disconnected we disable the switch. The
6581
// Window should not show the current Page if the RPC is disconnected.
6682
if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected)
6783
{
68-
VpnLifecycle = VpnLifecycle.Stopping;
84+
VpnLifecycle = VpnLifecycle.Unknown;
6985
VpnSwitchOn = false;
7086
Agents = [];
7187
return;
7288
}
7389

7490
VpnLifecycle = rpcModel.VpnLifecycle;
7591
VpnSwitchOn = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
76-
// TODO: convert from RpcModel once we send agent data
77-
Agents =
78-
[
79-
new AgentViewModel
80-
{
81-
Hostname = "pog",
82-
HostnameSuffix = ".coder",
83-
ConnectionStatus = AgentConnectionStatus.Green,
84-
DashboardUrl = "https://dev.coder.com/@dean/pog",
85-
},
86-
new AgentViewModel
87-
{
88-
Hostname = "pog2",
89-
HostnameSuffix = ".coder",
90-
ConnectionStatus = AgentConnectionStatus.Gray,
91-
DashboardUrl = "https://dev.coder.com/@dean/pog2",
92-
},
93-
new AgentViewModel
94-
{
95-
Hostname = "pog3",
96-
HostnameSuffix = ".coder",
97-
ConnectionStatus = AgentConnectionStatus.Red,
98-
DashboardUrl = "https://dev.coder.com/@dean/pog3",
99-
},
100-
new AgentViewModel
92+
93+
// Add every known agent.
94+
HashSet<ByteString> workspacesWithAgents = [];
95+
List<AgentViewModel> agents = [];
96+
foreach (var agent in rpcModel.Agents)
97+
{
98+
// Find the FQDN with the least amount of dots and split it into
99+
// prefix and suffix.
100+
var fqdn = agent.Fqdn
101+
.Select(a => a.Trim('.'))
102+
.Where(a => !string.IsNullOrWhiteSpace(a))
103+
.Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b);
104+
if (string.IsNullOrWhiteSpace(fqdn))
105+
continue;
106+
107+
var fqdnPrefix = fqdn;
108+
var fqdnSuffix = "";
109+
if (fqdn.Contains('.'))
101110
{
102-
Hostname = "pog4",
103-
HostnameSuffix = ".coder",
104-
ConnectionStatus = AgentConnectionStatus.Red,
105-
DashboardUrl = "https://dev.coder.com/@dean/pog4",
106-
},
107-
new AgentViewModel
111+
fqdnPrefix = fqdn[..fqdn.LastIndexOf('.')];
112+
fqdnSuffix = fqdn[fqdn.LastIndexOf('.')..];
113+
}
114+
115+
var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime());
116+
workspacesWithAgents.Add(agent.WorkspaceId);
117+
agents.Add(new AgentViewModel
108118
{
109-
Hostname = "pog5",
110-
HostnameSuffix = ".coder",
111-
ConnectionStatus = AgentConnectionStatus.Red,
112-
DashboardUrl = "https://dev.coder.com/@dean/pog5",
113-
},
114-
new AgentViewModel
119+
Hostname = fqdnPrefix,
120+
HostnameSuffix = fqdnSuffix,
121+
ConnectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5)
122+
? AgentConnectionStatus.Green
123+
: AgentConnectionStatus.Red,
124+
// TODO: we don't actually have any way of crafting a dashboard
125+
// URL without the owner's username
126+
DashboardUrl = "https://coder.com",
127+
});
128+
}
129+
130+
// For every workspace that doesn't have an agent, add a dummy agent.
131+
foreach (var workspace in rpcModel.Workspaces.Where(w => !workspacesWithAgents.Contains(w.Id)))
132+
{
133+
agents.Add(new AgentViewModel
115134
{
116-
Hostname = "pog6",
135+
// We just assume that it's a single-agent workspace.
136+
Hostname = workspace.Name,
117137
HostnameSuffix = ".coder",
118-
ConnectionStatus = AgentConnectionStatus.Red,
119-
DashboardUrl = "https://dev.coder.com/@dean/pog6",
120-
},
121-
];
138+
ConnectionStatus = AgentConnectionStatus.Gray,
139+
// TODO: we don't actually have any way of crafting a dashboard
140+
// URL without the owner's username
141+
DashboardUrl = "https://coder.com",
142+
});
143+
}
144+
145+
// Sort by status green, red, gray, then by hostname.
146+
agents.Sort((a, b) =>
147+
{
148+
if (a.ConnectionStatus != b.ConnectionStatus)
149+
return a.ConnectionStatus.CompareTo(b.ConnectionStatus);
150+
return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal);
151+
});
152+
Agents = agents;
122153

123154
if (Agents.Count < MaxAgents) ShowAllAgents = false;
124155
}
@@ -162,7 +193,8 @@ public void ToggleShowAllAgents()
162193
[RelayCommand]
163194
public void SignOut()
164195
{
165-
// TODO: this should either be blocked until the VPN is stopped or it should stop the VPN
196+
if (VpnLifecycle is not VpnLifecycle.Stopped)
197+
return;
166198
_credentialManager.ClearCredentials();
167199
}
168200
}

‎App/Views/Pages/SignInTokenPage.xaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
HorizontalAlignment="Stretch"
6464
PlaceholderText="Paste your token here"
6565
LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}"
66-
Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" />
66+
Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}"
67+
InputScope="Password" />
6768

6869
<TextBlock
6970
Grid.Column="1"

‎App/Views/Pages/TrayWindowMainPage.xaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public TrayWindowMainPage(TrayWindowViewModel viewModel)
1414
{
1515
InitializeComponent();
1616
ViewModel = viewModel;
17+
ViewModel.Initialize(DispatcherQueue);
1718
}
1819

1920
// HACK: using XAML to populate the text Runs results in an additional

‎App/Views/TrayWindow.xaml.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
7272
// Ensure the corner is rounded.
7373
var windowHandle = Win32Interop.GetWindowFromWindowId(AppWindow.Id);
7474
var value = 2;
75-
var result = NativeApi.DwmSetWindowAttribute(windowHandle, 33, ref value, Marshal.SizeOf<int>());
76-
if (result != 0) throw new Exception("Failed to set window corner preference");
75+
// Best effort. This does not work on Windows 10.
76+
_ = NativeApi.DwmSetWindowAttribute(windowHandle, 33, ref value, Marshal.SizeOf<int>());
7777
}
7878

7979
private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel)
@@ -108,6 +108,14 @@ private void CredentialManager_CredentialsChanged(object? _, CredentialModel mod
108108
// trigger when the Page's content changes.
109109
public void SetRootFrame(Page page)
110110
{
111+
if (!DispatcherQueue.HasThreadAccess)
112+
{
113+
DispatcherQueue.TryEnqueue(() => SetRootFrame(page));
114+
return;
115+
}
116+
117+
if (ReferenceEquals(page, RootFrame.Content)) return;
118+
111119
if (page.Content is not FrameworkElement newElement)
112120
throw new Exception("Failed to get Page.Content as FrameworkElement on RootFrame navigation");
113121
newElement.SizeChanged += Content_SizeChanged;
@@ -239,7 +247,7 @@ private void Tray_Open()
239247
[RelayCommand]
240248
private void Tray_Exit()
241249
{
242-
// TODO: implement exit
250+
Application.Current.Exit();
243251
}
244252

245253
public class NativeApi

‎App/packages.lock.json

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,6 @@
3535
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1"
3636
}
3737
},
38-
"Microsoft.Windows.SDK.BuildTools": {
39-
"type": "Direct",
40-
"requested": "[10.0.26100.1742, )",
41-
"resolved": "10.0.26100.1742",
42-
"contentHash": "ypcHjr4KEi6xQhgClnbXoANHcyyX/QsC4Rky4igs6M4GiDa+weegPo8JuV/VMxqrZCV4zlqDsp2krgkN7ReAAg=="
43-
},
4438
"Microsoft.WindowsAppSDK": {
4539
"type": "Direct",
4640
"requested": "[1.6.250108002, )",
@@ -87,6 +81,11 @@
8781
"resolved": "9.0.0",
8882
"contentHash": "z8FfGIaoeALdD+KF44A2uP8PZIQQtDGiXsOLuN8nohbKhkyKt7zGaZb+fKiCxTuBqG22Q7myIAioSWaIcOOrOw=="
8983
},
84+
"Microsoft.Windows.SDK.BuildTools": {
85+
"type": "Transitive",
86+
"resolved": "10.0.22621.756",
87+
"contentHash": "7ZL2sFSioYm1Ry067Kw1hg0SCcW5kuVezC2SwjGbcPE61Nn+gTbH86T73G3LcEOVj0S3IZzNuE/29gZvOLS7VA=="
88+
},
9089
"System.Collections.Immutable": {
9190
"type": "Transitive",
9291
"resolved": "9.0.0",

‎Package/Package.appxmanifest

Lines changed: 0 additions & 52 deletions
This file was deleted.

‎Package/Package.wapproj

Lines changed: 0 additions & 67 deletions
This file was deleted.

‎Publish-Alpha.ps1

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# CD to the directory of this PS script
2+
Push-Location $PSScriptRoot
3+
4+
# Create a publish directory
5+
$publishDir = Join-Path $PSScriptRoot "publish"
6+
if (Test-Path $publishDir) {
7+
# prompt the user to confirm the deletion
8+
$confirm = Read-Host "The directory $publishDir already exists. Do you want to delete it? (y/n)"
9+
if ($confirm -eq "y") {
10+
Remove-Item -Recurse -Force $publishDir
11+
} else {
12+
Write-Host "Aborting..."
13+
exit
14+
}
15+
}
16+
New-Item -ItemType Directory -Path $publishDir
17+
18+
# Build in release mode
19+
dotnet.exe clean
20+
dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a x64 -o $publishDir\service
21+
$msbuildBinary = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe
22+
& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=x64 /p:OutputPath=..\publish\app /p:GenerateAppxPackageOnBuild=true
23+
24+
$scriptsDir = Join-Path $publishDir "scripts"
25+
New-Item -ItemType Directory -Path $scriptsDir
26+
27+
# Download the 1.6.250108002 redistributable zip from here and drop the x64
28+
# version in the root of the repo:
29+
# https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/downloads
30+
$windowsAppSdkInstaller = Join-Path $PSScriptRoot "WindowsAppRuntimeInstall-x64.exe"
31+
Copy-Item $windowsAppSdkInstaller $scriptsDir
32+
33+
# Acquire wintun.dll and put it in the root of the repo.
34+
$wintunDll = Join-Path $PSScriptRoot "wintun.dll"
35+
Copy-Item $wintunDll $scriptsDir
36+
37+
# Add a PS1 script for installing the service
38+
$installScript = Join-Path $scriptsDir "Install.ps1"
39+
$installScriptContent = @"
40+
try {
41+
# Install Windows App SDK
42+
`$installerPath = Join-Path `$PSScriptRoot "WindowsAppRuntimeInstall-x64.exe"
43+
Start-Process `$installerPath -ArgumentList "/silent" -Wait
44+
45+
# Install wintun.dll
46+
`$wintunPath = Join-Path `$PSScriptRoot "wintun.dll"
47+
Copy-Item `$wintunPath "C:\wintun.dll"
48+
49+
# Install and start the service
50+
`$name = "Coder Desktop (Debug)"
51+
`$binaryPath = Join-Path `$PSScriptRoot "..\service\Vpn.Service.exe" | Resolve-Path
52+
New-Service -Name `$name -BinaryPathName `$binaryPath -StartupType Automatic
53+
Start-Service -Name `$name
54+
} catch {
55+
Write-Host ""
56+
Write-Host -Foreground Red "Error: $_"
57+
} finally {
58+
Write-Host ""
59+
Write-Host "Press Return to exit..."
60+
Read-Host
61+
}
62+
"@
63+
Set-Content -Path $installScript -Value $installScriptContent
64+
65+
# Add a batch script for running the install script
66+
$installBatch = Join-Path $publishDir "Install.bat"
67+
$installBatchContent = @"
68+
@echo off
69+
powershell -Command "Start-Process powershell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"%~dp0scripts\Install.ps1\"' -Verb RunAs"
70+
"@
71+
Set-Content -Path $installBatch -Value $installBatchContent
72+
73+
# Add a PS1 script for uninstalling the service
74+
$uninstallScript = Join-Path $scriptsDir "Uninstall.ps1"
75+
$uninstallScriptContent = @"
76+
try {
77+
# Uninstall the service
78+
`$name = "Coder Desktop (Debug)"
79+
Stop-Service -Name `$name
80+
sc.exe delete `$name
81+
82+
# Delete wintun.dll
83+
Remove-Item "C:\wintun.dll"
84+
85+
# Maybe delete C:\coder-vpn.exe and C:\CoderDesktop.log
86+
Remove-Item "C:\coder-vpn.exe" -ErrorAction SilentlyContinue
87+
Remove-Item "C:\CoderDesktop.log" -ErrorAction SilentlyContinue
88+
} catch {
89+
Write-Host ""
90+
Write-Host -Foreground Red "Error: $_"
91+
} finally {
92+
Write-Host ""
93+
Write-Host "Press Return to exit..."
94+
Read-Host
95+
}
96+
"@
97+
Set-Content -Path $uninstallScript -Value $uninstallScriptContent
98+
99+
# Add a batch script for running the uninstall script
100+
$uninstallBatch = Join-Path $publishDir "Uninstall.bat"
101+
$uninstallBatchContent = @"
102+
@echo off
103+
powershell -Command "Start-Process powershell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"%~dp0scripts\Uninstall.ps1\"' -Verb RunAs"
104+
"@
105+
Set-Content -Path $uninstallBatch -Value $uninstallBatchContent
106+
107+
# Add a PS1 script for starting the app
108+
$startAppScript = Join-Path $publishDir "StartTrayApp.bat"
109+
$startAppScriptContent = @"
110+
@echo off
111+
start /B app\App.exe
112+
"@
113+
Set-Content -Path $startAppScript -Value $startAppScriptContent
114+
115+
# Write README.md
116+
$readme = Join-Path $publishDir "README.md"
117+
$readmeContent = @"
118+
# Coder Desktop for Windows
119+
120+
## Install
121+
1. Install the service by double clicking `Install.bat`.
122+
2. Start the app by double clicking `StartTrayApp.bat`.
123+
3. The tray app should be available in the system tray.
124+
125+
## Uninstall
126+
1. Close the tray app by right clicking the icon in the system tray and
127+
selecting "Exit".
128+
2. Uninstall the service by double clicking `Uninstall.bat`.
129+
130+
## Notes
131+
- During install and uninstall a User Account Control popup will appear asking
132+
for admin permissions. This is normal.
133+
- During install and uninstall a bunch of console windows will appear and
134+
disappear. You will be asked to click "Return" to close the last one once
135+
it's finished doing its thing.
136+
- The system service will start automatically when the system starts.
137+
- The tray app will not start automatically on startup. You can start it again
138+
by double clicking `StartTrayApp.bat`.
139+
"@
140+
Set-Content -Path $readme -Value $readmeContent

‎Vpn.Proto/vpn.proto

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
syntax = "proto3";
1+
syntax = "proto3";
22
option go_package = "github.com/coder/coder/v2/vpn";
33
option csharp_namespace = "Coder.Desktop.Vpn.Proto";
44

@@ -44,21 +44,23 @@ message TunnelMessage {
4444
}
4545
}
4646

47-
// ClientMessage is a message from the client (to the service).
47+
// ClientMessage is a message from the client (to the service). Windows only.
4848
message ClientMessage {
4949
RPC rpc = 1;
5050
oneof msg {
5151
StartRequest start = 2;
5252
StopRequest stop = 3;
53+
StatusRequest status = 4;
5354
}
5455
}
5556

56-
// ServiceMessage is a message from the service (to the client).
57+
// ServiceMessage is a message from the service (to the client). Windows only.
5758
message ServiceMessage {
5859
RPC rpc = 1;
5960
oneof msg {
6061
StartResponse start = 2;
6162
StopResponse stop = 3;
63+
Status status = 4; // either in reply to a StatusRequest or broadcasted
6264
}
6365
}
6466

@@ -210,7 +212,7 @@ message StartResponse {
210212
string error_message = 2;
211213
}
212214

213-
// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a
215+
// StopRequest is a request to stop the tunnel. The tunnel replies with a
214216
// StopResponse.
215217
message StopRequest {}
216218

@@ -220,3 +222,26 @@ message StopResponse {
220222
bool success = 1;
221223
string error_message = 2;
222224
}
225+
226+
// StatusRequest is a request to get the status of the tunnel. The manager
227+
// replies with a Status.
228+
message StatusRequest {}
229+
230+
// Status is sent in response to a StatusRequest or broadcasted to all clients
231+
// when the status changes.
232+
message Status {
233+
enum Lifecycle {
234+
UNKNOWN = 0;
235+
STARTING = 1;
236+
STARTED = 2;
237+
STOPPING = 3;
238+
STOPPED = 4;
239+
}
240+
Lifecycle lifecycle = 1;
241+
string error_message = 2;
242+
243+
// This will be a FULL update with all workspaces and agents, so clients
244+
// should replace their current peer state. Only the Upserted fields will
245+
// be populated.
246+
PeerUpdate peer_update = 3;
247+
}

‎Vpn.Service/Manager.cs

Lines changed: 164 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@
88

99
namespace Coder.Desktop.Vpn.Service;
1010

11-
public interface IManager : IDisposable
11+
public enum TunnelStatus
1212
{
13-
public Task HandleClientRpcMessage(ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
14-
CancellationToken ct = default);
13+
Starting,
14+
Started,
15+
Stopping,
16+
Stopped,
17+
}
1518

19+
public interface IManager : IDisposable
20+
{
1621
public Task StopAsync(CancellationToken ct = default);
1722
}
1823

@@ -28,73 +33,94 @@ public class Manager : IManager
2833
private readonly IDownloader _downloader;
2934
private readonly ILogger<Manager> _logger;
3035
private readonly ITunnelSupervisor _tunnelSupervisor;
36+
private readonly IManagerRpc _managerRpc;
37+
38+
private volatile TunnelStatus _status = TunnelStatus.Stopped;
3139

3240
// TunnelSupervisor already has protections against concurrent operations,
3341
// but all the other stuff before starting the tunnel does not.
3442
private readonly RaiiSemaphoreSlim _tunnelOperationLock = new(1, 1);
3543
private SemVersion? _lastServerVersion;
3644
private StartRequest? _lastStartRequest;
3745

46+
private readonly RaiiSemaphoreSlim _statusLock = new(1, 1);
47+
private readonly List<Workspace> _trackedWorkspaces = [];
48+
private readonly List<Agent> _trackedAgents = [];
49+
3850
// ReSharper disable once ConvertToPrimaryConstructor
3951
public Manager(IOptions<ManagerConfig> config, ILogger<Manager> logger, IDownloader downloader,
40-
ITunnelSupervisor tunnelSupervisor)
52+
ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc)
4153
{
4254
_config = config.Value;
4355
_logger = logger;
4456
_downloader = downloader;
4557
_tunnelSupervisor = tunnelSupervisor;
58+
_managerRpc = managerRpc;
59+
_managerRpc.OnReceive += HandleClientRpcMessage;
4660
}
4761

4862
public void Dispose()
4963
{
64+
_managerRpc.OnReceive -= HandleClientRpcMessage;
5065
GC.SuppressFinalize(this);
5166
}
5267

68+
public async Task StopAsync(CancellationToken ct = default)
69+
{
70+
await _tunnelSupervisor.StopAsync(ct);
71+
await BroadcastStatus(null, ct);
72+
}
73+
5374
/// <summary>
5475
/// Processes a message sent from a Client to the ManagerRpcService over the codervpn RPC protocol.
5576
/// </summary>
5677
/// <param name="message">Client message</param>
5778
/// <param name="ct">Cancellation token</param>
58-
public async Task HandleClientRpcMessage(ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
79+
public async Task HandleClientRpcMessage(ulong clientId, ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
5980
CancellationToken ct = default)
6081
{
61-
_logger.LogInformation("ClientMessage: {MessageType}", message.Message.MsgCase);
62-
switch (message.Message.MsgCase)
82+
using (_logger.BeginScope("ClientMessage.{MessageType} (client: {ClientId})", message.Message.MsgCase,
83+
clientId))
6384
{
64-
case ClientMessage.MsgOneofCase.Start:
65-
// TODO: these sub-methods should be managed by some Task list and cancelled/awaited on stop
66-
var startResponse = await HandleClientMessageStart(message.Message, ct);
67-
await message.SendReply(new ServiceMessage
68-
{
69-
Start = startResponse,
70-
}, ct);
71-
break;
72-
case ClientMessage.MsgOneofCase.Stop:
73-
var stopResponse = await HandleClientMessageStop(message.Message, ct);
74-
await message.SendReply(new ServiceMessage
75-
{
76-
Stop = stopResponse,
77-
}, ct);
78-
break;
79-
case ClientMessage.MsgOneofCase.None:
80-
default:
81-
_logger.LogWarning("Received unknown message type {MessageType}", message.Message.MsgCase);
82-
break;
85+
switch (message.Message.MsgCase)
86+
{
87+
case ClientMessage.MsgOneofCase.Start:
88+
// TODO: these sub-methods should be managed by some Task list and cancelled/awaited on stop
89+
var startResponse = await HandleClientMessageStart(message.Message, ct);
90+
await message.SendReply(new ServiceMessage
91+
{
92+
Start = startResponse,
93+
}, ct);
94+
break;
95+
case ClientMessage.MsgOneofCase.Stop:
96+
var stopResponse = await HandleClientMessageStop(message.Message, ct);
97+
await message.SendReply(new ServiceMessage
98+
{
99+
Stop = stopResponse,
100+
}, ct);
101+
await BroadcastStatus(null, ct);
102+
break;
103+
case ClientMessage.MsgOneofCase.Status:
104+
await message.SendReply(new ServiceMessage
105+
{
106+
Status = await CurrentStatus(ct),
107+
}, ct);
108+
break;
109+
case ClientMessage.MsgOneofCase.None:
110+
default:
111+
_logger.LogWarning("Received unknown message type {MessageType}", message.Message.MsgCase);
112+
break;
113+
}
83114
}
84115
}
85116

86-
public async Task StopAsync(CancellationToken ct = default)
87-
{
88-
await _tunnelSupervisor.StopAsync(ct);
89-
}
90-
91117
private async ValueTask<StartResponse> HandleClientMessageStart(ClientMessage message,
92118
CancellationToken ct)
93119
{
94120
var opLock = await _tunnelOperationLock.LockAsync(TimeSpan.FromMilliseconds(500), ct);
95121
if (opLock == null)
96122
{
97-
_logger.LogWarning("ClientMessage.Start: Tunnel operation lock timed out");
123+
_logger.LogWarning("Tunnel operation lock timed out");
98124
return new StartResponse
99125
{
100126
Success = false,
@@ -109,18 +135,20 @@ private async ValueTask<StartResponse> HandleClientMessageStart(ClientMessage me
109135
var serverVersion =
110136
await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken,
111137
ct);
112-
if (_tunnelSupervisor.IsRunning && _lastStartRequest != null &&
138+
if (_status == TunnelStatus.Started && _lastStartRequest != null &&
113139
_lastStartRequest.Equals(message.Start) && _lastServerVersion == serverVersion)
114140
{
115141
// The client is requesting to start an identical tunnel while
116142
// we're already running it.
117-
_logger.LogInformation("ClientMessage.Start: Ignoring duplicate start request");
143+
_logger.LogInformation("Ignoring duplicate start request");
118144
return new StartResponse
119145
{
120146
Success = true,
121147
};
122148
}
123149

150+
ClearPeers();
151+
await BroadcastStatus(TunnelStatus.Starting, ct);
124152
_lastStartRequest = message.Start;
125153
_lastServerVersion = serverVersion;
126154

@@ -139,11 +167,14 @@ await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMess
139167
}, ct);
140168
if (reply.MsgCase != TunnelMessage.MsgOneofCase.Start)
141169
throw new InvalidOperationException("Tunnel did not reply with a Start response");
170+
171+
await BroadcastStatus(reply.Start.Success ? TunnelStatus.Started : TunnelStatus.Stopped, ct);
142172
return reply.Start;
143173
}
144174
catch (Exception e)
145175
{
146-
_logger.LogWarning(e, "ClientMessage.Start: Failed to start VPN client");
176+
await BroadcastStatus(TunnelStatus.Stopped, ct);
177+
_logger.LogWarning(e, "Failed to start VPN client");
147178
return new StartResponse
148179
{
149180
Success = false,
@@ -159,7 +190,7 @@ private async ValueTask<StopResponse> HandleClientMessageStop(ClientMessage mess
159190
var opLock = await _tunnelOperationLock.LockAsync(TimeSpan.FromMilliseconds(500), ct);
160191
if (opLock == null)
161192
{
162-
_logger.LogWarning("ClientMessage.Stop: Tunnel operation lock timed out");
193+
_logger.LogWarning("Tunnel operation lock timed out");
163194
return new StopResponse
164195
{
165196
Success = false,
@@ -171,6 +202,8 @@ private async ValueTask<StopResponse> HandleClientMessageStop(ClientMessage mess
171202
{
172203
try
173204
{
205+
ClearPeers();
206+
await BroadcastStatus(TunnelStatus.Stopping, ct);
174207
// This will handle sending the Stop message to the tunnel for us.
175208
await _tunnelSupervisor.StopAsync(ct);
176209
return new StopResponse
@@ -180,19 +213,110 @@ private async ValueTask<StopResponse> HandleClientMessageStop(ClientMessage mess
180213
}
181214
catch (Exception e)
182215
{
183-
_logger.LogWarning(e, "ClientMessage.Stop: Failed to stop VPN client");
216+
_logger.LogWarning(e, "Failed to stop VPN client");
184217
return new StopResponse
185218
{
186219
Success = false,
187220
ErrorMessage = e.ToString(),
188221
};
189222
}
223+
finally
224+
{
225+
// Always assume it's stopped.
226+
await BroadcastStatus(TunnelStatus.Stopped, ct);
227+
}
190228
}
191229
}
192230

193231
private void HandleTunnelRpcMessage(ReplyableRpcMessage<ManagerMessage, TunnelMessage> message)
194232
{
195-
// TODO: this
233+
using (_logger.BeginScope("TunnelMessage.{MessageType}", message.Message.MsgCase))
234+
{
235+
switch (message.Message.MsgCase)
236+
{
237+
case TunnelMessage.MsgOneofCase.Start:
238+
case TunnelMessage.MsgOneofCase.Stop:
239+
_logger.LogWarning("Received unexpected message reply type {MessageType}", message.Message.MsgCase);
240+
break;
241+
case TunnelMessage.MsgOneofCase.Log:
242+
case TunnelMessage.MsgOneofCase.NetworkSettings:
243+
_logger.LogWarning("Received message type {MessageType} that is not expected on Windows",
244+
message.Message.MsgCase);
245+
break;
246+
case TunnelMessage.MsgOneofCase.PeerUpdate:
247+
HandleTunnelMessagePeerUpdate(message.Message);
248+
BroadcastStatus().Wait();
249+
break;
250+
case TunnelMessage.MsgOneofCase.None:
251+
default:
252+
_logger.LogWarning("Received unknown message type {MessageType}", message.Message.MsgCase);
253+
break;
254+
}
255+
}
256+
}
257+
258+
private void ClearPeers()
259+
{
260+
using var _ = _statusLock.Lock();
261+
_trackedWorkspaces.Clear();
262+
_trackedAgents.Clear();
263+
}
264+
265+
private void HandleTunnelMessagePeerUpdate(TunnelMessage message)
266+
{
267+
using var _ = _statusLock.Lock();
268+
foreach (var newWorkspace in message.PeerUpdate.UpsertedWorkspaces)
269+
{
270+
_trackedWorkspaces.RemoveAll(w => w.Id == newWorkspace.Id);
271+
_trackedWorkspaces.Add(newWorkspace);
272+
}
273+
274+
foreach (var removedWorkspace in message.PeerUpdate.DeletedWorkspaces)
275+
_trackedWorkspaces.RemoveAll(w => w.Id == removedWorkspace.Id);
276+
foreach (var newAgent in message.PeerUpdate.UpsertedAgents)
277+
{
278+
_trackedAgents.RemoveAll(a => a.Id == newAgent.Id);
279+
_trackedAgents.Add(newAgent);
280+
}
281+
282+
foreach (var removedAgent in message.PeerUpdate.DeletedAgents)
283+
_trackedAgents.RemoveAll(a => a.Id == removedAgent.Id);
284+
285+
_trackedWorkspaces.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
286+
_trackedAgents.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
287+
}
288+
289+
private async ValueTask<Status> CurrentStatus(CancellationToken ct = default)
290+
{
291+
using var _ = await _statusLock.LockAsync(ct);
292+
var lifecycle = _status switch
293+
{
294+
TunnelStatus.Starting => Status.Types.Lifecycle.Starting,
295+
TunnelStatus.Started => Status.Types.Lifecycle.Started,
296+
TunnelStatus.Stopping => Status.Types.Lifecycle.Stopping,
297+
TunnelStatus.Stopped => Status.Types.Lifecycle.Stopped,
298+
_ => Status.Types.Lifecycle.Stopped,
299+
};
300+
301+
return new Status
302+
{
303+
Lifecycle = lifecycle,
304+
ErrorMessage = "",
305+
PeerUpdate = new PeerUpdate
306+
{
307+
UpsertedAgents = { _trackedAgents },
308+
UpsertedWorkspaces = { _trackedWorkspaces },
309+
},
310+
};
311+
}
312+
313+
private async Task BroadcastStatus(TunnelStatus? newStatus = null, CancellationToken ct = default)
314+
{
315+
if (newStatus != null) _status = newStatus.Value;
316+
await _managerRpc.BroadcastAsync(new ServiceMessage
317+
{
318+
Status = await CurrentStatus(ct),
319+
}, ct);
196320
}
197321

198322
private void HandleTunnelRpcError(Exception e)
@@ -201,7 +325,8 @@ private void HandleTunnelRpcError(Exception e)
201325
try
202326
{
203327
_tunnelSupervisor.StopAsync();
204-
// TODO: this should broadcast an update to all clients
328+
ClearPeers();
329+
BroadcastStatus().Wait();
205330
}
206331
catch (Exception e2)
207332
{

‎Vpn.Service/ManagerRpc.cs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
using System.Collections.Concurrent;
2+
using System.IO.Pipes;
3+
using System.Security.AccessControl;
4+
using System.Security.Principal;
5+
using Coder.Desktop.Vpn.Proto;
6+
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Options;
8+
9+
namespace Coder.Desktop.Vpn.Service;
10+
11+
public class ManagerRpcClient(Speaker<ServiceMessage, ClientMessage> speaker, Task task)
12+
{
13+
public Speaker<ServiceMessage, ClientMessage> Speaker { get; } = speaker;
14+
public Task Task { get; } = task;
15+
}
16+
17+
public interface IManagerRpc : IAsyncDisposable
18+
{
19+
delegate Task OnReceiveHandler(ulong clientId, ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
20+
CancellationToken ct = default);
21+
22+
event OnReceiveHandler? OnReceive;
23+
24+
Task StopAsync(CancellationToken cancellationToken);
25+
26+
Task ExecuteAsync(CancellationToken stoppingToken);
27+
28+
Task BroadcastAsync(ServiceMessage message, CancellationToken ct = default);
29+
}
30+
31+
/// <summary>
32+
/// Provides a named pipe server for communication between multiple RpcRole.Client and RpcRole.Manager.
33+
/// </summary>
34+
public class ManagerRpc : IManagerRpc
35+
{
36+
private readonly ConcurrentDictionary<ulong, ManagerRpcClient> _activeClients = new();
37+
private readonly ManagerConfig _config;
38+
private readonly CancellationTokenSource _cts = new();
39+
private readonly ILogger<ManagerRpc> _logger;
40+
private ulong _lastClientId;
41+
42+
// ReSharper disable once ConvertToPrimaryConstructor
43+
public ManagerRpc(IOptions<ManagerConfig> config, ILogger<ManagerRpc> logger)
44+
{
45+
_logger = logger;
46+
_config = config.Value;
47+
}
48+
49+
public event IManagerRpc.OnReceiveHandler? OnReceive;
50+
51+
public async ValueTask DisposeAsync()
52+
{
53+
await _cts.CancelAsync();
54+
while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
55+
_cts.Dispose();
56+
GC.SuppressFinalize(this);
57+
}
58+
59+
public async Task StopAsync(CancellationToken cancellationToken)
60+
{
61+
await _cts.CancelAsync();
62+
while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
63+
}
64+
65+
/// <summary>
66+
/// Starts the named pipe server, listens for incoming connections and starts handling them asynchronously.
67+
/// </summary>
68+
public async Task ExecuteAsync(CancellationToken stoppingToken)
69+
{
70+
_logger.LogInformation(@"Starting continuous named pipe RPC server at \\.\pipe\{PipeName}",
71+
_config.ServiceRpcPipeName);
72+
73+
// Allow everyone to connect to the named pipe
74+
var pipeSecurity = new PipeSecurity();
75+
pipeSecurity.AddAccessRule(new PipeAccessRule(
76+
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
77+
PipeAccessRights.FullControl,
78+
AccessControlType.Allow));
79+
80+
// Starting a named pipe server is not like a TCP server where you can
81+
// continuously accept new connections. You need to recreate the server
82+
// after accepting a connection in order to accept new connections.
83+
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _cts.Token);
84+
while (!linkedCts.IsCancellationRequested)
85+
{
86+
var pipeServer = NamedPipeServerStreamAcl.Create(_config.ServiceRpcPipeName, PipeDirection.InOut,
87+
NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0,
88+
0, pipeSecurity);
89+
90+
try
91+
{
92+
_logger.LogDebug("Waiting for new named pipe client connection");
93+
await pipeServer.WaitForConnectionAsync(linkedCts.Token);
94+
95+
var clientId = Interlocked.Add(ref _lastClientId, 1);
96+
_logger.LogInformation("Handling named pipe client connection for client {ClientId}", clientId);
97+
var speaker = new Speaker<ServiceMessage, ClientMessage>(pipeServer);
98+
var clientTask = HandleRpcClientAsync(clientId, speaker, linkedCts.Token);
99+
_activeClients.TryAdd(clientId, new ManagerRpcClient(speaker, clientTask));
100+
_ = clientTask.ContinueWith(task =>
101+
{
102+
if (task.IsFaulted)
103+
_logger.LogWarning(task.Exception, "Client {ClientId} RPC task faulted", clientId);
104+
_activeClients.TryRemove(clientId, out _);
105+
}, CancellationToken.None);
106+
}
107+
catch (OperationCanceledException)
108+
{
109+
await pipeServer.DisposeAsync();
110+
throw;
111+
}
112+
catch (Exception e)
113+
{
114+
_logger.LogWarning(e, "Failed to accept named pipe client");
115+
await pipeServer.DisposeAsync();
116+
}
117+
}
118+
}
119+
120+
public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct)
121+
{
122+
// Looping over a ConcurrentDictionary is exception-safe, but any items
123+
// added or removed during the loop may or may not be included.
124+
foreach (var (clientId, client) in _activeClients)
125+
try
126+
{
127+
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
128+
cts.CancelAfter(5 * 1000);
129+
await client.Speaker.SendMessage(message, cts.Token);
130+
}
131+
catch (ObjectDisposedException)
132+
{
133+
// The speaker was likely closed while we were iterating.
134+
}
135+
catch (Exception e)
136+
{
137+
_logger.LogWarning(e, "Failed to send message to client {ClientId}", clientId);
138+
// TODO: this should probably kill the client, but due to the
139+
// async nature of the client handling, calling Dispose
140+
// will not remove the client from the active clients list
141+
}
142+
}
143+
144+
private async Task HandleRpcClientAsync(ulong clientId, Speaker<ServiceMessage, ClientMessage> speaker,
145+
CancellationToken ct)
146+
{
147+
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
148+
await using (speaker)
149+
{
150+
var tcs = new TaskCompletionSource();
151+
var activeTasks = new ConcurrentDictionary<int, Task>();
152+
speaker.Receive += msg =>
153+
{
154+
var task = HandleRpcMessageAsync(clientId, msg, linkedCts.Token);
155+
activeTasks.TryAdd(task.Id, task);
156+
task.ContinueWith(t =>
157+
{
158+
if (t.IsFaulted)
159+
_logger.LogWarning(t.Exception, "Client {ClientId} RPC message handler task faulted", clientId);
160+
activeTasks.TryRemove(t.Id, out _);
161+
}, CancellationToken.None);
162+
};
163+
speaker.Error += tcs.SetException;
164+
speaker.Error += exception =>
165+
{
166+
_logger.LogWarning(exception, "Client {clientId} RPC speaker error", clientId);
167+
};
168+
await using (ct.Register(() => tcs.SetCanceled(ct)))
169+
{
170+
await speaker.StartAsync(ct);
171+
await tcs.Task;
172+
await linkedCts.CancelAsync();
173+
while (!activeTasks.IsEmpty)
174+
await Task.WhenAny(activeTasks.Values);
175+
}
176+
}
177+
}
178+
179+
private async Task HandleRpcMessageAsync(ulong clientId, ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
180+
CancellationToken ct)
181+
{
182+
_logger.LogInformation("Received RPC message from client {ClientId}: {Message}", clientId, message.Message);
183+
foreach (var handler in OnReceive?.GetInvocationList().Cast<IManagerRpc.OnReceiveHandler>() ?? [])
184+
try
185+
{
186+
await handler(clientId, message, ct);
187+
}
188+
catch (Exception e)
189+
{
190+
_logger.LogWarning(e, "Failed to handle RPC message from client {ClientId} with handler", clientId);
191+
}
192+
}
193+
}

‎Vpn.Service/ManagerRpcService.cs

Lines changed: 6 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -1,168 +1,24 @@
1-
using System.Collections.Concurrent;
2-
using System.IO.Pipes;
3-
using System.Security.AccessControl;
4-
using System.Security.Principal;
5-
using Coder.Desktop.Vpn.Proto;
61
using Microsoft.Extensions.Hosting;
7-
using Microsoft.Extensions.Logging;
8-
using Microsoft.Extensions.Options;
92

103
namespace Coder.Desktop.Vpn.Service;
114

12-
public class ManagerRpcClient(Speaker<ServiceMessage, ClientMessage> speaker, Task task)
5+
public class ManagerRpcService : BackgroundService
136
{
14-
public Speaker<ServiceMessage, ClientMessage> Speaker { get; } = speaker;
15-
public Task Task { get; } = task;
16-
}
17-
18-
/// <summary>
19-
/// Provides a named pipe server for communication between multiple RpcRole.Client and RpcRole.Manager.
20-
/// </summary>
21-
public class ManagerRpcService : BackgroundService, IAsyncDisposable
22-
{
23-
private readonly ConcurrentDictionary<ulong, ManagerRpcClient> _activeClients = new();
24-
private readonly ManagerConfig _config;
25-
private readonly CancellationTokenSource _cts = new();
26-
private readonly ILogger<ManagerRpcService> _logger;
27-
private readonly IManager _manager;
28-
private ulong _lastClientId;
7+
private readonly IManagerRpc _managerRpc;
298

309
// ReSharper disable once ConvertToPrimaryConstructor
31-
public ManagerRpcService(IOptions<ManagerConfig> config, ILogger<ManagerRpcService> logger, IManager manager)
32-
{
33-
_logger = logger;
34-
_manager = manager;
35-
_config = config.Value;
36-
}
37-
38-
public async ValueTask DisposeAsync()
10+
public ManagerRpcService(IManagerRpc managerRpc)
3911
{
40-
await _cts.CancelAsync();
41-
while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
42-
_cts.Dispose();
43-
GC.SuppressFinalize(this);
12+
_managerRpc = managerRpc;
4413
}
4514

4615
public override async Task StopAsync(CancellationToken cancellationToken)
4716
{
48-
await _cts.CancelAsync();
49-
while (!_activeClients.IsEmpty) await Task.WhenAny(_activeClients.Values.Select(c => c.Task));
17+
await _managerRpc.StopAsync(cancellationToken);
5018
}
5119

52-
/// <summary>
53-
/// Starts the named pipe server, listens for incoming connections and starts handling them asynchronously.
54-
/// </summary>
5520
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
5621
{
57-
_logger.LogInformation(@"Starting continuous named pipe RPC server at \\.\pipe\{PipeName}",
58-
_config.ServiceRpcPipeName);
59-
60-
// Allow everyone to connect to the named pipe
61-
var pipeSecurity = new PipeSecurity();
62-
pipeSecurity.AddAccessRule(new PipeAccessRule(
63-
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
64-
PipeAccessRights.FullControl,
65-
AccessControlType.Allow));
66-
67-
// Starting a named pipe server is not like a TCP server where you can
68-
// continuously accept new connections. You need to recreate the server
69-
// after accepting a connection in order to accept new connections.
70-
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _cts.Token);
71-
while (!linkedCts.IsCancellationRequested)
72-
{
73-
var pipeServer = NamedPipeServerStreamAcl.Create(_config.ServiceRpcPipeName, PipeDirection.InOut,
74-
NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 0,
75-
0, pipeSecurity);
76-
77-
try
78-
{
79-
_logger.LogDebug("Waiting for new named pipe client connection");
80-
await pipeServer.WaitForConnectionAsync(linkedCts.Token);
81-
82-
var clientId = Interlocked.Add(ref _lastClientId, 1);
83-
_logger.LogInformation("Handling named pipe client connection for client {ClientId}", clientId);
84-
var speaker = new Speaker<ServiceMessage, ClientMessage>(pipeServer);
85-
var clientTask = HandleRpcClientAsync(speaker, linkedCts.Token);
86-
_activeClients.TryAdd(clientId, new ManagerRpcClient(speaker, clientTask));
87-
_ = clientTask.ContinueWith(task =>
88-
{
89-
if (task.IsFaulted)
90-
_logger.LogWarning(task.Exception, "Client {ClientId} RPC task faulted", clientId);
91-
_activeClients.TryRemove(clientId, out _);
92-
}, CancellationToken.None);
93-
}
94-
catch (OperationCanceledException)
95-
{
96-
await pipeServer.DisposeAsync();
97-
throw;
98-
}
99-
catch (Exception e)
100-
{
101-
_logger.LogWarning(e, "Failed to accept named pipe client");
102-
await pipeServer.DisposeAsync();
103-
}
104-
}
105-
}
106-
107-
private async Task HandleRpcClientAsync(Speaker<ServiceMessage, ClientMessage> speaker, CancellationToken ct)
108-
{
109-
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
110-
await using (speaker)
111-
{
112-
var tcs = new TaskCompletionSource();
113-
var activeTasks = new ConcurrentDictionary<int, Task>();
114-
speaker.Receive += msg =>
115-
{
116-
var task = HandleRpcMessageAsync(msg, linkedCts.Token);
117-
activeTasks.TryAdd(task.Id, task);
118-
task.ContinueWith(t =>
119-
{
120-
if (t.IsFaulted)
121-
_logger.LogWarning(t.Exception, "Client RPC message handler task faulted");
122-
activeTasks.TryRemove(t.Id, out _);
123-
}, CancellationToken.None);
124-
};
125-
speaker.Error += tcs.SetException;
126-
speaker.Error += exception => { _logger.LogWarning(exception, "Client RPC speaker error"); };
127-
await using (ct.Register(() => tcs.SetCanceled(ct)))
128-
{
129-
await speaker.StartAsync(ct);
130-
await tcs.Task;
131-
await linkedCts.CancelAsync();
132-
while (!activeTasks.IsEmpty)
133-
await Task.WhenAny(activeTasks.Values);
134-
}
135-
}
136-
}
137-
138-
private async Task HandleRpcMessageAsync(ReplyableRpcMessage<ServiceMessage, ClientMessage> message,
139-
CancellationToken ct)
140-
{
141-
_logger.LogInformation("Received RPC message: {Message}", message.Message);
142-
await _manager.HandleClientRpcMessage(message, ct);
143-
}
144-
145-
public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct)
146-
{
147-
// Looping over a ConcurrentDictionary is exception-safe, but any items
148-
// added or removed during the loop may or may not be included.
149-
foreach (var (clientId, client) in _activeClients)
150-
try
151-
{
152-
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
153-
cts.CancelAfter(5 * 1000);
154-
await client.Speaker.SendMessage(message, cts.Token);
155-
}
156-
catch (ObjectDisposedException)
157-
{
158-
// The speaker was likely closed while we were iterating.
159-
}
160-
catch (Exception e)
161-
{
162-
_logger.LogWarning(e, "Failed to send message to client {ClientId}", clientId);
163-
// TODO: this should probably kill the client, but due to the
164-
// async nature of the client handling, calling Dispose
165-
// will not remove the client from the active clients list
166-
}
22+
await _managerRpc.ExecuteAsync(stoppingToken);
16723
}
16824
}

‎Vpn.Service/Program.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ namespace Coder.Desktop.Vpn.Service;
99
public static class Program
1010
{
1111
#if DEBUG
12-
private const string serviceName = "Coder Desktop (Debug)";
12+
private const string ServiceName = "Coder Desktop (Debug)";
1313
#else
14-
const string serviceName = "Coder Desktop";
14+
const string ServiceName = "Coder Desktop";
1515
#endif
1616

1717
private static readonly ILogger MainLogger = Log.ForContext("SourceContext", "Coder.Desktop.Vpn.Service.Program");
@@ -69,14 +69,14 @@ private static async Task BuildAndRun(string[] args)
6969
// Singletons
7070
builder.Services.AddSingleton<IDownloader, Downloader>();
7171
builder.Services.AddSingleton<ITunnelSupervisor, TunnelSupervisor>();
72+
builder.Services.AddSingleton<IManagerRpc, ManagerRpc>();
7273
builder.Services.AddSingleton<IManager, Manager>();
7374

7475
// Services
75-
// TODO: is this sound enough to determine if we're a service?
7676
if (!Environment.UserInteractive)
7777
{
7878
MainLogger.Information("Running as a windows service");
79-
builder.Services.AddWindowsService(options => { options.ServiceName = serviceName; });
79+
builder.Services.AddWindowsService(options => { options.ServiceName = ServiceName; });
8080
}
8181
else
8282
{

‎Vpn.Service/TunnelSupervisor.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
using Coder.Desktop.Vpn.Proto;
44
using Coder.Desktop.Vpn.Utilities;
55
using Microsoft.Extensions.Logging;
6+
using Log = Serilog.Log;
7+
using Process = System.Diagnostics.Process;
68

79
namespace Coder.Desktop.Vpn.Service;
810

911
public interface ITunnelSupervisor : IAsyncDisposable
1012
{
11-
public bool IsRunning { get; }
12-
1313
/// <summary>
1414
/// Starts the tunnel subprocess with the given executable path. If the subprocess is already running, this method will
1515
/// kill it first.
@@ -62,7 +62,6 @@ public class TunnelSupervisor : ITunnelSupervisor
6262
private AnonymousPipeServerStream? _inPipe;
6363
private AnonymousPipeServerStream? _outPipe;
6464
private Speaker<ManagerMessage, TunnelMessage>? _speaker;
65-
6665
private Process? _subprocess;
6766

6867
// ReSharper disable once ConvertToPrimaryConstructor
@@ -71,8 +70,6 @@ public TunnelSupervisor(ILogger<TunnelSupervisor> logger)
7170
_logger = logger;
7271
}
7372

74-
public bool IsRunning => _speaker != null;
75-
7673
public async Task StartAsync(string binPath,
7774
Speaker<ManagerMessage, TunnelMessage>.OnReceiveDelegate messageHandler,
7875
Speaker<ManagerMessage, TunnelMessage>.OnErrorDelegate errorHandler,
@@ -101,15 +98,19 @@ public async Task StartAsync(string binPath,
10198
RedirectStandardOutput = true,
10299
},
103100
};
101+
// TODO: maybe we should change the log format in the inner binary
102+
// to something without a timestamp
103+
var outLogger = Log.ForContext("SourceContext", "coder-vpn.exe[OUT]");
104+
var errLogger = Log.ForContext("SourceContext", "coder-vpn.exe[ERR]");
104105
_subprocess.OutputDataReceived += (_, args) =>
105106
{
106107
if (!string.IsNullOrWhiteSpace(args.Data))
107-
_logger.LogDebug("OUT: {Data}", args.Data);
108+
outLogger.Debug("{Data}", args.Data);
108109
};
109110
_subprocess.ErrorDataReceived += (_, args) =>
110111
{
111112
if (!string.IsNullOrWhiteSpace(args.Data))
112-
_logger.LogDebug("ERR: {Data}", args.Data);
113+
errLogger.Debug("{Data}", args.Data);
113114
};
114115

115116
// Pass the other end of the pipes to the subprocess and dispose

0 commit comments

Comments
 (0)
Please sign in to comment.