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