Redesign the desktop workspace with task sidebars, live filters, clearer settings, and an accessible update dialog. Harden encrypted backup imports, configuration persistence, history retention, queue snapshots, shutdown recovery, and update installation ordering. Publish verified Windows artifacts and refreshed English documentation.
151 lines
4.4 KiB
HTML
151 lines
4.4 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Remote Capture</title></head>
|
|
<body>
|
|
<script>
|
|
// Maps clientId -> { pc: RTCPeerConnection, dc: RTCDataChannel }
|
|
const clients = new Map();
|
|
let captureStream = null;
|
|
|
|
async function getCaptureStream() {
|
|
if (captureStream) return captureStream;
|
|
|
|
// desktopCapturer runs in main process (Electron 33+), we get the source ID via IPC
|
|
const sourceId = await window.capture.getSourceId();
|
|
window.capture.log('getSourceId returned:', sourceId || 'NULL');
|
|
if (!sourceId) throw new Error('No capture source ID from main process');
|
|
|
|
try {
|
|
captureStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: false,
|
|
video: {
|
|
mandatory: {
|
|
chromeMediaSource: 'desktop',
|
|
chromeMediaSourceId: sourceId,
|
|
maxFrameRate: 15
|
|
}
|
|
}
|
|
});
|
|
const tracks = captureStream.getTracks();
|
|
window.capture.log('getUserMedia OK, tracks:', tracks.length, tracks.map(t => `${t.kind}:${t.readyState}`).join(','));
|
|
return captureStream;
|
|
} catch (err) {
|
|
window.capture.log('getUserMedia FAILED:', err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function handleOffer(clientId, offer, role) {
|
|
window.capture.log('handleOffer called for', clientId);
|
|
let stream;
|
|
try {
|
|
stream = await getCaptureStream();
|
|
} catch (err) {
|
|
window.capture.log('FATAL: getCaptureStream failed:', err.message);
|
|
// Send diagnostic back to dashboard
|
|
window.capture.sendSignaling({ type: 'capture-error', clientId, error: err.message });
|
|
return;
|
|
}
|
|
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
});
|
|
clients.set(clientId, { pc, role });
|
|
|
|
// Add video tracks
|
|
const tracks = stream.getTracks();
|
|
window.capture.log('Adding', tracks.length, 'tracks to peer connection');
|
|
for (const track of tracks) {
|
|
window.capture.log('addTrack:', track.kind, track.label, track.readyState);
|
|
pc.addTrack(track, stream);
|
|
}
|
|
window.capture.log('Senders after addTrack:', pc.getSenders().length);
|
|
|
|
// Handle DataChannel from dashboard (dashboard creates it as offerer)
|
|
pc.ondatachannel = (event) => {
|
|
const dc = event.channel;
|
|
clients.get(clientId).dc = dc;
|
|
dc.onmessage = (msg) => {
|
|
try {
|
|
const input = JSON.parse(msg.data);
|
|
input.clientId = clientId;
|
|
input.role = role;
|
|
window.capture.sendInput(input);
|
|
} catch {}
|
|
};
|
|
};
|
|
|
|
// ICE candidates — serialize to plain object (WebRTC objects don't survive IPC)
|
|
pc.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
window.capture.sendSignaling({
|
|
type: 'ice-candidate',
|
|
clientId,
|
|
candidate: {
|
|
candidate: event.candidate.candidate,
|
|
sdpMid: event.candidate.sdpMid,
|
|
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
usernameFragment: event.candidate.usernameFragment
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
pc.onconnectionstatechange = () => {
|
|
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
|
removeClient(clientId);
|
|
}
|
|
};
|
|
|
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
|
|
// Serialize to plain object (RTCSessionDescription doesn't survive IPC)
|
|
window.capture.sendSignaling({
|
|
type: 'answer',
|
|
clientId,
|
|
answer: { type: pc.localDescription.type, sdp: pc.localDescription.sdp }
|
|
});
|
|
|
|
window.capture.notifyClientCount(clients.size);
|
|
}
|
|
|
|
function handleIceCandidate(clientId, candidate) {
|
|
const client = clients.get(clientId);
|
|
if (client && client.pc) {
|
|
client.pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function removeClient(clientId) {
|
|
const client = clients.get(clientId);
|
|
if (client) {
|
|
if (client.dc) client.dc.close();
|
|
client.pc.close();
|
|
clients.delete(clientId);
|
|
window.capture.notifyClientCount(clients.size);
|
|
}
|
|
}
|
|
|
|
// Listen for signaling messages from main process
|
|
window.capture.onSignaling((data) => {
|
|
switch (data.type) {
|
|
case 'offer':
|
|
handleOffer(data.clientId, data.offer, data.role).catch(err => {
|
|
console.error('Failed to handle offer:', err);
|
|
window.capture.sendSignaling({ type: 'error', clientId: data.clientId, error: err.message });
|
|
});
|
|
break;
|
|
case 'ice-candidate':
|
|
handleIceCandidate(data.clientId, data.candidate);
|
|
break;
|
|
case 'client-disconnected':
|
|
removeClient(data.clientId);
|
|
break;
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|