Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/build-terminal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -214,17 +246,78 @@ 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:
name: DevolutionsTerminal-nuget-packages
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/<rid>/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
Expand Down Expand Up @@ -1370,6 +1463,7 @@ jobs:
- nuget
- nuget-macos
- nuget-pack
- nuget-pack-unix-smoke
- release-metadata
runs-on: ubuntu-latest
permissions:
Expand Down
8 changes: 5 additions & 3 deletions docs/parity-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
38 changes: 33 additions & 5 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<arch>/OpenConsole.exe`, copied into Windows consumers by
`buildTransitive/ConPtyHost.targets`
- `runtimes/<rid>/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/<rid>/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:

Expand All @@ -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/<rid>/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 <nupkg>`.

Consume it as a package (see `samples/Devolutions.Terminal.Control.Sample`
for a full working app):

Expand All @@ -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
Expand All @@ -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.
Expand Down
11 changes: 7 additions & 4 deletions samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}
}
4 changes: 3 additions & 1 deletion samples/Devolutions.Terminal.Control.Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<App>()
Expand Down
67 changes: 67 additions & 0 deletions samples/Devolutions.Terminal.Control.Sample/PtySmoke.cs
Original file line number Diff line number Diff line change
@@ -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/<rid>/native on Linux and macOS.
internal static class PtySmoke
{
private const string Marker = "DT_PTY_SMOKE_OK";

public static async Task<int> 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.");
}
}
22 changes: 20 additions & 2 deletions samples/Devolutions.Terminal.Control.Sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<rid>/`; 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.
Loading
Loading