Skip to content

Commit acc89ee

Browse files
authored
chore: various finish line tasks (#23)
Related to #22 Fixes: - Exception handler - Exit button shouldn't hang the app - No default locations for `coder-vpn.exe` and `CoderDesktop.log` => use registry for locations - Release build pipeline
1 parent c2791f5 commit acc89ee

26 files changed

+403
-107
lines changed

.github/workflows/ci.yaml

+12-7
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,15 @@ 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+
# This doesn't call `dotnet publish` on the entire solution, just the
55+
# projects we care about building. Doing a full publish includes test
56+
# libraries and stuff which is pointless.
57+
- name: dotnet publish Coder.Desktop.Vpn.Service
58+
run: dotnet publish .\Vpn.Service\Vpn.Service.csproj --configuration Release --output .\publish\Vpn.Service
59+
- name: dotnet publish Coder.Desktop.App
60+
run: dotnet publish .\App\App.csproj --configuration Release --output .\publish\App
61+
- name: Upload artifact
62+
uses: actions/upload-artifact@v4
63+
with:
64+
name: publish
65+
path: .\publish\

.github/workflows/release.yaml

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- '*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
build:
13+
runs-on: windows-latest
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: Setup .NET
19+
uses: actions/setup-dotnet@v4
20+
with:
21+
dotnet-version: '8.0.x'
22+
23+
- name: Get version from tag
24+
id: version
25+
shell: pwsh
26+
run: |
27+
$tag = $env:GITHUB_REF -replace 'refs/tags/',''
28+
if ($tag -notmatch '^v\d+\.\d+\.\d+$') {
29+
throw "Tag must be in format v1.2.3"
30+
}
31+
$version = $tag -replace '^v',''
32+
$assemblyVersion = "$version.0"
33+
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
34+
echo "ASSEMBLY_VERSION=$assemblyVersion" >> $env:GITHUB_OUTPUT
35+
36+
- name: Build and publish x64
37+
run: |
38+
dotnet publish src/App/App.csproj -c Release -r win-x64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/x64
39+
dotnet publish src/Vpn.Service/Vpn.Service.csproj -c Release -r win-x64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/x64
40+
41+
- name: Build and publish arm64
42+
run: |
43+
dotnet publish src/App/App.csproj -c Release -r win-arm64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/arm64
44+
dotnet publish src/Vpn.Service/Vpn.Service.csproj -c Release -r win-arm64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/arm64
45+
46+
- name: Create ZIP archives
47+
shell: pwsh
48+
run: |
49+
Compress-Archive -Path "publish/x64/*" -DestinationPath "./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-x64.zip"
50+
Compress-Archive -Path "publish/arm64/*" -DestinationPath "./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-arm64.zip"
51+
52+
- name: Create Release
53+
uses: softprops/action-gh-release@v1
54+
with:
55+
files: |
56+
./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-x64.zip
57+
./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-arm64.zip
58+
name: Release ${{ steps.version.outputs.VERSION }}
59+
generate_release_notes: true
60+
env:
61+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -403,5 +403,7 @@ FodyWeavers.xsd
403403
.idea/**/shelf
404404

405405
publish
406-
WindowsAppRuntimeInstall-x64.exe
406+
WindowsAppRuntimeInstall-*.exe
407+
windowsdesktop-runtime-*.exe
407408
wintun.dll
409+
wintun-*.dll

App/App.csproj

+26-6
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,42 @@
1010
<PublishProfile>Properties\PublishProfiles\win-$(Platform).pubxml</PublishProfile>
1111
<UseWinUI>true</UseWinUI>
1212
<Nullable>enable</Nullable>
13-
<EnableMsixTooling>false</EnableMsixTooling>
13+
<EnableMsixTooling>true</EnableMsixTooling>
1414
<WindowsPackageType>None</WindowsPackageType>
1515
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
1616
<!-- To use CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute: -->
1717
<LangVersion>preview</LangVersion>
18+
<!-- We have our own implementation of main with exception handling -->
19+
<DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants>
20+
</PropertyGroup>
21+
22+
<PropertyGroup Condition="$(Configuration) == 'Release'">
23+
<PublishTrimmed>true</PublishTrimmed>
24+
<TrimMode>CopyUsed</TrimMode>
25+
<PublishReadyToRun>true</PublishReadyToRun>
26+
<SelfContained>true</SelfContained>
1827
</PropertyGroup>
1928

2029
<ItemGroup>
2130
<Manifest Include="$(ApplicationManifest)" />
2231
</ItemGroup>
2332

24-
<ItemGroup>
25-
<Content Include="Images\SplashScreen.scale-200.png" />
26-
<Content Include="Images\Square150x150Logo.scale-200.png" />
27-
<Content Include="Images\Square44x44Logo.scale-200.png" />
28-
</ItemGroup>
33+
<!--
34+
Clean up unnecessary files (including .xbf XAML Binary Format files)
35+
and (now) empty directories from target. The .xbf files are not
36+
necessary as they are contained within resources.pri.
37+
-->
38+
<Target Name="CleanupTargetDir" AfterTargets="Build;_GenerateProjectPriFileCore" Condition="$(Configuration) == 'Release'">
39+
<ItemGroup>
40+
<FilesToDelete Include="$(TargetDir)**\*.xbf" />
41+
<FilesToDelete Include="$(TargetDir)createdump.exe" />
42+
<DirsToDelete Include="$(TargetDir)Controls" />
43+
<DirsToDelete Include="$(TargetDir)Views" />
44+
</ItemGroup>
45+
46+
<Delete Files="@(FilesToDelete)" />
47+
<RemoveDir Directories="@(DirsToDelete)" />
48+
</Target>
2949

3050
<ItemGroup>
3151
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />

App/App.xaml.cs

+16-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
using System;
2-
using System.Diagnostics;
2+
using System.Threading.Tasks;
33
using Coder.Desktop.App.Services;
44
using Coder.Desktop.App.ViewModels;
55
using Coder.Desktop.App.Views;
@@ -13,6 +13,8 @@ public partial class App : Application
1313
{
1414
private readonly IServiceProvider _services;
1515

16+
private bool _handleWindowClosed = true;
17+
1618
public App()
1719
{
1820
var services = new ServiceCollection();
@@ -36,18 +38,27 @@ public App()
3638

3739
_services = services.BuildServiceProvider();
3840

39-
#if DEBUG
40-
UnhandledException += (_, e) => { Debug.WriteLine(e.Exception.ToString()); };
41-
#endif
42-
4341
InitializeComponent();
4442
}
4543

44+
public async Task ExitApplication()
45+
{
46+
_handleWindowClosed = false;
47+
Exit();
48+
var rpcManager = _services.GetRequiredService<IRpcController>();
49+
// TODO: send a StopRequest if we're connected???
50+
await rpcManager.DisposeAsync();
51+
Environment.Exit(0);
52+
}
53+
4654
protected override void OnLaunched(LaunchActivatedEventArgs args)
4755
{
4856
var trayWindow = _services.GetRequiredService<TrayWindow>();
57+
58+
// Prevent the TrayWindow from closing, just hide it.
4959
trayWindow.Closed += (sender, args) =>
5060
{
61+
if (!_handleWindowClosed) return;
5162
args.Handled = true;
5263
trayWindow.AppWindow.Hide();
5364
};

App/Images/SplashScreen.scale-200.png

-5.25 KB
Binary file not shown.
-1.71 KB
Binary file not shown.
-637 Bytes
Binary file not shown.

App/Program.cs

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using System.Threading;
4+
using Microsoft.UI.Dispatching;
5+
using Microsoft.UI.Xaml;
6+
using Microsoft.Windows.AppLifecycle;
7+
using WinRT;
8+
9+
namespace Coder.Desktop.App;
10+
11+
#if DISABLE_XAML_GENERATED_MAIN
12+
public static class Program
13+
{
14+
private static App? app;
15+
#if DEBUG
16+
[DllImport("kernel32.dll")]
17+
private static extern bool AllocConsole();
18+
#endif
19+
20+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
21+
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, int type);
22+
23+
[STAThread]
24+
private static void Main(string[] args)
25+
{
26+
try
27+
{
28+
ComWrappersSupport.InitializeComWrappers();
29+
if (!CheckSingleInstance()) return;
30+
Application.Start(p =>
31+
{
32+
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
33+
SynchronizationContext.SetSynchronizationContext(context);
34+
35+
app = new App();
36+
app.UnhandledException += (_, e) =>
37+
{
38+
e.Handled = true;
39+
ShowExceptionAndCrash(e.Exception);
40+
};
41+
});
42+
}
43+
catch (Exception e)
44+
{
45+
ShowExceptionAndCrash(e);
46+
}
47+
}
48+
49+
[STAThread]
50+
private static bool CheckSingleInstance()
51+
{
52+
#if !DEBUG
53+
const string appInstanceName = "Coder.Desktop.App";
54+
#else
55+
const string appInstanceName = "Coder.Desktop.App.Debug";
56+
#endif
57+
58+
var instance = AppInstance.FindOrRegisterForKey(appInstanceName);
59+
return instance.IsCurrent;
60+
}
61+
62+
[STAThread]
63+
private static void ShowExceptionAndCrash(Exception e)
64+
{
65+
const string title = "Coder Desktop Fatal Error";
66+
var message =
67+
"Coder Desktop has encountered a fatal error and must exit.\n\n" +
68+
e + "\n\n" +
69+
Environment.StackTrace;
70+
MessageBoxW(IntPtr.Zero, message, title, 0);
71+
72+
if (app != null)
73+
app.Exit();
74+
75+
// This will log the exception to the Windows Event Log.
76+
#if DEBUG
77+
// And, if in DEBUG mode, it will also log to the console window.
78+
AllocConsole();
79+
#endif
80+
Environment.FailFast("Coder Desktop has encountered a fatal error and must exit.", e);
81+
}
82+
}
83+
#endif
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?xml version="1.0" encoding="utf-8"?>
1+
<?xml version="1.0" encoding="utf-8"?>
22
<!--
33
https://go.microsoft.com/fwlink/?LinkID=208121.
44
-->
@@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
88
<Platform>ARM64</Platform>
99
<RuntimeIdentifier>win-arm64</RuntimeIdentifier>
1010
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
11-
<SelfContained>true</SelfContained>
12-
<PublishSingleFile>False</PublishSingleFile>
1311
</PropertyGroup>
1412
</Project>
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?xml version="1.0" encoding="utf-8"?>
1+
<?xml version="1.0" encoding="utf-8"?>
22
<!--
33
https://go.microsoft.com/fwlink/?LinkID=208121.
44
-->
@@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
88
<Platform>x64</Platform>
99
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
1010
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
11-
<SelfContained>true</SelfContained>
12-
<PublishSingleFile>False</PublishSingleFile>
1311
</PropertyGroup>
1412
</Project>
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?xml version="1.0" encoding="utf-8"?>
1+
<?xml version="1.0" encoding="utf-8"?>
22
<!--
33
https://go.microsoft.com/fwlink/?LinkID=208121.
44
-->
@@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
88
<Platform>x86</Platform>
99
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
1010
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
11-
<SelfContained>true</SelfContained>
12-
<PublishSingleFile>False</PublishSingleFile>
1311
</PropertyGroup>
1412
</Project>

App/Services/CredentialManager.cs

+14-8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Runtime.InteropServices;
33
using System.Text;
44
using System.Text.Json;
5+
using System.Text.Json.Serialization;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using Coder.Desktop.App.Models;
@@ -10,6 +11,17 @@
1011

1112
namespace Coder.Desktop.App.Services;
1213

14+
public class RawCredentials
15+
{
16+
public required string CoderUrl { get; set; }
17+
public required string ApiToken { get; set; }
18+
}
19+
20+
[JsonSerializable(typeof(RawCredentials))]
21+
public partial class RawCredentialsJsonContext : JsonSerializerContext
22+
{
23+
}
24+
1325
public interface ICredentialManager
1426
{
1527
public event EventHandler<CredentialModel> CredentialsChanged;
@@ -123,7 +135,7 @@ private void UpdateState(CredentialModel newModel)
123135
RawCredentials? credentials;
124136
try
125137
{
126-
credentials = JsonSerializer.Deserialize<RawCredentials>(raw);
138+
credentials = JsonSerializer.Deserialize(raw, RawCredentialsJsonContext.Default.RawCredentials);
127139
}
128140
catch (JsonException)
129141
{
@@ -138,16 +150,10 @@ private void UpdateState(CredentialModel newModel)
138150

139151
private static void WriteCredentials(RawCredentials credentials)
140152
{
141-
var raw = JsonSerializer.Serialize(credentials);
153+
var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials);
142154
NativeApi.WriteCredentials(CredentialsTargetName, raw);
143155
}
144156

145-
private class RawCredentials
146-
{
147-
public required string CoderUrl { get; set; }
148-
public required string ApiToken { get; set; }
149-
}
150-
151157
private static class NativeApi
152158
{
153159
private const int CredentialTypeGeneric = 1;

App/Services/RpcController.cs

+8-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public VpnLifecycleException(string message) : base(message)
3232
}
3333
}
3434

35-
public interface IRpcController
35+
public interface IRpcController : IAsyncDisposable
3636
{
3737
public event EventHandler<RpcModel> StateChanged;
3838

@@ -224,6 +224,13 @@ public async Task StopVpn(CancellationToken ct = default)
224224
new InvalidOperationException($"Service reported failure: {reply.Stop.ErrorMessage}"));
225225
}
226226

227+
public async ValueTask DisposeAsync()
228+
{
229+
if (_speaker != null)
230+
await _speaker.DisposeAsync();
231+
GC.SuppressFinalize(this);
232+
}
233+
227234
private void MutateState(Action<RpcModel> mutator)
228235
{
229236
RpcModel newState;

0 commit comments

Comments
 (0)