159 lines
4.3 KiB
TypeScript
159 lines
4.3 KiB
TypeScript
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
|
|
|
|
const BROWSER_SCREENCAST_KIND = 0x62
|
|
const BROWSER_SCREENCAST_VERSION = 1
|
|
const HEADER_BYTES = 16
|
|
export const BROWSER_SCREENCAST_MAX_METADATA_BYTES = 64 * 1024
|
|
export const BROWSER_SCREENCAST_METADATA_JSON_STRUCTURE_LIMITS = {
|
|
structuralTokens: 512,
|
|
nestingDepth: 8
|
|
} as const
|
|
const METADATA_KEYS = [
|
|
'offsetTop',
|
|
'pageScaleFactor',
|
|
'deviceWidth',
|
|
'deviceHeight',
|
|
'imageWidth',
|
|
'imageHeight',
|
|
'scrollOffsetX',
|
|
'scrollOffsetY',
|
|
'timestamp'
|
|
] as const
|
|
|
|
export enum BrowserScreencastOpcode {
|
|
Frame = 1
|
|
}
|
|
|
|
export type BrowserScreencastFormat = 'jpeg' | 'png'
|
|
|
|
export type BrowserScreencastFrameMetadata = {
|
|
offsetTop?: number
|
|
pageScaleFactor?: number
|
|
deviceWidth?: number
|
|
deviceHeight?: number
|
|
imageWidth?: number
|
|
imageHeight?: number
|
|
scrollOffsetX?: number
|
|
scrollOffsetY?: number
|
|
timestamp?: number
|
|
}
|
|
|
|
export type BrowserScreencastFrame = {
|
|
opcode: BrowserScreencastOpcode.Frame
|
|
seq: number
|
|
format: BrowserScreencastFormat
|
|
metadata: BrowserScreencastFrameMetadata
|
|
image: Uint8Array
|
|
}
|
|
|
|
function formatToByte(format: BrowserScreencastFormat): number {
|
|
return format === 'png' ? 2 : 1
|
|
}
|
|
|
|
function byteToFormat(value: number): BrowserScreencastFormat | null {
|
|
if (value === 1) {
|
|
return 'jpeg'
|
|
}
|
|
if (value === 2) {
|
|
return 'png'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function encodeJson(value: unknown): Uint8Array {
|
|
return new TextEncoder().encode(JSON.stringify(value))
|
|
}
|
|
|
|
function decodeJson(bytes: Uint8Array): unknown {
|
|
if (bytes.byteLength > BROWSER_SCREENCAST_MAX_METADATA_BYTES) {
|
|
return null
|
|
}
|
|
try {
|
|
const content = new TextDecoder().decode(bytes)
|
|
assertJsonTextStructureWithinLimits(content, BROWSER_SCREENCAST_METADATA_JSON_STRUCTURE_LIMITS)
|
|
return JSON.parse(content) as unknown
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isFiniteNumber(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value)
|
|
}
|
|
|
|
function decodeFrameMetadata(bytes: Uint8Array): BrowserScreencastFrameMetadata | null {
|
|
const raw = decodeJson(bytes)
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
return null
|
|
}
|
|
const input = raw as Record<string, unknown>
|
|
const metadata: BrowserScreencastFrameMetadata = {}
|
|
for (const key of METADATA_KEYS) {
|
|
const value = input[key]
|
|
if (isFiniteNumber(value)) {
|
|
metadata[key] = value
|
|
}
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
export function encodeBrowserScreencastFrame(frame: BrowserScreencastFrame): Uint8Array {
|
|
const metadata = encodeJson(frame.metadata)
|
|
const out = new Uint8Array(HEADER_BYTES + metadata.byteLength + frame.image.byteLength)
|
|
const view = new DataView(out.buffer, out.byteOffset, out.byteLength)
|
|
view.setUint8(0, BROWSER_SCREENCAST_KIND)
|
|
view.setUint8(1, BROWSER_SCREENCAST_VERSION)
|
|
view.setUint8(2, frame.opcode)
|
|
view.setUint8(3, formatToByte(frame.format))
|
|
view.setUint32(4, Math.max(0, Math.floor(frame.seq)) >>> 0, true)
|
|
view.setUint32(8, metadata.byteLength, true)
|
|
view.setUint32(12, 0, true)
|
|
out.set(metadata, HEADER_BYTES)
|
|
out.set(frame.image, HEADER_BYTES + metadata.byteLength)
|
|
return out
|
|
}
|
|
|
|
export function decodeBrowserScreencastFrame(bytes: Uint8Array): BrowserScreencastFrame | null {
|
|
if (bytes.byteLength < HEADER_BYTES) {
|
|
return null
|
|
}
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
if (
|
|
view.getUint8(0) !== BROWSER_SCREENCAST_KIND ||
|
|
view.getUint8(1) !== BROWSER_SCREENCAST_VERSION
|
|
) {
|
|
return null
|
|
}
|
|
if (view.getUint8(2) !== BrowserScreencastOpcode.Frame) {
|
|
return null
|
|
}
|
|
const format = byteToFormat(view.getUint8(3))
|
|
if (!format) {
|
|
return null
|
|
}
|
|
const seq = view.getUint32(4, true)
|
|
const metadataLength = view.getUint32(8, true)
|
|
if (metadataLength > BROWSER_SCREENCAST_MAX_METADATA_BYTES) {
|
|
return null
|
|
}
|
|
if (view.getUint32(12, true) !== 0) {
|
|
return null
|
|
}
|
|
const payloadStart = HEADER_BYTES
|
|
const imageStart = payloadStart + metadataLength
|
|
if (imageStart > bytes.byteLength) {
|
|
return null
|
|
}
|
|
const metadata = decodeFrameMetadata(bytes.subarray(payloadStart, imageStart))
|
|
if (!metadata) {
|
|
return null
|
|
}
|
|
return {
|
|
opcode: BrowserScreencastOpcode.Frame,
|
|
seq,
|
|
format,
|
|
metadata,
|
|
image: bytes.subarray(imageStart)
|
|
}
|
|
}
|