From 80c0e6770486b8fa6e693b2c4d0b1123a930b482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 23 Oct 2026 14:37:23 -0400 Subject: [PATCH 1/2] Update macOS parity status for notarized DMG releases docs/parity-status.md still listed notarization and DMG as out of scope for macOS, while docs/macos.md documents signed, notarized .dmg release artifacts built in CI by scripts/Release-MacOsPackage.ps1. Align the distribution row and the final UI results bullet with macos.md. Co-Authored-By: Claude Opus 5.5 --- docs/parity-status.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/parity-status.md b/docs/parity-status.md index 8a32a2d..7bc4015 100644 --- a/docs/parity-status.md +++ b/docs/parity-status.md @@ -18,7 +18,7 @@ and NativeAOT gates completed before the final live UI matrix. | Terminal engines | Selectable built-in and pinned Ghostty engines on Windows, Linux, and macOS | | Application shell | Multi-window broker, tabs, panes, settings editor, palettes, tray behavior, accessibility, clipboard, and notifications | | Settings | Layering, dynamic profiles, fragments, source-generated JSON, Windows paths, XDG paths, macOS Application Support, and state persistence | -| Distribution | Windows x64/ARM64 MSIX and bundle; reproducible Linux x64/ARM64 tar, DEB, RPM, and AppImage packages with canonical freedesktop assets, licenses, checksums, inventory/SPDX SBOM, and DESTDIR-aware helpers; macOS NativeAOT `.app` and zip on Darwin | +| Distribution | Windows x64/ARM64 MSIX and bundle; reproducible Linux x64/ARM64 tar, DEB, RPM, and AppImage packages with canonical freedesktop assets, licenses, checksums, inventory/SPDX SBOM, and DESTDIR-aware helpers; macOS NativeAOT `.app` and zip on Darwin, plus signed, notarized `.dmg` release artifacts from CI | ## Actions @@ -182,5 +182,7 @@ truecolor, Unicode/CJK/emoji, row rendition, images, and shader effects. no ARM64 desktop was available in this session. - macOS ARM64 NativeAOT `.app` packaging, `dt` parser, Ghostty ABI, real `forkpty`, broker named-pipe length, and a live GUI-host process start were - validated on Darwin. Notarization, DMG, Homebrew, global hotkeys, and - default-terminal registration remain out of scope. + validated on Darwin. Signed, notarized `.dmg` release artifacts are produced + in CI by `scripts/Release-MacOsPackage.ps1` (see [macos.md](macos.md)). + Homebrew, global hotkeys, and default-terminal registration remain out of + scope. From 5e248847a6ed2b6ecef05b8f1d0432196260f144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 23 Oct 2026 16:22:32 -0400 Subject: [PATCH 2/2] Ship dt-pty-host in the Devolutions.Terminal.Control NuGet package The Control package only carried the Windows ConPTY hosts, so embedding apps on Linux and macOS failed with "The Unix PTY host is missing." Pack dt-pty-host for linux-x64, linux-arm64, osx-x64 and osx-arm64 as runtimes//native assets. LinuxPtyConnection now also probes runtimes//native, where NuGet places the host for RID-less builds, and restores the execute bit that NuGet drops. A buildTransitive UnixPtyHost.targets marks the host executable when building on Linux or macOS. CI builds the four hosts on one macOS runner, packs with RequireUnixPtyHosts=true, validates the package with scripts/Test-ControlNuGetPackage.ps1, and runs the sample's new headless --pty-smoke mode on Windows, Linux x64/ARM64 and macOS ARM64. The sample and PACKAGE.md now launch $SHELL on Unix, because ProfileSettings defaults to Windows PowerShell on every OS. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-terminal.yml | 94 +++++++++++++++ docs/release.md | 38 +++++- .../MainWindow.axaml.cs | 11 +- .../Program.cs | 4 +- .../PtySmoke.cs | 67 +++++++++++ .../README.md | 22 +++- scripts/Test-ControlNuGetPackage.ps1 | 113 ++++++++++++++++++ .../LinuxPtyConnection.cs | 24 +++- .../UnixPtyHost.cs | 63 ++++++++++ .../Devolutions.Terminal.Control.csproj | 34 +++++- src/Devolutions.Terminal.Control/PACKAGE.md | 38 ++++-- .../Devolutions.Terminal.Control.targets | 4 + .../buildTransitive/UnixPtyHost.targets | 28 +++++ .../UnixPtyHostTests.cs | 82 +++++++++++++ 14 files changed, 598 insertions(+), 24 deletions(-) create mode 100644 samples/Devolutions.Terminal.Control.Sample/PtySmoke.cs create mode 100644 scripts/Test-ControlNuGetPackage.ps1 create mode 100644 src/Devolutions.Terminal.Connection/UnixPtyHost.cs create mode 100644 src/Devolutions.Terminal.Control/buildTransitive/Devolutions.Terminal.Control.targets create mode 100644 src/Devolutions.Terminal.Control/buildTransitive/UnixPtyHost.targets create mode 100644 tests/Devolutions.Terminal.Connection.Tests/UnixPtyHostTests.cs diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index b2511cd..c1b750a 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -187,15 +187,47 @@ jobs: - name: Test Windows NuGet signature guards run: pwsh -NoLogo -NoProfile -File src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1 + unix-pty-hosts: + name: Unix PTY hosts for Devolutions.Terminal.Control + # macOS hosts need Apple clang; Linux hosts cross-compile with Zig on the same runner. + runs-on: macos-26 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache Zig + uses: actions/cache@v4 + with: + path: artifacts/tools + key: zig-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/Zig.ps1', 'native/ghostty/ghostty-upstream.json') }} + + - name: Build dt-pty-host for every Unix RID + shell: pwsh + run: ./native/linux-pty/Build-LinuxPtyHost.ps1 -Rid linux-x64, linux-arm64, osx-x64, osx-arm64 -Force + + - name: Upload Unix PTY hosts + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-unix-pty-hosts + path: native/linux-pty/*/dt-pty-host + if-no-files-found: error + nuget-pack: name: Pack Devolutions.Terminal.Control NuGet package runs-on: windows-latest needs: - release-metadata + - unix-pty-hosts steps: - name: Checkout uses: actions/checkout@v4 + - name: Download Unix PTY hosts + uses: actions/download-artifact@v4 + with: + name: DevolutionsTerminal-unix-pty-hosts + path: native/linux-pty + - name: Cache native libraries uses: actions/cache@v4 with: @@ -214,10 +246,19 @@ jobs: -c Release -o artifacts/nuget -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + -p:RequireUnixPtyHosts=true + + - name: Validate Devolutions.Terminal.Control package contents + run: >- + pwsh -NoLogo -NoProfile -File scripts/Test-ControlNuGetPackage.ps1 + -PackagePath artifacts/nuget/Devolutions.Terminal.Control.${{ needs.release-metadata.outputs.release_version }}.nupkg - name: Smoke-test package via samples/Devolutions.Terminal.Control.Sample run: dotnet build samples/Devolutions.Terminal.Control.Sample -c Release + - name: Start a ConPTY session from the packed package + run: dotnet run --project samples/Devolutions.Terminal.Control.Sample -c Release --no-build -- --pty-smoke + - name: Upload NuGet package artifacts uses: actions/upload-artifact@v4 with: @@ -225,6 +266,58 @@ jobs: path: artifacts/nuget if-no-files-found: error + nuget-pack-unix-smoke: + name: Devolutions.Terminal.Control PTY smoke ${{ matrix.rid }} + runs-on: ${{ matrix.os }} + needs: + - nuget-pack + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + rid: linux-x64 + - os: ubuntu-24.04-arm + rid: linux-arm64 + - os: macos-26 + rid: osx-arm64 + env: + SAMPLE: samples/Devolutions.Terminal.Control.Sample + SAMPLE_OUTPUT: samples/Devolutions.Terminal.Control.Sample/bin/Release/net10.0 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download Devolutions.Terminal.Control NuGet package + uses: actions/download-artifact@v4 + with: + name: DevolutionsTerminal-nuget-packages + path: artifacts/nuget + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: RID-less build uses runtimes//native/dt-pty-host + run: | + dotnet build "$SAMPLE" -c Release + test -x "$SAMPLE_OUTPUT/runtimes/${{ matrix.rid }}/native/dt-pty-host" + test ! -e "$SAMPLE_OUTPUT/dt-pty-host" + dotnet "$SAMPLE_OUTPUT/Devolutions.Terminal.Control.Sample.dll" --pty-smoke + + - name: Host restored without the execute bit is repaired at startup + run: | + chmod a-x "$SAMPLE_OUTPUT/runtimes/${{ matrix.rid }}/native/dt-pty-host" + dotnet "$SAMPLE_OUTPUT/Devolutions.Terminal.Control.Sample.dll" --pty-smoke + test -x "$SAMPLE_OUTPUT/runtimes/${{ matrix.rid }}/native/dt-pty-host" + + - name: RID-specific publish places dt-pty-host next to the app + run: | + dotnet publish "$SAMPLE" -c Release -r ${{ matrix.rid }} --self-contained false -o artifacts/pty-smoke + test -x artifacts/pty-smoke/dt-pty-host + artifacts/pty-smoke/Devolutions.Terminal.Control.Sample --pty-smoke + native-aot: name: NativeAOT desktop ${{ matrix.rid }} runs-on: windows-latest @@ -1370,6 +1463,7 @@ jobs: - nuget - nuget-macos - nuget-pack + - nuget-pack-unix-smoke - release-metadata runs-on: ubuntu-latest permissions: diff --git a/docs/release.md b/docs/release.md index 76db676..412a0c1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -357,7 +357,19 @@ product). The package bundles the build output of `Devolutions.Terminal.Core`, four projects are marked `IsPackable=false` and are never published as separate packages, so the only NuGet dependencies a consumer sees are the genuine third-party ones (`Avalonia`, `Avalonia.Skia`, `SkiaSharp`, -`SkiaSharp.HarfBuzz`, and their Linux native-asset packages). +`SkiaSharp.HarfBuzz`, their Linux native-asset packages, and +`Microsoft.Windows.Console.ConPTY`). + +The package also carries the native hosts that local shells need: + +- `tools/conpty//OpenConsole.exe`, copied into Windows consumers by + `buildTransitive/ConPtyHost.targets` +- `runtimes//native/dt-pty-host` for `linux-x64`, `linux-arm64`, + `osx-x64`, and `osx-arm64`. NuGet places it next to the app for RID-specific + builds and under `runtimes//native/` for RID-less builds; + `LinuxPtyConnection` probes both and restores the execute bit that NuGet + drops. `buildTransitive/UnixPtyHost.targets` also marks it executable when + building on Linux or macOS. Pack it locally: @@ -366,6 +378,13 @@ dotnet pack src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj -c Release -o artifacts/nuget ``` +The pack includes whichever `native/linux-pty//dt-pty-host` files exist +and reports the missing ones. Linux hosts cross-compile on any OS +(`./native/linux-pty/Build-LinuxPtyHost.ps1 -Rid linux-x64, linux-arm64`); +macOS hosts need Apple clang on macOS. Pass `-p:RequireUnixPtyHosts=true` to +fail the pack when any host is missing, and check a package with +`scripts/Test-ControlNuGetPackage.ps1 -PackagePath `. + Consume it as a package (see `samples/Devolutions.Terminal.Control.Sample` for a full working app): @@ -383,7 +402,10 @@ using Devolutions.Terminal.Settings; var terminal = new TermControl(); Content = terminal; -await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30); +var profile = OperatingSystem.IsWindows() + ? new ProfileSettings() + : new ProfileSettings { Commandline = Environment.GetEnvironmentVariable("SHELL") ?? "/bin/sh" }; +await terminal.StartAsync(profile, columns: 120, rows: 30); ``` `samples/Devolutions.Terminal.Control.Sample` is a minimal Avalonia app that @@ -398,9 +420,15 @@ consumer smoke test that would catch packaging regressions (missing bundled assemblies/assets, broken dependencies, API usage that doesn't actually work from outside the repo) that an in-repo `ProjectReference` build cannot surface. -CI packs the project in the `nuget-pack` job of `build-terminal.yml` on every -build, builds the sample against the freshly packed package as a smoke test, -and uploads the `.nupkg`/`.snupkg` as a workflow artifact. The release job +CI builds all four Unix PTY hosts in the `unix-pty-hosts` job (one macOS +runner, Apple clang plus Zig) and packs the project in the `nuget-pack` job of +`build-terminal.yml` on every build with `RequireUnixPtyHosts=true`. That job +validates the package contents with `scripts/Test-ControlNuGetPackage.ps1`, +builds the sample against the freshly packed package, starts a ConPTY session +with `--pty-smoke`, and uploads the `.nupkg`/`.snupkg` as a workflow artifact. +`nuget-pack-unix-smoke` then runs `--pty-smoke` from the packed package on +Linux x64, Linux ARM64, and macOS ARM64: RID-less, with the execute bit +removed, and RID-specific published. The release job downloads this artifact alongside the `Devolutions.Terminal.App` packages and publishes all NuGet packages together through NuGet trusted publishing (OIDC). There is no separate Control publication option or publishing job. diff --git a/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs index d18d623..3dbeb1d 100644 --- a/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs +++ b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs @@ -17,10 +17,13 @@ public MainWindow() Opened += async (_, _) => { - // A bare ProfileSettings() launches the platform default shell - // (Windows PowerShell on Windows, /bin/sh-family elsewhere via the - // package's own PTY connection). - await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30); + // ProfileSettings defaults to Windows PowerShell, which runs through + // ConPTY. On Linux and macOS, launch the user's shell instead; it runs + // through the package's dt-pty-host. + var profile = OperatingSystem.IsWindows() + ? new ProfileSettings() + : new ProfileSettings { Commandline = Environment.GetEnvironmentVariable("SHELL") ?? "/bin/sh" }; + await terminal.StartAsync(profile, columns: 120, rows: 30); }; } } diff --git a/samples/Devolutions.Terminal.Control.Sample/Program.cs b/samples/Devolutions.Terminal.Control.Sample/Program.cs index 578c227..16d570f 100644 --- a/samples/Devolutions.Terminal.Control.Sample/Program.cs +++ b/samples/Devolutions.Terminal.Control.Sample/Program.cs @@ -6,7 +6,9 @@ internal static class Program { [STAThread] public static int Main(string[] args) => - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + args.Contains("--pty-smoke", StringComparer.Ordinal) + ? PtySmoke.RunAsync().GetAwaiter().GetResult() + : BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() diff --git a/samples/Devolutions.Terminal.Control.Sample/PtySmoke.cs b/samples/Devolutions.Terminal.Control.Sample/PtySmoke.cs new file mode 100644 index 0000000..4ea043f --- /dev/null +++ b/samples/Devolutions.Terminal.Control.Sample/PtySmoke.cs @@ -0,0 +1,67 @@ +using System.Text; +using Devolutions.Terminal.Connection; + +namespace Devolutions.Terminal.Control.Sample; + +// Headless check, used by CI, that the package's own PTY transport can start a +// local process: ConPTY with the bundled OpenConsole.exe on Windows, and +// dt-pty-host from runtimes//native on Linux and macOS. +internal static class PtySmoke +{ + private const string Marker = "DT_PTY_SMOKE_OK"; + + public static async Task RunAsync() + { + await using ITerminalConnection connection = CreateConnection(out var commandLine); + var output = new StringBuilder(); + var seen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.OutputReceived += (_, data) => + { + lock (output) + { + output.Append(Encoding.UTF8.GetString(data.Span)); + if (output.ToString().Contains(Marker, StringComparison.Ordinal)) + { + seen.TrySetResult(); + } + } + }; + connection.Faulted += (_, error) => seen.TrySetException(error); + + try + { + await connection.StartAsync(commandLine, Environment.CurrentDirectory, 80, 24); + await seen.Task.WaitAsync(TimeSpan.FromSeconds(30)); + } + catch (Exception exception) + { + lock (output) + { + Console.Error.WriteLine($"PTY smoke failed: {exception.Message}\nOutput so far:\n{output}"); + } + + return 1; + } + + Console.WriteLine($"PTY smoke passed: {connection.GetType().Name} ran '{commandLine}'."); + return 0; + } + + // The marker is split in the command so that only executed output can match it. + private static ITerminalConnection CreateConnection(out string commandLine) + { + if (OperatingSystem.IsWindows()) + { + commandLine = "cmd.exe /d /c echo DT_PTY_^SMOKE_OK"; + return new ConPtyConnection(); + } + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + commandLine = "printf 'DT_PTY_%s\\n' SMOKE_OK"; + return new LinuxPtyConnection(); + } + + throw new PlatformNotSupportedException("No local PTY implementation is available for this platform."); + } +} diff --git a/samples/Devolutions.Terminal.Control.Sample/README.md b/samples/Devolutions.Terminal.Control.Sample/README.md index 72aa0ad..8295a12 100644 --- a/samples/Devolutions.Terminal.Control.Sample/README.md +++ b/samples/Devolutions.Terminal.Control.Sample/README.md @@ -27,5 +27,23 @@ dotnet run --project samples/Devolutions.Terminal.Control.Sample ``` `NuGet.Config` in this folder maps the `Devolutions.Terminal.Control` package -id exclusively to `../../artifacts/nuget`, so it always picks up the package -you just packed rather than whatever version (if any) is on nuget.org. +id exclusively to `../../artifacts/nuget`, so it never resolves a version from +nuget.org. Local packs all share the repository's `VersionPrefix`, and NuGet +reuses a version already in its global packages folder, so after repacking +either pass a new version (`-p:VersionPrefix=2026.3.1`) or clear the cached +copy (`dotnet nuget locals global-packages --clear`). + +On Linux and macOS the sample launches `$SHELL` through the package's +`dt-pty-host`. A local pack only contains the hosts that exist under +`native/linux-pty//`; see the NuGet section of +[docs/release.md](../../docs/release.md) for building them. + +## Headless PTY smoke test + +```pwsh +dotnet run --project samples/Devolutions.Terminal.Control.Sample -- --pty-smoke +``` + +`--pty-smoke` skips the UI, starts the package's own PTY transport (ConPTY on +Windows, `dt-pty-host` on Linux and macOS), and exits with 0 once the command +it ran produced output. CI runs it on Windows, Linux x64/ARM64, and macOS ARM64. diff --git a/scripts/Test-ControlNuGetPackage.ps1 b/scripts/Test-ControlNuGetPackage.ps1 new file mode 100644 index 0000000..9f6ac5e --- /dev/null +++ b/scripts/Test-ControlNuGetPackage.ps1 @@ -0,0 +1,113 @@ +#!/usr/bin/env pwsh +#Requires -Version 7 +<# +.SYNOPSIS +Validates the native and build assets of a Devolutions.Terminal.Control package. + +.DESCRIPTION +Checks that the package carries the bundled assemblies, the buildTransitive targets, +the ConPTY hosts, and a dt-pty-host of the right format and architecture for every +Unix RID, so local shells can start on Windows, Linux and macOS. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] + [string] $PackagePath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression.FileSystem + +# ELF e_machine and Mach-O cputype for each Unix RID. +$ptyHosts = [ordered]@{ + 'linux-x64' = @{ Format = 'ELF'; Machine = 0x3E } + 'linux-arm64' = @{ Format = 'ELF'; Machine = 0xB7 } + 'osx-x64' = @{ Format = 'Mach-O'; Machine = 0x01000007 } + 'osx-arm64' = @{ Format = 'Mach-O'; Machine = 0x0100000C } +} +$requiredEntries = @( + 'lib/net10.0/Devolutions.Terminal.Control.dll', + 'lib/net10.0/Devolutions.Terminal.Connection.dll', + 'buildTransitive/Devolutions.Terminal.Control.targets', + 'buildTransitive/ConPtyHost.targets', + 'buildTransitive/UnixPtyHost.targets', + 'buildTransitive/THIRD-PARTY-NOTICES-CONPTY.txt', + 'tools/conpty/x86/OpenConsole.exe', + 'tools/conpty/x64/OpenConsole.exe', + 'tools/conpty/arm64/OpenConsole.exe' +) + +function Read-Header([IO.Compression.ZipArchiveEntry] $Entry) { + $buffer = [byte[]]::new(20) + $stream = $Entry.Open() + try { + $read = 0 + while ($read -lt $buffer.Length) { + $count = $stream.Read($buffer, $read, $buffer.Length - $read) + if ($count -eq 0) { break } + $read += $count + } + if ($read -lt $buffer.Length) { + throw "$($Entry.FullName) is too small to be a native executable ($read bytes)." + } + } + finally { + $stream.Dispose() + } + return $buffer +} + +$archive = [IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath).Path) +try { + $entries = @{} + foreach ($entry in $archive.Entries) { + $entries[$entry.FullName] = $entry + } + + foreach ($name in $requiredEntries) { + if (-not $entries.ContainsKey($name)) { + throw "Package is missing $name." + } + } + + foreach ($rid in $ptyHosts.Keys) { + $name = "runtimes/$rid/native/dt-pty-host" + if (-not $entries.ContainsKey($name)) { + throw "Package is missing $name; local shells cannot start on $rid." + } + + $header = Read-Header $entries[$name] + $expected = $ptyHosts[$rid] + if ($expected.Format -eq 'ELF') { + $isElf64 = $header[0] -eq 0x7F -and $header[1] -eq 0x45 -and $header[2] -eq 0x4C -and + $header[3] -eq 0x46 -and $header[4] -eq 2 + $machine = [BitConverter]::ToUInt16($header, 18) + if (-not $isElf64 -or $machine -ne $expected.Machine) { + throw "$name is not a 64-bit ELF executable for $rid (e_machine 0x$($machine.ToString('X')))." + } + } + else { + $magic = [BitConverter]::ToUInt32($header, 0) + $cpuType = [BitConverter]::ToUInt32($header, 4) + if ($magic -ne 0xFEEDFACFu -or $cpuType -ne $expected.Machine) { + throw "$name is not a 64-bit Mach-O executable for $rid (cputype 0x$($cpuType.ToString('X')))." + } + } + Write-Host "ControlNuGetPackage: $name is a $($expected.Format) $rid executable ($($entries[$name].Length) bytes)." + } + + $expectedHostEntries = @($ptyHosts.Keys | ForEach-Object { "runtimes/$_/native/dt-pty-host" }) + $unexpected = @($archive.Entries | Where-Object { + $_.Name -eq 'dt-pty-host' -and $_.FullName -notin $expectedHostEntries + }) + if ($unexpected.Count -gt 0) { + throw "Package has unexpected dt-pty-host entries: $($unexpected.FullName -join ', ')." + } +} +finally { + $archive.Dispose() +} + +Write-Host "ControlNuGetPackage: $PackagePath carries assemblies, build targets, ConPTY hosts and all Unix PTY hosts." diff --git a/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs b/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs index 7fcd62e..69b1df5 100644 --- a/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs +++ b/src/Devolutions.Terminal.Connection/LinuxPtyConnection.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; @@ -183,14 +184,29 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella { State = TerminalConnectionState.Connecting; LastExitInfo = null; - var helper = Path.Combine(AppContext.BaseDirectory, "dt-pty-host"); - if (!File.Exists(helper)) - { - var error = new FileNotFoundException("The Unix PTY host is missing.", helper); + var runtimeIdentifier = UnixPtyHost.GetPortableRuntimeIdentifier( + OperatingSystem.IsMacOS(), + RuntimeInformation.ProcessArchitecture); + var helper = UnixPtyHost.Find(AppContext.BaseDirectory, runtimeIdentifier); + if (helper is null) + { + var error = new FileNotFoundException( + "The Unix PTY host is missing.", + UnixPtyHost.GetCandidatePaths(AppContext.BaseDirectory, runtimeIdentifier)[0]); RecordStartupFailure(error); throw error; } + try + { + UnixPtyHost.EnsureExecutable(helper); + } + catch (Exception exception) + { + RecordStartupFailure(exception); + throw; + } + var process = new Process { StartInfo = CreateStartInfo(helper, options), diff --git a/src/Devolutions.Terminal.Connection/UnixPtyHost.cs b/src/Devolutions.Terminal.Connection/UnixPtyHost.cs new file mode 100644 index 0000000..4892bad --- /dev/null +++ b/src/Devolutions.Terminal.Connection/UnixPtyHost.cs @@ -0,0 +1,63 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Devolutions.Terminal.Connection; + +internal static class UnixPtyHost +{ + public const string FileName = "dt-pty-host"; + + private const UnixFileMode ReadBits = + UnixFileMode.UserRead | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + + // App and RID-specific publishes place the host next to the executable. + // RID-less builds that consume the Devolutions.Terminal.Control package + // keep NuGet's runtimes//native layout instead. + public static IReadOnlyList GetCandidatePaths(string baseDirectory, string runtimeIdentifier) => + [ + Path.Combine(baseDirectory, FileName), + Path.Combine(baseDirectory, "runtimes", runtimeIdentifier, "native", FileName), + ]; + + public static string GetPortableRuntimeIdentifier(bool isMacOs, Architecture architecture) => + $"{(isMacOs ? "osx" : "linux")}-{architecture.ToString().ToLowerInvariant()}"; + + public static string? Find(string baseDirectory, string runtimeIdentifier) + { + foreach (var candidate in GetCandidatePaths(baseDirectory, runtimeIdentifier)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + // NuGet does not preserve Unix file modes, so a host restored from the + // package arrives without execute permission. + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + public static void EnsureExecutable(string path) + { + var mode = File.GetUnixFileMode(path); + if ((mode & UnixFileMode.UserExecute) != 0) + { + return; + } + + var executable = mode | UnixFileMode.UserExecute | (UnixFileMode)((int)(mode & ReadBits) >> 2); + try + { + File.SetUnixFileMode(path, executable); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + throw new InvalidOperationException( + $"The Unix PTY host '{path}' is not executable and its mode could not be changed. " + + "Run 'chmod +x' on it.", + exception); + } + } +} diff --git a/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj b/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj index 1e449fb..f7e4c3a 100644 --- a/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj +++ b/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj @@ -64,14 +64,46 @@ + + + Pack="true" PackagePath="buildTransitive" /> + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\native\linux-pty')) + + + + + + + + + + + <_MissingUnixPtyHost Include="@(UnixPtyHostRid)" Condition="!Exists('$(UnixPtyHostRoot)\%(Identity)\dt-pty-host')" /> + + + + + + + + <_UnixPtyHostOutput Include="$(TargetDir)dt-pty-host;$(TargetDir)runtimes/*/native/dt-pty-host" /> + <_UnixPtyHostOutput Remove="@(_UnixPtyHostOutput)" Condition="!Exists('%(FullPath)')" /> + + + + + + + <_UnixPtyHostPublished Include="$(PublishDir)dt-pty-host;$(PublishDir)runtimes/*/native/dt-pty-host" /> + <_UnixPtyHostPublished Remove="@(_UnixPtyHostPublished)" Condition="!Exists('%(FullPath)')" /> + + + + diff --git a/tests/Devolutions.Terminal.Connection.Tests/UnixPtyHostTests.cs b/tests/Devolutions.Terminal.Connection.Tests/UnixPtyHostTests.cs new file mode 100644 index 0000000..80b533a --- /dev/null +++ b/tests/Devolutions.Terminal.Connection.Tests/UnixPtyHostTests.cs @@ -0,0 +1,82 @@ +using System.Runtime.InteropServices; +using Devolutions.Terminal.Connection; +using Xunit; + +namespace Devolutions.Terminal.Connection.Tests; + +public sealed class UnixPtyHostTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "dt-pty-host-" + Guid.NewGuid().ToString("N")); + + public UnixPtyHostTests() => Directory.CreateDirectory(_root); + + public static bool IsUnix => OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(); + + public void Dispose() => Directory.Delete(_root, recursive: true); + + [Theory] + [InlineData(false, Architecture.X64, "linux-x64")] + [InlineData(false, Architecture.Arm64, "linux-arm64")] + [InlineData(true, Architecture.X64, "osx-x64")] + [InlineData(true, Architecture.Arm64, "osx-arm64")] + public void PortableRuntimeIdentifierMatchesPackageLayout(bool isMacOs, Architecture architecture, string expected) => + Assert.Equal(expected, UnixPtyHost.GetPortableRuntimeIdentifier(isMacOs, architecture)); + + [Fact] + public void HostNextToExecutableWinsOverRuntimesLayout() + { + var root = CreateHost(_root); + CreateHost(Path.Combine(_root, "runtimes", "linux-x64", "native")); + + Assert.Equal(root, UnixPtyHost.Find(_root, "linux-x64")); + } + + [Fact] + public void RidLessPackageLayoutIsFound() + { + CreateHost(Path.Combine(_root, "runtimes", "osx-x64", "native")); + var expected = CreateHost(Path.Combine(_root, "runtimes", "osx-arm64", "native")); + + Assert.Equal(expected, UnixPtyHost.Find(_root, "osx-arm64")); + } + + [Fact] + public void MissingHostIsReportedAtTheExecutableLocation() + { + CreateHost(Path.Combine(_root, "runtimes", "linux-arm64", "native")); + + Assert.Null(UnixPtyHost.Find(_root, "linux-x64")); + Assert.Equal( + Path.Combine(_root, UnixPtyHost.FileName), + UnixPtyHost.GetCandidatePaths(_root, "linux-x64")[0]); + } + + [Theory(Skip = "Unix file modes are Linux/macOS-only.", SkipUnless = nameof(IsUnix))] + [InlineData(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute)] + [InlineData(UnixFileMode.UserRead | UnixFileMode.UserWrite, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute)] + public void RestoredHostWithoutExecuteBitBecomesExecutable(UnixFileMode restored, UnixFileMode expected) + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + return; + } + + var host = CreateHost(_root); + File.SetUnixFileMode(host, restored); + + UnixPtyHost.EnsureExecutable(host); + + Assert.Equal(expected, File.GetUnixFileMode(host)); + } + + private static string CreateHost(string directory) + { + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, UnixPtyHost.FileName); + File.WriteAllBytes(path, [0x7F, (byte)'E', (byte)'L', (byte)'F']); + return path; + } +}