Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/publish-libraries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,16 @@ 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
- library: multi-video-player
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 }}
Expand Down
19 changes: 19 additions & 0 deletions webapp/packages/web-recorder/build.ps1
Original file line number Diff line number Diff line change
@@ -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
}
34 changes: 34 additions & 0 deletions webapp/packages/web-recorder/package.dist.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
23 changes: 23 additions & 0 deletions webapp/packages/web-recorder/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
94 changes: 94 additions & 0 deletions webapp/packages/web-recorder/src/ascast-v2-recorder.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
5 changes: 5 additions & 0 deletions webapp/packages/web-recorder/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
5 changes: 5 additions & 0 deletions webapp/packages/web-recorder/src/recordable-session.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading