Browser-based Remote Desktop client using vanilla JavaScript frontend and a Python WebSocket proxy with native FreeRDP3 integration.
It is a works for me project, but looking forward to any contributors.
┌───────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────┐ ┌────────────┐ ┌─────────────────┐ │ WebSocket ┌─────────────────┐ RDP/GFX ┌─────────┐
│ │ Main Thread │ │ GFX Worker │ │ AudioWorklet │ │ ◄─────────────────► │ Python Proxy │ ◄────────────► │ Windows │
│ │ (WS, Opus │ │ (Offscreen │ │ (Low-latency │ │ Wire Format Proto │ (Native FreeRDP)│ AVC444/AVC420 │ OS │
│ │ Decoder) │ │ Canvas) │ │ Ring Buffer) │ │ H264+SURF+TILE+... │ │ │ │
│ └─────────────┘ └────────────┘ └─────────────────┘ │ └─────────────────┘ └─────────┘
└───────────────────────────────────────────────────────┘
- Frontend: Vanilla JavaScript SPA with GFX Worker (OffscreenCanvas) served by nginx (port 8000)
- Backend: Python WebSocket server with native C library for FreeRDP3 integration (port 8765)
- Native Bridge: C library (
librdp_bridge.so) for direct RDP connection with GFX event streaming - Wire Protocol: Binary message format with 4-byte ASCII magic headers for efficient frame transmission
- 🎬 RDP GFX pipeline with H.264/AVC444 - Hardware-accelerated video streaming
- 🔄 AVC444 → 4:2:0 transcoding - Server-side FFmpeg conversion for browser compatibility
- 🖼️ Off-main-thread rendering - GFX Worker with OffscreenCanvas for smooth 60fps
- 🧩 Wire format protocol - Binary messages with typed headers (SURF, TILE, H264, etc.)
- 🎯 Client-side GFX compositor - Surface management, tile decoding, frame composition
- 🧮 Progressive codec WASM decoder - RFX Progressive tiles decoded in WebAssembly (pthreads)
- 🎨 ClearCodec WASM decoder - Clear codec tiles decoded in WebAssembly
- 🔊 Low-latency audio with AudioWorklet + SharedArrayBuffer ring buffer (~5-20ms latency)
- 🎵 Native audio streaming with Opus encoding (per-session isolation)
- ⌨️ Full keyboard support with scan code translation
- ⌨️ Virtual on-screen keyboard - Touch-friendly US layout with modifier support
- 🖱️ Mouse support (move, click, drag, wheel - horizontal & vertical)
- 🖱️ Server cursor support - Remote cursor updates with custom bitmaps and hotspots
- 📺 Fullscreen mode with dynamic resolution
- 📸 Screenshot capture - Save the current remote desktop view as an image
- 🤖 JS-Driven automation - Programmatic keyboard and mouse control with screenshot functionality for RPA and testing
- 🎨 Customizable theming - Built-in presets (dark, light, midnight, high-contrast) and shadow dom for robustness
- 📊 Latency monitoring (ping/pong)
- 🩺 Health check endpoint (
/health) - 🐳 Docker support with multi-stage builds
- 👥 Multi-user support (isolated RDP sessions per WebSocket connection)
- Clipboard support (copy/paste)
- File transfer support
- Python 3.x with
websocketsfor async WebSocket server - Native C library built with FreeRDP3 SDK (compiled from source with H.264 support)
- RDPGFX channel for H.264/AVC444 video (MS-RDPEGFX protocol)
- GFX Event Queue - Thread-safe queue streaming raw GFX events to frontend
- FFmpeg (libavcodec) for AVC444 → 4:2:0 transcoding
- RDPSND bridge plugin for direct audio capture (no PulseAudio or Alsa required)
- libopus for Opus audio encoding (64kbps, 20ms frames)
- Ubuntu 24.04 base image
Note: Ubuntu's FreeRDP3 package is compiled without H.264 support. The Docker build compiles FreeRDP3 from source with
-DWITH_FFMPEG=ONto enable H.264/AVC444 codec negotiation.
- Vanilla JavaScript (no frameworks, ES modules)
- GFX Worker - Web Worker for off-main-thread rendering
- OffscreenCanvas - Hardware-accelerated canvas in worker context (REQUIRED)
- WebCodecs VideoDecoder for H.264 decoding in worker (hardware accelerated)
- WebCodecs AudioDecoder for Opus decoding (main thread)
- AudioWorklet - Low-latency audio playback with SharedArrayBuffer ring buffer
- Wire Format Parser - Binary protocol with 4-byte ASCII magic headers
- nginx:alpine for static file serving
- Chrome 94+ or Edge 94+ or Safari 26+ or Firefox 130+
- OffscreenCanvas support (REQUIRED - no fallback)
- WebCodecs support (REQUIRED - no fallback)
- SharedArrayBuffer support (REQUIRED - no fallback)
- AudioWorklet support (REQUIRED - no fallback)
Note: SharedArrayBuffer requires CORS isolation headers (
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp). These are already configured in the includednginx.conf.
The easiest way to run the application is using Docker Compose:
# Build and start both services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down- Frontend: http://localhost:8000
- Backend WebSocket: ws://localhost:8765
- Health Check: http://localhost:8765/health
The backend requires building the native C library against FreeRDP3. This is best done inside Docker.
cd backend
docker build -t rdp-backend .
docker run --rm -it -p 8765:8765 rdp-backendcd frontend
docker build -t rdp-frontend .
docker run --rm -it -p 8000:8000 rdp-frontend
# Then open http://localhost:8000The RDP client is available as a reusable ES module with Shadow DOM isolation, making it easy to integrate into any web application.
Generate the WASM decoder and javascript files by running the included PowerShell script:
# the below script will build all files and but it into the 'wasmbuild' folder
justbuildwasm.ps1
# copy the contents of the 'wasmbuild' folder to your web server's static file directory
cp -r wasmbuild/* /path/to/your/webserver/The frontend requires cross-origin isolation headers for SharedArrayBuffer support (used by WASM pthreads). Your web server must include these headers:
# Required for SharedArrayBuffer (WASM pthreads)
# These headers enable cross-origin isolation
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;Without these headers, the progressive codec WASM decoder will not function. The included nginx.conf already has these configured.
import { RDPClient } from './rdp-client.js';const client = new RDPClient(document.getElementById('container'), {
wsUrl: 'ws://localhost:8765',
showTopBar: true, // Show/hide the connection toolbar
showBottomBar: true // Show/hide the status bar
});await client.connect({
host: '192.168.1.100',
port: 3389,
user: 'Administrator',
pass: 'password'
});| Option | Type | Default | Description |
|---|---|---|---|
wsUrl |
string | 'ws://localhost:8765' |
WebSocket server URL |
showTopBar |
boolean | true |
Show/hide the top toolbar |
showBottomBar |
boolean | true |
Show/hide the bottom status bar |
reconnectDelay |
number | 3000 |
Reconnection delay in milliseconds |
mouseThrottleMs |
number | 16 |
Mouse move event throttle (~60fps) |
resizeDebounceMs |
number | 2000 |
Resize debounce delay |
keepConnectionModalOpen |
boolean | false |
Keep connection modal open when not connected |
loadingSpinnerOpensModal |
boolean | true |
Clicking on the loading area opens the connection modal |
minWidth |
number | 0 |
Minimum canvas width in pixels (0 = no minimum, scrollbar appears if container is smaller) |
minHeight |
number | 0 |
Minimum canvas height in pixels (0 = no minimum, scrollbar appears if container is smaller) |
theme |
object | null |
Theme configuration (see Theming section) |
securityPolicy |
object | null |
Security policy for connection restrictions (see Security Policy section) |
visibleTopBarButtons |
object | { connect: true, disconnect: true, screenshot: true, fullscreen: true } |
Control visibility of top bar buttons |
additionalTopBarButtons |
array | [] |
Custom buttons (max 4) added before built-in controls |
| Property | Type | Default | Description |
|---|---|---|---|
connect |
boolean | true |
Show/hide the Connect button |
disconnect |
boolean | true |
Show/hide the Disconnect button |
screenshot |
boolean | true |
Show/hide the Screenshot button (📷) |
fullscreen |
boolean | true |
Show/hide the Fullscreen button (⛶) |
// Example: Hide screenshot and fullscreen buttons
const client = new RDPClient(container, {
wsUrl: 'ws://localhost:8765',
visibleTopBarButtons: {
connect: true,
disconnect: true,
screenshot: false,
fullscreen: false
}
});Add up to 10 custom buttons to the top bar. Buttons are inserted before the built-in controls (Connect, Disconnect, etc.).
| Property | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Button label text (also used as tooltip) |
click |
function | Yes | Callback function invoked when button is clicked |
Security Note: Click callbacks are isolated and cannot access RDPClient internals. The callback receives a simple object { buttonName: string } as its only argument.
// Example: Add custom buttons (remove Connect button and add Reconnect button)
const client = new RDPClient(container, {
wsUrl: 'ws://localhost:8765',
visibleTopBarButtons: {
connect: false
},
loadingSpinnerOpensModal: false,
additionalTopBarButtons: [
{
name: 'Reconnect',
click: async () => {
// disconnect if connected
if (client.isConnected()) {
await client.disconnect();
}
// then connect
await client.connect({
host: '1.2.3.4',
port: 3389,
user: 'user1',
pass: 'passwd'
});
}
},
]
});The RDP client supports a security policy to restrict which destinations users can connect to. This is useful for enterprise deployments where you want to limit connections to approved hosts only.
The security policy is immutable by design - it uses JavaScript private class fields and deep freezing to prevent tampering after initialization.
Want to learn more? See the complete Security Policy Guide for detailed explanations, examples, and best practices.
const client = new RDPClient(container, {
wsUrl: 'ws://localhost:8765',
securityPolicy: {
allowedHostnames: ['*.internal.corp', 'rdp.example.com'],
allowedIpv4Cidrs: ['10.0.0.0/8', '192.168.1.0/24'],
allowedDestinationRegex: ['^prod-server-\\d+:3389$']
}
});| Property | Type | Description |
|---|---|---|
allowedHostnames |
string[] |
Glob patterns for allowed hostnames (IPs are ignored) |
allowedIpv4Cidrs |
string[] |
CIDR ranges for allowed IPv4 addresses (non-IPs are ignored) |
allowedDestinationRegex |
string[] |
Regex patterns matched against host:port strings (does not matter if ip or hostname) |
Note: If no security policy is provided, or if all arrays are empty, all connections are allowed. When at least one rule is defined, only destinations matching at least one rule will be permitted.
The backend primarily loads security policies from a JSON file (see Security Policy Guide). As a fallback when no file-based policy exists, the backend can load policies from environment variables:
export SECURITY_ALLOWED_HOSTNAMES="*.internal.mycompany.com, *.prod.mycompany.com"
export SECURITY_ALLOWED_IPV4_CIDRS="10.0.0.0/8, 172.16.0.0/12"
export SECURITY_ALLOWED_DEST_REGEX="^jumpbox\\.dmz\\.mycompany\\.com:3389$"
export SECURITY_ALLOWED_DEST_REGEX_1="^backup\\.corp:3389$"
⚠️ Not Recommended: Environment variable configuration is provided only as a fallback. The file-based approach is highly recommended for production deployments. See CREATING-SECURITY-POLICY.md for details.
The allowedHostnames option supports full glob pattern matching:
| Pattern | Description | Example Matches |
|---|---|---|
* |
Matches any sequence of characters | *.example.com matches server.example.com, db.example.com |
? |
Matches exactly one character | web?.prod matches web1.prod, webA.prod |
[abc] |
Matches any character in the set | server[123].corp matches server1.corp, server2.corp |
[!abc] |
Matches any character NOT in the set | db[!0].prod matches db1.prod, dbA.prod but not db0.prod |
Hostname matching is case-insensitive.
securityPolicy: {
allowedHostnames: [
'*.internal.corp', // All subdomains of internal.corp
'rdp-server-??.prod', // rdp-server-01.prod through rdp-server-99.prod
'web[123].example.com', // web1, web2, or web3.example.com
'dev-*', // Anything starting with "dev-"
'exact-host.mycompany.com', // Exact match only
]
}The allowedIpv4Cidrs option accepts standard CIDR notation:
securityPolicy: {
allowedIpv4Cidrs: [
'10.0.0.0/8', // All 10.x.x.x addresses (Class A private)
'172.16.0.0/12', // 172.16.x.x - 172.31.x.x (Class B private)
'192.168.1.0/24', // 192.168.1.0 - 192.168.1.255
'192.168.100.50/32', // Single IP: 192.168.100.50 only
]
}| CIDR | Range | Host Count |
|---|---|---|
/32 |
Single IP | 1 |
/24 |
x.x.x.0 - x.x.x.255 | 256 |
/16 |
x.x.0.0 - x.x.255.255 | 65,536 |
/8 |
x.0.0.0 - x.255.255.255 | 16,777,216 |
The allowedDestinationRegex option matches against the full host:port string, allowing complex rules that consider both hostname/IP and port:
securityPolicy: {
allowedDestinationRegex: [
'^.*:3389$', // Any host, but only port 3389
'^prod-server-\\d+:3389$', // prod-server-01:3389, prod-server-99:3389, etc.
'^192\\.168\\.1\\.\\d+:33[89]\\d$', // 192.168.1.x on ports 3380-3399
'^(web|app|db)\\.corp:3389$', // web.corp, app.corp, or db.corp on 3389
]
}Note: Regex patterns are applied to both hostnames and IP addresses. Remember to escape special regex characters like
.and\.
When multiple rule types are defined, a connection is allowed if it matches any rule:
securityPolicy: {
// Connection allowed if it matches ANY of these:
allowedHostnames: ['*.internal.corp'], // OR any internal.corp subdomain
allowedIpv4Cidrs: ['10.0.0.0/8'], // OR any 10.x.x.x IP
allowedDestinationRegex: ['^backup-server:3390$'] // OR backup-server on port 3390
}You can check if a destination would be allowed before attempting to connect:
// Check a destination
const result = client.validateDestination('192.168.1.50', 3389);
if (result.allowed) {
console.log('Connection would be allowed');
} else {
console.log('Blocked:', result.reason);
}
// Get the current policy (read-only, frozen)
const policy = client.getSecurityPolicy();
console.log(policy.allowedHostnames); // Cannot be modifiedconst client = new RDPClient(container, {
wsUrl: 'wss://rdp-proxy.mycompany.com',
keepConnectionModalOpen: true,
securityPolicy: {
// Allow corporate hostnames
allowedHostnames: [
'*.internal.mycompany.com',
'*.prod.mycompany.com',
'*.dev.mycompany.com',
],
// Allow corporate IP ranges
allowedIpv4Cidrs: [
'10.0.0.0/8', // Corporate network
'172.16.0.0/12', // VPN ranges
],
// Special cases with port restrictions
allowedDestinationRegex: [
'^jumpbox\\.dmz\\.mycompany\\.com:3389$',
]
},
theme: { preset: 'light' }
});
// User attempts to connect - automatically validated
client.connect({ host: '10.50.100.25', user: 'admin', pass: 'secret' })
.then(() => console.log('Connected!'))
.catch(err => console.error('Blocked:', err.message));The RDP client supports comprehensive theming through a semantic, type-safe API. You can use built-in presets or create custom themes with fine-grained control over colors, typography, and shape.
📖 Want to create your own theme? See the complete Creating Custom Themes Guide for step-by-step instructions, all available options, and best practices.
| Preset | Description |
|---|---|
dark |
Default theme with deep blue tones |
light |
Clean light mode for bright environments |
midnight |
Pure dark with purple accents |
highContrast |
Accessibility-focused with maximum contrast |
import { RDPClient, themes } from './rdp-client.js';
// 1. Default dark theme (no config needed)
const client = new RDPClient(container);
// 2. Use a preset
const client = new RDPClient(container, {
theme: { preset: 'light' }
});
// 3. Custom accent color
const client = new RDPClient(container, {
theme: {
colors: {
accent: '#00b4d8',
buttonHover: '#0096c7'
}
}
});
// 4. Extend a preset with custom colors
const client = new RDPClient(container, {
theme: {
preset: 'dark',
colors: {
accent: '#ff5722',
success: '#4caf50'
}
}
});{
// Optional: Base preset to extend
preset: 'dark' | 'light' | 'midnight' | 'highContrast',
// Color customization
colors: {
background: '#1a1a2e', // Main background
surface: '#16213e', // Panels, modals, toolbars
border: '#0f3460', // Borders and separators
text: '#eeeeee', // Primary text color
textMuted: '#888888', // Secondary/muted text
accent: '#51cf66', // Primary accent (focus, active states)
accentText: '#000000', // Text on accent backgrounds
error: '#ff6b6b', // Error/disconnect state
success: '#51cf66', // Success/connected state
buttonBg: '#0f3460', // Button background
buttonHover: '#1a4a7a', // Button hover background
buttonText: '#eeeeee', // Button text color
buttonActiveBg: '#51cf66', // Button pressed background
buttonActiveText: '#000000', // Button pressed text
inputBg: '#1a1a2e', // Input field background
inputBorder: '#0f3460', // Input field border
inputFocusBorder: '#51cf66', // Input focus border
},
// Typography customization
typography: {
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontSize: '14px', // Base font size
fontSizeSmall: '0.85rem', // Small text (labels, status)
},
// Shape customization
shape: {
borderRadius: '4px', // Buttons, inputs
borderRadiusLarge: '8px', // Modals, overlays
},
// Custom fonts (loaded into Shadow DOM)
fonts: {
googleFonts: ['https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap'],
fontFaces: [{ family: 'CustomFont', src: 'https://example.com/font.woff2' }]
}
}You can change the theme at runtime using the setTheme() method:
// Switch to light mode
client.setTheme({ preset: 'light' });
// Change just the accent color
client.setTheme({
colors: {
accent: '#e91e63',
buttonActiveBg: '#e91e63'
}
});
// Reset to default dark theme
client.setTheme({ preset: 'dark' });const client = new RDPClient(container, {
theme: {
preset: 'dark',
colors: {
accent: '#0066cc', // Brand blue
accentText: '#ffffff',
success: '#28a745',
buttonBg: '#2d3748',
buttonHover: '#4a5568',
buttonActiveBg: '#0066cc',
buttonActiveText: '#ffffff',
inputFocusBorder: '#0066cc',
},
typography: {
fontFamily: "'Inter', 'Helvetica Neue', Arial, sans-serif",
},
shape: {
borderRadius: '6px',
borderRadiusLarge: '12px',
}
}
});You can access the full theme preset objects for reference or modification:
import { themes } from './rdp-client.js';
// Log all available colors in dark theme
console.log(themes.dark.colors);
// Use preset colors in your app
document.body.style.background = themes.midnight.colors.background;| Method | Description |
|---|---|
connect(credentials) |
Connect to RDP server. Returns a Promise. |
disconnect() |
Disconnect the current session. Returns a Promise. |
sendKeys(keys, opts) |
Send keystrokes. Options: { ctrl, alt, shift, meta, delay }. Returns a Promise. |
sendKeyCombo(combo) |
Send key combination (e.g., 'Ctrl+Alt+Delete') |
sendCtrlAltDel() |
Shortcut for sendKeyCombo('Ctrl+Alt+Delete') |
sendBackspace(count) |
Send Backspace key press(es). Default count: 1. Returns a Promise. |
sendMouseMove(x, y) |
Move mouse cursor to coordinates (shows visual cursor overlay) |
sendMouseClick(opts) |
Perform mouse click. Options: { x, y, button, count, delay }. Returns a Promise. |
sendMouseScroll(opts) |
Perform mouse scroll. Options: { x, y, deltaX, deltaY } |
showKeyboard() |
Show the virtual on-screen keyboard |
hideKeyboard() |
Hide the virtual on-screen keyboard |
isKeyboardVisible() |
Returns true if virtual keyboard is visible |
setTheme(config) |
Apply a new theme configuration dynamically |
setMinDimensions(w, h) |
Set minimum canvas dimensions (0 = no minimum, scrollbars appear if container is smaller) |
getStatus() |
Returns { connected, resolution, muted } |
isConnected() |
Returns true if connected to RDP server |
getLatency() |
Returns current latency in ms, or null if not measured |
getMuted() |
Returns current mute state (boolean) |
setMuted(bool) |
Set audio mute state |
getResolution() |
Returns { width, height } or null if not connected |
getSecurityPolicy() |
Returns the frozen security policy object (read-only) |
validateDestination(host, port) |
Check if destination is allowed. Returns { allowed, reason? } |
getScreenshot(type, quality) |
Capture screenshot. Returns Promise<{ blob, width, height }>. Type: 'png' or 'jpg' |
downloadScreenshot(type, quality) |
Capture and download screenshot as screenshot-YYYY-mm-dd--hh-mm.(png|jpg). Returns a Promise. |
on(event, handler) |
Register an event handler |
off(event, handler) |
Remove an event handler |
destroy() |
Clean up resources and remove from DOM. Returns a Promise. |
The RDP client provides programmatic methods for sending keyboard and mouse input to the remote desktop. These are useful for automation, scripting, or building custom UI controls.
Visual Cursor: When using
sendMouseMove,sendMouseClick, orsendMouseScroll, a visual cursor overlay appears on the canvas to indicate the programmatic mouse position. This overlay automatically hides when the user moves their real mouse over the canvas.
| Method | Async | Description |
|---|---|---|
| ---- | ||
sendKeys(keys, options) |
✅ Yes | Send one or more keystrokes with optional modifiers |
sendKeyCombo(combo) |
❌ No | Send a key combination string (e.g., 'Ctrl+C') |
sendCtrlAltDel() |
❌ No | Send Ctrl+Alt+Delete |
sendBackspace(count) |
✅ Yes | Send Backspace key press(es) |
Send keystrokes to the remote desktop with optional modifier keys.
// Send a single character
await client.sendKeys('a');
// Send multiple characters with delay between each
await client.sendKeys('Hello World', { delay: 50 });
// Send with modifiers (Ctrl+S to save)
await client.sendKeys('s', { ctrl: true });
// Send array of keys
await client.sendKeys(['H', 'i', '!'], { delay: 100 });Options:
| Property | Type | Default | Description |
|---|---|---|---|
ctrl |
boolean | false |
Hold Ctrl while sending |
alt |
boolean | false |
Hold Alt while sending |
shift |
boolean | false |
Hold Shift while sending |
meta |
boolean | false |
Hold Meta/Win while sending |
delay |
number | 0 |
Delay in ms between keystrokes |
Send a key combination using a simple string format.
// Common shortcuts
client.sendKeyCombo('Ctrl+C'); // Copy
client.sendKeyCombo('Ctrl+V'); // Paste
client.sendKeyCombo('Alt+Tab'); // Switch windows
client.sendKeyCombo('Ctrl+Alt+Delete'); // Security attention
client.sendKeyCombo('Win+R'); // Run dialogConvenience method for sending the Ctrl+Alt+Delete security attention sequence.
client.sendCtrlAltDel();Send one or more Backspace key presses with a 20ms delay between each. This is an async method.
// Single backspace
await client.sendBackspace();
// Delete 5 characters
await client.sendBackspace(5);| Method | Async | Description |
|---|---|---|
sendMouseMove(x, y) |
❌ No | Move cursor to coordinates |
sendMouseClick(options) |
✅ Yes | Perform mouse click(s) |
sendMouseScroll(options) |
❌ No | Perform scroll operation |
Move the mouse cursor to specific coordinates in remote desktop pixels.
// Move to position (100, 200)
client.sendMouseMove(100, 200);
// Move to center of screen
const res = client.getResolution();
client.sendMouseMove(res.width / 2, res.height / 2);Perform mouse click(s) at the current or specified position. This is an async method that returns a Promise.
// Left click at current cursor position
await client.sendMouseClick();
// Left click at specific coordinates
await client.sendMouseClick({ x: 500, y: 300 });
// Right click
await client.sendMouseClick({ button: 'right' });
// Double click
await client.sendMouseClick({ x: 100, y: 100, count: 2 });
// Middle click with custom delay between clicks
await client.sendMouseClick({ button: 'middle', count: 3, delay: 100 });Options:
| Property | Type | Default | Description |
|---|---|---|---|
x |
number | last position | X coordinate in remote pixels |
y |
number | last position | Y coordinate in remote pixels |
button |
string | 'left' |
Button: 'left', 'right', or 'middle' |
count |
number | 1 |
Number of clicks (e.g., 2 for double-click) |
delay |
number | 50 |
Delay in ms between multiple clicks |
Perform a scroll operation at the current or specified position.
// Scroll down at current position
client.sendMouseScroll({ deltaY: 100 });
// Scroll up
client.sendMouseScroll({ deltaY: -100 });
// Scroll at specific position
client.sendMouseScroll({ x: 500, y: 300, deltaY: 50 });
// Horizontal scroll (right)
client.sendMouseScroll({ deltaX: 50 });
// Horizontal scroll (left)
client.sendMouseScroll({ deltaX: -50 });
// Combined scroll
client.sendMouseScroll({ deltaX: 20, deltaY: 100 });Options:
| Property | Type | Default | Description |
|---|---|---|---|
x |
number | last position | X coordinate in remote pixels |
y |
number | last position | Y coordinate in remote pixels |
deltaX |
number | 0 |
Horizontal scroll (positive = right) |
deltaY |
number | 0 |
Vertical scroll (positive = down) |
// Simple automation: Open Run dialog and type a command
async function openNotepad(client) {
// Open Run dialog
client.sendKeyCombo('Win+R');
// Wait for dialog to appear
await new Promise(r => setTimeout(r, 500));
// Type command
await client.sendKeys('notepad', { delay: 30 });
// Press Enter
client.sendKeyCombo('Enter');
}
// Click automation: Navigate a menu
async function clickFileMenu(client) {
// Click on File menu (coordinates depend on application)
await client.sendMouseClick({ x: 30, y: 50 });
// Wait for menu to open
await new Promise(r => setTimeout(r, 200));
// Click on Save option
await client.sendMouseClick({ x: 50, y: 120 });
}| Event | Data | Description |
|---|---|---|
'connected' |
{ width, height } |
RDP session established |
'disconnected' |
- | Session ended |
'resize' |
{ width, height } |
Resolution changed |
'latency' |
{ latencyMs } |
Latency measurement updated (every 5 seconds) |
'error' |
{ message } |
Error occurred |
'mute' |
{ muted } |
Audio mute state changed |
'keyboardShow' |
- | Virtual keyboard was shown |
'keyboardHide' |
- | Virtual keyboard was hidden |
The RDP client includes a built-in virtual on-screen keyboard, ideal for touch devices or when physical keyboard access is limited.
- US keyboard layout (without numeric keypad)
- Movable & resizable overlay that stays within canvas bounds
- Touch-friendly with support for both mouse and touch events
- Modifier key toggle - Shift, Ctrl, Alt, and Win keys stay pressed until a regular key is typed
- Special key combos:
Alt+Tab- Switch windowsCtrl+Alt+Delete- Security attention sequence
- Scalable UI - Keyboard scales between 60% and 120% of its original size
// Show the virtual keyboard
client.showKeyboard();
// Hide the virtual keyboard
client.hideKeyboard();
// Check visibility
if (client.isKeyboardVisible()) {
console.log('Keyboard is open');
}
// Listen for keyboard events
client.on('keyboardShow', () => console.log('Keyboard opened'));
client.on('keyboardHide', () => console.log('Keyboard closed'));The keyboard can also be toggled using the ⌨️ button in the top toolbar when connected.
<!DOCTYPE html>
<html>
<head>
<title>My RDP App</title>
<style>
#rdp-container {
width: 100%;
height: 600px;
}
</style>
</head>
<body>
<div id="rdp-container"></div>
<script type="module">
import { RDPClient } from './rdp-client.js';
const client = new RDPClient(document.getElementById('rdp-container'), {
wsUrl: 'ws://localhost:8765',
showTopBar: true,
showBottomBar: false
});
// Event handlers
client.on('connected', ({ width, height }) => {
console.log(`Connected at ${width}x${height}`);
});
client.on('disconnected', () => {
console.log('Session ended');
});
client.on('error', ({ message }) => {
console.error('RDP Error:', message);
});
client.on('resize', ({ width, height }) => {
console.log(`Resolution changed to ${width}x${height}`);
});
// Connect automatically
client.connect({
host: '192.168.1.100',
port: 3389,
user: 'admin',
pass: 'secret'
}).catch(err => console.error(err));
// Expose for debugging
window.rdpClient = client;
</script>
</body>
</html>- Open http://localhost:8000 in your browser
- Click Connect
- Enter VM details:
- Host: IP or hostname of Windows VM
- Port: RDP port (default: 3389)
- Username: Windows username
- Password: Windows password
- Click Connect in the modal
| Type | Description | Fields |
|---|---|---|
connect |
Start RDP session | host, port, username, password, width, height |
disconnect |
End session | - |
mouse |
Mouse event | action, x, y, button, deltaX, deltaY |
key |
Keyboard event | action, code, key |
resize |
Request resolution change | width, height |
ping |
Latency measurement | - |
| Type | Description | Fields |
|---|---|---|
connected |
Session started | width, height |
disconnected |
Session ended | - |
error |
Error occurred | message |
pong |
Ping response | - |
All binary messages use a 4-byte ASCII magic header for efficient parsing:
| Magic | Type | Description |
|---|---|---|
SURF |
createSurface | Create a new GFX surface |
DELS |
deleteSurface | Delete a surface |
MAPS |
mapSurfaceToOutput | Map surface to primary output |
STFR |
startFrame | Begin frame composition |
ENFR |
endFrame | End frame, commit to screen |
CAPS |
capsConfirm | Server capability confirmation (GFX version/flags) |
INIT |
initSettings | RDP session settings (codec flags, color depth) |
RSGR |
resetGraphics | Reset all GFX state (surfaces, cache, codec) |
H264 |
H.264 frame | Encoded video NAL units (AVC420/AVC444) |
WEBP |
WebP tile | Compressed image tile |
TILE |
Raw tile | Uncompressed RGBA pixels |
PROG |
Progressive tile | RFX Progressive compressed (WASM decoded) |
CLRC |
ClearCodec tile | ClearCodec compressed (WASM decoded) |
SFIL |
solidFill | Fill rectangle with color |
S2SF |
surfaceToSurface | Copy region between surfaces |
S2CH |
surfaceToCache | Store surface region in bitmap cache |
C2SF |
cacheToSurface | Restore cached bitmap to surface |
EVCT |
evictCache | Delete bitmap cache slot |
OPUS |
Audio frame | Opus-encoded audio |
AUDI |
PCM Audio | Raw PCM audio data |
| Magic | Type | Description |
|---|---|---|
FACK |
frameAck | Acknowledge frame completion (with queue depth) |
| Variable | Default | Description |
|---|---|---|
WS_HOST |
0.0.0.0 |
WebSocket bind address |
WS_PORT |
8765 |
WebSocket port |
LOG_LEVEL |
INFO |
Logging verbosity |
RDP_MAX_SESSIONS |
100 |
Maximum concurrent RDP sessions (range: 2-1000) also take a look at Memory Usage |
RDP_BRIDGE_SECURITY_POLICY_PATH |
/app/security/rdp-bridge-policy.json |
Path to the security policy JSON file |
SECURITY_ALLOWED_HOSTNAMES |
*.crop.domain, other.host.domain |
Comma-separated Hostname glob patterns fallback if no policy file is present |
SECURITY_ALLOWED_IPV4_CIDRS |
10.0.0.0/34, 10.1.2.3/32 |
Comma-separated IPv4 CIDR ranges fallback if no policy file is present |
The backend supports an optional security policy to restrict which RDP destinations users can connect to. This provides server-side enforcement in addition to (or instead of) the frontend security policy.
Note: The backend security policy uses the same format and logic as the frontend security policy. You can use the same JSON configuration for both.
Create a JSON file with the following structure:
{
"allowedHostnames": ["*.internal.corp", "rdp.example.com"],
"allowedIpv4Cidrs": ["10.0.0.0/8", "192.168.1.0/24"],
"allowedDestinationRegex": ["^prod-server-\\d+:3389$"]
}See the Security Policy section above for detailed documentation on each rule type.
Mount a local directory containing your policy file into the container:
# Create a local policy file
mkdir -p ./security
cat > ./security/rdp-bridge-policy.json << 'EOF'
{
"allowedHostnames": ["*.internal.corp"],
"allowedIpv4Cidrs": ["10.0.0.0/8", "192.168.1.0/24"]
}
EOF
# Run with volume mount
docker run -d \
-p 8765:8765 \
-v $(pwd)/security:/app/security:ro \
qxsch/freerdpweb-backend:latestOr in docker-compose.yml:
services:
backend:
image: qxsch/freerdpweb-backend:latest
ports:
- "8765:8765"
volumes:
- ./security:/app/security:roYou can also use a custom path with the environment variable:
services:
backend:
image: qxsch/freerdpweb-backend:latest
ports:
- "8765:8765"
volumes:
- ./my-policies:/etc/rdp-policies:ro
environment:
- RDP_BRIDGE_SECURITY_POLICY_PATH=/etc/rdp-policies/production-policy.jsonCreate a custom Docker image with the policy baked in:
FROM qxsch/freerdpweb-backend:latest
RUN mkdir -p /app/security/
COPY rdp-bridge-policy.json /app/security/Build and run:
# Create your policy file
cat > rdp-bridge-policy.json << 'EOF'
{
"allowedHostnames": ["*.mycompany.com"],
"allowedIpv4Cidrs": ["172.16.0.0/12"],
"allowedDestinationRegex": ["^jumpbox:3389$"]
}
EOF
# Build custom image
docker build -t my-rdp-backend .
# Run
docker run -d -p 8765:8765 my-rdp-backend- If no policy file exists at the configured path, all connections are allowed (open policy)
- If the policy file exists but contains no rules (empty arrays), all connections are allowed
- If at least one rule is defined, only destinations matching at least one rule will be permitted
- Blocked connections return an error to the client with
type: 'error'anderror: 'security_policy_violation'
const client = new RDPClient(container, {
wsUrl: 'ws://localhost:8765', // WebSocket server URL
mouseThrottleMs: 16, // Mouse event throttling (~60fps)
resizeDebounceMs: 2000, // Resize debounce delay
});├── docker-compose.yml # Multi-service orchestration
├── backend/
│ ├── Dockerfile # Multi-stage build (Ubuntu 24.04)
│ ├── server.py # WebSocket server entry point
│ ├── rdp_bridge.py # Python wrapper for native library
│ ├── wire_format.py # Binary message builders (SURF, TILE, H264, etc.)
│ ├── requirements.txt # Python dependencies
│ └── native/
│ ├── CMakeLists.txt # CMake build configuration
│ ├── rdp_bridge.c # FreeRDP3 + GFX event queue + FFmpeg transcoding
│ ├── rdp_bridge.h # Library header
│ ├── rdpsnd_bridge.c # RDPSND audio plugin (Opus encoding)
│ └── GFX_DEBUGGING_NOTES.md # GFX pipeline debugging notes
└── frontend/
├── Dockerfile # nginx:alpine image
├── index.html # SPA entry point
├── rdp-client.js # RDP client (Shadow DOM, WebSocket, audio)
├── audio-worklet.js # AudioWorklet processor (low-latency ring buffer)
├── gfx-worker.js # GFX compositor worker (OffscreenCanvas, H.264, WASM)
├── wire-format.js # Binary protocol parser
├── nginx.conf # nginx configuration
├── progressive/ # RFX Progressive codec WASM decoder (Emscripten)
│ ├── progressive_wasm.c
│ ├── rfx_decode.c
│ ├── rfx_dwt.c
│ └── rfx_rlgr.c
└── clearcodec/ # ClearCodec WASM decoder (Emscripten)
└── clearcodec_wasm.c
The RDPGFX channel (MS-RDPEGFX) provides a client-side compositor with off-main-thread rendering:
┌─────────────┐ AVC444/420 ┌─────────────────┐ Wire Format ┌──────────────────┐
│ Windows VM │ ──────────────►│ Native Bridge │ ─────────────────►│ GFX Event Queue │
│ (Desktop) │ GFX Commands │ (C Library) │ SURF/TILE/H264 │ (Thread-safe) │
└─────────────┘ └─────────────────┘ └──────────────────┘
│ │
│ FFmpeg Transcode │
│ (AVC444 → AVC420) ▼
│ ┌──────────────────┐
│ │ Python Backend │
└──────────────────────────────│ (WebSocket) │
└──────────────────┘
│
WebSocket Binary Messages │
(SURF, H264, TILE, WEBP, ...) │
▼
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌───────────────────────┐ postMessage ┌───────────────────────────┐ │
│ │ Main Thread │ ─────────────────────────────► │ GFX Worker │ │
│ │ • WebSocket receive │ │ • Wire format parsing │ │
│ │ • Opus AudioDecoder │ ◄───────────────────────────── │ • Surface management │ │
│ │ • Keyboard/mouse │ frameAck │ • H.264 VideoDecoder │ │
│ │ • UI events │ │ • Tile decoding │ │
│ └──────────┬────────────┘ │ • Frame composition │ │
│ │ SharedArrayBuffer │ • OffscreenCanvas render │ │
│ │ (ring buffer) └───────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ AudioWorklet │ Low-latency audio thread (~5-20ms latency) │
│ │ • Ring buffer read │ - Reads samples via Atomics from SharedArrayBuffer │
│ │ • 128-sample output │ - Outputs 128 samples per process() call │
│ │ • Underrun detection │ - Outputs silence when buffer empty │
│ └───────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────────────┘
All GFX events are encoded with a 4-byte ASCII magic header:
┌───────┬────────────────────────────────────────────────────┐
│ Magic │ Payload (variable length, little-endian) │
│ 4B │ frameId, surfaceId, x, y, width, height, data, ... │
└───────┴────────────────────────────────────────────────────┘
| Magic | Event | Layout | Total Size |
|---|---|---|---|
SURF |
createSurface | magic(4) + surfaceId(2) + width(2) + height(2) + format(2) | 12 bytes |
DELS |
deleteSurface | magic(4) + surfaceId(2) | 6 bytes |
MAPS |
mapSurfaceToOutput | magic(4) + surfaceId(2) + outputX(2) + outputY(2) | 10 bytes |
RSGR |
resetGraphics | magic(4) + width(2) + height(2) | 8 bytes |
CAPS |
capsConfirm | magic(4) + version(4) + flags(4) | 12 bytes |
INIT |
initSettings | magic(4) + colorDepth(4) + flagsLow(4) + flagsHigh(4) | 16 bytes |
| Magic | Event | Layout | Total Size |
|---|---|---|---|
STFR |
startFrame | magic(4) + frameId(4) | 8 bytes |
ENFR |
endFrame | magic(4) + frameId(4) | 8 bytes |
FACK |
frameAck | magic(4) + frameId(4) + totalFramesDecoded(4) + queueDepth(4) | 16 bytes |
| Magic | Event | Layout | Header Size |
|---|---|---|---|
H264 |
H.264 frame | magic(4) + frameId(4) + surfaceId(2) + codecId(2) + frameType(1) + x(2) + y(2) + w(2) + h(2) + nalSize(4) + chromaSize(4) + NAL data | 29 bytes + data |
PROG |
Progressive tile | magic(4) + frameId(4) + surfaceId(2) + x(2) + y(2) + w(2) + h(2) + dataSize(4) + data | 22 bytes + data |
CLRC |
ClearCodec tile | magic(4) + frameId(4) + surfaceId(2) + x(2) + y(2) + w(2) + h(2) + dataSize(4) + data | 22 bytes + data |
WEBP |
WebP tile | magic(4) + frameId(4) + surfaceId(2) + x(2) + y(2) + w(2) + h(2) + dataSize(4) + data | 22 bytes + data |
TILE |
Raw RGBA tile | magic(4) + frameId(4) + surfaceId(2) + x(2) + y(2) + w(2) + h(2) + data (w×h×4 bytes) | 18 bytes + data |
| Magic | Event | Layout | Total Size |
|---|---|---|---|
SFIL |
solidFill | magic(4) + frameId(4) + surfaceId(2) + x(2) + y(2) + w(2) + h(2) + color(4) | 22 bytes |
S2SF |
surfaceToSurface | magic(4) + frameId(4) + srcId(2) + dstId(2) + srcX(2) + srcY(2) + srcW(2) + srcH(2) + dstX(2) + dstY(2) | 24 bytes |
S2CH |
surfaceToCache | magic(4) + frameId(4) + surfaceId(2) + cacheSlot(2) + x(2) + y(2) + w(2) + h(2) | 20 bytes |
C2SF |
cacheToSurface | magic(4) + frameId(4) + surfaceId(2) + cacheSlot(2) + dstX(2) + dstY(2) | 16 bytes |
EVCT |
evictCache | magic(4) + frameId(4) + cacheSlot(2) | 10 bytes |
| Magic | Event | Layout | Total Size |
|---|---|---|---|
PPOS |
pointerPosition | magic(4) + x(2) + y(2) | 8 bytes |
PSYS |
pointerSystem | magic(4) + ptrType(1) | 5 bytes |
PSET |
pointerSet | magic(4) + width(2) + height(2) + hotspotX(2) + hotspotY(2) + dataLen(4) + bgraData | 16 bytes + data |
Pointer System Types (PSYS):
0= PTR_NULL - Hide cursor1= PTR_DEFAULT - Show default arrow cursor
Pointer Set (PSET):
- Contains custom cursor bitmap as BGRA32 pixel data
hotspotX/Ydefine the click point offset within the cursor image- Frontend converts BGRA → RGBA and creates CSS cursor via canvas.toDataURL()
- Maximum cursor size depends on browser (typically 128×128 or 256×256)
The INIT message packs RDP session settings as bitfields for efficient transmission:
| Bit | Setting | Description |
|---|---|---|
| 0 | SupportGraphicsPipeline | GFX pipeline enabled |
| 1 | GfxH264 | H.264 codec support |
| 2 | GfxAVC444 | AVC444 (4:4:4 chroma) support |
| 3 | GfxAVC444v2 | AVC444 version 2 support |
| 4 | GfxProgressive | Progressive codec support |
| 5 | GfxProgressiveV2 | Progressive codec v2 support |
| 6 | RemoteFxCodec | RemoteFX codec support |
| 7 | NSCodec | NSCodec support |
| 8 | JpegCodec | JPEG codec support |
| 9 | GfxPlanar | Planar codec support |
| 10 | GfxSmallCache | Small cache mode |
| 11 | GfxThinClient | Thin client mode |
| 12 | GfxSendQoeAck | Quality of Experience ACK |
| 13 | GfxSuspendFrameAck | Suspend frame acknowledgments |
| 14 | AudioPlayback | Audio playback enabled |
| 15 | AudioCapture | Audio capture enabled |
| 16 | RemoteConsoleAudio | Remote console audio enabled |
The GFX Worker handles all rendering on a dedicated thread:
- Surface Management: Create/delete surfaces, map to output, track dimensions
- H.264 Decoding: WebCodecs VideoDecoder with hardware acceleration
- Progressive Decoding: RFX Progressive codec via WASM (with pthreads support)
- ClearCodec Decoding: ClearCodec tiles via WASM decoder
- Tile Decoding: WebP via createImageBitmap, raw RGBA via ImageData
- Bitmap Cache: Store/restore surface regions for efficient updates
- Frame Composition: startFrame → tiles/H.264 → endFrame → commit
- Flow Control: Frame acknowledgments (FACK) with MS-RDPEGFX compliant queueDepth
Audio uses a custom RDPSND device plugin that captures PCM directly from FreeRDP and encodes to Opus:
┌─────────────┐ RDPSND ┌─────────────────┐ Opus ┌──────────────┐
│ Windows VM │ ──────────────► │ rdpsnd-bridge │ ────────────────►│ Ring Buffer │
│ (Audio) │ PCM 44.1kHz │ (Opus Encoder) │ 64kbps frames │ (per-user) │
└─────────────┘ └─────────────────┘ └──────────────┘
│
▼
┌─────────────┐ WebSocket ┌─────────────────┐ WebCodecs ┌──────────────┐
│ Browser │ ◄─────────────── │ Python Proxy │ ◄────────────────│ AudioDecoder │
│ (Speakers) │ OPUS frames │ (rdp_bridge) │ Opus→PCM │ │
└─────────────┘ └─────────────────┘ └──────────────┘
Key benefits:
- Per-session isolation: Each RDP connection has its own audio buffer (no mixing)
- No PulseAudio: Direct capture eliminates shared audio daemon
- Bandwidth efficient: Opus at 64kbps vs ~1.4Mbps raw PCM
- Low latency: 20ms frame size
The native library wasn't built or installed. Use Docker which handles this automatically.
- Verify the VM IP and RDP port (3389)
- Ensure Remote Desktop is enabled on the Windows VM
- Check firewall allows RDP connections
- The VM display may need to wake up - try moving the mouse
- Check if the VM is at a lock screen
- H.264 frames are decoded in the GFX Worker with hardware acceleration
- Check network connectivity between backend and VM
- Monitor browser console for decode errors
- This application requires OffscreenCanvas (no fallback mode)
- Upgrade to a modern browser: Chrome 94+, Edge 94+, Firefox 130+, Safari 26+
- OffscreenCanvas is required for off-main-thread rendering in the GFX Worker
- Check browser compatibility: Audio requires Chrome 94+, Edge 94+, Safari 26+, or Firefox 130+ (WebCodecs AudioDecoder)
- Check RDP server settings: Ensure audio redirection is enabled on the Windows VM
The backend exposes /health endpoint. Test with:
curl http://localhost:8765/healthflowchart TB
subgraph Browser["🌐 Browser"]
subgraph MainThread["Main Thread"]
WS_Client["WebSocket Client"]
AudioDecoder["WebCodecs<br/>AudioDecoder"]
Input["Keyboard/Mouse<br/>Events"]
CursorMgr["Cursor Manager<br/>(CSS cursor)"]
end
subgraph GFXWorker["GFX Worker Thread"]
WireParser["Wire Format<br/>Parser"]
SurfaceMgr["Surface<br/>Manager"]
BitmapCache["Bitmap<br/>Cache"]
VideoDecoder["WebCodecs<br/>VideoDecoder"]
ProgWasm["Progressive<br/>WASM Decoder"]
ClearWasm["ClearCodec<br/>WASM Decoder"]
TileDecoder["Tile Decoder<br/>(WebP/RGBA)"]
Compositor["Frame<br/>Compositor"]
OffscreenCanvases["OffscreenCanvases (Surfaces)"]
PrimaryCanvas["PrimaryCanvas"]
end
subgraph AudioThread["AudioWorklet Thread"]
AudioWorklet["AudioWorklet<br/>Processor"]
RingBuffer["Ring Buffer<br/>(SharedArrayBuffer)"]
AudioOut["Audio Output<br/>(128 samples/frame)"]
end
end
subgraph Backend["🐍 Python Backend (Docker)"]
WS_Server["WebSocket Server<br/>:8765"]
WireFormat["Wire Format<br/>Builder"]
subgraph Native["📦 librdp_bridge.so (C)"]
FreeRDP["FreeRDP3 Client"]
GFXQueue["GFX Event Queue<br/>(Thread-safe)"]
subgraph GFX["RDPGFX Channel"]
H264Codecs["H.264 Codecs<br/>(AVC420/444)"]
FFmpeg["FFmpeg Transcode<br/>4:4:4 → 4:2:0"]
TileCodecs["Tile Codecs<br/>(Planar/WebP)"]
ProgCodec["Progressive/Clear<br/>(passthrough)"]
SurfaceOps["Surface Ops<br/>(Fill/Copy/Cache)"]
end
subgraph Pointer["Pointer Channel"]
PointerCB["Pointer Callbacks<br/>(New/Set/Free)"]
end
subgraph Audio["RDPSND Channel"]
Bridge["rdpsnd-bridge"]
Opus["Opus Encoder"]
end
end
end
subgraph WindowsVM["🖥️ Windows VM"]
RDP["RDP Server<br/>:3389"]
Desktop["Desktop / Apps"]
end
%% RDP connection
Desktop -->|"Screen/Audio/Cursor"| RDP
RDP -->|"RDPGFX + RDPSND + Pointer"| FreeRDP
%% GFX event processing
FreeRDP --> H264Codecs
FreeRDP --> TileCodecs
FreeRDP --> ProgCodec
FreeRDP --> SurfaceOps
FreeRDP --> PointerCB
H264Codecs --> FFmpeg
FFmpeg --> GFXQueue
TileCodecs --> GFXQueue
ProgCodec --> GFXQueue
SurfaceOps --> GFXQueue
PointerCB --> GFXQueue
%% Wire format encoding
GFXQueue --> WireFormat
WireFormat -->|"SURF/H264/PROG/CLRC/PSET/..."| WS_Server
%% WebSocket to browser
WS_Server -->|"Binary Messages"| WS_Client
WS_Client -->|"postMessage"| WireParser
WS_Client -->|"PPOS/PSYS/PSET"| CursorMgr
%% GFX Worker processing
WireParser --> SurfaceMgr
WireParser --> BitmapCache
WireParser --> VideoDecoder
WireParser --> ProgWasm
WireParser --> ClearWasm
WireParser --> TileDecoder
VideoDecoder --> Compositor
ProgWasm --> Compositor
ClearWasm --> Compositor
TileDecoder --> Compositor
SurfaceMgr --> Compositor
BitmapCache --> Compositor
Compositor --> OffscreenCanvases
OffscreenCanvases --> PrimaryCanvas
%% Audio flow (main thread → AudioWorklet)
FreeRDP --> Bridge
Bridge --> Opus
Opus --> WS_Server
WS_Server -->|"OPUS frames"| WS_Client
WS_Client --> AudioDecoder
AudioDecoder -->|"Atomics.store"| RingBuffer
RingBuffer -->|"Atomics.load"| AudioWorklet
AudioWorklet --> AudioOut
%% Input flow
Input --> WS_Client
WS_Client -->|"JSON events"| WS_Server
WS_Server --> FreeRDP
FreeRDP --> RDP
%% Flow control
Compositor -->|"frameAck"| WS_Client
WS_Client -->|"FACK"| WS_Server
style Browser fill:#e1f5fe
style Backend fill:#fff3e0
style WindowsVM fill:#e8f5e9
style Native fill:#ffecb3
style MainThread fill:#bbdefb
style AudioThread fill:#f3e5f5
style GFXWorker fill:#c8e6c9
style GFX fill:#fff59d
style Audio fill:#b3e5fc
style Pointer fill:#f8bbd9
| Source | Wire Magic | Processing Location | Output |
|---|---|---|---|
| H.264 (AVC420) | H264 |
GFX Worker VideoDecoder | Canvas frame |
| H.264 (AVC444) | H264 |
Backend FFmpeg → Worker VideoDecoder | Canvas frame |
| Progressive tiles | PROG |
GFX Worker WASM decoder (pthreads) | Canvas blit |
| ClearCodec tiles | CLRC |
GFX Worker WASM decoder | Canvas blit |
| WebP tiles | WEBP |
GFX Worker createImageBitmap | Canvas blit |
| Raw RGBA | TILE |
GFX Worker ImageData | Canvas blit |
| Solid fills | SFIL |
GFX Worker fillRect | Canvas draw |
| Surface copy | S2SF |
GFX Worker drawImage | Canvas blit |
| Store to cache | S2CH |
GFX Worker getImageData | Bitmap cache |
| Cache restore | C2SF |
GFX Worker drawImage | Canvas blit |
| Evict cache | EVCT |
GFX Worker Cache Manager | Cache cleanup |
| Create surface | SURF |
GFX Worker Surface Manager | New canvas |
| Delete surface | DELS |
GFX Worker Surface Manager | Cleanup |
| Map surface | MAPS |
GFX Worker Surface Manager | Primary output |
| Reset graphics | RSGR |
GFX Worker | Full state reset |
| Start frame | STFR |
GFX Worker Compositor | Begin batch |
| End frame | ENFR |
GFX Worker Compositor | Commit + ack |
| Frame ack | FACK |
Backend (from browser) | Flow control (with queue depth) |
| Audio | OPUS |
Main Thread AudioDecoder | Speakers |