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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="Microsoft.Windows.Console.ConPTY" Version="1.24.260710001" />
</ItemGroup>
</Project>
14 changes: 8 additions & 6 deletions docs/advanced-vt-protocols.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
# Advanced VT protocols

> [!NOTE]
> The public out-of-process Windows ConPTY may filter DCS payloads before they
> reach a terminal client on some Windows builds, and APC sequences (kitty
> graphics) can be affected the same way. The Core parser and renderer
> support Sixel and kitty graphics when a connection transports those bytes
> unchanged (for example, remote/Azure transports); local ConPTY support is
> limited by the installed Windows pseudoconsole implementation.
> Windows local sessions use the bundled Microsoft ConPTY runtime, including
> `OpenConsole.exe`, rather than the OS copy that filters Sixel on some Windows
> builds. With the built-in engine, Sixel producers such as `Get-XKCD -Show`
> can render inline images. Other transports must likewise preserve graphics
> payloads; the optional Ghostty engine does not expose image resources.

`Devolutions.Terminal.Core` parses advanced string protocols without depending on Avalonia,
Skia, Win32, or an image codec. It exposes decoded Sixel pixels and bounded
Expand Down Expand Up @@ -36,6 +35,9 @@ terminator split across input chunks. BEL does not terminate DCS. An `ESC`
followed by anything other than `\` aborts the DCS and starts the new escape
sequence.

DECSDM defaults to reset (scrolling mode): Sixel images anchor at the cursor
unless the application explicitly selects display mode with `CSI ? 80 h`.

## Limits

Limits are public constants on `TerminalImageLimits` and `VtResourceLimits`.
Expand Down
18 changes: 14 additions & 4 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ Commands are relative to the repository root.
- WinApp CLI 0.6.0 for MSIX creation and validation
- A trusted code-signing certificate for distributable MSIX artifacts

Windows local sessions use ConPTY. Linux and macOS local sessions use the
Windows local sessions use the pinned `Microsoft.Windows.Console.ConPTY` NuGet
runtime. Ship `conpty.dll` and the architecture-specific `OpenConsole.exe`
subdirectories together: without its host, ConPTY can fall back to the OS copy
and silently lose Sixel output. `ConPtyHost.targets` supplies these files for
RID-less builds, RID-specific publishes, and Control NuGet consumers, including
the ARM64 host required by x64 processes running under emulation. Because
`conpty.dll` lands under `runtimes\<rid>\native` for RID-less builds but at the
output root once a RID is applied, the hosts are emitted in both layouts; a RID
does not reliably flow to referenced projects, so the placement cannot depend on
it.
Linux and macOS local sessions use the
bundled `forkpty` relay. The Avalonia shell, settings, renderer, and terminal
engines are shared.

Expand Down Expand Up @@ -400,9 +410,9 @@ that `Control` needs, or a project that needs direct access to

## Platform constraints

- Public out-of-process ConPTY can filter or alter DCS/APC payloads on some
Windows builds. Sixel works when the selected connection passes DCS bytes
through unchanged.
- The OS-provided ConPTY can filter graphics payloads on some Windows builds.
Local sessions use the bundled runtime to preserve Sixel; remote connections
must also transport image sequences unchanged.
- Avalonia 12 exposes the terminal as a readable UIA Document/Value provider,
but does not provide a public bridge for native UIA TextPattern/TextPattern2
or LiveSetting events. Managed ranges and visible notification text remain
Expand Down
4 changes: 2 additions & 2 deletions src/Devolutions.Terminal.Connection/ConPtyConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public void Resize(int columns, int rows)
if (pseudoConsole is not null && !pseudoConsole.IsInvalid && !pseudoConsole.IsClosed)
{
var size = new Kernel32.Coord { X = (short)validatedColumns, Y = (short)validatedRows };
var hr = Kernel32.ResizePseudoConsole(pseudoConsole, size);
var hr = ConPty.ResizePseudoConsole(pseudoConsole, size);
if (hr != 0)
{
Marshal.ThrowExceptionForHR(hr);
Expand Down Expand Up @@ -225,7 +225,7 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella
X = (short)options.Columns,
Y = (short)options.Rows,
};
var hr = Kernel32.CreatePseudoConsole(size, inputRead, outputWrite, 0, out var pseudoConsoleValue);
var hr = ConPty.CreatePseudoConsole(size, inputRead, outputWrite, 0, out var pseudoConsoleValue);
if (hr != 0)
{
Marshal.ThrowExceptionForHR(hr);
Expand Down
34 changes: 34 additions & 0 deletions src/Devolutions.Terminal.Connection/ConPtyHost.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<Project>
<PropertyGroup>
<ConPtyHostSourceRoot Condition="'$(ConPtyHostSourceRoot)' == ''">$(MSBuildThisFileDirectory)..\tools\conpty</ConPtyHostSourceRoot>
</PropertyGroup>
<!-- ConPTY resolves the host for the native OS architecture relative to conpty.dll,
and silently falls back to the OS conhost.exe (which strips DCS/sixel) when it is
missing. conpty.dll lands under runtimes\<rid>\native for RID-less builds and at the
output root for RID/self-contained publishes, so both layouts are populated: a RID
does not necessarily flow to this project when a referencing app is published.
ARM64 hosts are included for x64/x86 processes running under Windows emulation. -->
<Target Name="IncludeConPtyHosts" BeforeTargets="AssignTargetPaths">
<ItemGroup>
<_ConPtyArch Include="x86;x64;arm64" />
<_ConPtyRuntime Include="win-x86;win-x64;win-arm64" Condition="'$(RuntimeIdentifier)' == ''" />
<None Include="@(_ConPtyArch->'$(ConPtyHostSourceRoot)\%(Identity)\OpenConsole.exe')"
Link="%(_ConPtyArch.Identity)\OpenConsole.exe"
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="@(_ConPtyRuntime->'$(ConPtyHostSourceRoot)\x86\OpenConsole.exe')"
Condition="'%(_ConPtyRuntime.Identity)' == 'win-x86'"
Link="runtimes\%(_ConPtyRuntime.Identity)\native\x86\OpenConsole.exe"
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="@(_ConPtyRuntime->'$(ConPtyHostSourceRoot)\x64\OpenConsole.exe')"
Condition="'%(_ConPtyRuntime.Identity)' == 'win-x86' or '%(_ConPtyRuntime.Identity)' == 'win-x64'"
Link="runtimes\%(_ConPtyRuntime.Identity)\native\x64\OpenConsole.exe"
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="@(_ConPtyRuntime->'$(ConPtyHostSourceRoot)\arm64\OpenConsole.exe')"
Link="runtimes\%(_ConPtyRuntime.Identity)\native\arm64\OpenConsole.exe"
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="$(MSBuildThisFileDirectory)THIRD-PARTY-NOTICES-CONPTY.txt"
Link="THIRD-PARTY-NOTICES-CONPTY.txt"
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Target>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.Console.ConPTY" GeneratePathProperty="true" ExcludeAssets="build" />
<ProjectReference Include="..\Devolutions.Terminal.Core\Devolutions.Terminal.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<ConPtyHostSourceRoot>$(PkgMicrosoft_Windows_Console_ConPTY)\build\native\runtimes</ConPtyHostSourceRoot>
</PropertyGroup>
<Import Project="ConPtyHost.targets" />
</Project>
18 changes: 18 additions & 0 deletions src/Devolutions.Terminal.Connection/Native/ConPty.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Win32.SafeHandles;

namespace Devolutions.Terminal.Connection.Native;

[SupportedOSPlatform("windows")]
internal static partial class ConPty
{
[LibraryImport("conpty.dll", EntryPoint = "ConptyCreatePseudoConsole")]
internal static partial int CreatePseudoConsole(Kernel32.Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out nint phPC);

[LibraryImport("conpty.dll", EntryPoint = "ConptyResizePseudoConsole")]
internal static partial int ResizePseudoConsole(SafePseudoConsoleHandle hPC, Kernel32.Coord size);

[LibraryImport("conpty.dll", EntryPoint = "ConptyClosePseudoConsole")]
internal static partial void ClosePseudoConsole(nint hPC);
}
9 changes: 0 additions & 9 deletions src/Devolutions.Terminal.Connection/Native/Kernel32.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,6 @@ internal struct ProcessInformation
public int dwThreadId;
}

[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial int CreatePseudoConsole(Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out nint phPC);

[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial int ResizePseudoConsole(SafePseudoConsoleHandle hPC, Coord size);

[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial void ClosePseudoConsole(nint hPC);

[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CreatePipe(out SafeFileHandle hReadPipe, out SafeFileHandle hWritePipe, nint lpPipeAttributes, uint nSize);
Expand Down
2 changes: 1 addition & 1 deletion src/Devolutions.Terminal.Connection/Native/SafeHandles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ internal SafePseudoConsoleHandle(nint handle)

protected override bool ReleaseHandle()
{
Kernel32.ClosePseudoConsole(handle);
ConPty.ClosePseudoConsole(handle);
return true;
}
}
Expand Down
24 changes: 24 additions & 0 deletions src/Devolutions.Terminal.Connection/THIRD-PARTY-NOTICES-CONPTY.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Microsoft Windows Console ConPTY
https://github.com/microsoft/terminal

Copyright (c) Microsoft Corporation. All rights reserved.

MIT License

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<PackageReference Include="SkiaSharp.HarfBuzz" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" />
<PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" />
<PackageReference Include="Microsoft.Windows.Console.ConPTY" GeneratePathProperty="true" ExcludeAssets="build" />
</ItemGroup>
<ItemGroup>
<!--
Expand All @@ -62,6 +63,14 @@
Link="Assets\Fonts\NotoColorEmoji.ttf" />
</ItemGroup>
<Import Project="..\..\native\noto-emoji\NotoEmoji.targets" />
<ItemGroup>
<None Include="..\Devolutions.Terminal.Connection\ConPtyHost.targets"
Pack="true" PackagePath="buildTransitive\Devolutions.Terminal.Control.targets" />
<None Include="..\Devolutions.Terminal.Connection\THIRD-PARTY-NOTICES-CONPTY.txt"
Pack="true" PackagePath="buildTransitive" />
<None Include="$(PkgMicrosoft_Windows_Console_ConPTY)\build\native\runtimes\**\OpenConsole.exe"
Pack="true" PackagePath="tools\conpty" />
</ItemGroup>

<!--
Bundle the build outputs of the internal, non-packable project
Expand All @@ -81,4 +90,3 @@
</ItemGroup>
</Target>
</Project>

6 changes: 3 additions & 3 deletions src/Devolutions.Terminal.Core/TerminalEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public sealed class TerminalEngine : ITerminalEngine, IVtDispatch
private int _modifyOtherKeys;
private bool _win32InputMode;
private bool _allowKittyKeyboard = true;
private bool _sixelDisplayMode = true;
private bool _sixelDisplayMode;
private double _cellWidth = 10;
private double _cellHeight = 20;
private int _cursorStyle;
Expand Down Expand Up @@ -252,7 +252,7 @@ public void Reset()
_alternateScroll = false;
_inBandResize = false;
_titleStack.Clear();
_sixelDisplayMode = true;
_sixelDisplayMode = false;
_vt52Graphics = false;
_rectangularAttributeExtent = false;
_drcsDesignator = null;
Expand Down Expand Up @@ -3039,7 +3039,7 @@ private void SoftReset()
_synchronizedOutputDepth = 0;
_alternateScroll = false;
_inBandResize = false;
_sixelDisplayMode = true;
_sixelDisplayMode = false;
_vt52Graphics = false;
_rectangularAttributeExtent = false;
_sgr = CellAttributes.Default;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function Get-ParentPath {
return ''
}

$normalized = $Path.Replace('\\', '/')
$normalized = $Path.Replace('\', '/')
$index = $normalized.LastIndexOf('/')
if ($index -lt 0) {
return ''
Expand All @@ -54,13 +54,13 @@ function Get-RelativePath {
return '.'
}

return $relative.Replace('\\', '/')
return $relative.Replace('\', '/')
}

$baseUri = [Uri]::new(($baseFull + [IO.Path]::DirectorySeparatorChar))
$targetUri = [Uri]::new($targetFull)
$relative = [Uri]::UnescapeDataString($baseUri.MakeRelativeUri($targetUri).ToString())
return $relative.Replace('\\', '/')
return $relative.Replace('\', '/')
}

foreach ($file in $files) {
Expand Down Expand Up @@ -120,7 +120,7 @@ foreach ($file in $files) {
$directoryId = 'INSTALLLOCATION'
}
else {
$directoryId = $directoryIds[$relativeDirectory.Replace('\\', '/')]
$directoryId = $directoryIds[$relativeDirectory.Replace('\', '/')]
}

$componentId = 'cmp_' + (($relativePath -replace '[^A-Za-z0-9_]', '_').Trim('_'))
Expand Down
5 changes: 3 additions & 2 deletions src/Devolutions.Terminal.Render/SkiaTerminalRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -583,8 +583,9 @@ private SKRect ImageDestination(
var naturalHeight = (float)bitmap.Height;
if (image.Sixel is not null)
{
var sixelWidth = naturalWidth * (float)(image.CellGeometry.CellWidth / 10);
var sixelHeight = naturalHeight * (float)(image.CellGeometry.CellHeight / 20);
// The engine retains physical cell pixels; the canvas uses DIPs.
var sixelWidth = naturalWidth * (float)(image.CellGeometry.CellWidth / 10) * PhysicalPixel;
var sixelHeight = naturalHeight * (float)(image.CellGeometry.CellHeight / 20) * PhysicalPixel;
return SKRect.Create(
left,
top,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ public async Task CloseAbortsWhenCloseOutputDoesNotComplete()
await using var connection = fixture.Connection;
await connection.StartAsync(string.Empty, null, 80, 24);

await connection.CloseAsync().WaitAsync(TimeSpan.FromSeconds(2));
await connection.CloseAsync().WaitAsync(TimeSpan.FromSeconds(10));

Assert.Equal(TerminalConnectionState.Closed, connection.State);
Assert.Contains(diagnostics, value => value.Code == "WebSocketCloseTimedOut");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Text;
using System.Text.RegularExpressions;
using Devolutions.Terminal.Connection;
using Devolutions.Terminal.Core;
using Microsoft.Win32;
using Xunit;

Expand Down Expand Up @@ -109,6 +110,48 @@ public async Task CapturesUnicodeOutputAndExitCode()
Assert.Equal(TerminalExitReason.ProcessExited, connection.LastExitInfo?.Reason);
}

[Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))]
public async Task SixelSurvivesConPtyAndDecodesToImage()
{
await using var connection = new ConPtyConnection();
var engine = new TerminalEngine(80, 24);
var output = new List<byte>();
var exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
connection.OutputReceived += (_, bytes) =>
{
lock (output)
{
output.AddRange(bytes.ToArray());
engine.Feed(bytes.Span);
}
};
engine.ResponseReady += (_, bytes) => connection.Write(bytes);
connection.Exited += (_, code) => exited.TrySetResult(code);
connection.Faulted += (_, error) => exited.TrySetException(error);
const string sixel = "\u001bPq\"1;1;60;6#0;2;100;0;0#0!60~\u001b\\";
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("BEFORE" + sixel + "AFTER"));
var script = $"[Console]::Write([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{payload}')))";
var command = "powershell.exe -NoLogo -NoProfile -EncodedCommand " +
Convert.ToBase64String(Encoding.Unicode.GetBytes(script));

await connection.StartAsync(command, null, 80, 24, TestContext.Current.CancellationToken);

Assert.Equal(0, await exited.Task.WaitAsync(TimeSpan.FromSeconds(10)));
await WaitForOutputAsync(output, "AFTER");
lock (output)
{
var text = Encoding.UTF8.GetString([.. output]);
Assert.Contains("BEFORE", text);
Assert.Contains(sixel, text);
var image = Assert.Single(engine.Images);
Assert.Equal(TerminalImageProtocol.Sixel, image.Protocol);
Assert.NotNull(image.Sixel);
Assert.Equal(60, image.Sixel.Width);
Assert.Equal(6, image.Sixel.Height);
Assert.All(image.Sixel.ToRgba32(), pixel => Assert.Equal(0xFFFF0000u, pixel));
}
}

[Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))]
public async Task ResizesRunningPseudoConsole()
{
Expand Down Expand Up @@ -455,13 +498,14 @@ public async Task InteractivePowerShellLoadsPsReadLine()
};
connection.Exited += (_, code) => exited.TrySetResult(code);

await connection.StartAsync($"\"{powershell}\" -NoLogo", null, 80, 24);
await connection.StartAsync($"\"{powershell}\" -NoLogo -NoProfile", null, 80, 24);
await WaitForOutputAsync(output, "PS ", TimeSpan.FromSeconds(20));
connection.Write(
"if (Get-Module PSReadLine) { 'PSREADLINE_OK' } else { 'PSREADLINE_MISSING' }\r");
"if (Get-Module PSReadLine) { 'PSREAD' + 'LINE_OK' } else { 'PSREADLINE_MISSING' }\r");
await WaitForOutputAsync(output, "PSREADLINE_OK", TimeSpan.FromSeconds(20));
connection.Write("exit\r");

Assert.Equal(0, await exited.Task.WaitAsync(TimeSpan.FromSeconds(10)));
await WaitForOutputAsync(output, "PSREADLINE_OK");
Assert.Equal(0, await exited.Task.WaitAsync(TimeSpan.FromSeconds(20)));
lock (output)
{
Assert.DoesNotContain(
Expand Down
Loading
Loading