Skip to content

Commit 5e24884

Browse files
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/<rid>/native assets. LinuxPtyConnection now also probes runtimes/<rid>/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 <noreply@anthropic.com>
1 parent 80c0e67 commit 5e24884

14 files changed

Lines changed: 598 additions & 24 deletions

File tree

‎.github/workflows/build-terminal.yml‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,47 @@ jobs:
187187
- name: Test Windows NuGet signature guards
188188
run: pwsh -NoLogo -NoProfile -File src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1
189189

190+
unix-pty-hosts:
191+
name: Unix PTY hosts for Devolutions.Terminal.Control
192+
# macOS hosts need Apple clang; Linux hosts cross-compile with Zig on the same runner.
193+
runs-on: macos-26
194+
steps:
195+
- name: Checkout
196+
uses: actions/checkout@v4
197+
198+
- name: Cache Zig
199+
uses: actions/cache@v4
200+
with:
201+
path: artifacts/tools
202+
key: zig-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/Zig.ps1', 'native/ghostty/ghostty-upstream.json') }}
203+
204+
- name: Build dt-pty-host for every Unix RID
205+
shell: pwsh
206+
run: ./native/linux-pty/Build-LinuxPtyHost.ps1 -Rid linux-x64, linux-arm64, osx-x64, osx-arm64 -Force
207+
208+
- name: Upload Unix PTY hosts
209+
uses: actions/upload-artifact@v4
210+
with:
211+
name: DevolutionsTerminal-unix-pty-hosts
212+
path: native/linux-pty/*/dt-pty-host
213+
if-no-files-found: error
214+
190215
nuget-pack:
191216
name: Pack Devolutions.Terminal.Control NuGet package
192217
runs-on: windows-latest
193218
needs:
194219
- release-metadata
220+
- unix-pty-hosts
195221
steps:
196222
- name: Checkout
197223
uses: actions/checkout@v4
198224

225+
- name: Download Unix PTY hosts
226+
uses: actions/download-artifact@v4
227+
with:
228+
name: DevolutionsTerminal-unix-pty-hosts
229+
path: native/linux-pty
230+
199231
- name: Cache native libraries
200232
uses: actions/cache@v4
201233
with:
@@ -214,17 +246,78 @@ jobs:
214246
-c Release
215247
-o artifacts/nuget
216248
-p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }}
249+
-p:RequireUnixPtyHosts=true
250+
251+
- name: Validate Devolutions.Terminal.Control package contents
252+
run: >-
253+
pwsh -NoLogo -NoProfile -File scripts/Test-ControlNuGetPackage.ps1
254+
-PackagePath artifacts/nuget/Devolutions.Terminal.Control.${{ needs.release-metadata.outputs.release_version }}.nupkg
217255
218256
- name: Smoke-test package via samples/Devolutions.Terminal.Control.Sample
219257
run: dotnet build samples/Devolutions.Terminal.Control.Sample -c Release
220258

259+
- name: Start a ConPTY session from the packed package
260+
run: dotnet run --project samples/Devolutions.Terminal.Control.Sample -c Release --no-build -- --pty-smoke
261+
221262
- name: Upload NuGet package artifacts
222263
uses: actions/upload-artifact@v4
223264
with:
224265
name: DevolutionsTerminal-nuget-packages
225266
path: artifacts/nuget
226267
if-no-files-found: error
227268

269+
nuget-pack-unix-smoke:
270+
name: Devolutions.Terminal.Control PTY smoke ${{ matrix.rid }}
271+
runs-on: ${{ matrix.os }}
272+
needs:
273+
- nuget-pack
274+
strategy:
275+
fail-fast: false
276+
matrix:
277+
include:
278+
- os: ubuntu-24.04
279+
rid: linux-x64
280+
- os: ubuntu-24.04-arm
281+
rid: linux-arm64
282+
- os: macos-26
283+
rid: osx-arm64
284+
env:
285+
SAMPLE: samples/Devolutions.Terminal.Control.Sample
286+
SAMPLE_OUTPUT: samples/Devolutions.Terminal.Control.Sample/bin/Release/net10.0
287+
steps:
288+
- name: Checkout
289+
uses: actions/checkout@v4
290+
291+
- name: Download Devolutions.Terminal.Control NuGet package
292+
uses: actions/download-artifact@v4
293+
with:
294+
name: DevolutionsTerminal-nuget-packages
295+
path: artifacts/nuget
296+
297+
- name: Set up .NET
298+
uses: actions/setup-dotnet@v4
299+
with:
300+
dotnet-version: 10.0.x
301+
302+
- name: RID-less build uses runtimes/<rid>/native/dt-pty-host
303+
run: |
304+
dotnet build "$SAMPLE" -c Release
305+
test -x "$SAMPLE_OUTPUT/runtimes/${{ matrix.rid }}/native/dt-pty-host"
306+
test ! -e "$SAMPLE_OUTPUT/dt-pty-host"
307+
dotnet "$SAMPLE_OUTPUT/Devolutions.Terminal.Control.Sample.dll" --pty-smoke
308+
309+
- name: Host restored without the execute bit is repaired at startup
310+
run: |
311+
chmod a-x "$SAMPLE_OUTPUT/runtimes/${{ matrix.rid }}/native/dt-pty-host"
312+
dotnet "$SAMPLE_OUTPUT/Devolutions.Terminal.Control.Sample.dll" --pty-smoke
313+
test -x "$SAMPLE_OUTPUT/runtimes/${{ matrix.rid }}/native/dt-pty-host"
314+
315+
- name: RID-specific publish places dt-pty-host next to the app
316+
run: |
317+
dotnet publish "$SAMPLE" -c Release -r ${{ matrix.rid }} --self-contained false -o artifacts/pty-smoke
318+
test -x artifacts/pty-smoke/dt-pty-host
319+
artifacts/pty-smoke/Devolutions.Terminal.Control.Sample --pty-smoke
320+
228321
native-aot:
229322
name: NativeAOT desktop ${{ matrix.rid }}
230323
runs-on: windows-latest
@@ -1370,6 +1463,7 @@ jobs:
13701463
- nuget
13711464
- nuget-macos
13721465
- nuget-pack
1466+
- nuget-pack-unix-smoke
13731467
- release-metadata
13741468
runs-on: ubuntu-latest
13751469
permissions:

‎docs/release.md‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,19 @@ product). The package bundles the build output of `Devolutions.Terminal.Core`,
357357
four projects are marked `IsPackable=false` and are never published as
358358
separate packages, so the only NuGet dependencies a consumer sees are the
359359
genuine third-party ones (`Avalonia`, `Avalonia.Skia`, `SkiaSharp`,
360-
`SkiaSharp.HarfBuzz`, and their Linux native-asset packages).
360+
`SkiaSharp.HarfBuzz`, their Linux native-asset packages, and
361+
`Microsoft.Windows.Console.ConPTY`).
362+
363+
The package also carries the native hosts that local shells need:
364+
365+
- `tools/conpty/<arch>/OpenConsole.exe`, copied into Windows consumers by
366+
`buildTransitive/ConPtyHost.targets`
367+
- `runtimes/<rid>/native/dt-pty-host` for `linux-x64`, `linux-arm64`,
368+
`osx-x64`, and `osx-arm64`. NuGet places it next to the app for RID-specific
369+
builds and under `runtimes/<rid>/native/` for RID-less builds;
370+
`LinuxPtyConnection` probes both and restores the execute bit that NuGet
371+
drops. `buildTransitive/UnixPtyHost.targets` also marks it executable when
372+
building on Linux or macOS.
361373

362374
Pack it locally:
363375

@@ -366,6 +378,13 @@ dotnet pack src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj
366378
-c Release -o artifacts/nuget
367379
```
368380

381+
The pack includes whichever `native/linux-pty/<rid>/dt-pty-host` files exist
382+
and reports the missing ones. Linux hosts cross-compile on any OS
383+
(`./native/linux-pty/Build-LinuxPtyHost.ps1 -Rid linux-x64, linux-arm64`);
384+
macOS hosts need Apple clang on macOS. Pass `-p:RequireUnixPtyHosts=true` to
385+
fail the pack when any host is missing, and check a package with
386+
`scripts/Test-ControlNuGetPackage.ps1 -PackagePath <nupkg>`.
387+
369388
Consume it as a package (see `samples/Devolutions.Terminal.Control.Sample`
370389
for a full working app):
371390

@@ -383,7 +402,10 @@ using Devolutions.Terminal.Settings;
383402

384403
var terminal = new TermControl();
385404
Content = terminal;
386-
await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30);
405+
var profile = OperatingSystem.IsWindows()
406+
? new ProfileSettings()
407+
: new ProfileSettings { Commandline = Environment.GetEnvironmentVariable("SHELL") ?? "/bin/sh" };
408+
await terminal.StartAsync(profile, columns: 120, rows: 30);
387409
```
388410

389411
`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
398420
assemblies/assets, broken dependencies, API usage that doesn't actually work
399421
from outside the repo) that an in-repo `ProjectReference` build cannot surface.
400422

401-
CI packs the project in the `nuget-pack` job of `build-terminal.yml` on every
402-
build, builds the sample against the freshly packed package as a smoke test,
403-
and uploads the `.nupkg`/`.snupkg` as a workflow artifact. The release job
423+
CI builds all four Unix PTY hosts in the `unix-pty-hosts` job (one macOS
424+
runner, Apple clang plus Zig) and packs the project in the `nuget-pack` job of
425+
`build-terminal.yml` on every build with `RequireUnixPtyHosts=true`. That job
426+
validates the package contents with `scripts/Test-ControlNuGetPackage.ps1`,
427+
builds the sample against the freshly packed package, starts a ConPTY session
428+
with `--pty-smoke`, and uploads the `.nupkg`/`.snupkg` as a workflow artifact.
429+
`nuget-pack-unix-smoke` then runs `--pty-smoke` from the packed package on
430+
Linux x64, Linux ARM64, and macOS ARM64: RID-less, with the execute bit
431+
removed, and RID-specific published. The release job
404432
downloads this artifact alongside the `Devolutions.Terminal.App` packages and
405433
publishes all NuGet packages together through NuGet trusted publishing (OIDC).
406434
There is no separate Control publication option or publishing job.

‎samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ public MainWindow()
1717

1818
Opened += async (_, _) =>
1919
{
20-
// A bare ProfileSettings() launches the platform default shell
21-
// (Windows PowerShell on Windows, /bin/sh-family elsewhere via the
22-
// package's own PTY connection).
23-
await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30);
20+
// ProfileSettings defaults to Windows PowerShell, which runs through
21+
// ConPTY. On Linux and macOS, launch the user's shell instead; it runs
22+
// through the package's dt-pty-host.
23+
var profile = OperatingSystem.IsWindows()
24+
? new ProfileSettings()
25+
: new ProfileSettings { Commandline = Environment.GetEnvironmentVariable("SHELL") ?? "/bin/sh" };
26+
await terminal.StartAsync(profile, columns: 120, rows: 30);
2427
};
2528
}
2629
}

‎samples/Devolutions.Terminal.Control.Sample/Program.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ internal static class Program
66
{
77
[STAThread]
88
public static int Main(string[] args) =>
9-
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
9+
args.Contains("--pty-smoke", StringComparer.Ordinal)
10+
? PtySmoke.RunAsync().GetAwaiter().GetResult()
11+
: BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
1012

1113
public static AppBuilder BuildAvaloniaApp() =>
1214
AppBuilder.Configure<App>()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System.Text;
2+
using Devolutions.Terminal.Connection;
3+
4+
namespace Devolutions.Terminal.Control.Sample;
5+
6+
// Headless check, used by CI, that the package's own PTY transport can start a
7+
// local process: ConPTY with the bundled OpenConsole.exe on Windows, and
8+
// dt-pty-host from runtimes/<rid>/native on Linux and macOS.
9+
internal static class PtySmoke
10+
{
11+
private const string Marker = "DT_PTY_SMOKE_OK";
12+
13+
public static async Task<int> RunAsync()
14+
{
15+
await using ITerminalConnection connection = CreateConnection(out var commandLine);
16+
var output = new StringBuilder();
17+
var seen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
18+
connection.OutputReceived += (_, data) =>
19+
{
20+
lock (output)
21+
{
22+
output.Append(Encoding.UTF8.GetString(data.Span));
23+
if (output.ToString().Contains(Marker, StringComparison.Ordinal))
24+
{
25+
seen.TrySetResult();
26+
}
27+
}
28+
};
29+
connection.Faulted += (_, error) => seen.TrySetException(error);
30+
31+
try
32+
{
33+
await connection.StartAsync(commandLine, Environment.CurrentDirectory, 80, 24);
34+
await seen.Task.WaitAsync(TimeSpan.FromSeconds(30));
35+
}
36+
catch (Exception exception)
37+
{
38+
lock (output)
39+
{
40+
Console.Error.WriteLine($"PTY smoke failed: {exception.Message}\nOutput so far:\n{output}");
41+
}
42+
43+
return 1;
44+
}
45+
46+
Console.WriteLine($"PTY smoke passed: {connection.GetType().Name} ran '{commandLine}'.");
47+
return 0;
48+
}
49+
50+
// The marker is split in the command so that only executed output can match it.
51+
private static ITerminalConnection CreateConnection(out string commandLine)
52+
{
53+
if (OperatingSystem.IsWindows())
54+
{
55+
commandLine = "cmd.exe /d /c echo DT_PTY_^SMOKE_OK";
56+
return new ConPtyConnection();
57+
}
58+
59+
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
60+
{
61+
commandLine = "printf 'DT_PTY_%s\\n' SMOKE_OK";
62+
return new LinuxPtyConnection();
63+
}
64+
65+
throw new PlatformNotSupportedException("No local PTY implementation is available for this platform.");
66+
}
67+
}

‎samples/Devolutions.Terminal.Control.Sample/README.md‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,23 @@ dotnet run --project samples/Devolutions.Terminal.Control.Sample
2727
```
2828

2929
`NuGet.Config` in this folder maps the `Devolutions.Terminal.Control` package
30-
id exclusively to `../../artifacts/nuget`, so it always picks up the package
31-
you just packed rather than whatever version (if any) is on nuget.org.
30+
id exclusively to `../../artifacts/nuget`, so it never resolves a version from
31+
nuget.org. Local packs all share the repository's `VersionPrefix`, and NuGet
32+
reuses a version already in its global packages folder, so after repacking
33+
either pass a new version (`-p:VersionPrefix=2026.3.1`) or clear the cached
34+
copy (`dotnet nuget locals global-packages --clear`).
35+
36+
On Linux and macOS the sample launches `$SHELL` through the package's
37+
`dt-pty-host`. A local pack only contains the hosts that exist under
38+
`native/linux-pty/<rid>/`; see the NuGet section of
39+
[docs/release.md](../../docs/release.md) for building them.
40+
41+
## Headless PTY smoke test
42+
43+
```pwsh
44+
dotnet run --project samples/Devolutions.Terminal.Control.Sample -- --pty-smoke
45+
```
46+
47+
`--pty-smoke` skips the UI, starts the package's own PTY transport (ConPTY on
48+
Windows, `dt-pty-host` on Linux and macOS), and exits with 0 once the command
49+
it ran produced output. CI runs it on Windows, Linux x64/ARM64, and macOS ARM64.

0 commit comments

Comments
 (0)