using System.Buffers.Binary;
namespace DodoSSH.Client.Terminal;
/// Frames the host sends to the renderer.
public enum TerminalServerOpcode : byte
{
/// Not a legal value.
Unspecified = 0,
/// Terminal output. Payload is raw bytes for term.write.
Output = 1,
/// A session has been created; the renderer should attach a terminal to it.
SessionOpened = 2,
/// A session has ended. Payload is a UTF-8 reason for the user.
SessionClosed = 3,
///
/// This session's pane is the one to show. No payload.
///
///
///
/// Every session gets a pane and all but one are hidden, so something has to say which. The page picked
/// the newest on its own, which is right exactly once — at the moment a session opens — and wrong for
/// every tab switch afterwards, because the page has no idea a tab strip exists. It is the host that
/// knows which tab is selected, so it is the host that says.
///
///
/// An opcode rather than script evaluation through the WebView bridge, which would have been the smaller
/// change. The bridge is UI-thread-bound and unordered with respect to this socket: a switch sent that
/// way could arrive before the frame for the pane it names, and the page
/// would activate a session that does not exist yet. On this socket the two are ordered by construction.
///
///
SessionActivated = 4,
///
/// This session is gone for good; the renderer should destroy its pane. No payload.
///
///
///
/// Deliberately not the same thing as . That one means the shell ended and
/// the pane must stay — the user was probably reading the last thing the remote said, and that
/// is usually why it ended. This one means the user closed the tab, so there is nothing left to read.
///
///
/// It exists because a pane is expensive and the page never reclaimed one. Every closed tab used to
/// leave an xterm instance, its scrollback and a WebGL context behind — and WebGL contexts are a hard
/// browser limit of about sixteen, so a working day of opening and closing terminals ends with panes
/// that cannot get a renderer. Nothing surfaced that, because the leak is inside the WebView.
///
///
SessionRemoved = 5,
///
/// Text to insert into this session, as if it had been pasted.
///
///
///
/// Payload is one flag byte — non-zero to press Enter after the text — followed by UTF-8.
///
///
/// Through the renderer rather than straight into the input stream, and that is the whole reason this
/// opcode exists. Writing the bytes to the pump would have been fewer lines and is wrong: xterm.js
/// watches the remote for \e[?2004h and wraps pasted text in bracketed-paste markers when the mode
/// is on, which is what makes a shell treat embedded newlines as text instead of as "run this". The host
/// process cannot do that — TerminalDataPlane moves opaque bytes and never parses output — so it
/// would have to guess, and guessing wrong executes every line of a multi-line snippet.
///
///
/// The flag is separate from the text for the same reason. A trailing newline inside the payload would be
/// wrapped along with everything else and arrive at the shell as a literal character; the Enter has to go
/// through term.input, outside the wrapper, or nothing runs.
///
///
Paste = 6,
}
/// Frames the renderer sends to the host.
public enum TerminalClientOpcode : byte
{
/// Not a legal value.
Unspecified = 0,
/// Keystrokes. Payload is raw bytes for the remote.
Input = 1,
///
/// Bytes actually rendered. Payload is a big-endian , returning credit.
///
Acknowledge = 2,
/// The terminal was resized. Payload is four big-endian values.
Resize = 3,
}
///
/// The wire format between the host process and the renderer page.
///
///
///
/// Binary frames over a loopback WebSocket, not the WebView's JavaScript bridge. The official bridge
/// is UI-thread-bound string evaluation: at 10 MB/s in 4 KiB chunks that is roughly 2,500 script
/// evaluations per second on the thread that also has to paint, with base64's 33% overhead on top and
/// — decisively — no backpressure signal at all. A socket gives flow control for free.
///
///
/// Every frame carries a session id because one WebView hosts every terminal. A WebView2 instance is
/// a separate browser process, so one per tab would mean twenty renderer processes and hundreds of
/// megabytes for a normal working set of tabs.
///
///
/// Fixed 5-byte header, big-endian, no length prefix: WebSocket already delimits messages, so adding
/// our own length would be a second source of truth about where a frame ends.
///
///
public static class TerminalFrame
{
/// Opcode plus session id.
public const int HeaderLength = 1 + sizeof(uint);
/// Writes a frame into and returns its length.
public static int Write(
Span destination,
byte opcode,
uint sessionId,
ReadOnlySpan payload)
{
var total = HeaderLength + payload.Length;
if (destination.Length < total)
{
throw new ArgumentException(
$"Need {total} bytes for the frame, got {destination.Length}.",
nameof(destination));
}
destination[0] = opcode;
BinaryPrimitives.WriteUInt32BigEndian(destination[1..], sessionId);
payload.CopyTo(destination[HeaderLength..]);
return total;
}
/// Allocates and writes a frame.
public static byte[] Create(byte opcode, uint sessionId, ReadOnlySpan payload)
{
var frame = new byte[HeaderLength + payload.Length];
Write(frame, opcode, sessionId, payload);
return frame;
}
///
/// Reads a frame's header and payload.
///
///
/// Returns false rather than throwing on anything malformed. These frames arrive from a WebView
/// page — a different process running code we shipped but do not control at runtime — so a bad
/// frame is untrusted input to be dropped, not an exceptional condition.
///
public static bool TryRead(
ReadOnlySpan frame,
out byte opcode,
out uint sessionId,
out ReadOnlySpan payload)
{
opcode = 0;
sessionId = 0;
payload = default;
if (frame.Length < HeaderLength)
{
return false;
}
opcode = frame[0];
sessionId = BinaryPrimitives.ReadUInt32BigEndian(frame[1..]);
payload = frame[HeaderLength..];
return true;
}
/// Reads an payload.
public static bool TryReadAcknowledgement(ReadOnlySpan payload, out uint rendered)
{
rendered = 0;
if (payload.Length != sizeof(uint))
{
return false;
}
rendered = BinaryPrimitives.ReadUInt32BigEndian(payload);
return true;
}
/// Reads a payload.
public static bool TryReadResize(ReadOnlySpan payload, out DodoSSH.Client.Ssh.TerminalSize size)
{
size = default;
if (payload.Length != sizeof(ushort) * 4)
{
return false;
}
size = new DodoSSH.Client.Ssh.TerminalSize(
BinaryPrimitives.ReadUInt16BigEndian(payload),
BinaryPrimitives.ReadUInt16BigEndian(payload[2..]),
BinaryPrimitives.ReadUInt16BigEndian(payload[4..]),
BinaryPrimitives.ReadUInt16BigEndian(payload[6..]));
return true;
}
/// Writes a payload. Used by tests and tooling.
public static byte[] CreateResizePayload(DodoSSH.Client.Ssh.TerminalSize size)
{
var payload = new byte[sizeof(ushort) * 4];
BinaryPrimitives.WriteUInt16BigEndian(payload, size.Columns);
BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(2), size.Rows);
BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(4), size.PixelWidth);
BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(6), size.PixelHeight);
return payload;
}
/// Writes an payload.
public static byte[] CreateAcknowledgementPayload(uint rendered)
{
var payload = new byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32BigEndian(payload, rendered);
return payload;
}
}