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(); pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy