- Remove restrictive resolution constraints, capture at native res - Account for window frame/title bar when mapping click coordinates (capture includes title bar but sendInputEvent is content-relative) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
|