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,
///
/// What size every pane should draw at. Payload is one byte, the size in CSS pixels.
///
///
///
/// Not per session, although the frame carries a session id as every frame does: a font size is a
/// preference about reading rather than a property of one remote, and somebody who makes the text
/// bigger means all of it. The id is sent as zero and ignored.
///
///
/// Changing it reflows the remote, and that is the point. The page refits after applying, which
/// changes the column and row count and sends back — so a
/// larger font is a terminal with fewer columns, and the far end is told about it. That is why this is
/// a font size rather than a zoom: zoom scales pixels and leaves the remote wrapping to a width that
/// is no longer on screen, which is what made the phone's pinch-zoom worse than useless.
///
///
FontSize = 7,
///
/// The system clipboard's text, for this session's pane to paste. Payload is UTF-8.
///
///
///
/// Sent in answer to , which is the only thing that
/// asks for it: a right-click over a pane with nothing selected. The host reads the clipboard because
/// the page cannot — see that opcode for why not.
///
///
/// Not , and the difference is the bracketing. That opcode carries a snippet
/// off the sidebar, and the page deliberately types a single-line one rather than bracketing
/// it, because bash marks a bracketed paste as an active region and a snippet should read as though it
/// had been typed. A clipboard paste is the case those markers are for — readline uses them to decide it
/// has been pasted into — so this one is always bracketed. One opcode for both would need a flag
/// meaning "which of the two kinds of paste is this", which is the same distinction spelled less
/// clearly, on a payload that already has a flag byte meaning something else.
///
///
/// No flag byte, therefore, and no Enter: a clipboard paste inserts and stops. Pressing Enter for
/// somebody is what the sidebar's RUN does, and it is a thing they asked for by name.
///
///
Clipboard = 8,
///
/// A line for the pane saying that output was dropped. Payload is UTF-8, already styled.
///
///
///
/// Written into the pane exactly where it arrives, which is where the gap is — the socket keeps order
/// within a session, so this frame lands between the bytes before the drop and the bytes after it.
///
///
/// Its own opcode rather than a few bytes spliced into an frame, which is
/// what it was first, and the reason is that the two are not the same kind of thing. Output is what the
/// remote said, and this is what this application says about it. Keeping them apart means the host's
/// own sentence is never mistaken for the transcript — by anything reading a frame, and in particular
/// by the arithmetic that has to hold if dropping is to stay honest: every byte read from the remote is
/// either in an payload or counted in TerminalSessionPump.BytesDropped, and
/// a notice inside the output stream would put this process's words on the left of that equation.
///
///
/// Not acknowledged, and so not part of the credit window: credit accounts for what the remote sent
/// and what the renderer has drawn of it. A notice is a few dozen bytes the host chose to add, and
/// billing them to a window that is meant to describe the remote would make the number mean two
/// things.
///
///
OutputDropped = 9,
}
/// 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 page asking for a different font size. Payload is one signed byte: a step, or zero to reset.
///
///
///
/// A request rather than a statement, and the direction matters. The page owns the keyboard whenever a
/// terminal has focus — the host's window sees no key events at all then — so Ctrl+plus and Ctrl+minus
/// can only be heard there. But the size has to be remembered across launches and shown in a
/// control the page knows nothing about, so it is decided here. The page asks, the host
/// clamps, stores and answers with .
///
///
/// Which also means one path, not two: the phone's buttons and the desktop's chords arrive at the same
/// place, and a size set by either is the size both remember.
///
///
FontSizeStep = 4,
///
/// Text to put on the system clipboard. Payload is UTF-8.
///
///
///
/// A right-click over a pane with something selected. What gets copied is the selection, and only the
/// page knows what that is — xterm draws it into a canvas and the host never sees a glyph of it — so it
/// is the text that travels here rather than a request for it.
///
///
/// Through the host rather than through the page's own navigator.clipboard. Writing the
/// clipboard from the page would work on Windows, where the renderer is Blink and
/// clipboard-write is granted to a focused document without asking. It is the reading
/// that cannot be done that way — see — and a clipboard that is the host's
/// in one direction and the page's in the other is two mechanisms to keep working across WebView2,
/// WebKitGTK and Android's WebView. The host has owned a clipboard delegate since the keychain grew its
/// COPY buttons; this is that one.
///
///
/// The session id is the pane the selection came from. Nothing routes on it — a clipboard is not a
/// property of one terminal — and it is carried because every frame carries one.
///
///
ClipboardCopy = 5,
///
/// The page asking for the clipboard's text, to paste into this session. No payload.
///
///
///
/// A right-click over a pane with nothing selected. The answer arrives as
/// , addressed to the session id this frame carried, so the
/// text lands in the pane the pointer was over rather than in whichever one the host believes is
/// selected. They agree in every arrangement that exists today, because only the active pane is
/// visible — but the page is the one that knows, so the page is the one that says.
///
///
/// A request rather than the page reading the clipboard itself, and this is the half with no
/// alternative. navigator.clipboard.readText() is gated behind the clipboard-read
/// permission in every Chromium, and an embedded WebView answers a permission request it has no handler
/// for by putting a prompt on the screen. Trading a context menu for a permission dialog is not the
/// change this was.
///
///
ClipboardPaste = 6,
}
///
/// 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;
}
/// Reads a payload.
///
/// Signed, because the one byte carries a direction as well as a magnitude — and zero means reset,
/// which is why this cannot be a flag pair.
///
public static bool TryReadFontSizeStep(ReadOnlySpan payload, out int step)
{
step = 0;
if (payload.Length != 1)
{
return false;
}
step = (sbyte)payload[0];
return true;
}
/// Writes a payload.
public static byte[] CreateFontSizeStepPayload(int step) => [(byte)(sbyte)Math.Clamp(step, -128, 127)];
/// Writes a payload.
public static byte[] CreateFontSizePayload(int pixels) => [(byte)Math.Clamp(pixels, 1, 255)];
/// Writes an payload.
public static byte[] CreateAcknowledgementPayload(uint rendered)
{
var payload = new byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32BigEndian(payload, rendered);
return payload;
}
}