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/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. 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; + } +}