(function() {
'use strict';
const RDESKTOP_BASE = '/__rdesktop__';
const RECORDING_BASE = `${RDESKTOP_BASE}/agent/recording`;
async function postJson(url, body, options) {
const response = await fetch(url, Object.assign({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
}, options || {}));
const json = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(json.error || `rdesktop request failed (${response.status})`);
}
return json;
}
window.__RDESKTOP_INVOKE__ = async function(cmd, payload) {
const id = Math.random().toString(36).slice(2);
return postJson(`${RDESKTOP_BASE}/agent/ipc`, { id, cmd, payload });
};
window.__RDESKTOP_SET_STATE__ = async function(state) {
await postJson(`${RDESKTOP_BASE}/state`, state || {});
};
function reportDom() {
const html = document.documentElement.outerHTML;
postJson(`${RDESKTOP_BASE}/dom`, { html }).catch(() => {});
}
let domReportTimeout = null;
const observer = new MutationObserver(() => {
clearTimeout(domReportTimeout);
domReportTimeout = setTimeout(reportDom, 500);
});
function startDomReporting() {
if (!document.body) return;
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
reportDom();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startDomReporting, { once: true });
} else {
startDomReporting();
}
async function pollActions() {
try {
const response = await fetch(`${RDESKTOP_BASE}/agent/action/pending`);
if (!response.ok) return;
const actions = await response.json();
for (const action of actions) executeAction(action);
} catch (_) {
}
}
function executeAction(action) {
const el = action.selector ? document.querySelector(action.selector) : null;
if (!el && action.action !== 'scroll') {
console.warn('[rdesktop] Element not found:', action.selector);
return;
}
switch (action.action) {
case 'click': el.click(); break;
case 'double_click': el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); break;
case 'right_click': el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); break;
case 'type':
el.focus();
el.value = (el.value || '') + (action.value || '');
el.dispatchEvent(new Event('input', { bubbles: true }));
break;
case 'fill':
el.focus();
el.value = action.value || '';
el.dispatchEvent(new Event('input', { bubbles: true }));
break;
case 'clear':
el.focus();
el.value = '';
el.dispatchEvent(new Event('input', { bubbles: true }));
break;
case 'scroll':
window.scrollBy((action.coordinates || [0, 0])[0], (action.coordinates || [0, 0])[1]);
break;
case 'hover': el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); break;
case 'focus': el.focus(); break;
case 'select':
el.value = action.value || '';
el.dispatchEvent(new Event('change', { bubbles: true }));
break;
default: console.warn('[rdesktop] Unknown action:', action.action);
}
}
let reloadGeneration = null;
async function pollReload() {
try {
const response = await fetch(`${RDESKTOP_BASE}/reload`, { cache: 'no-store' });
if (!response.ok) return;
const state = await response.json();
if (reloadGeneration !== null && state.generation !== reloadGeneration) {
window.location.reload();
return;
}
reloadGeneration = state.generation;
} catch (_) {
}
}
const recorderState = {
sessionId: null,
recorder: null,
stream: null,
uploadChain: Promise.resolve(),
starting: false,
lastErrorAt: 0,
};
function supportedMimeType() {
if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return null;
const candidates = [
'video/mp4;codecs=avc1.42E01E',
'video/mp4',
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
];
return candidates.find((mime) => MediaRecorder.isTypeSupported(mime)) || null;
}
async function reportRecordingError(sessionId, error) {
try {
await postJson(`${RECORDING_BASE}/error`, {
session_id: sessionId,
error: String(error && error.message ? error.message : error),
});
} catch (_) {}
}
async function uploadRecordingChunk(sessionId, blob) {
const response = await fetch(`${RECORDING_BASE}/chunk`, {
method: 'POST',
headers: { 'X-Rdesktop-Recording-Id': sessionId },
body: blob,
});
if (!response.ok) {
const json = await response.json().catch(() => ({}));
throw new Error(json.error || `recording chunk failed (${response.status})`);
}
}
async function requestDisplayStream() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
throw new Error('display capture is not supported by this browser');
}
return navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 30, cursor: 'always' },
audio: false,
});
}
async function stopBrowserRecorder(sessionId) {
if (!recorderState.recorder || recorderState.sessionId !== sessionId) return;
const recorder = recorderState.recorder;
if (recorder.state === 'recording' || recorder.state === 'paused') {
recorder.stop();
}
}
async function startBrowserRecorder(recording, providedStream) {
if (recorderState.starting && !providedStream) return;
if (recorderState.recorder && recorderState.sessionId === recording.session_id) return;
if (!recording.session_id) return;
if (Date.now() - recorderState.lastErrorAt < 4000) return;
recorderState.starting = true;
const sessionId = recording.session_id;
try {
const stream = providedStream || await requestDisplayStream();
const mimeType = supportedMimeType();
if (!mimeType) throw new Error('this browser cannot encode MP4 or WebM with MediaRecorder');
const recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: 5_000_000,
});
recorderState.sessionId = sessionId;
recorderState.stream = stream;
recorderState.recorder = recorder;
recorderState.uploadChain = Promise.resolve();
await postJson(`${RECORDING_BASE}/started`, {
session_id: sessionId,
mime_type: mimeType,
});
recorder.ondataavailable = (event) => {
if (!event.data || event.data.size === 0) return;
recorderState.uploadChain = recorderState.uploadChain.then(() =>
uploadRecordingChunk(sessionId, event.data)
);
recorderState.uploadChain.catch((error) => reportRecordingError(sessionId, error));
};
recorder.onerror = (event) => {
reportRecordingError(sessionId, event.error || 'MediaRecorder error');
};
recorder.onstop = async () => {
try {
await recorderState.uploadChain;
await postJson(`${RECORDING_BASE}/complete`, {
session_id: sessionId,
mime_type: mimeType,
});
} catch (error) {
await reportRecordingError(sessionId, error);
} finally {
stream.getTracks().forEach((track) => track.stop());
if (recorderState.sessionId === sessionId) {
recorderState.sessionId = null;
recorderState.recorder = null;
recorderState.stream = null;
}
}
};
recorder.start(1000);
} catch (error) {
recorderState.lastErrorAt = Date.now();
if (recorderState.stream) recorderState.stream.getTracks().forEach((track) => track.stop());
recorderState.sessionId = null;
recorderState.recorder = null;
recorderState.stream = null;
await reportRecordingError(sessionId, error);
} finally {
recorderState.starting = false;
}
}
async function pollRecording() {
try {
const response = await fetch(`${RECORDING_BASE}/poll`, { cache: 'no-store' });
if (!response.ok) return;
const recording = await response.json();
if (recording.status === 'recording' && recording.session_id && !recording.native) {
await startBrowserRecorder(recording);
} else if (recording.status === 'stop_requested' && recording.session_id && !recording.native) {
await stopBrowserRecorder(recording.session_id);
}
} catch (_) {
}
}
window.__RDESKTOP_START_RECORDING__ = async function() {
if (recorderState.starting) return { recording: { mime_type: 'starting' } };
recorderState.starting = true;
let stream;
try {
const current = await fetch(`${RECORDING_BASE}`, { cache: 'no-store' }).then((response) => {
if (!response.ok) throw new Error(`recording status failed (${response.status})`);
return response.json();
});
if (current.native) return await postJson(`${RECORDING_BASE}/start`, {});
stream = await requestDisplayStream();
const response = await postJson(`${RECORDING_BASE}/start`, {});
await startBrowserRecorder(response.recording, stream);
return response;
} catch (error) {
if (stream) stream.getTracks().forEach((track) => track.stop());
recorderState.starting = false;
throw error;
}
};
window.__RDESKTOP_STOP_RECORDING__ = (sessionId) => postJson(`${RECORDING_BASE}/stop`, sessionId ? { session_id: sessionId } : {});
setInterval(pollActions, 250);
setInterval(pollReload, 400);
setInterval(pollRecording, 400);
pollActions();
pollReload();
pollRecording();
function installDevtools() {
if (new URLSearchParams(window.location.search).has('__rdesktop_devtools') && document.body) {
const badge = document.createElement('div');
badge.style.cssText = 'position:fixed;bottom:8px;right:8px;background:#667eea;color:white;padding:4px 8px;border-radius:4px;font-size:11px;z-index:99999;font-family:monospace;opacity:0.7;';
badge.textContent = 'rdesktop dev';
document.body && document.body.appendChild(badge);
const controls = document.createElement('div');
controls.style.cssText = 'position:fixed;bottom:36px;right:8px;background:rgba(20,24,38,.92);color:#fff;padding:8px;border-radius:6px;z-index:99999;font:12px monospace;display:flex;gap:6px;align-items:center;';
const startButton = document.createElement('button');
startButton.id = 'rdesktop-recording-start';
startButton.textContent = 'Record';
const stopButton = document.createElement('button');
stopButton.id = 'rdesktop-recording-stop';
stopButton.textContent = 'Stop';
const label = document.createElement('span');
label.textContent = 'idle';
startButton.onclick = async () => {
label.textContent = 'starting…';
try {
const result = await window.__RDESKTOP_START_RECORDING__();
label.textContent = result.recording.mime_type || 'recording';
} catch (error) {
label.textContent = `error: ${error.message}`;
}
};
stopButton.onclick = async () => {
try {
await window.__RDESKTOP_STOP_RECORDING__();
label.textContent = 'stopping…';
} catch (error) {
label.textContent = `error: ${error.message}`;
}
};
controls.append(startButton, stopButton, label);
document.body.appendChild(controls);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', installDevtools, { once: true });
} else {
installDevtools();
}
console.log('[rdesktop] Bridge script loaded');
})();