| 1 | import type { ExecutionEnvironmentPlatformOs } from "@t3tools/contracts"; |
| 2 | |
| 3 | export type PendingModifier = "ctrl" | "meta"; |
| 4 | export type HostPlatform = "mac" | "linux" | "windows" | "unknown"; |
| 5 | |
| 6 | /** Upper bound of `TerminalWriteInput.data`; longer writes are rejected by the server. */ |
| 7 | export const TERMINAL_WRITE_MAX_LENGTH = 65_536; |
| 8 | |
| 9 | export type ModifiedTerminalInput = |
| 10 | | { readonly kind: "write"; readonly data: string } |
| 11 | | { readonly kind: "paste" }; |
| 12 | |
| 13 | // C0 controls other than tab, LF, and CR, plus DEL. |
| 14 | // eslint-disable-next-line no-control-regex -- Pasted text must not carry raw terminal controls. |
| 15 | const UNSAFE_PASTE_BYTES = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; |
| 16 | |
| 17 | /** |
| 18 | * Encodes a key pressed while the toolbar's one-shot ctrl modifier is armed |
| 19 | * into the control byte a terminal expects. |
| 20 | */ |
| 21 | export function applyCtrlModifier(input: string): string { |
| 22 | const firstCharacter = input[0]; |
| 23 | if (!firstCharacter) { |
| 24 | return input; |
| 25 | } |
| 26 | |
| 27 | const lowerCharacter = firstCharacter.toLowerCase(); |
| 28 | if (lowerCharacter >= "a" && lowerCharacter <= "z") { |
| 29 | return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); |
| 30 | } |
| 31 | |
| 32 | if (firstCharacter === "@") return "\u0000"; |
| 33 | if (firstCharacter === "[") return "\u001b"; |
| 34 | if (firstCharacter === "\\") return "\u001c"; |
| 35 | if (firstCharacter === "]") return "\u001d"; |
| 36 | if (firstCharacter === "^") return "\u001e"; |
| 37 | if (firstCharacter === "_") return "\u001f"; |
| 38 | if (firstCharacter === "?") return "\u007f"; |
| 39 | |
| 40 | return input; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Resolves what a keypress means once a toolbar modifier is armed. The host's |
| 45 | * paste chord (cmd+v on a macOS host, ctrl+v elsewhere) pastes the device |
| 46 | * clipboard instead of reaching the remote shell as a raw control byte, which |
| 47 | * matches what the web terminal does with the same chord. Forwarding the byte |
| 48 | * is never what a phone user means: PowerShell binds ctrl+v to paste from the |
| 49 | * host machine's clipboard, so the shell inserts whatever the desktop last |
| 50 | * copied rather than the text on the phone. |
| 51 | */ |
| 52 | export function resolveModifiedTerminalInput(input: { |
| 53 | readonly data: string; |
| 54 | readonly modifier: PendingModifier; |
| 55 | readonly hostPlatform: HostPlatform; |
| 56 | }): ModifiedTerminalInput { |
| 57 | const pasteModifier: PendingModifier = input.hostPlatform === "mac" ? "meta" : "ctrl"; |
| 58 | if (input.modifier === pasteModifier && input.data.toLowerCase() === "v") { |
| 59 | return { kind: "paste" }; |
| 60 | } |
| 61 | |
| 62 | return { |
| 63 | kind: "write", |
| 64 | data: input.modifier === "ctrl" ? applyCtrlModifier(input.data) : `\u001b${input.data}`, |
| 65 | }; |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * Encodes clipboard text for the remote pty the way the web terminal does when |
| 70 | * bracketed paste is off: unsafe control bytes become spaces (which also |
| 71 | * defuses an embedded bracketed-paste end marker, since its ESC goes too) and |
| 72 | * line breaks become carriage returns, since a bare LF is Ctrl+J to a raw-mode |
| 73 | * TUI. The native mobile surface does not expose DECSET 2004, so mobile never |
| 74 | * wraps a paste in bracketed-paste markers. |
| 75 | */ |
| 76 | export function encodeTerminalPaste(text: string): string { |
| 77 | return text.replace(UNSAFE_PASTE_BYTES, " ").replace(/\r\n|\n/g, "\r"); |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Splits terminal input into writes the wire contract accepts, never cutting |
| 82 | * through a surrogate pair so every chunk stays valid UTF-16. |
| 83 | */ |
| 84 | export function chunkTerminalWrite(data: string): ReadonlyArray<string> { |
| 85 | const chunks: string[] = []; |
| 86 | let start = 0; |
| 87 | while (start < data.length) { |
| 88 | let end = Math.min(start + TERMINAL_WRITE_MAX_LENGTH, data.length); |
| 89 | const last = data.charCodeAt(end - 1); |
| 90 | if (end < data.length && last >= 0xd800 && last <= 0xdbff) { |
| 91 | end -= 1; |
| 92 | } |
| 93 | chunks.push(data.slice(start, end)); |
| 94 | start = end; |
| 95 | } |
| 96 | return chunks; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Maps the OS reported by the environment descriptor onto the toolbar's host |
| 101 | * layout. Returns null for "unknown" so callers can fall back to a weaker signal. |
| 102 | */ |
| 103 | export function hostPlatformFromOs(os: ExecutionEnvironmentPlatformOs | null): HostPlatform | null { |
| 104 | if (os === "darwin") return "mac"; |
| 105 | if (os === "linux") return "linux"; |
| 106 | if (os === "windows") return "windows"; |
| 107 | return null; |
| 108 | } |