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