Improve mobile terminal streaming performance

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-12 17:25:20 -04:00
co-authored by Orca
parent 3bfc871f6a
commit eaca343735
18 changed files with 869 additions and 131 deletions
+4
View File
@@ -52,6 +52,10 @@ export type RpcContext = {
// server reap all subscriptions for a closing socket, even when other
// sockets for the same deviceToken stay alive (multi-screen mobile).
connectionId?: string
// Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming
// responses after the binary terminal cutover. Undefined on Unix/socket
// transports and non-E2EE WebSocket paths.
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
}
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown
+8 -3
View File
@@ -77,7 +77,7 @@ export class RpcDispatcher {
async dispatchStreaming(
request: RpcRequest,
reply: (response: string) => void,
options?: { connectionId?: string }
options?: { connectionId?: string; sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void }
): Promise<void> {
const meta = this.meta()
const method = this.registry.get(request.method)
@@ -100,7 +100,8 @@ export class RpcDispatcher {
try {
const result = await method.handler(parsedParams.value, {
runtime: this.runtime,
connectionId: options?.connectionId
connectionId: options?.connectionId,
sendBinary: options?.sendBinary
})
reply(JSON.stringify(successResponse(request.id, meta, result)))
} catch (error) {
@@ -118,7 +119,11 @@ export class RpcDispatcher {
try {
await method.handler(
parsedParams.value,
{ runtime: this.runtime, connectionId: options?.connectionId },
{
runtime: this.runtime,
connectionId: options?.connectionId,
sendBinary: options?.sendBinary
},
emit
)
} catch (error) {
+39 -9
View File
@@ -2,7 +2,7 @@
// It owns the handshake state machine and transparent encrypt/decrypt so the RPC
// handler only sees plaintext JSON, identical to the Unix socket path.
import type { WebSocket } from 'ws'
import { deriveSharedKey, encrypt, decrypt } from './e2ee-crypto'
import { deriveSharedKey, encrypt, decrypt, encryptBytes, decryptBytes } from './e2ee-crypto'
type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready'
@@ -40,7 +40,11 @@ export class E2EEChannel {
// can forward decrypted messages. Kept as a callback rather than constructor
// param because the handler needs the encrypt function for replies.
private messageHandler:
| ((plaintext: string, encryptedReply: (response: string) => void) => void)
| ((
plaintext: string,
encryptedReply: (response: string) => void,
encryptedBinaryReply: (response: Uint8Array<ArrayBufferLike>) => void
) => void)
| null = null
deviceToken: string | null = null
@@ -58,13 +62,21 @@ export class E2EEChannel {
}
onMessage(
handler: (plaintext: string, encryptedReply: (response: string) => void) => void
handler: (
plaintext: string,
encryptedReply: (response: string) => void,
encryptedBinaryReply: (response: Uint8Array<ArrayBufferLike>) => void
) => void
): void {
this.messageHandler = handler
}
handleRawMessage(raw: string): void {
handleRawMessage(raw: string | Uint8Array<ArrayBufferLike>): void {
if (this.state === 'awaiting_hello') {
if (typeof raw !== 'string') {
this.onError(4001, 'Invalid handshake message')
return
}
this.handleHello(raw)
return
}
@@ -73,12 +85,17 @@ export class E2EEChannel {
return
}
if (typeof raw !== 'string') {
const plaintextBytes = decryptBytes(raw, this.sharedKey)
if (plaintextBytes === null) {
this.trackDecryptFailure()
}
return
}
const plaintext = decrypt(raw, this.sharedKey)
if (plaintext === null) {
this.consecutiveFailures++
if (this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) {
this.onError(4003, 'Too many decryption failures')
}
this.trackDecryptFailure()
return
}
@@ -100,7 +117,20 @@ export class E2EEChannel {
}
this.ws.send(encrypt(response, this.sharedKey))
}
this.messageHandler?.(plaintext, encryptedReply)
const encryptedBinaryReply = (response: Uint8Array<ArrayBufferLike>) => {
if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) {
return
}
this.ws.send(Buffer.from(encryptBytes(response, this.sharedKey)), { binary: true })
}
this.messageHandler?.(plaintext, encryptedReply, encryptedBinaryReply)
}
private trackDecryptFailure(): void {
this.consecutiveFailures++
if (this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) {
this.onError(4003, 'Too many decryption failures')
}
}
private handleHello(raw: string): void {
+21 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'
import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto'
import {
generateKeyPair,
deriveSharedKey,
encrypt,
decrypt,
encryptBytes,
decryptBytes
} from './e2ee-crypto'
describe('e2ee-crypto', () => {
it('encrypt/decrypt round-trips with shared key', () => {
@@ -76,4 +83,17 @@ describe('e2ee-crypto', () => {
expect(decrypt(tampered, shared)).toBeNull()
})
it('encrypt/decrypt round-trips raw bytes for binary terminal frames', () => {
const server = generateKeyPair()
const client = generateKeyPair()
const serverShared = deriveSharedKey(server.secretKey, client.publicKey)
const clientShared = deriveSharedKey(client.secretKey, server.publicKey)
const payload = new Uint8Array([0, 1, 2, 127, 128, 255])
const encrypted = encryptBytes(payload, clientShared)
const decrypted = decryptBytes(encrypted, serverShared)
expect(decrypted).toEqual(payload)
})
})
+20 -8
View File
@@ -1,6 +1,6 @@
// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide
// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext], encoded
// as base64 for transmission over WebSocket text frames.
// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext]. JSON
// RPC uses base64 text frames; terminal streams use the raw byte bundle.
import nacl from 'tweetnacl'
export function generateKeyPair(): nacl.BoxKeyPair {
@@ -12,19 +12,31 @@ export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Ar
}
export function encrypt(plaintext: string, sharedKey: Uint8Array): string {
const nonce = nacl.randomBytes(nacl.box.nonceLength)
const messageBytes = new TextEncoder().encode(plaintext)
const ciphertext = nacl.box.after(messageBytes, nonce, sharedKey)
return Buffer.from(encryptBytes(messageBytes, sharedKey)).toString('base64')
}
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
const plaintext = decryptBytes(bundle, sharedKey)
return plaintext ? new TextDecoder().decode(plaintext) : null
}
export function encryptBytes(
plaintext: Uint8Array<ArrayBufferLike>,
sharedKey: Uint8Array
): Uint8Array {
const nonce = nacl.randomBytes(nacl.box.nonceLength)
const ciphertext = nacl.box.after(plaintext, nonce, sharedKey)
const bundle = new Uint8Array(nonce.length + ciphertext.length)
bundle.set(nonce)
bundle.set(ciphertext, nonce.length)
return Buffer.from(bundle).toString('base64')
return bundle
}
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
export function decryptBytes(bundle: Uint8Array, sharedKey: Uint8Array): Uint8Array | null {
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) {
return null
}
@@ -37,5 +49,5 @@ export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null
return null
}
return new TextDecoder().decode(plaintext)
return plaintext
}
+236 -47
View File
@@ -2,6 +2,13 @@
import { z } from 'zod'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import type { OrcaRuntimeService } from '../../orca-runtime'
import {
TerminalStreamOpcode,
encodeTerminalStreamFrame,
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../../shared/terminal-stream-protocol'
// Why: when a mobile client subscribes the server resizes the PTY to phone
// dims and serializes the buffer. Sending only the visible screen meant
@@ -10,6 +17,87 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
// agent runs (Claude Code chats, command output) reachable. The mobile
// WebView's xterm has a 5000-row buffer so this fits comfortably.
const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000
const MOBILE_SNAPSHOT_BYTE_BUDGET = 512 * 1024
const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024
let nextTerminalStreamId = 1
type SnapshotFrameOptions = {
kind: 'scrollback' | 'resized'
cols: number
rows: number
data: string
displayMode?: string
reason?: string
seq?: number
truncated?: boolean
truncatedByByteBudget?: boolean
}
type SerializedSnapshot = {
data: string
cols: number
rows: number
scrollbackRows: number
truncatedByByteBudget: boolean
} | null
function sendSnapshotFrames(
sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array<ArrayBufferLike>) => void,
options: SnapshotFrameOptions
): { bytes: number; chunks: number } {
sendFrame(
TerminalStreamOpcode.SnapshotStart,
encodeTerminalStreamJson({
kind: options.kind,
cols: options.cols,
rows: options.rows,
displayMode: options.displayMode,
reason: options.reason,
seq: options.seq,
truncated: options.truncated === true,
truncatedByByteBudget: options.truncatedByByteBudget === true
})
)
const bytes = encodeTerminalStreamText(options.data)
let chunks = 0
for (let offset = 0; offset < bytes.length; offset += TERMINAL_STREAM_CHUNK_BYTES) {
chunks++
sendFrame(
TerminalStreamOpcode.SnapshotChunk,
bytes.slice(offset, offset + TERMINAL_STREAM_CHUNK_BYTES)
)
}
sendFrame(TerminalStreamOpcode.SnapshotEnd)
return { bytes: bytes.byteLength, chunks }
}
async function serializeBudgetedMobileSnapshot(
runtime: OrcaRuntimeService,
ptyId: string,
isMobile: boolean
): Promise<SerializedSnapshot> {
if (!isMobile) {
const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 })
return serialized ? { ...serialized, scrollbackRows: 0, truncatedByByteBudget: false } : null
}
const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0]
for (const rows of candidates) {
const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows })
if (!serialized) {
return null
}
const bytes = new TextEncoder().encode(serialized.data).byteLength
if (bytes <= MOBILE_SNAPSHOT_BYTE_BUDGET || rows === 0) {
return {
...serialized,
scrollbackRows: rows,
truncatedByByteBudget:
rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || bytes > MOBILE_SNAPSHOT_BYTE_BUDGET
}
}
}
return null
}
const TerminalHandle = z.object({
terminal: requiredString('Missing terminal handle')
@@ -127,6 +215,11 @@ const TerminalSubscribe = TerminalHandle.extend({
cols: z.number().int().min(20).max(240),
rows: z.number().int().min(8).max(120)
})
.optional(),
capabilities: z
.object({
terminalBinaryStream: z.literal(1).optional()
})
.optional()
})
@@ -384,9 +477,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
defineStreamingMethod({
name: 'terminal.subscribe',
params: TerminalSubscribe,
handler: async (params, { runtime }, emit) => {
handler: async (params, { runtime, connectionId, sendBinary }, emit) => {
let leaf = runtime.resolveLeafForHandle(params.terminal)
const isMobile = params.client?.type === 'mobile'
const useBinaryStream = isMobile && params.capabilities?.terminalBinaryStream === 1
// Why: the left pane's PTY spawns asynchronously after the tab is created.
// Mobile clients that subscribe before the PTY is ready would get a bare
@@ -403,30 +497,99 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (!leaf?.ptyId) {
const read = await runtime.readTerminal(params.terminal)
emit({
type: 'scrollback',
lines: read.tail,
truncated: read.truncated,
serialized: undefined,
cols: undefined,
rows: undefined
})
emit({ type: 'subscribed', streamId: null, lines: read.tail, truncated: read.truncated })
emit({ type: 'end' })
return
}
if (isMobile && (!useBinaryStream || !sendBinary)) {
throw new Error('binary_terminal_stream_required')
}
const ptyId = leaf.ptyId
const clientId = params.client?.id
if (!isMobile) {
const read = await runtime.readTerminal(params.terminal)
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false)
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
const seq = runtime.getLayout(ptyId)?.seq
emit({
type: 'scrollback',
lines: read.tail,
truncated: read.truncated,
serialized: serialized?.data,
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode,
seq
})
await new Promise<void>((resolve) => {
const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
emit({ type: 'data', chunk: data })
})
const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
emit({
type: 'fit-override-changed',
mode: event.mode,
cols: event.cols,
rows: event.rows
})
})
runtime.registerSubscriptionCleanup(
params.terminal,
() => {
unsubscribeData()
unsubscribeFit()
emit({ type: 'end' })
resolve()
},
connectionId
)
})
return
}
const streamId = nextTerminalStreamId++
let cursor = 0
let closed = false
let buffering = true
const pendingOutput: string[] = []
const sendFrame = (
opcode: TerminalStreamOpcode,
payload: Uint8Array<ArrayBufferLike> = new Uint8Array()
): void => {
if (closed || !sendBinary) {
return
}
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload }))
}
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
if (isMobile && clientId) {
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
}
const read = await runtime.readTerminal(params.terminal)
const serialized = await runtime.serializeTerminalBuffer(ptyId, {
scrollbackRows: isMobile ? MOBILE_SUBSCRIBE_SCROLLBACK_ROWS : 0
const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
if (closed) {
return
}
if (buffering) {
pendingOutput.push(data)
return
}
sendBinary!(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: cursor++,
payload: encodeTerminalStreamText(data)
})
)
})
const read = await runtime.readTerminal(params.terminal)
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true)
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
// Why: emit the current layout seq with the initial scrollback so
@@ -435,40 +598,61 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// See docs/mobile-terminal-layout-state-machine.md.
const seq = runtime.getLayout(ptyId)?.seq
emit({
type: 'scrollback',
type: 'subscribed',
streamId,
lines: read.tail,
truncated: read.truncated,
serialized: serialized?.data,
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode,
seq
})
const snapshotStats = sendSnapshotFrames(sendFrame, {
kind: 'scrollback',
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
displayMode,
seq,
truncated: read.truncated,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
data: serialized?.data ?? ''
})
console.log('[mobile-terminal-stream] snapshot', {
terminal: params.terminal,
streamId,
kind: 'scrollback',
bytes: snapshotStats.bytes,
chunks: snapshotStats.chunks,
scrollbackRows: serialized?.scrollbackRows,
truncatedByByteBudget: serialized?.truncatedByByteBudget === true
})
buffering = false
for (const item of pendingOutput.splice(0)) {
sendBinary!(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: cursor++,
payload: encodeTerminalStreamText(item)
})
)
}
await new Promise<void>((resolve) => {
const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
emit({ type: 'data', chunk: data })
})
// Inline resize events replace the old fit-override-changed event for
// mobile clients. They include fresh serialized scrollback so the client
// can reinitialize xterm without resubscribing.
const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, async (event) => {
// Why: mobile subscriptions need the same scrollback on inline
// resize as on initial subscribe — without it, toggling phone/desktop
// mode or the keyboard-driven refit would silently wipe history.
const fresh = await runtime.serializeTerminalBuffer(ptyId, {
scrollbackRows: isMobile ? MOBILE_SUBSCRIBE_SCROLLBACK_ROWS : 0
})
emit({
type: 'resized',
cols: event.cols,
rows: event.rows,
serialized: fresh?.data,
displayMode: event.displayMode,
reason: event.reason,
seq: event.seq
})
const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
// Why: true PTY geometry changes should be followed by the TUI's
// redraw output, not a full scrollback replay. The client resizes
// xterm geometry and consumes subsequent live output on this stream.
sendFrame(
TerminalStreamOpcode.Resized,
encodeTerminalStreamJson({
cols: event.cols,
rows: event.rows,
displayMode: event.displayMode,
reason: event.reason,
seq: event.seq
})
)
})
// Legacy fit-override-changed for non-mobile (desktop) subscribers
@@ -488,16 +672,21 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// evict each other via registerSubscriptionCleanup's
// duplicate-key cleanup. See docs/mobile-presence-lock.md.
const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal
runtime.registerSubscriptionCleanup(subscriptionId, () => {
unsubscribeData()
unsubscribeResize()
unsubscribeFit()
if (isMobile && clientId) {
runtime.handleMobileUnsubscribe(ptyId, clientId)
}
emit({ type: 'end' })
resolve()
})
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
closed = true
unsubscribeData()
unsubscribeResize()
unsubscribeFit()
if (isMobile && clientId) {
runtime.handleMobileUnsubscribe(ptyId, clientId)
}
emit({ type: 'end' })
resolve()
},
connectionId
)
})
}
}),
+17 -8
View File
@@ -10,6 +10,14 @@ import type { RpcTransport } from './transport'
const MAX_WS_MESSAGE_BYTES = 1024 * 1024
const MAX_WS_CONNECTIONS = 32
type WebSocketMessagePayload = string | Uint8Array<ArrayBufferLike>
type WebSocketMessageHandler = {
bivarianceHack(
msg: WebSocketMessagePayload,
reply: (response: string) => void,
ws: WebSocket
): void
}['bivarianceHack']
// Why: mobile clients (iOS/Android) regularly background-suspend their
// sockets without the OS sending a TCP FIN/RST, leaving the server with
@@ -45,9 +53,7 @@ export class WebSocketTransport implements RpcTransport {
// sweep. A socket missing from the set when the next sweep fires is
// assumed dead and terminated.
private wsAlive = new WeakSet<WebSocket>()
private messageHandler:
| ((msg: string, reply: (response: string) => void, ws: WebSocket) => void)
| null = null
private messageHandler: WebSocketMessageHandler | null = null
private connectionCloseHandler:
| ((clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void)
| null = null
@@ -63,9 +69,7 @@ export class WebSocketTransport implements RpcTransport {
this.heartbeatIntervalMs = heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS
}
onMessage(
handler: (msg: string, reply: (response: string) => void, ws: WebSocket) => void
): void {
onMessage(handler: WebSocketMessageHandler): void {
this.messageHandler = handler
}
@@ -240,13 +244,18 @@ export class WebSocketTransport implements RpcTransport {
this.wsAlive.add(ws)
})
ws.on('message', (data) => {
ws.on('message', (data, isBinary) => {
// Why: any inbound traffic counts as proof of life, not just pongs.
// RN's WebSocket runtime auto-pongs server pings transparently, but
// app-level frames also count toward liveness so an actively-talking
// client doesn't get terminated mid-request.
this.wsAlive.add(ws)
const msg = typeof data === 'string' ? data : data.toString('utf-8')
const msg =
typeof data === 'string'
? data
: isBinary
? new Uint8Array(data as Buffer)
: data.toString()
this.messageHandler?.(
msg,
(response) => {
+10 -3
View File
@@ -250,8 +250,14 @@ export class OrcaRuntimeRpcServer {
ws.close(code, reason)
}
})
channel.onMessage((plaintext, encryptedReply) => {
void this.handleWebSocketMessage(plaintext, encryptedReply, wsTransport, ws)
channel.onMessage((plaintext, encryptedReply, encryptedBinaryReply) => {
void this.handleWebSocketMessage(
plaintext,
encryptedReply,
encryptedBinaryReply,
wsTransport,
ws
)
})
this.e2eeChannels.set(ws, channel)
}
@@ -411,6 +417,7 @@ export class OrcaRuntimeRpcServer {
private async handleWebSocketMessage(
rawMessage: string,
reply: (response: string) => void,
sendBinary: (response: Uint8Array<ArrayBufferLike>) => void,
wsTransport?: WebSocketTransport,
ws?: WebSocket
): Promise<void> {
@@ -451,7 +458,7 @@ export class OrcaRuntimeRpcServer {
}
const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined
await this.dispatcher.dispatchStreaming(request, reply, { connectionId })
await this.dispatcher.dispatchStreaming(request, reply, { connectionId, sendBinary })
}
private buildError(id: string, code: string, message: string): RpcResponse {
+17
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { evaluateCompat } from './protocol-compat'
import { DESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION } from './protocol-version'
const MOBILE_V = 1
@@ -104,4 +105,20 @@ describe('evaluateCompat', () => {
).toEqual({ kind: 'ok' })
}
})
it('hard-blocks protocol-1 mobile for the binary terminal stream cutover', () => {
const verdict = evaluateCompat({
mobileProtocolVersion: 1,
minCompatibleDesktopVersion: DESKTOP_PROTOCOL_VERSION,
desktopProtocolVersion: DESKTOP_PROTOCOL_VERSION,
desktopMinCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION
})
expect(verdict).toEqual({
kind: 'blocked',
reason: 'mobile-too-old',
desktopVersion: DESKTOP_PROTOCOL_VERSION,
requiredMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION
})
})
})
+2 -2
View File
@@ -19,5 +19,5 @@
// "kill switch": desktop can refuse old mobile builds without needing
// a desktop release of mobile.
export const DESKTOP_PROTOCOL_VERSION = 1
export const MIN_COMPATIBLE_MOBILE_VERSION = 0
export const DESKTOP_PROTOCOL_VERSION = 2
export const MIN_COMPATIBLE_MOBILE_VERSION = 2
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamJson,
decodeTerminalStreamText,
encodeTerminalStreamFrame,
encodeTerminalStreamJson,
encodeTerminalStreamText
} from './terminal-stream-protocol'
describe('terminal-stream-protocol', () => {
it('round-trips fixed-width binary frame headers and payloads', () => {
const payload = encodeTerminalStreamText('hello terminal')
const encoded = encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId: 42,
seq: 9,
payload
})
const decoded = decodeTerminalStreamFrame(encoded)
expect(decoded?.opcode).toBe(TerminalStreamOpcode.Output)
expect(decoded?.streamId).toBe(42)
expect(decoded?.seq).toBe(9)
expect(decoded ? decodeTerminalStreamText(decoded.payload) : '').toBe('hello terminal')
})
it('round-trips snapshot metadata JSON payloads', () => {
const encoded = encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotStart,
streamId: 7,
seq: 1,
payload: encodeTerminalStreamJson({ kind: 'scrollback', cols: 49, rows: 28 })
})
const decoded = decodeTerminalStreamFrame(encoded)
expect(decoded && decodeTerminalStreamJson(decoded.payload)).toEqual({
kind: 'scrollback',
cols: 49,
rows: 28
})
})
it('rejects unknown frame versions and opcodes', () => {
const encoded = encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId: 1,
seq: 1,
payload: new Uint8Array()
})
const badVersion = encoded.slice()
badVersion[1] = 99
expect(decodeTerminalStreamFrame(badVersion)).toBeNull()
const badOpcode = encoded.slice()
badOpcode[2] = 99
expect(decodeTerminalStreamFrame(badOpcode)).toBeNull()
})
})
+87
View File
@@ -0,0 +1,87 @@
const TERMINAL_STREAM_KIND = 0x74
const TERMINAL_STREAM_VERSION = 1
const HEADER_BYTES = 16
export enum TerminalStreamOpcode {
Output = 1,
SnapshotStart = 2,
SnapshotChunk = 3,
SnapshotEnd = 4,
Resized = 5,
Error = 6
}
export type TerminalStreamFrame = {
opcode: TerminalStreamOpcode
streamId: number
seq: number
payload: Uint8Array
}
export function encodeTerminalStreamFrame(frame: TerminalStreamFrame): Uint8Array {
const out = new Uint8Array(HEADER_BYTES + frame.payload.length)
const view = new DataView(out.buffer, out.byteOffset, out.byteLength)
view.setUint8(0, TERMINAL_STREAM_KIND)
view.setUint8(1, TERMINAL_STREAM_VERSION)
view.setUint8(2, frame.opcode)
view.setUint8(3, 0)
view.setUint32(4, frame.streamId, true)
const seq = Math.max(0, Math.floor(frame.seq))
view.setUint32(8, Math.floor(seq / 0x100000000), true)
view.setUint32(12, seq >>> 0, true)
out.set(frame.payload, HEADER_BYTES)
return out
}
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
if (bytes.length < HEADER_BYTES) {
return null
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
if (view.getUint8(0) !== TERMINAL_STREAM_KIND || view.getUint8(1) !== TERMINAL_STREAM_VERSION) {
return null
}
const opcode = view.getUint8(2)
if (!isTerminalStreamOpcode(opcode)) {
return null
}
const high = view.getUint32(8, true)
const low = view.getUint32(12, true)
return {
opcode,
streamId: view.getUint32(4, true),
seq: high * 0x100000000 + low,
payload: bytes.slice(HEADER_BYTES)
}
}
export function encodeTerminalStreamJson(value: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(value))
}
export function decodeTerminalStreamJson<T>(payload: Uint8Array): T | null {
try {
return JSON.parse(new TextDecoder().decode(payload)) as T
} catch {
return null
}
}
export function encodeTerminalStreamText(value: string): Uint8Array {
return new TextEncoder().encode(value)
}
export function decodeTerminalStreamText(payload: Uint8Array): string {
return new TextDecoder().decode(payload)
}
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
return (
value === TerminalStreamOpcode.Output ||
value === TerminalStreamOpcode.SnapshotStart ||
value === TerminalStreamOpcode.SnapshotChunk ||
value === TerminalStreamOpcode.SnapshotEnd ||
value === TerminalStreamOpcode.Resized ||
value === TerminalStreamOpcode.Error
)
}