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; } }