diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml
index d231561..ad7d31a 100644
--- a/.github/workflows/build-terminal.yml
+++ b/.github/workflows/build-terminal.yml
@@ -159,6 +159,90 @@ jobs:
- name: Test
run: dotnet test Devolutions.Terminal.slnx -c Release --no-build -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }}
+ nuget-pack:
+ name: Pack Devolutions.Terminal.Control NuGet package
+ runs-on: windows-latest
+ needs:
+ - release-metadata
+ - build
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Cache native libraries
+ uses: actions/cache@v4
+ with:
+ path: |
+ native/noto-emoji/NotoColorEmoji.ttf
+ key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }}
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Pack Devolutions.Terminal.Control
+ run: >-
+ dotnet pack src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj
+ -c Release
+ -o artifacts/nuget
+ -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }}
+
+ - name: Smoke-test package via samples/Devolutions.Terminal.Control.Sample
+ run: dotnet build samples/Devolutions.Terminal.Control.Sample -c Release
+
+ - name: Upload NuGet package artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: DevolutionsTerminal-nuget-packages
+ path: artifacts/nuget
+ if-no-files-found: error
+
+ nuget-publish:
+ name: Publish Devolutions.Terminal.Control to nuget.org
+ if: ${{ needs.release-metadata.outputs.dry_run != 'true' && startsWith(github.ref, 'refs/tags/') }}
+ needs:
+ - release-metadata
+ - nuget-pack
+ runs-on: ubuntu-latest
+ environment:
+ name: nuget.org
+ steps:
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Download NuGet package artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: DevolutionsTerminal-nuget-packages
+ path: artifacts/nuget
+
+ - name: Push to nuget.org
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ shell: pwsh
+ run: |
+ if ([string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)) {
+ throw "Missing NUGET_API_KEY secret."
+ }
+
+ $packages = Get-ChildItem -LiteralPath artifacts/nuget -Filter "*.nupkg" -File
+ if ($packages.Count -eq 0) {
+ throw "No .nupkg files found to publish."
+ }
+
+ foreach ($package in $packages) {
+ dotnet nuget push $package.FullName `
+ --source https://api.nuget.org/v3/index.json `
+ --api-key $env:NUGET_API_KEY `
+ --skip-duplicate
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to push $($package.Name) to nuget.org."
+ }
+ }
+
native-aot:
name: NativeAOT ${{ matrix.rid }}
runs-on: windows-latest
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 69fc3f0..e40a7d1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,5 +18,6 @@
+
diff --git a/README.md b/README.md
index 9932c7e..a16027d 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,13 @@ Advanced VT protocols are documented in
Azure Cloud Shell is documented in [docs/azure-cloud-shell.md](docs/azure-cloud-shell.md).
Build and release gates are documented in [docs/release.md](docs/release.md).
+`Devolutions.Terminal.Control` is also published to
+[nuget.org](https://www.nuget.org/packages/Devolutions.Terminal.Control) as a
+reusable, self-contained Avalonia terminal control package (it bundles
+`Core`/`Render`/`Connection`/`Settings` internally) for embedding in other
+Avalonia applications. See the "NuGet package" section of
+[docs/release.md](docs/release.md) for packaging and consumption details.
+
## Compatibility inventory
### Safety and compatibility settings
diff --git a/docs/release.md b/docs/release.md
index d016e93..9ab23ac 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -21,7 +21,8 @@ CI workflows:
- `build-ghostty.yml` — compile `libghostty-vt` for every RID and upload
artifacts (optional cache; not required to develop).
- `build-terminal.yml` — restore natives from source, test, NativeAOT, Linux
- packages, macOS `.app`/zip, MSIX.
+ packages, macOS `.app`/zip, MSIX, and the `Devolutions.Terminal.Control`
+ NuGet package.
## Developer build
@@ -275,6 +276,87 @@ trusted publishing. Configure the `publish-test` and `publish-prod`
environments as trusted publishers for the package on NuGet.org. Dry runs do
not request a NuGet API key or publish the package.
+## NuGet package (`Devolutions.Terminal.Control`)
+
+`src/Devolutions.Terminal.Control` is published to nuget.org as a single,
+self-contained package so it can be embedded in other Avalonia applications
+(for example, replacing an internal terminal-control package in a downstream
+product). The package bundles the build output of `Devolutions.Terminal.Core`,
+`Devolutions.Terminal.Render`, `Devolutions.Terminal.Connection`, and
+`Devolutions.Terminal.Settings` directly into its `lib/net10.0` folder — those
+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).
+
+Pack it locally:
+
+```powershell
+dotnet pack src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj `
+ -c Release -o artifacts/nuget
+```
+
+Consume it as a package (see `samples/Devolutions.Terminal.Control.Sample`
+for a full working app):
+
+```xml
+
+```
+
+`TermControl` has no parameterless constructor usable from XAML (its
+constructor takes an optional `ITerminalEngine`), so instantiate it in
+code-behind rather than declaring it directly as a XAML element:
+
+```csharp
+using Devolutions.Terminal;
+using Devolutions.Terminal.Settings;
+
+var terminal = new TermControl();
+Content = terminal;
+await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30);
+```
+
+`samples/Devolutions.Terminal.Control.Sample` is a minimal Avalonia app that
+consumes the package this way via a plain `PackageReference` (never a
+`ProjectReference`), and is deliberately excluded from
+`Devolutions.Terminal.slnx` since it must resolve the package from a feed. Its
+`NuGet.Config` maps the `Devolutions.Terminal.Control` package id exclusively
+to a local `artifacts/nuget` feed via package source mapping, so it always
+builds against whatever was just packed rather than an already-published
+version. CI builds it in the `nuget-pack` job right after packing, as a
+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
+`nuget-publish` job pushes those packages to nuget.org only for tag-triggered,
+non-dry-run runs, using the `NUGET_API_KEY` secret configured on the
+`nuget.org` GitHub Environment.
+
+Required environment secret:
+
+- `NUGET_API_KEY` — an API key scoped to the `Devolutions.Terminal.Control`
+ package ID on nuget.org.
+
+When changing the internal project boundary (adding a new internal project
+that `Control` needs, or a project that needs direct access to
+`Core`/`Render`/`Connection`/`Settings` types), keep two things in sync:
+
+- Any new internal, non-packable dependency needs its own
+ `` entry in `Control.csproj` so
+ it is bundled into `lib/net10.0` without leaking a dangling NuGet
+ dependency.
+- Any third-party `PackageReference` declared only on one of those internal
+ projects (such as `Render`'s SkiaSharp/HarfBuzzSharp references) must also
+ be declared directly on `Control.csproj`, because `PrivateAssets="all"`
+ prevents it from flowing into `Control`'s nuspec automatically.
+- In-repo consumers (tests, tools, `Devolutions.Terminal.App`) that use
+ `Core`/`Render`/`Connection`/`Settings` types directly can no longer rely on
+ transitive project-reference flow through `Control` and must add their own
+ explicit `ProjectReference`.
+
## Release gates
1. Regenerate `compat/windows-terminal.json` and review inventory changes.
diff --git a/samples/Devolutions.Terminal.Control.Sample/App.axaml b/samples/Devolutions.Terminal.Control.Sample/App.axaml
new file mode 100644
index 0000000..c76b918
--- /dev/null
+++ b/samples/Devolutions.Terminal.Control.Sample/App.axaml
@@ -0,0 +1,4 @@
+
+
diff --git a/samples/Devolutions.Terminal.Control.Sample/App.axaml.cs b/samples/Devolutions.Terminal.Control.Sample/App.axaml.cs
new file mode 100644
index 0000000..42198ce
--- /dev/null
+++ b/samples/Devolutions.Terminal.Control.Sample/App.axaml.cs
@@ -0,0 +1,20 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace Devolutions.Terminal.Control.Sample;
+
+public sealed class App : Application
+{
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/samples/Devolutions.Terminal.Control.Sample/Devolutions.Terminal.Control.Sample.csproj b/samples/Devolutions.Terminal.Control.Sample/Devolutions.Terminal.Control.Sample.csproj
new file mode 100644
index 0000000..6de3e41
--- /dev/null
+++ b/samples/Devolutions.Terminal.Control.Sample/Devolutions.Terminal.Control.Sample.csproj
@@ -0,0 +1,39 @@
+
+
+
+
+
+ WinExe
+ Devolutions.Terminal.Control.Sample
+ Devolutions.Terminal.Control.Sample
+ Minimal Avalonia app demonstrating Devolutions.Terminal.Control consumed as a NuGet package.
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
diff --git a/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml
new file mode 100644
index 0000000..4e73ca4
--- /dev/null
+++ b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs
new file mode 100644
index 0000000..d18d623
--- /dev/null
+++ b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs
@@ -0,0 +1,26 @@
+using Avalonia.Controls;
+using Devolutions.Terminal.Settings;
+
+namespace Devolutions.Terminal.Control.Sample;
+
+public sealed partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ // TermControl has no parameterless constructor usable from XAML (its
+ // constructor takes an optional ITerminalEngine), so it is built here
+ // instead of declared in MainWindow.axaml.
+ var terminal = new Devolutions.Terminal.TermControl();
+ Content = terminal;
+
+ 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);
+ };
+ }
+}
diff --git a/samples/Devolutions.Terminal.Control.Sample/NuGet.Config b/samples/Devolutions.Terminal.Control.Sample/NuGet.Config
new file mode 100644
index 0000000..bbaea6e
--- /dev/null
+++ b/samples/Devolutions.Terminal.Control.Sample/NuGet.Config
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Devolutions.Terminal.Control.Sample/Program.cs b/samples/Devolutions.Terminal.Control.Sample/Program.cs
new file mode 100644
index 0000000..578c227
--- /dev/null
+++ b/samples/Devolutions.Terminal.Control.Sample/Program.cs
@@ -0,0 +1,15 @@
+using Avalonia;
+
+namespace Devolutions.Terminal.Control.Sample;
+
+internal static class Program
+{
+ [STAThread]
+ public static int Main(string[] args) =>
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .UsePlatformDetect()
+ .LogToTrace();
+}
diff --git a/samples/Devolutions.Terminal.Control.Sample/README.md b/samples/Devolutions.Terminal.Control.Sample/README.md
new file mode 100644
index 0000000..72aa0ad
--- /dev/null
+++ b/samples/Devolutions.Terminal.Control.Sample/README.md
@@ -0,0 +1,31 @@
+# Devolutions.Terminal.Control sample
+
+A minimal Avalonia desktop app that consumes the published
+[`Devolutions.Terminal.Control`](../../src/Devolutions.Terminal.Control) NuGet
+package via a plain `PackageReference` — never a `ProjectReference`. It exists
+for two reasons:
+
+1. **Living usage example** for anyone integrating the control into their own
+ Avalonia app.
+2. **Consumer smoke test** for CI: the `nuget-pack` workflow job packs
+ `Devolutions.Terminal.Control` locally, then builds this project against
+ that freshly produced package to catch packaging regressions (missing
+ bundled assemblies/assets, broken dependencies, wrong TFM, etc.) that a
+ `ProjectReference`-based build would never surface.
+
+This project is intentionally **not** part of `Devolutions.Terminal.slnx` —
+it must resolve `Devolutions.Terminal.Control` from a NuGet feed, so it can't
+be part of the normal restore/build/test path that runs before the package
+has ever been packed.
+
+## Running it locally
+
+```pwsh
+# From the repository root:
+dotnet pack src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj -c Release -o artifacts/nuget
+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.
diff --git a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj
index c2e3b3a..b492f09 100644
--- a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj
+++ b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj
@@ -22,6 +22,8 @@
+
+
diff --git a/src/Devolutions.Terminal.Connection/Devolutions.Terminal.Connection.csproj b/src/Devolutions.Terminal.Connection/Devolutions.Terminal.Connection.csproj
index a58b2f5..a141dcc 100644
--- a/src/Devolutions.Terminal.Connection/Devolutions.Terminal.Connection.csproj
+++ b/src/Devolutions.Terminal.Connection/Devolutions.Terminal.Connection.csproj
@@ -3,6 +3,8 @@
Devolutions.Terminal.Connection
true
ConPTY and Azure Cloud Shell connections for the .NET Windows Terminal port.
+
+ false
diff --git a/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj b/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj
index 49f3fe6..3e58d76 100644
--- a/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj
+++ b/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj
@@ -2,16 +2,56 @@
Devolutions.Terminal
Avalonia terminal control that renders Devolutions.Terminal.Core.
+
+ Devolutions.Terminal.Control
+ avalonia;terminal;console;vt100;ansi;pty;conpty;xterm
+ PACKAGE.md
+ MIT
+ https://github.com/Devolutions/devolutions-terminal
+ true
+ true
+ true
+ snupkg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+ $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage
+
+
+
+
+
+
diff --git a/src/Devolutions.Terminal.Control/PACKAGE.md b/src/Devolutions.Terminal.Control/PACKAGE.md
new file mode 100644
index 0000000..fea5732
--- /dev/null
+++ b/src/Devolutions.Terminal.Control/PACKAGE.md
@@ -0,0 +1,34 @@
+# Devolutions.Terminal.Control
+
+A self-contained, NativeAOT-friendly Avalonia terminal control (`TermControl`)
+extracted from [Devolutions Terminal](https://github.com/Devolutions/devolutions-terminal).
+
+This package bundles the VT engine, renderer, connection layer (ConPTY on
+Windows, PTY on Linux/macOS), and Windows Terminal-compatible settings parser
+that `TermControl` depends on, so a single package reference is enough to
+embed a fully functional terminal in any Avalonia application.
+
+## Getting started
+
+```xml
+
+
+
+```
+
+`TermControl` has no parameterless constructor usable from XAML (its
+constructor takes an optional `ITerminalEngine`), so create and start it in
+code-behind instead of declaring it as a XAML element:
+
+```csharp
+using Devolutions.Terminal;
+using Devolutions.Terminal.Settings;
+
+var terminal = new TermControl();
+Content = terminal;
+await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30);
+```
+
+See the [`samples/Devolutions.Terminal.Control.Sample`](https://github.com/Devolutions/devolutions-terminal/tree/main/samples/Devolutions.Terminal.Control.Sample)
+project in the [Devolutions Terminal repository](https://github.com/Devolutions/devolutions-terminal)
+for a full working app, plus the full source and documentation.
diff --git a/src/Devolutions.Terminal.Core/Devolutions.Terminal.Core.csproj b/src/Devolutions.Terminal.Core/Devolutions.Terminal.Core.csproj
index ef0f9a2..84d0557 100644
--- a/src/Devolutions.Terminal.Core/Devolutions.Terminal.Core.csproj
+++ b/src/Devolutions.Terminal.Core/Devolutions.Terminal.Core.csproj
@@ -2,5 +2,7 @@
Devolutions.Terminal.Core
VT parser, text buffer, and terminal engine for the .NET Windows Terminal port.
+
+ false
diff --git a/src/Devolutions.Terminal.Render/Devolutions.Terminal.Render.csproj b/src/Devolutions.Terminal.Render/Devolutions.Terminal.Render.csproj
index 4e8fae0..1a2988b 100644
--- a/src/Devolutions.Terminal.Render/Devolutions.Terminal.Render.csproj
+++ b/src/Devolutions.Terminal.Render/Devolutions.Terminal.Render.csproj
@@ -2,6 +2,8 @@
Devolutions.Terminal.Render
Renderer-neutral snapshots and Skia rendering contracts.
+
+ false
diff --git a/src/Devolutions.Terminal.Settings/Devolutions.Terminal.Settings.csproj b/src/Devolutions.Terminal.Settings/Devolutions.Terminal.Settings.csproj
index c199d96..61970d4 100644
--- a/src/Devolutions.Terminal.Settings/Devolutions.Terminal.Settings.csproj
+++ b/src/Devolutions.Terminal.Settings/Devolutions.Terminal.Settings.csproj
@@ -2,6 +2,8 @@
Devolutions.Terminal.Settings
JSON settings model compatible with a Windows Terminal subset.
+
+ false
diff --git a/tests/Devolutions.Terminal.Control.Tests/Devolutions.Terminal.Control.Tests.csproj b/tests/Devolutions.Terminal.Control.Tests/Devolutions.Terminal.Control.Tests.csproj
index de9216f..37c4d15 100644
--- a/tests/Devolutions.Terminal.Control.Tests/Devolutions.Terminal.Control.Tests.csproj
+++ b/tests/Devolutions.Terminal.Control.Tests/Devolutions.Terminal.Control.Tests.csproj
@@ -1,6 +1,10 @@
+
+
+
+
diff --git a/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj b/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj
index 232b266..59d258d 100644
--- a/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj
+++ b/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj
@@ -12,5 +12,9 @@
+
+
+
+