From adf2a70b20ab76f26ff85b82c056d0c9f367afeb Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 13 Feb 2025 00:00:15 +1100 Subject: [PATCH 1/4] chore: various finish line tasks --- .github/workflows/ci.yaml | 19 ++-- .github/workflows/release.yaml | 0 .gitignore | 4 +- App/App.csproj | 32 +++++-- App/App.xaml.cs | 21 ++++- App/Images/SplashScreen.scale-200.png | Bin 5372 -> 0 bytes App/Images/Square150x150Logo.scale-200.png | Bin 1755 -> 0 bytes App/Images/Square44x44Logo.scale-200.png | Bin 637 -> 0 bytes App/Program.cs | 83 +++++++++++++++++ .../PublishProfiles/win-arm64.pubxml | 4 +- App/Properties/PublishProfiles/win-x64.pubxml | 4 +- App/Properties/PublishProfiles/win-x86.pubxml | 4 +- App/Services/CredentialManager.cs | 22 +++-- App/Services/RpcController.cs | 9 +- App/ViewModels/TrayWindowViewModel.cs | 88 ++++++++++++++---- App/Views/Pages/SignInTokenPage.xaml | 5 +- App/Views/Pages/TrayWindowMainPage.xaml | 6 +- App/Views/SignInWindow.xaml.cs | 12 +++ App/Views/TrayWindow.xaml.cs | 5 +- App/packages.lock.json | 6 ++ Publish-Alpha.ps1 | 60 +++++++++--- Vpn.Service/Manager.cs | 24 +++-- Vpn.Service/ManagerConfig.cs | 6 +- Vpn.Service/Program.cs | 38 ++++---- 24 files changed, 349 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/release.yaml delete mode 100644 App/Images/SplashScreen.scale-200.png delete mode 100644 App/Images/Square150x150Logo.scale-200.png delete mode 100644 App/Images/Square44x44Logo.scale-200.png create mode 100644 App/Program.cs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8c42c13..b5059b2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -51,10 +51,15 @@ jobs: cache-dependency-path: '**/packages.lock.json' - name: dotnet restore run: dotnet restore --locked-mode - #- name: dotnet publish - # run: dotnet publish --no-restore --configuration Release --output .\publish - #- name: Upload artifact - # uses: actions/upload-artifact@v4 - # with: - # name: publish - # path: .\publish\ + # This doesn't call `dotnet publish` on the entire solution, just the + # projects we care about building. Doing a full publish includes test + # libraries and stuff which is pointless. + - name: dotnet publish Coder.Desktop.Vpn.Service + run: dotnet publish .\Vpn.Service\Vpn.Service.csproj --configuration Release --output .\publish\Vpn.Service + - name: dotnet publish Coder.Desktop.App + run: dotnet publish .\App\App.csproj --configuration Release --output .\publish\App + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: publish + path: .\publish\ diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 4ea0881..54c47a0 100644 --- a/.gitignore +++ b/.gitignore @@ -403,5 +403,7 @@ FodyWeavers.xsd .idea/**/shelf publish -WindowsAppRuntimeInstall-x64.exe +WindowsAppRuntimeInstall-*.exe +windowsdesktop-runtime-*.exe wintun.dll +wintun-*.dll diff --git a/App/App.csproj b/App/App.csproj index f6e3c0d..c28256a 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -10,22 +10,42 @@ Properties\PublishProfiles\win-$(Platform).pubxml true enable - false + true None true preview + + DISABLE_XAML_GENERATED_MAIN + + + + true + CopyUsed + true + true - - - - - + + + + + + + + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 515d404..af4217e 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,5 +1,5 @@ using System; -using System.Diagnostics; +using System.Threading.Tasks; using Coder.Desktop.App.Services; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views; @@ -13,6 +13,8 @@ public partial class App : Application { private readonly IServiceProvider _services; + private bool _handleWindowClosed = true; + public App() { var services = new ServiceCollection(); @@ -36,18 +38,27 @@ public App() _services = services.BuildServiceProvider(); -#if DEBUG - UnhandledException += (_, e) => { Debug.WriteLine(e.Exception.ToString()); }; -#endif - InitializeComponent(); } + public async Task ExitApplication() + { + _handleWindowClosed = false; + Exit(); + var rpcManager = _services.GetRequiredService(); + // TODO: send a StopRequest if we're connected??? + await rpcManager.DisposeAsync(); + Environment.Exit(0); + } + protected override void OnLaunched(LaunchActivatedEventArgs args) { var trayWindow = _services.GetRequiredService(); + + // Prevent the TrayWindow from closing, just hide it. trayWindow.Closed += (sender, args) => { + if (!_handleWindowClosed) return; args.Handled = true; trayWindow.AppWindow.Hide(); }; diff --git a/App/Images/SplashScreen.scale-200.png b/App/Images/SplashScreen.scale-200.png deleted file mode 100644 index 32f486a86792a5e34cd9a8261b394c49b48f86be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5372 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 diff --git a/App/Images/Square44x44Logo.scale-200.png b/App/Images/Square44x44Logo.scale-200.png deleted file mode 100644 index f713bba67f551ef91020b75716a4dc8ebd744b1c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 637 zcmeAS@N?(olHy`uVBq!ia0vp^5g^RL1|$oo8kjIJFu8cTIEGZ*dUI*J;2{SImxtDO zm%3!R$UazoY}x{$j0P5ABYXWr(l=jxJ6ps1W{tV=^>{Dl><3nv3A}sm=EZ)#l3`NR zpZda3^rNox*D1%NC98Z~L*6zipLw~Gxn&(Y-;KmJ+aR6eLabU-L#y8HW%7P-E_-VlLqIabbHPHKT*)fT@9iWJ7iWgOT9%0}Lrj>lztPxWq6sPw3pi z#-<=#$jjrP_DD*i!RLsn0mIA=>4~N)IMYWIf=j%-zuKCdMG%tHYot70D1| zvWa0wMhauW#S>1CnI_;>!1Q3zMA17@DOVq{MQ+{U7^a&yA+%dMCG;WNPV0i;w$tu; zX^b}UKziPM)(<;)ruW;-`)bBN+rQNM*Zs_>?n$|FVFo-e*PZb*@U7VAd+tHb4e + { + var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(context); + + app = new App(); + app.UnhandledException += (_, e) => + { + e.Handled = true; + ShowExceptionAndCrash(e.Exception); + }; + }); + } + catch (Exception e) + { + ShowExceptionAndCrash(e); + } + } + + [STAThread] + private static bool CheckSingleInstance() + { +#if !DEBUG + const string appInstanceName = "Coder.Desktop.App"; +#else + const string appInstanceName = "Coder.Desktop.App.Debug"; +#endif + + var instance = AppInstance.FindOrRegisterForKey(appInstanceName); + return instance.IsCurrent; + } + + [STAThread] + private static void ShowExceptionAndCrash(Exception e) + { + const string title = "Coder Desktop Fatal Error"; + var message = + "Coder Desktop has encountered a fatal error and must exit.\n\n" + + e + "\n\n" + + Environment.StackTrace; + MessageBoxW(IntPtr.Zero, message, title, 0); + + if (app != null) + app.Exit(); + + // This will log the exception to the Windows Event Log. +#if DEBUG + // And, if in DEBUG mode, it will also log to the console window. + AllocConsole(); +#endif + Environment.FailFast("Coder Desktop has encountered a fatal error and must exit.", e); + } +} +#endif diff --git a/App/Properties/PublishProfiles/win-arm64.pubxml b/App/Properties/PublishProfiles/win-arm64.pubxml index 5b906a9..ac9753e 100644 --- a/App/Properties/PublishProfiles/win-arm64.pubxml +++ b/App/Properties/PublishProfiles/win-arm64.pubxml @@ -1,4 +1,4 @@ - + @@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121. ARM64 win-arm64 bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ - true - False diff --git a/App/Properties/PublishProfiles/win-x64.pubxml b/App/Properties/PublishProfiles/win-x64.pubxml index d6e3ca5..942523b 100644 --- a/App/Properties/PublishProfiles/win-x64.pubxml +++ b/App/Properties/PublishProfiles/win-x64.pubxml @@ -1,4 +1,4 @@ - + @@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121. x64 win-x64 bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ - true - False diff --git a/App/Properties/PublishProfiles/win-x86.pubxml b/App/Properties/PublishProfiles/win-x86.pubxml index 084c7fe..e763481 100644 --- a/App/Properties/PublishProfiles/win-x86.pubxml +++ b/App/Properties/PublishProfiles/win-x86.pubxml @@ -1,4 +1,4 @@ - + @@ -8,7 +8,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121. x86 win-x86 bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ - true - False diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index 05dceec..4e47f47 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Coder.Desktop.App.Models; @@ -10,6 +11,17 @@ namespace Coder.Desktop.App.Services; +public class RawCredentials +{ + public required string CoderUrl { get; set; } + public required string ApiToken { get; set; } +} + +[JsonSerializable(typeof(RawCredentials))] +public partial class RawCredentialsJsonContext : JsonSerializerContext +{ +} + public interface ICredentialManager { public event EventHandler CredentialsChanged; @@ -123,7 +135,7 @@ private void UpdateState(CredentialModel newModel) RawCredentials? credentials; try { - credentials = JsonSerializer.Deserialize(raw); + credentials = JsonSerializer.Deserialize(raw, RawCredentialsJsonContext.Default.RawCredentials); } catch (JsonException) { @@ -138,16 +150,10 @@ private void UpdateState(CredentialModel newModel) private static void WriteCredentials(RawCredentials credentials) { - var raw = JsonSerializer.Serialize(credentials); + var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials); NativeApi.WriteCredentials(CredentialsTargetName, raw); } - private class RawCredentials - { - public required string CoderUrl { get; set; } - public required string ApiToken { get; set; } - } - private static class NativeApi { private const int CredentialTypeGeneric = 1; diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 07ae38e..a02347f 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -32,7 +32,7 @@ public VpnLifecycleException(string message) : base(message) } } -public interface IRpcController +public interface IRpcController : IAsyncDisposable { public event EventHandler StateChanged; @@ -224,6 +224,13 @@ public async Task StopVpn(CancellationToken ct = default) new InvalidOperationException($"Service reported failure: {reply.Stop.ErrorMessage}")); } + public async ValueTask DisposeAsync() + { + if (_speaker != null) + await _speaker.DisposeAsync(); + GC.SuppressFinalize(this); + } + private void MutateState(Action mutator) { RpcModel newState; diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 5fcd84e..7044770 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -3,6 +3,7 @@ using System.Linq; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; +using Coder.Desktop.Vpn.Proto; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Google.Protobuf; @@ -15,19 +16,19 @@ 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; + private ToggleSwitch? _vpnActiveSwitch; + private bool _isProgrammaticStateChange; + private DispatcherQueue? _dispatcherQueue; [ObservableProperty] public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; - // VpnSwitchOn needs to be its own property as it is a two-way binding - [ObservableProperty] - public partial bool VpnSwitchOn { get; set; } = false; - [ObservableProperty] public partial string? VpnFailedMessage { get; set; } = null; @@ -82,13 +83,26 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected) { VpnLifecycle = VpnLifecycle.Unknown; - VpnSwitchOn = false; + SetVpnSwitch(false); Agents = []; return; } VpnLifecycle = rpcModel.VpnLifecycle; - VpnSwitchOn = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started; + SetVpnSwitch(rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started); + + // Get the current dashboard URL. + var credentialModel = _credentialManager.GetCredentials(); + Uri? coderUri = null; + if (credentialModel.State == CredentialState.Valid && !string.IsNullOrWhiteSpace(credentialModel.CoderUrl)) + try + { + coderUri = new Uri(credentialModel.CoderUrl, UriKind.Absolute); + } + catch + { + // Ignore + } // Add every known agent. HashSet workspacesWithAgents = []; @@ -114,6 +128,8 @@ private void UpdateFromRpcModel(RpcModel rpcModel) var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); workspacesWithAgents.Add(agent.WorkspaceId); + var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId); + agents.Add(new AgentViewModel { Hostname = fqdnPrefix, @@ -121,26 +137,21 @@ private void UpdateFromRpcModel(RpcModel rpcModel) ConnectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) ? AgentConnectionStatus.Green : AgentConnectionStatus.Red, - // TODO: we don't actually have any way of crafting a dashboard - // URL without the owner's username - DashboardUrl = "https://coder.com", + DashboardUrl = WorkspaceUri(coderUri, workspace?.Name), }); } - // For every workspace that doesn't have an agent, add a dummy agent. - foreach (var workspace in rpcModel.Workspaces.Where(w => !workspacesWithAgents.Contains(w.Id))) - { + // For every stopped workspace that doesn't have any agents, add a + // dummy agent row. + foreach (var workspace in rpcModel.Workspaces.Where(w => w.Status == Workspace.Types.Status.Stopped && !workspacesWithAgents.Contains(w.Id))) agents.Add(new AgentViewModel { // We just assume that it's a single-agent workspace. Hostname = workspace.Name, HostnameSuffix = ".coder", ConnectionStatus = AgentConnectionStatus.Gray, - // TODO: we don't actually have any way of crafting a dashboard - // URL without the owner's username - DashboardUrl = "https://coder.com", + DashboardUrl = WorkspaceUri(coderUri, workspace.Name), }); - } // Sort by status green, red, gray, then by hostname. agents.Sort((a, b) => @@ -154,25 +165,61 @@ 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; + } + + private void SetVpnSwitch(bool value) + { + if (_vpnActiveSwitch == null) return; + _isProgrammaticStateChange = true; + _vpnActiveSwitch.IsOn = value; + _isProgrammaticStateChange = false; + } + + // HACK: using a two-way bool to store the VPN active state results in + // erroneous events being sent (even outside our change handlers). This + // sucks and breaks the ViewModel separation but is necessary for the + // switch to function correctly. + public void VpnSwitch_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not ToggleSwitch toggleSwitch) return; + _vpnActiveSwitch = toggleSwitch; + SetVpnSwitch(VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started); } - // 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; + // HACK: the toggled event gets fired even when the switch state is + // changed from code, so we ignore all events while we're performing + // changes. + if (_isProgrammaticStateChange) return; + VpnFailedMessage = ""; try { + // The start/stop methods will call back to update the state. if (toggleSwitch.IsOn) _rpcController.StartVpn(); else @@ -180,6 +227,7 @@ public void VpnSwitch_Toggled(object sender, RoutedEventArgs e) } catch { + // TODO: display error VpnFailedMessage = e.ToString(); } } diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml index a09efb8..93a1796 100644 --- a/App/Views/Pages/SignInTokenPage.xaml +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -57,14 +57,13 @@ HorizontalAlignment="Right" Padding="10" /> - + Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" /> - + + @@ -56,8 +57,8 @@ Grid.Column="2" OnContent="" OffContent="" - IsOn="{x:Bind ViewModel.VpnSwitchOn, Mode=TwoWay}" IsEnabled="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource NotConnectingBoolConverter}, Mode=OneWay}" + Loaded="{x:Bind ViewModel.VpnSwitch_Loaded}" Toggled="{x:Bind ViewModel.VpnSwitch_Toggled}" Margin="0,0,-110,0" HorizontalAlignment="Right" /> @@ -204,6 +205,7 @@ diff --git a/App/Views/SignInWindow.xaml.cs b/App/Views/SignInWindow.xaml.cs index 5549e7c..2d866e7 100644 --- a/App/Views/SignInWindow.xaml.cs +++ b/App/Views/SignInWindow.xaml.cs @@ -1,7 +1,9 @@ using Windows.Graphics; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views.Pages; +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using WinRT.Interop; namespace Coder.Desktop.App.Views; @@ -24,6 +26,7 @@ public SignInWindow(SignInViewModel viewModel) NavigateToUrlPage(); ResizeWindow(); + MoveWindowToCenterOfDisplay(); } public void NavigateToTokenPage() @@ -43,4 +46,13 @@ private void ResizeWindow() var width = (int)(WIDTH * scale); AppWindow.Resize(new SizeInt32(width, height)); } + + private void MoveWindowToCenterOfDisplay() + { + var workArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Primary).WorkArea; + var x = (workArea.Width - AppWindow.Size.Width) / 2; + var y = (workArea.Height - AppWindow.Size.Height) / 2; + if (x < 0 || y < 0) return; + AppWindow.Move(new PointInt32(x, y)); + } } diff --git a/App/Views/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs index 224fae2..b528723 100644 --- a/App/Views/TrayWindow.xaml.cs +++ b/App/Views/TrayWindow.xaml.cs @@ -247,10 +247,11 @@ private void Tray_Open() [RelayCommand] private void Tray_Exit() { - Application.Current.Exit(); + // It's fine that this happens in the background. + _ = ((App)Application.Current).ExitApplication(); } - public class NativeApi + public static class NativeApi { [DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); diff --git a/App/packages.lock.json b/App/packages.lock.json index ca5e679..78dfa1b 100644 --- a/App/packages.lock.json +++ b/App/packages.lock.json @@ -35,6 +35,12 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" } }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.12, )", + "resolved": "8.0.12", + "contentHash": "FV4HnQ3JI15PHnJ5PGTbz+rYvrih42oLi/7UMIshNwCwUZhTq13UzrggtXk4ygrcMcN+4jsS6hhshx2p/Zd0ig==" + }, "Microsoft.WindowsAppSDK": { "type": "Direct", "requested": "[1.6.250108002, )", diff --git a/Publish-Alpha.ps1 b/Publish-Alpha.ps1 index 79032b3..86ce8e7 100644 --- a/Publish-Alpha.ps1 +++ b/Publish-Alpha.ps1 @@ -1,8 +1,15 @@ +# Usage: Publish-Alpha.ps1 [-arch ] +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 diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 6ed7b82..f2e6ddd 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -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); @@ -132,11 +138,9 @@ private async ValueTask HandleClientMessageStart(ClientMessage me { try { - var serverVersion = - await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken, - ct); + var serverVersion = 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 +160,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 +365,7 @@ private static string SystemArchitecture() /// Cancellation token /// The server version /// The server version is not within the required range - private async ValueTask CheckServerVersionAndCredentials(string baseUrl, string apiToken, + private async ValueTask CheckServerVersionAndCredentials(string baseUrl, string apiToken, CancellationToken ct = default) { var client = new CoderApiClient(baseUrl, apiToken); @@ -377,7 +381,11 @@ private async ValueTask 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, + }; } /// diff --git a/Vpn.Service/ManagerConfig.cs b/Vpn.Service/ManagerConfig.cs index 906a0b8..3cbdb89 100644 --- a/Vpn.Service/ManagerConfig.cs +++ b/Vpn.Service/ManagerConfig.cs @@ -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"; } diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs index c2a1037..3d963a6 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -8,29 +8,27 @@ 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 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 +59,13 @@ private static async Task BuildAndRun(string[] args) // Options types (these get registered as IOptions singletons) builder.Services.AddOptions() .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(); From 4819b794c7ece66cea08cec864700be3d6d15535 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 13 Feb 2025 00:01:23 +1100 Subject: [PATCH 2/4] fixup! chore: various finish line tasks --- .github/workflows/release.yaml | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e69de29..b9810fd 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -0,0 +1,61 @@ +name: Release + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Get version from tag + id: version + shell: pwsh + run: | + $tag = $env:GITHUB_REF -replace 'refs/tags/','' + if ($tag -notmatch '^v\d+\.\d+\.\d+$') { + throw "Tag must be in format v1.2.3" + } + $version = $tag -replace '^v','' + $assemblyVersion = "$version.0" + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + echo "ASSEMBLY_VERSION=$assemblyVersion" >> $env:GITHUB_OUTPUT + + - name: Build and publish x64 + run: | + dotnet publish src/App/App.csproj -c Release -r win-x64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/x64 + dotnet publish src/Vpn.Service/Vpn.Service.csproj -c Release -r win-x64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/x64 + + - name: Build and publish arm64 + run: | + dotnet publish src/App/App.csproj -c Release -r win-arm64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/arm64 + dotnet publish src/Vpn.Service/Vpn.Service.csproj -c Release -r win-arm64 -p:Version=${{ steps.version.outputs.ASSEMBLY_VERSION }} -o publish/arm64 + + - name: Create ZIP archives + shell: pwsh + run: | + Compress-Archive -Path "publish/x64/*" -DestinationPath "./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-x64.zip" + Compress-Archive -Path "publish/arm64/*" -DestinationPath "./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-arm64.zip" + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + ./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-x64.zip + ./publish/CoderDesktop-${{ steps.version.outputs.VERSION }}-arm64.zip + name: Release ${{ steps.version.outputs.VERSION }} + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 6af0b7b7366913ed0f4d3c6030bc364ab2a0e088 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 17 Feb 2025 06:44:17 +1100 Subject: [PATCH 3/4] PR comments --- App/ViewModels/TrayWindowViewModel.cs | 41 ++++++------------------- App/Views/Pages/TrayWindowMainPage.xaml | 2 +- App/packages.lock.json | 6 ---- CoderSdk/CoderApiClient.cs | 15 +++++++-- CoderSdk/Deployment.cs | 2 +- CoderSdk/Users.cs | 2 +- 6 files changed, 25 insertions(+), 43 deletions(-) diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 7044770..6133dbc 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -21,14 +21,15 @@ public partial class TrayWindowViewModel : ObservableObject private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; - private ToggleSwitch? _vpnActiveSwitch; - private bool _isProgrammaticStateChange; - private DispatcherQueue? _dispatcherQueue; [ObservableProperty] public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; + // This is a separate property because we need the switch to be 2-way. + [ObservableProperty] + public partial bool VpnSwitchActive { get; set; } = false; + [ObservableProperty] public partial string? VpnFailedMessage { get; set; } = null; @@ -83,13 +84,13 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected) { VpnLifecycle = VpnLifecycle.Unknown; - SetVpnSwitch(false); + VpnSwitchActive = false; Agents = []; return; } VpnLifecycle = rpcModel.VpnLifecycle; - SetVpnSwitch(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(); @@ -188,42 +189,20 @@ private void UpdateFromCredentialsModel(CredentialModel credentialModel) DashboardUrl = credentialModel.CoderUrl ?? DefaultDashboardUrl; } - private void SetVpnSwitch(bool value) - { - if (_vpnActiveSwitch == null) return; - _isProgrammaticStateChange = true; - _vpnActiveSwitch.IsOn = value; - _isProgrammaticStateChange = false; - } - - // HACK: using a two-way bool to store the VPN active state results in - // erroneous events being sent (even outside our change handlers). This - // sucks and breaks the ViewModel separation but is necessary for the - // switch to function correctly. - public void VpnSwitch_Loaded(object sender, RoutedEventArgs e) - { - if (sender is not ToggleSwitch toggleSwitch) return; - _vpnActiveSwitch = toggleSwitch; - SetVpnSwitch(VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started); - } - public void VpnSwitch_Toggled(object sender, RoutedEventArgs e) { if (sender is not ToggleSwitch toggleSwitch) return; - // HACK: the toggled event gets fired even when the switch state is - // changed from code, so we ignore all events while we're performing - // changes. - if (_isProgrammaticStateChange) return; - VpnFailedMessage = ""; try { // The start/stop methods will call back to update the state. - if (toggleSwitch.IsOn) + 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 { diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index d6e419e..bd05aaf 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -57,8 +57,8 @@ Grid.Column="2" OnContent="" OffContent="" + IsOn="{x:Bind ViewModel.VpnSwitchActive, Mode=TwoWay}" IsEnabled="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource NotConnectingBoolConverter}, Mode=OneWay}" - Loaded="{x:Bind ViewModel.VpnSwitch_Loaded}" Toggled="{x:Bind ViewModel.VpnSwitch_Toggled}" Margin="0,0,-110,0" HorizontalAlignment="Right" /> diff --git a/App/packages.lock.json b/App/packages.lock.json index 78dfa1b..ca5e679 100644 --- a/App/packages.lock.json +++ b/App/packages.lock.json @@ -35,12 +35,6 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" } }, - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[8.0.12, )", - "resolved": "8.0.12", - "contentHash": "FV4HnQ3JI15PHnJ5PGTbz+rYvrih42oLi/7UMIshNwCwUZhTq13UzrggtXk4ygrcMcN+4jsS6hhshx2p/Zd0ig==" - }, "Microsoft.WindowsAppSDK": { "type": "Direct", "requested": "[1.6.250108002, )", diff --git a/CoderSdk/CoderApiClient.cs b/CoderSdk/CoderApiClient.cs index 34863f1..8624e9d 100644 --- a/CoderSdk/CoderApiClient.cs +++ b/CoderSdk/CoderApiClient.cs @@ -17,6 +17,12 @@ public override string ConvertName(string name) } } +[JsonSerializable(typeof(BuildInfo))] +[JsonSerializable(typeof(User))] +public partial class CoderSdkJsonContext : JsonSerializerContext +{ +} + /// /// Provides a limited selection of API methods for a Coder instance. /// @@ -37,6 +43,7 @@ public CoderApiClient(Uri baseUrl) _httpClient.BaseAddress = baseUrl; _jsonOptions = new JsonSerializerOptions { + TypeInfoResolver = CoderSdkJsonContext.Default, PropertyNameCaseInsensitive = true, PropertyNamingPolicy = new SnakeCaseNamingPolicy(), DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, @@ -53,9 +60,11 @@ public void SetSessionToken(string token) _httpClient.DefaultRequestHeaders.Remove("Coder-Session-Token"); _httpClient.DefaultRequestHeaders.Add("Coder-Session-Token", token); } + private async Task SendRequestNoBodyAsync(HttpMethod method, string path, CancellationToken ct = default) => + await SendRequestAsync(method, path, null, ct); - private async Task SendRequestAsync(HttpMethod method, string path, - object? payload, CancellationToken ct = default) + private async Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) { try { @@ -63,7 +72,7 @@ private async Task SendRequestAsync(HttpMethod method, str 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"); } diff --git a/CoderSdk/Deployment.cs b/CoderSdk/Deployment.cs index b00d49f..d85a458 100644 --- a/CoderSdk/Deployment.cs +++ b/CoderSdk/Deployment.cs @@ -17,6 +17,6 @@ public partial class CoderApiClient { public Task GetBuildInfo(CancellationToken ct = default) { - return SendRequestAsync(HttpMethod.Get, "/api/v2/buildinfo", null, ct); + return SendRequestNoBodyAsync(HttpMethod.Get, "/api/v2/buildinfo", ct); } } diff --git a/CoderSdk/Users.cs b/CoderSdk/Users.cs index 58ff474..2d99e02 100644 --- a/CoderSdk/Users.cs +++ b/CoderSdk/Users.cs @@ -12,6 +12,6 @@ public partial class CoderApiClient { public Task GetUser(string user, CancellationToken ct = default) { - return SendRequestAsync(HttpMethod.Get, $"/api/v2/users/{user}", null, ct); + return SendRequestNoBodyAsync(HttpMethod.Get, $"/api/v2/users/{user}", ct); } } From 9bda625932c3123d47e6ffbbaa0d31bf2f20a056 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 17 Feb 2025 06:45:18 +1100 Subject: [PATCH 4/4] fixup! PR comments --- App/ViewModels/TrayWindowViewModel.cs | 3 ++- App/Views/Pages/TrayWindowMainPage.xaml | 3 ++- App/Views/SignInWindow.xaml.cs | 1 - CoderSdk/CoderApiClient.cs | 8 ++++++-- Vpn.Service/Manager.cs | 3 ++- Vpn.Service/Program.cs | 1 + 6 files changed, 13 insertions(+), 6 deletions(-) diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 6133dbc..c643d2f 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -144,7 +144,8 @@ private void UpdateFromRpcModel(RpcModel rpcModel) // 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))) + 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. diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index bd05aaf..66ec273 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -15,7 +15,8 @@ - + diff --git a/App/Views/SignInWindow.xaml.cs b/App/Views/SignInWindow.xaml.cs index 2d866e7..771dda0 100644 --- a/App/Views/SignInWindow.xaml.cs +++ b/App/Views/SignInWindow.xaml.cs @@ -3,7 +3,6 @@ using Coder.Desktop.App.Views.Pages; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; -using WinRT.Interop; namespace Coder.Desktop.App.Views; diff --git a/CoderSdk/CoderApiClient.cs b/CoderSdk/CoderApiClient.cs index 8624e9d..016998d 100644 --- a/CoderSdk/CoderApiClient.cs +++ b/CoderSdk/CoderApiClient.cs @@ -60,8 +60,12 @@ public void SetSessionToken(string token) _httpClient.DefaultRequestHeaders.Remove("Coder-Session-Token"); _httpClient.DefaultRequestHeaders.Add("Coder-Session-Token", token); } - private async Task SendRequestNoBodyAsync(HttpMethod method, string path, CancellationToken ct = default) => - await SendRequestAsync(method, path, null, ct); + + private async Task SendRequestNoBodyAsync(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync(method, path, null, ct); + } private async Task SendRequestAsync(HttpMethod method, string path, TRequest? payload, CancellationToken ct = default) diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index f2e6ddd..93c08dd 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -138,7 +138,8 @@ private async ValueTask HandleClientMessageStart(ClientMessage me { try { - var serverVersion = await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken, ct); + var serverVersion = + await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken, ct); if (_status == TunnelStatus.Started && _lastStartRequest != null && _lastStartRequest.Equals(message.Start) && _lastServerVersion?.String == serverVersion.String) { diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs index 3d963a6..e5447bc 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -16,6 +16,7 @@ public static class Program 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}";