diff --git a/mobile/src/transport/compressed-frame-inflate.ts b/mobile/src/transport/compressed-frame-inflate.ts new file mode 100644 index 00000000000..2287f82e7f7 --- /dev/null +++ b/mobile/src/transport/compressed-frame-inflate.ts @@ -0,0 +1,31 @@ +import { gunzipSync } from 'fflate' +import { readCompressedFrame } from '../../../src/shared/paired-e2ee-compressed-frame' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../src/shared/remote-runtime/remote-runtime-memory-limits' +import { pairedE2EENativeCodec } from './paired-e2ee-native-codec' + +export type InflatedFrame = { kind: 'text'; text: string } | { kind: 'binary'; bytes: Uint8Array } + +/** What a compressed frame carries, or null for any other binary frame; throws on a corrupt one. */ +export function inflateCompressedFrame(frame: Uint8Array): InflatedFrame | null { + const compressed = readCompressedFrame(frame) + if (!compressed) { + return null + } + const { payloadKind, inflatedByteLength, gzipped } = compressed + // Why: the desktop never sends a frame above its outbound cap, so a larger claim is a corrupt frame. + if (inflatedByteLength > REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES) { + throw new Error(`Compressed frame claims ${inflatedByteLength} bytes`) + } + const native = pairedE2EENativeCodec() + // One byte past the claim lets a member that inflates larger fail the length check, not overrun. + const bytes = native + ? native.gunzip(gzipped, inflatedByteLength + 1) + : gunzipSync(gzipped, { out: new Uint8Array(inflatedByteLength + 1) }) + if (bytes.byteLength !== inflatedByteLength) { + throw new Error('Compressed frame inflated to an unexpected length') + } + if (payloadKind === 'binary') { + return { kind: 'binary', bytes } + } + return { kind: 'text', text: native?.decodeUtf8(bytes) ?? new TextDecoder().decode(bytes) } +} diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts index 007c0a98424..4437ae5099f 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' import { setFlagsFromString } from 'node:v8' import { runInNewContext } from 'node:vm' +import { gzipSync } from 'node:zlib' import { encodeMobileE2EEV2Transcript, validateMobileE2EEV2Handshake, @@ -11,6 +12,7 @@ import { openMobileE2EEV2Frame, sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing' +import { encodeCompressedFrame } from '../../../src/shared/paired-e2ee-compressed-frame' vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length).fill(9) @@ -205,6 +207,32 @@ describe('mobile E2EE v2 physical channel', () => { expect(ctx.onError).not.toHaveBeenCalled() }) + it('delivers compressed frames as what they carry, in counter order with plain frames', async () => { + const ctx = setup(async (raw) => (raw instanceof Uint8Array ? raw : null)) + await authenticate(ctx) + ctx.events.length = 0 + + const reply = JSON.stringify({ id: 'rpc-1', ok: true, result: { note: 'é'.repeat(4096) } }) + const utf8 = Buffer.from(reply, 'utf8') + const text = encodeCompressedFrame({ + payloadKind: 'text', + inflatedByteLength: utf8.byteLength, + gzipped: gzipSync(utf8) + }) + const terminal = new Uint8Array(2048).fill(0x74) + const binary = encodeCompressedFrame({ + payloadKind: 'binary', + inflatedByteLength: terminal.byteLength, + gzipped: gzipSync(terminal) + }) + await ctx.channel.handleMessage(serverFrame(text, 'binary', 1n, ctx.schedule)) + await ctx.channel.handleMessage(serverFrame(binary, 'binary', 2n, ctx.schedule)) + await ctx.channel.handleMessage(serverFrame(new Uint8Array([7]), 'binary', 3n, ctx.schedule)) + + expect(ctx.events).toEqual([`text:${reply}`, 'binary:116', 'binary:7']) + expect(ctx.onError).not.toHaveBeenCalled() + }) + it('queues outbound text and binary in one counter order', async () => { const ctx = setup(async () => null) await authenticate(ctx) diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts index 0371cfe78a0..f9ef16def7c 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts @@ -8,6 +8,7 @@ import { } from '../../../src/shared/paired-e2ee-client-handshake' import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { WsInboundMessageQueue } from '../../../src/shared/ws-inbound-message-queue' +import { inflateCompressedFrame } from './compressed-frame-inflate' type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready' type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array } @@ -118,7 +119,12 @@ export class MobileE2EEV2PhysicalChannel { } else if (typeof plaintext === 'string') { this.args.onText(plaintext) } else { - this.args.onBinary(plaintext) + const inflated = inflateCompressedFrame(plaintext) + if (inflated?.kind === 'text') { + this.args.onText(inflated.text) + } else { + this.args.onBinary(inflated?.bytes ?? plaintext) + } } } diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 5bb7195022f..bda1b1a9566 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -23,6 +23,7 @@ import { import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-optional-model-capability' import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime/remote-runtime-client-capabilities' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { COMPRESSED_FRAMES_CLIENT_CAPABILITY } from '../../../src/shared/paired-e2ee-compressed-frame' export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([ AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY, @@ -50,7 +51,9 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie // Native chat offers no Stop for a turn the host restored but could not confirm. AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_IMAGE_CARDS_CLIENT_CAPABILITY + AGENT_SESSION_QUEUED_IMAGE_CARDS_CLIENT_CAPABILITY, + // Every socket this list is sent on reads frames through MobileE2EEV2PhysicalChannel, which inflates them. + COMPRESSED_FRAMES_CLIENT_CAPABILITY ]) export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = diff --git a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts index 82d5ab11626..1c0c9b5ae98 100644 --- a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts +++ b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts @@ -13,8 +13,12 @@ import { BrowserScreencastOpcode, encodeBrowserScreencastFrame } from '../../../shared/browser-screencast-protocol' +import { COMPRESSED_FRAMES_CLIENT_CAPABILITY } from '../../../shared/paired-e2ee-compressed-frame' +import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { + declareRelayedPhoneCapabilities, RelayedPhoneReplies, + relayedPhoneCapabilities, rewriteRelayedPhoneRequest, withRelayedLaunchChatDefault } from './relayed-phone-frames' @@ -57,6 +61,26 @@ describe('rewriteRelayedPhoneRequest', () => { }) }) +describe('relayed phone capabilities', () => { + // The relaying desktop rewrites stream ids and reply bookkeeping, which compressed frames would hide. + it('never offers a paired server compressed frames, at auth or on update', () => { + const phone = ['agent-session.turn-item.v1', COMPRESSED_FRAMES_CLIENT_CAPABILITY] + const update = JSON.stringify({ + id: 'r1', + method: 'runtime.clientCapabilities.update', + params: { clientCapabilities: phone } + }) + + expect(relayedPhoneCapabilities(phone)).toEqual([ + 'agent-session.turn-item.v1', + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY + ]) + expect(JSON.parse(declareRelayedPhoneCapabilities(update)).params.clientCapabilities).toEqual( + relayedPhoneCapabilities(phone) + ) + }) +}) + describe('RelayedPhoneReplies', () => { function replies(firstDesktopId = 100) { let next = firstDesktopId diff --git a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts index f1405988321..0b4e801bfa8 100644 --- a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts +++ b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts @@ -1,4 +1,5 @@ import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { COMPRESSED_FRAMES_CLIENT_CAPABILITY } from '../../../shared/paired-e2ee-compressed-frame' import { readTerminalStreamFrameId, writeTerminalStreamFrameId @@ -25,10 +26,14 @@ export function rewriteRelayedPhoneRequest( } // Servers expose client-placed tabs only with this capability; mobile scope still cannot host them. +// Withhold compressed frames: they would hide relayed replies and stream ids from the rewriting below. export function relayedPhoneCapabilities(capabilities: readonly string[]): readonly string[] { - return capabilities.includes(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY) - ? capabilities - : [...capabilities, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY] + const forwarded = capabilities.filter( + (capability) => capability !== COMPRESSED_FRAMES_CLIENT_CAPABILITY + ) + return forwarded.includes(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY) + ? forwarded + : [...forwarded, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY] } export function declareRelayedPhoneCapabilities(frame: string): string { diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index dc1ca50373e..f5a90222ca1 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -13,7 +13,11 @@ import { describeMobileE2EEAuthFailure, type DeviceKeyResolution } from './mobile-e2ee-auth-validation' -import { isMobileE2EEOutboundItemWithinLimit } from './mobile-e2ee-outbound-admission' +import { + isMobileE2EEOutboundItemWithinLimit, + trackMobileE2EEOutboundBudgetClose, + type MobileE2EEOutboundBudgetEmitter +} from './mobile-e2ee-outbound-admission' import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime/requests/remote-runtime-request-frames' import type { MobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' import { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-owner' @@ -24,12 +28,8 @@ import type { RpcBinarySendResult, RpcBinarySender } from './rpc-binary-sender' -import type { EventProps } from '../../../shared/telemetry-events' -import { track } from '../../telemetry/client' import type { DevicePrincipal } from '../device-principal' -type OutboundBudgetEmitter = EventProps<'remote_outbound_budget_close'>['emitter'] - const HANDSHAKE_TIMEOUT_MS = 10_000 const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 @@ -98,6 +98,7 @@ export class E2EEChannel { ws, () => Boolean(this.sharedKey), () => this.closeForOutboundBudget('queue'), + this.capabilitySource, options.outboundMemoryBudget ) @@ -310,12 +311,8 @@ export class E2EEChannel { } // Both budget closes kill the remote session: size identifies an oversized producer, queue a backed-up link. - private closeForOutboundBudget(emitter: OutboundBudgetEmitter): void { - try { - track('remote_outbound_budget_close', { emitter }) - } catch { - // Telemetry is best-effort; closing the unsafe socket remains authoritative. - } + private closeForOutboundBudget(emitter: MobileE2EEOutboundBudgetEmitter): void { + trackMobileE2EEOutboundBudgetClose(emitter) this.onError(1013, 'Outbound reply buffer overflow') } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts index 2b095b6716e..02fae5a70ec 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts @@ -37,7 +37,12 @@ export function installMultiplexFrameDelivery(state: TerminalMultiplexConnection typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : state.cursor++ let sent: RpcBinarySendResult try { - sent = sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload })) + sent = sendBinary( + encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }), + { + compressible: true + } + ) } catch { state.closeMultiplex() return false diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts index 8cf6dfeb654..d621183f964 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts @@ -83,7 +83,9 @@ export async function runTerminalBinarySubscription(args: TerminalSubscriptionAr if (closed || !sendBinary) { return } - sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) + sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload }), { + compressible: true + }) } outputBatcher = createTerminalOutputBatcher((data, meta) => { if (meta?.cwd !== undefined) { diff --git a/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts b/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts index bfe85f68cae..a24491381c3 100644 --- a/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts +++ b/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts @@ -14,6 +14,8 @@ import { type MobileE2EEOutboundSocketMemory } from './mobile-e2ee-outbound-memory-budget' import type { RpcBinarySendOptions } from './rpc-binary-sender' +import type { RuntimeClientCapabilities } from './runtime-client-capabilities' +import { compressMobileE2EEV2Item } from './mobile-e2ee-v2-compressed-frame' export class MobileE2EEDesktopOutboundOwner { private readonly memoryBudget: MobileE2EEOutboundMemoryBudget @@ -26,6 +28,7 @@ export class MobileE2EEDesktopOutboundOwner { private readonly ws: WebSocket, private readonly isLegacyKeyed: () => boolean, private readonly onOverflow: () => void, + private readonly clientCapabilities: RuntimeClientCapabilities, memoryBudget: MobileE2EEOutboundMemoryBudget = createMobileE2EEOutboundMemoryBudget() ) { this.memoryBudget = memoryBudget @@ -62,10 +65,11 @@ export class MobileE2EEDesktopOutboundOwner { } enqueueV2( - item: DesktopMobileE2EEV2OutboundItem, + plain: DesktopMobileE2EEV2OutboundItem, session: DesktopMobileE2EEV2Session, options?: RpcBinarySendOptions ): boolean | 'backlogged' { + const item = compressMobileE2EEV2Item(plain, this.clientCapabilities.current, options) if (!this.socketMemory) { this.onOverflow() return false diff --git a/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts b/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts index 8dc68369eaf..9c6cdba7c6b 100644 --- a/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts +++ b/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts @@ -11,6 +11,18 @@ import type { MobileE2EEOutboundMemoryBudget, MobileE2EEOutboundSocketMemory } from './mobile-e2ee-outbound-memory-budget' +import type { EventProps } from '../../../shared/telemetry-events' +import { track } from '../../telemetry/client' + +export type MobileE2EEOutboundBudgetEmitter = EventProps<'remote_outbound_budget_close'>['emitter'] + +export function trackMobileE2EEOutboundBudgetClose(emitter: MobileE2EEOutboundBudgetEmitter): void { + try { + track('remote_outbound_budget_close', { emitter }) + } catch { + // Telemetry is best-effort; closing the unsafe socket remains authoritative. + } +} export function mobileE2EETextPayloadAdmissionBytes(value: string): number { const bytes = Buffer.byteLength(value, 'utf8') diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.test.ts b/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.test.ts new file mode 100644 index 00000000000..8fb0f261fc8 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.test.ts @@ -0,0 +1,52 @@ +import { gunzipSync } from 'node:zlib' +import { describe, expect, it, vi } from 'vitest' +import { + COMPRESSED_FRAMES_CLIENT_CAPABILITY, + readCompressedFrame +} from '../../../shared/paired-e2ee-compressed-frame' +import { compressMobileE2EEV2Item } from './mobile-e2ee-v2-compressed-frame' + +const capable = [COMPRESSED_FRAMES_CLIENT_CAPABILITY] + +function inflate(item: ReturnType) { + const frame = item.kind === 'binary' ? readCompressedFrame(item.plaintext) : null + return frame && { payloadKind: frame.payloadKind, bytes: gunzipSync(frame.gzipped) } +} + +describe('compressMobileE2EEV2Item', () => { + it('sends large text to a capable phone as a compressed frame that inflates to the same text', () => { + const plaintext = JSON.stringify({ rows: Array.from({ length: 200 }, (_, i) => `ü-row-${i}`) }) + + const inflated = inflate(compressMobileE2EEV2Item({ kind: 'text', plaintext }, capable)) + + expect(inflated?.payloadKind).toBe('text') + expect(inflated?.bytes.toString('utf8')).toBe(plaintext) + }) + + it('compresses binary only when its sender marks it compressible', () => { + const terminal = { kind: 'binary' as const, plaintext: new Uint8Array(4096).fill(0x74) } + + expect(compressMobileE2EEV2Item(terminal, capable)).toBe(terminal) + const inflated = inflate(compressMobileE2EEV2Item(terminal, capable, { compressible: true })) + expect(inflated?.payloadKind).toBe('binary') + expect(new Uint8Array(inflated!.bytes)).toEqual(terminal.plaintext) + }) + + it('leaves frames alone for phones without the capability and for small frames', () => { + const large = { kind: 'text' as const, plaintext: 'x'.repeat(4096) } + const small = { kind: 'text' as const, plaintext: '{"id":"1"}' } + + expect(compressMobileE2EEV2Item(large, [])).toBe(large) + expect(compressMobileE2EEV2Item(small, capable)).toBe(small) + }) + + it('sends plain frames to every phone while the rollback switch is set', () => { + const large = { kind: 'text' as const, plaintext: 'x'.repeat(4096) } + vi.stubEnv('ORCA_DISABLE_MOBILE_COMPRESSED_FRAMES', '1') + try { + expect(compressMobileE2EEV2Item(large, capable)).toBe(large) + } finally { + vi.unstubAllEnvs() + } + }) +}) diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.ts b/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.ts new file mode 100644 index 00000000000..22b334968f0 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-compressed-frame.ts @@ -0,0 +1,42 @@ +import { gzipSync } from 'node:zlib' +import { + COMPRESSED_FRAMES_CLIENT_CAPABILITY, + COMPRESSED_FRAME_MIN_BYTES, + encodeCompressedFrame +} from '../../../shared/paired-e2ee-compressed-frame' +import type { RuntimeCapability } from '../../../shared/protocol-version' +import type { DesktopMobileE2EEV2OutboundItem } from './mobile-e2ee-v2-desktop-outbound' +import type { RpcBinarySendOptions } from './rpc-binary-sender' + +/** Gzips large text, and binary its sender marked compressible, for phones that read compressed frames. */ +export function compressMobileE2EEV2Item( + item: DesktopMobileE2EEV2OutboundItem, + clientCapabilities: readonly RuntimeCapability[], + options?: RpcBinarySendOptions +): DesktopMobileE2EEV2OutboundItem { + // Why: screencast JPEGs never shrink, so binary is gzipped only when its sender knows it is text-like. + const eligible = item.kind === 'text' || options?.compressible === true + if ( + !eligible || + // Field rollback without a release: phones then read plain frames, as from older hosts. + process.env.ORCA_DISABLE_MOBILE_COMPRESSED_FRAMES === '1' || + item.plaintext.length < COMPRESSED_FRAME_MIN_BYTES || + !clientCapabilities.includes(COMPRESSED_FRAMES_CLIENT_CAPABILITY) + ) { + return item + } + const payload = item.kind === 'text' ? Buffer.from(item.plaintext, 'utf8') : item.plaintext + const gzipped = gzipSync(payload) + // Why: already-dense payloads can grow under gzip; the plain frame then costs less. + if (gzipped.byteLength >= payload.byteLength) { + return item + } + return { + kind: 'binary', + plaintext: encodeCompressedFrame({ + payloadKind: item.kind, + inflatedByteLength: payload.byteLength, + gzipped + }) + } +} diff --git a/src/main/runtime/rpc/rpc-binary-sender.ts b/src/main/runtime/rpc/rpc-binary-sender.ts index 2a56ac5f28a..3f6e36c50aa 100644 --- a/src/main/runtime/rpc/rpc-binary-sender.ts +++ b/src/main/runtime/rpc/rpc-binary-sender.ts @@ -1,6 +1,8 @@ export type RpcBinarySendOptions = { // Why: a lossy stream resends its newest frame later, so queueing stale frames behind a backlog only delays it. dropWhenBacklogged?: boolean + // Text-like payloads (terminal output, snapshots) a phone that reads compressed frames receives gzipped. + compressible?: boolean } /** False: not sent or queued. 'backlogged': skipped under dropWhenBacklogged; the socket is alive. */ diff --git a/src/shared/paired-e2ee-compressed-frame.ts b/src/shared/paired-e2ee-compressed-frame.ts new file mode 100644 index 00000000000..cfb0e84e872 --- /dev/null +++ b/src/shared/paired-e2ee-compressed-frame.ts @@ -0,0 +1,55 @@ +/** + * A text or binary frame the desktop gzipped and sealed as a binary frame: a 6-byte header (kind, + * inflated payload kind, little-endian inflated byte length) and one gzip member. Binary carries it + * without base64's third, and the kind byte keeps it apart from terminal (0x74) and screencast frames. + */ +export const COMPRESSED_FRAMES_CLIENT_CAPABILITY = 'transport.compressed-frames.v1' as const + +const COMPRESSED_FRAME_KIND = 0x7a +const HEADER_BYTES = 6 +const PAYLOAD_KIND_BYTES = { text: 1, binary: 2 } as const + +export type CompressedFramePayloadKind = keyof typeof PAYLOAD_KIND_BYTES + +// Why: below this a gzip member's fixed overhead and the inflate call outweigh the saved bytes. +export const COMPRESSED_FRAME_MIN_BYTES = 1024 + +export function encodeCompressedFrame(args: { + payloadKind: CompressedFramePayloadKind + inflatedByteLength: number + gzipped: Uint8Array +}): Uint8Array { + const frame = new Uint8Array(HEADER_BYTES + args.gzipped.byteLength) + const view = new DataView(frame.buffer) + view.setUint8(0, COMPRESSED_FRAME_KIND) + view.setUint8(1, PAYLOAD_KIND_BYTES[args.payloadKind]) + view.setUint32(2, args.inflatedByteLength, true) + frame.set(args.gzipped, HEADER_BYTES) + return frame +} + +/** Null for any other binary frame, so callers pass it through unchanged. */ +export function readCompressedFrame(frame: Uint8Array): { + payloadKind: CompressedFramePayloadKind + inflatedByteLength: number + gzipped: Uint8Array +} | null { + if (frame.byteLength < HEADER_BYTES || frame[0] !== COMPRESSED_FRAME_KIND) { + return null + } + const payloadKind = + frame[1] === PAYLOAD_KIND_BYTES.text + ? 'text' + : frame[1] === PAYLOAD_KIND_BYTES.binary + ? 'binary' + : null + if (!payloadKind) { + return null + } + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength) + return { + payloadKind, + inflatedByteLength: view.getUint32(2, true), + gzipped: frame.subarray(HEADER_BYTES) + } +}