diff --git a/.github/workflows/publish-libraries.yml b/.github/workflows/publish-libraries.yml index 519d8da24..4a2d4a53a 100644 --- a/.github/workflows/publish-libraries.yml +++ b/.github/workflows/publish-libraries.yml @@ -95,7 +95,7 @@ jobs: strategy: fail-fast: false matrix: - library: [ ts-angular-client, multi-video-player, shadow-player ] + library: [ ts-angular-client, multi-video-player, shadow-player, web-recorder ] include: - library: ts-angular-client libpath: ./devolutions-gateway/openapi/ts-angular-client @@ -103,6 +103,8 @@ jobs: libpath: ./webapp/packages/multi-video-player - library: shadow-player libpath: ./webapp/packages/shadow-player + - library: web-recorder + libpath: ./webapp/packages/web-recorder steps: - name: Check out ${{ github.repository }} diff --git a/webapp/packages/web-recorder/build.ps1 b/webapp/packages/web-recorder/build.ps1 new file mode 100644 index 000000000..b88b2a8fc --- /dev/null +++ b/webapp/packages/web-recorder/build.ps1 @@ -0,0 +1,19 @@ +#!/usr/bin/env pwsh + +$ErrorActionPreference = "Stop" + +Push-Location -Path $PSScriptRoot + +try +{ + pnpm install + + pnpm --filter @devolutions/web-recorder... build + + Set-Location -Path ./dist/ + npm pack +} +finally +{ + Pop-Location +} diff --git a/webapp/packages/web-recorder/package.dist.json b/webapp/packages/web-recorder/package.dist.json new file mode 100644 index 000000000..d04f281d4 --- /dev/null +++ b/webapp/packages/web-recorder/package.dist.json @@ -0,0 +1,34 @@ +{ + "name": "@devolutions/web-recorder", + "version": "0.1.0", + "description": "Framework-agnostic browser session-recording capture (WebM video + asciicast terminal) for Devolutions Gateway jrec push", + "type": "module", + "main": "./index.js", + "module": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.js", + "types": "./index.d.ts" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/Devolutions/devolutions-gateway.git" + }, + "keywords": [ + "recording", + "session-recording", + "webm", + "asciicast", + "capture", + "media-recorder" + ], + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/webapp/packages/web-recorder/package.json b/webapp/packages/web-recorder/package.json new file mode 100644 index 000000000..423df5645 --- /dev/null +++ b/webapp/packages/web-recorder/package.json @@ -0,0 +1,23 @@ +{ + "name": "@devolutions/web-recorder", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "devDependencies": { + "rxjs": "^7.8.1", + "typescript": "~5.6.2", + "vite": "^5.4.9", + "vite-plugin-dts": "^4.3.0", + "vite-plugin-static-copy": "^2.3.0" + } +} diff --git a/webapp/packages/web-recorder/src/ascast-v2-recorder.ts b/webapp/packages/web-recorder/src/ascast-v2-recorder.ts new file mode 100644 index 000000000..374b98f47 --- /dev/null +++ b/webapp/packages/web-recorder/src/ascast-v2-recorder.ts @@ -0,0 +1,94 @@ +export interface AssciiCastV2Header { + version: 2; + width: number; + height: number; + timestamp: number; + env: {[key: string]: string}; +} + +export type AsciiCastV2EventCode = 'o' | 'i' | 'r'; + +export type AsciinCastV2Event = [number, 'o' | 'i' | 'r', string]; +export type AsciiCastV2Event = AssciiCastV2Header | AsciinCastV2Event; + +export class AsciiCastV2Recorder { + private startTime = 0; + private websocket: QueuedWebsocket | null = null; + + constructor( + private initConfig: { + wsUrl: URL; + cols: number; + rows: number; + env: {[key: string]: string}; + terminal: { + onServerOutput: (callback: (data: string) => void) => void; + }; + }, + ) {} + + public start() { + const {wsUrl, cols, rows, env, terminal} = this.initConfig; + wsUrl.searchParams.set('fileType', 'asciicast'); + this.websocket = new QueuedWebsocket(wsUrl.toString()); + this.startTime = Date.now(); + this.send({ + version: 2, + timestamp: this.startTime, + width: cols, + height: rows, + env, + }); + + terminal.onServerOutput(data => { + // Convert \n to \r\n for proper terminal recording + const normalizedData = data.replace(/\r?\n/g, '\r\n'); + this.onEvent('o', normalizedData); + }); + } + + public stop() { + this.websocket?.close(); + } + + private onEvent(eventCode: AsciiCastV2EventCode, data: string) { + const date = (Date.now() - this.startTime) / 1000; + this.send([date, eventCode, data]); + } + + private send(data: AsciiCastV2Event | AssciiCastV2Header) { + this.websocket?.send(JSON.stringify(data) + '\n'); + } +} + +class QueuedWebsocket { + private ws: WebSocket; + private queue: string[] = []; + private ready = false; + + constructor(url: string) { + this.ws = new WebSocket(url); + this.ws.onopen = () => { + this.ready = true; + for (const data of this.queue) { + this.ws.send(data); + } + this.queue = []; + }; + } + + public send(data: string) { + if (this.ready) { + this.ws.send(data); + } else { + this.queue.push(data); + } + } + + public close() { + // Only close if not already closing or closed + if (this.ws.readyState !== WebSocket.CLOSING && this.ws.readyState !== WebSocket.CLOSED) { + this.ws.close(); + } + } +} diff --git a/webapp/packages/web-recorder/src/index.ts b/webapp/packages/web-recorder/src/index.ts new file mode 100644 index 000000000..3ab11b15f --- /dev/null +++ b/webapp/packages/web-recorder/src/index.ts @@ -0,0 +1,5 @@ +export {WebMRecorder} from './webm-recorder'; +export type {WebMRecorderOptions, WebMRecorderTelemetryEvent} from './webm-recorder'; +export {AsciiCastV2Recorder} from './ascast-v2-recorder'; +export type {AssciiCastV2Header, AsciiCastV2Event, AsciinCastV2Event, AsciiCastV2EventCode} from './ascast-v2-recorder'; +export type {IRecordableSession} from './recordable-session'; diff --git a/webapp/packages/web-recorder/src/recordable-session.ts b/webapp/packages/web-recorder/src/recordable-session.ts new file mode 100644 index 000000000..eb6fbc2b8 --- /dev/null +++ b/webapp/packages/web-recorder/src/recordable-session.ts @@ -0,0 +1,5 @@ +// The minimal contract a session must satisfy to be recorded. The recorder itself only needs to +// know a recording was requested; richer per-app session shapes (DVLS/Hub) structurally satisfy this. +export interface IRecordableSession { + shouldStartRecording: boolean; +} diff --git a/webapp/packages/web-recorder/src/webm-recorder.ts b/webapp/packages/web-recorder/src/webm-recorder.ts new file mode 100644 index 000000000..2583eec3e --- /dev/null +++ b/webapp/packages/web-recorder/src/webm-recorder.ts @@ -0,0 +1,237 @@ +import {Observable, Subject} from 'rxjs'; + +const CanvasStreamFPS = 8; +const MediaRecorderRecordInterval = 10; + +// Telemetry is reported through an optional injected hook so the library stays framework-agnostic; +// consumers (DVLS/Hub) map these events onto their own telemetry stack. +export type WebMRecorderTelemetryEvent = 'recording-initialized' | 'recording-stopped'; + +export interface WebMRecorderOptions { + onTelemetry?: (event: WebMRecorderTelemetryEvent) => void; +} + +export class WebMRecorder { + private mediaRecorder: MediaRecorder | null = null; + private ws: WebSocket | null = null; + private subject = new Subject(); + private _isRecording = false; + private stream: MediaStream | null = null; + private canvas: HTMLCanvasElement | null = null; + private keepaliveRaf: number | null = null; + private keepaliveTick = false; + private isCleaningUp = false; + + private blobQueue: Blob[] = []; + + constructor(private readonly options: WebMRecorderOptions = {}) {} + + get isRecording() { + return this._isRecording; + } + + // Combined method for backward compatibility + start(canvas: HTMLCanvasElement, recordingUrl: string): Observable { + // Prevent starting multiple recordings simultaneously + if (this._isRecording || this.isCleaningUp) { + console.warn('[WebMRecorder] Recording already in progress or cleaning up'); + return this.subject.asObservable(); + } + + // Create new Subject for each start cycle since completed Subjects cannot emit + this.subject = new Subject(); + this.canvas = canvas; + if (!this.initializeCapture(canvas)) { + console.error('Failed to initialize capture. Aborting recording.'); + throw new Error('UnableToStartRecording'); + } + return this.startStreaming(recordingUrl); + } + + stop(): void { + if (this.isCleaningUp) { + return; // Prevent circular cleanup calls + } + this.isCleaningUp = true; + + if (this._isRecording && this.mediaRecorder) { + if (this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + } + } + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.close(); + } + + this.cleanupResources(); + this.subject.complete(); + this.isCleaningUp = false; // Reset only after all cleanup is done + + this.options.onTelemetry?.('recording-stopped'); + } + + // Initialize canvas capture stream + private initializeCapture(canvas: HTMLCanvasElement): boolean { + if (!canvas) { + console.error('IronRDP canvas not found'); + return false; + } + + this.options.onTelemetry?.('recording-initialized'); + try { + // Automatic capture, throttled to CanvasStreamFPS: the browser emits a frame whenever the canvas + // is modified. (Manual mode — captureStream(0) + requestFrame() — does NOT feed MediaRecorder + // reliably; it produces zero frames in Chromium, so we keep the canvas "dirty" instead.) + this.stream = canvas.captureStream(CanvasStreamFPS); + return true; + } catch (error) { + console.error('Failed to initialize canvas capture:', error); + return false; + } + } + + // Start streaming to WebSocket + private startStreaming(recordingUrl: string): Observable { + if (!this.stream) { + console.error('No capture stream initialized'); + this.subject.error('lblCantViewRecording'); + this.subject.complete(); + return this.subject.asObservable(); + } + + this.initializeWebSocket(recordingUrl); + return this.subject.asObservable(); + } + + private initializeWebSocket(recordingUrl: string): void { + this.ws = new WebSocket(recordingUrl + '&fileType=webm'); + this.ws.onopen = this.handleWebSocketOpen.bind(this); + this.ws.onerror = this.handleWebSocketError.bind(this); + this.ws.onclose = this.handleWebSocketClose.bind(this); + } + + private handleWebSocketOpen(): void { + if (!this.stream) { + this.subject.error('lblCantViewRecording'); + this.subject.complete(); + return; + } + + const recorder = new MediaRecorder(this.stream, {mimeType: 'video/webm'}); + this.mediaRecorder = recorder; + + recorder.onstart = this.handleMediaRecorderStart.bind(this); + recorder.ondataavailable = this.handleMediaRecorderDataAvailable.bind(this); + recorder.onstop = this.handleMediaRecorderStop.bind(this); + recorder.onerror = this.handleMediaRecorderError.bind(this); + + recorder.start(MediaRecorderRecordInterval); + + // Flush any queued blobs now that WebSocket is open + if (this.blobQueue.length > 0) { + for (const blob of this.blobQueue) { + this.ws?.send(blob); + } + this.blobQueue.length = 0; + } + } + + private handleWebSocketClose(): void { + if (!this._isRecording) { + this.subject.error('UnableToConnectToTheRecordingServer'); + } + this.subject.complete(); + } + + private handleWebSocketError(event: Event): void { + console.error('[WebMRecorder] WebSocket error:', event); + this.subject.error('ConnectionToTheRecordingServerLost'); + this.subject.complete(); + this.stop(); // Safe to call - stop() guards against circular calls + } + + // captureStream only captures a frame when the canvas is *composited*, and compositing is driven by + // the requestAnimationFrame / vsync loop — NOT by setInterval. With static content (no app animation) + // a setInterval keepalive marks the canvas dirty but it is never presented, so zero frames are + // captured (verified empirically: setInterval -> 0 frames, rAF -> frames). Drive the keepalive from + // rAF so the compositor ticks, and make a real change each frame so a fresh frame is always ready. + private handleMediaRecorderStart(): void { + const tick = (): void => { + this.nudgeCanvas(); + this.keepaliveRaf = requestAnimationFrame(tick); + }; + this.keepaliveRaf = requestAnimationFrame(tick); + } + + // Nudge a single corner pixel with a *real* value change. A zero-alpha / no-op draw is elided by + // some engines' dirty-tracking (Edge 149 → empty WebM), so we alternate the pixel value. captureStream + // throttles the actual capture to CanvasStreamFPS regardless of the rAF rate. save()/restore() keeps + // this from leaking state onto the canvas's shared 2D context. + private nudgeCanvas(): void { + const ctx = this.canvas?.getContext('2d'); + if (!ctx) { + return; + } + ctx.save(); + ctx.globalAlpha = 1; + ctx.fillStyle = this.keepaliveTick ? '#000000' : '#000001'; + ctx.fillRect(0, 0, 1, 1); + ctx.restore(); + this.keepaliveTick = !this.keepaliveTick; + } + + private handleMediaRecorderDataAvailable(event: BlobEvent): void { + if (!event.data || event.data.size === 0) return; + + if (!this._isRecording) { + this._isRecording = true; + this.subject.next(); + } + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(event.data); + } else { + console.warn('[WebMRecorder] WebSocket not ready, buffering data.'); + this.blobQueue.push(event.data); + } + } + + private handleMediaRecorderStop(): void { + if (!this._isRecording) return; + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.close(); + } + + this.cleanupResources(); + this.subject.complete(); + } + + private handleMediaRecorderError(error: Event): void { + console.error('[WebMRecorder] MediaRecorder encountered an error:', error); + this.subject.error('UnableToStartRecording'); + this.subject.complete(); + this.stop(); // Safe to call - stop() guards against circular calls + } + + private cleanupResources(): void { + this._isRecording = false; + + if (this.keepaliveRaf !== null) { + cancelAnimationFrame(this.keepaliveRaf); + this.keepaliveRaf = null; + } + + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + this.stream = null; + } + + this.canvas = null; + this.mediaRecorder = null; + this.ws = null; + this.blobQueue.length = 0; + // isCleaningUp flag is reset in stop() method to prevent race conditions + } +} diff --git a/webapp/packages/web-recorder/tsconfig.json b/webapp/packages/web-recorder/tsconfig.json new file mode 100644 index 000000000..f68fe7e5b --- /dev/null +++ b/webapp/packages/web-recorder/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "declaration": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/webapp/packages/web-recorder/vite.config.ts b/webapp/packages/web-recorder/vite.config.ts new file mode 100644 index 000000000..a10885683 --- /dev/null +++ b/webapp/packages/web-recorder/vite.config.ts @@ -0,0 +1,78 @@ +import path from 'node:path'; +import {UserConfig, defineConfig} from 'vite'; +import dts from 'vite-plugin-dts'; +import {viteStaticCopy} from 'vite-plugin-static-copy'; + +// Simple deep merge function +function deepMerge(target: Partial, source: T): T { + for (const key in source) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + target[key] = deepMerge(target[key] || {}, source[key]); + } else { + target[key] = source[key]; + } + } + return target as T; +} + +const DefaultConfig: UserConfig = { + build: { + lib: { + entry: path.resolve(__dirname, 'src/index.ts'), + name: 'WebRecorder', + fileName: 'index', + formats: ['es'], + }, + rollupOptions: { + // rxjs is a peer dependency provided by the consuming app; do not bundle it. + external: ['rxjs'], + output: { + globals: {}, + }, + }, + }, +}; + +const OutDir = { + debug: 'dist', + release: 'dist', +}; + +const staticCopyPlugin = viteStaticCopy({ + targets: [ + { + src: './package.dist.json', + dest: './', + rename: 'package.json', + }, + ], +}); + +const Plugins = { + debug: [ + dts({ + rollupTypes: true, + }), + staticCopyPlugin, + ], + release: [ + dts({ + rollupTypes: true, + }), + staticCopyPlugin, + ], +}; + +export default defineConfig(({mode}) => { + const isDebug = mode === 'debug'; + console.log(`Building in mode ${mode}`); + + const config: UserConfig = deepMerge({}, DefaultConfig); + config.build = { + ...config.build, + outDir: isDebug ? OutDir.debug : OutDir.release, + }; + config.plugins = isDebug ? Plugins.debug : Plugins.release; + + return config; +}); diff --git a/webapp/pnpm-lock.yaml b/webapp/pnpm-lock.yaml index c75145d60..c312e62b1 100644 --- a/webapp/pnpm-lock.yaml +++ b/webapp/pnpm-lock.yaml @@ -257,6 +257,24 @@ importers: specifier: ^2.3.0 version: 2.3.2(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + packages/web-recorder: + devDependencies: + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vite: + specifier: ^5.4.9 + version: 5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + vite-plugin-dts: + specifier: ^4.3.0 + version: 4.5.4(@types/node@22.19.3)(rollup@4.59.0)(typescript@5.6.3)(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + vite-plugin-static-copy: + specifier: ^2.3.0 + version: 2.3.2(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + tools/recording-player-tester: dependencies: '@tailwindcss/vite':