first commit
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
|
||||
# Certificates & keys
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,103 @@
|
||||
// OS input injection layer.
|
||||
//
|
||||
// Cross-platform mouse/keyboard control via @nut-tree-fork/nut-js (optional
|
||||
// native dependency). If nut-js isn't installed (e.g. CI, or a sandbox without
|
||||
// a display), this module degrades to a logging no-op so the rest of the agent
|
||||
// still runs and can be tested. On Windows, nut-js drives the Win32 SendInput
|
||||
// API under the hood — the same mechanism TeamViewer/AnyDesk use.
|
||||
|
||||
let nut = null;
|
||||
try {
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
nut = require('@nut-tree-fork/nut-js');
|
||||
nut.mouse.config.autoDelayMs = 0;
|
||||
nut.keyboard.config.autoDelayMs = 0;
|
||||
} catch {
|
||||
nut = null;
|
||||
}
|
||||
|
||||
const available = !!nut;
|
||||
|
||||
// Map browser KeyboardEvent.key values to nut-js Key enum names.
|
||||
function mapKey(key, code) {
|
||||
if (!nut) return null;
|
||||
const K = nut.Key;
|
||||
const direct = {
|
||||
'Enter': K.Enter, 'Backspace': K.Backspace, 'Tab': K.Tab, 'Escape': K.Escape,
|
||||
' ': K.Space, 'ArrowLeft': K.Left, 'ArrowRight': K.Right, 'ArrowUp': K.Up, 'ArrowDown': K.Down,
|
||||
'Home': K.Home, 'End': K.End, 'PageUp': K.PageUp, 'PageDown': K.PageDown, 'Delete': K.Delete,
|
||||
'Control': K.LeftControl, 'Shift': K.LeftShift, 'Alt': K.LeftAlt, 'Meta': K.LeftSuper,
|
||||
'CapsLock': K.CapsLock,
|
||||
};
|
||||
if (direct[key] !== undefined) return [direct[key]];
|
||||
if (/^F\d{1,2}$/.test(key) && K[key] !== undefined) return [K[key]];
|
||||
if (key && key.length === 1) {
|
||||
const upper = key.toUpperCase();
|
||||
if (/[A-Z]/.test(upper) && K[upper] !== undefined) return [K[upper]];
|
||||
if (/[0-9]/.test(key) && K['Num' + key] !== undefined) return [K['Num' + key]];
|
||||
// Fall back to typing the literal character (handles symbols/shifted chars)
|
||||
return { type: key };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function moveTo(xNorm, yNorm) {
|
||||
if (!nut) return;
|
||||
const { width, height } = await nut.screen.getResolution();
|
||||
await nut.mouse.setPosition(new nut.Point(Math.round(xNorm * width), Math.round(yNorm * height)));
|
||||
}
|
||||
|
||||
function buttonEnum(b) {
|
||||
if (!nut) return null;
|
||||
return b === 2 ? nut.Button.RIGHT : b === 1 ? nut.Button.MIDDLE : nut.Button.LEFT;
|
||||
}
|
||||
|
||||
const pressed = new Set();
|
||||
|
||||
// Inject a single normalized input event coming from the viewer.
|
||||
async function inject(evt) {
|
||||
if (!nut) {
|
||||
if (evt.kind !== 'mousemove') console.log('[input:noop]', JSON.stringify(evt));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
switch (evt.kind) {
|
||||
case 'mousemove':
|
||||
await moveTo(evt.x, evt.y); break;
|
||||
case 'mousedown':
|
||||
await moveTo(evt.x, evt.y); await nut.mouse.pressButton(buttonEnum(evt.button)); break;
|
||||
case 'mouseup':
|
||||
await nut.mouse.releaseButton(buttonEnum(evt.button)); break;
|
||||
case 'dblclick':
|
||||
await moveTo(evt.x, evt.y); await nut.mouse.doubleClick(nut.Button.LEFT); break;
|
||||
case 'scroll':
|
||||
if (evt.dy) await (evt.dy > 0 ? nut.mouse.scrollDown(Math.abs(evt.dy)) : nut.mouse.scrollUp(Math.abs(evt.dy)));
|
||||
if (evt.dx) await (evt.dx > 0 ? nut.mouse.scrollRight(Math.abs(evt.dx)) : nut.mouse.scrollLeft(Math.abs(evt.dx)));
|
||||
break;
|
||||
case 'keydown': {
|
||||
const m = mapKey(evt.key, evt.code);
|
||||
if (!m) break;
|
||||
if (m.type) { await nut.keyboard.type(m.type); break; }
|
||||
await nut.keyboard.pressKey(...m); m.forEach((k) => pressed.add(k));
|
||||
break;
|
||||
}
|
||||
case 'keyup': {
|
||||
const m = mapKey(evt.key, evt.code);
|
||||
if (!m || m.type) break;
|
||||
await nut.keyboard.releaseKey(...m); m.forEach((k) => pressed.delete(k));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[input] inject error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: release any stuck modifier keys when a session ends.
|
||||
async function releaseAll() {
|
||||
if (!nut) { pressed.clear(); return; }
|
||||
for (const k of pressed) { try { await nut.keyboard.releaseKey(k); } catch {} }
|
||||
pressed.clear();
|
||||
}
|
||||
|
||||
module.exports = { inject, releaseAll, available, mapKey };
|
||||
@@ -0,0 +1,44 @@
|
||||
// Unit test for the input mapping logic — runs without a display or nut-js.
|
||||
// Verifies keymap returns sane shapes and inject() no-ops gracefully.
|
||||
const inject = require('./inject');
|
||||
const assert = require('assert');
|
||||
|
||||
let pass = 0;
|
||||
function check(name, cond) {
|
||||
assert.ok(cond, name);
|
||||
console.log(' ok -', name);
|
||||
pass++;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('input layer tests:');
|
||||
check('module exposes inject/releaseAll/available', typeof inject.inject === 'function' && typeof inject.releaseAll === 'function');
|
||||
check('available is boolean', typeof inject.available === 'boolean');
|
||||
|
||||
// mapKey returns null when nut-js absent (sandbox) — that is expected & safe.
|
||||
if (!inject.available) {
|
||||
check('mapKey is null-safe without nut-js', inject.mapKey('a', 'KeyA') === null);
|
||||
} else {
|
||||
check('mapKey letter -> array', Array.isArray(inject.mapKey('a', 'KeyA')));
|
||||
check('mapKey Enter -> array', Array.isArray(inject.mapKey('Enter', 'Enter')));
|
||||
check('mapKey symbol -> type fallback', inject.mapKey('@', 'Digit2').type === '@');
|
||||
}
|
||||
|
||||
// inject() must not throw on any event kind, even with no backend.
|
||||
for (const evt of [
|
||||
{ kind: 'mousemove', x: 0.5, y: 0.5 },
|
||||
{ kind: 'mousedown', button: 0, x: 0.1, y: 0.2 },
|
||||
{ kind: 'mouseup', button: 0 },
|
||||
{ kind: 'dblclick', x: 0.3, y: 0.3 },
|
||||
{ kind: 'scroll', dx: 0, dy: 120 },
|
||||
{ kind: 'keydown', key: 'a', code: 'KeyA' },
|
||||
{ kind: 'keyup', key: 'a', code: 'KeyA' },
|
||||
]) {
|
||||
await inject.inject(evt);
|
||||
}
|
||||
check('inject handled all event kinds without throwing', true);
|
||||
await inject.releaseAll();
|
||||
check('releaseAll clears state', true);
|
||||
|
||||
console.log(`\n${pass} checks passed.`);
|
||||
})();
|
||||
@@ -0,0 +1,47 @@
|
||||
// OS input injection for Windows via user32 SendInput (koffi FFI).
|
||||
// On non-Windows platforms, exports a stub that logs instead of injecting,
|
||||
// so the agent can run in dev/test environments.
|
||||
|
||||
const { toVirtualKey } = require('./keymap');
|
||||
|
||||
const MOUSEEVENTF_MOVE = 0x0001;
|
||||
const MOUSEEVENTF_ABSOLUTE = 0x8000;
|
||||
const MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||
const MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
const MOUSEEVENTF_RIGHTDOWN = 0x0008;
|
||||
const MOUSEEVENTF_RIGHTUP = 0x0010;
|
||||
const MOUSEEVENTF_MIDDLEDOWN = 0x0020;
|
||||
const MOUSEEVENTF_MIDDLEUP = 0x0040;
|
||||
const MOUSEEVENTF_WHEEL = 0x0800;
|
||||
const KEYEVENTF_KEYUP = 0x0002;
|
||||
const INPUT_MOUSE = 0;
|
||||
const INPUT_KEYBOARD = 1;
|
||||
|
||||
const BUTTON_DOWN = { 0: MOUSEEVENTF_LEFTDOWN, 1: MOUSEEVENTF_MIDDLEDOWN, 2: MOUSEEVENTF_RIGHTDOWN };
|
||||
const BUTTON_UP = { 0: MOUSEEVENTF_LEFTUP, 1: MOUSEEVENTF_MIDDLEUP, 2: MOUSEEVENTF_RIGHTUP };
|
||||
|
||||
function createWindowsInjector() {
|
||||
const koffi = require('koffi');
|
||||
const user32 = koffi.load('user32.dll');
|
||||
|
||||
const MOUSEINPUT = koffi.struct('MOUSEINPUT', {
|
||||
dx: 'long', dy: 'long', mouseData: 'int32',
|
||||
dwFlags: 'uint32', time: 'uint32', dwExtraInfo: 'uintptr_t',
|
||||
});
|
||||
const KEYBDINPUT = koffi.struct('KEYBDINPUT', {
|
||||
wVk: 'uint16', wScan: 'uint16',
|
||||
dwFlags: 'uint32', time: 'uint32', dwExtraInfo: 'uintptr_t',
|
||||
});
|
||||
const HARDWAREINPUT = koffi.struct('HARDWAREINPUT', {
|
||||
uMsg: 'uint32', wParamL: 'uint16', wParamH: 'uint16',
|
||||
});
|
||||
const INPUT_UNION = koffi.union('INPUT_UNION', {
|
||||
mi: MOUSEINPUT, ki: KEYBDINPUT, hi: HARDWAREINPUT,
|
||||
});
|
||||
const INPUT = koffi.struct('INPUT', { type: 'uint32', u: INPUT_UNION });
|
||||
|
||||
const SendInput = user32.func('uint32 SendInput(uint32 cInputs, INPUT *pInputs, int cbSize)');
|
||||
const INPUT_SIZE = koffi.sizeof(INPUT);
|
||||
|
||||
function sendMouse(mi) {
|
||||
SendInput(1, [{ type: INPUT_MOUSE, u: { mi:
|
||||
@@ -0,0 +1,71 @@
|
||||
// Maps browser KeyboardEvent.code values to Windows virtual-key codes.
|
||||
// Reference: https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
|
||||
|
||||
const VK = {};
|
||||
|
||||
// Letters (KeyA..KeyZ -> 0x41..0x5A)
|
||||
for (let i = 0; i < 26; i++) VK['Key' + String.fromCharCode(65 + i)] = 0x41 + i;
|
||||
// Top-row digits (Digit0..Digit9 -> 0x30..0x39)
|
||||
for (let i = 0; i <= 9; i++) VK['Digit' + i] = 0x30 + i;
|
||||
// Numpad digits
|
||||
for (let i = 0; i <= 9; i++) VK['Numpad' + i] = 0x60 + i;
|
||||
// Function keys
|
||||
for (let i = 1; i <= 24; i++) VK['F' + i] = 0x70 + (i - 1);
|
||||
|
||||
Object.assign(VK, {
|
||||
Escape: 0x1b,
|
||||
Tab: 0x09,
|
||||
CapsLock: 0x14,
|
||||
ShiftLeft: 0xa0,
|
||||
ShiftRight: 0xa1,
|
||||
ControlLeft: 0xa2,
|
||||
ControlRight: 0xa3,
|
||||
AltLeft: 0xa4,
|
||||
AltRight: 0xa5,
|
||||
MetaLeft: 0x5b,
|
||||
MetaRight: 0x5c,
|
||||
ContextMenu: 0x5d,
|
||||
Space: 0x20,
|
||||
Enter: 0x0d,
|
||||
NumpadEnter: 0x0d,
|
||||
Backspace: 0x08,
|
||||
Delete: 0x2e,
|
||||
Insert: 0x2d,
|
||||
Home: 0x24,
|
||||
End: 0x23,
|
||||
PageUp: 0x21,
|
||||
PageDown: 0x22,
|
||||
ArrowUp: 0x26,
|
||||
ArrowDown: 0x28,
|
||||
ArrowLeft: 0x25,
|
||||
ArrowRight: 0x27,
|
||||
PrintScreen: 0x2c,
|
||||
ScrollLock: 0x91,
|
||||
Pause: 0x13,
|
||||
NumLock: 0x90,
|
||||
// OEM punctuation (US layout)
|
||||
Semicolon: 0xba,
|
||||
Equal: 0xbb,
|
||||
Comma: 0xbc,
|
||||
Minus: 0xbd,
|
||||
Period: 0xbe,
|
||||
Slash: 0xbf,
|
||||
Backquote: 0xc0,
|
||||
BracketLeft: 0xdb,
|
||||
Backslash: 0xdc,
|
||||
BracketRight: 0xdd,
|
||||
Quote: 0xde,
|
||||
// Numpad operators
|
||||
NumpadMultiply: 0x6a,
|
||||
NumpadAdd: 0x6b,
|
||||
NumpadSubtract: 0x6d,
|
||||
NumpadDecimal: 0x6e,
|
||||
NumpadDivide: 0x6f,
|
||||
});
|
||||
|
||||
/** @param {string} code KeyboardEvent.code @returns {number|undefined} Windows VK code */
|
||||
function toVirtualKey(code) {
|
||||
return VK[code];
|
||||
}
|
||||
|
||||
module.exports = { toVirtualKey, VK };
|
||||
@@ -0,0 +1,66 @@
|
||||
// Electron main process for the host agent.
|
||||
// Owns: the consent window, screen-source selection, and OS input injection.
|
||||
// WebRTC lives in the renderer (it needs a DOM/navigator); input events arrive
|
||||
// from the renderer over IPC and are injected here.
|
||||
const { app, BrowserWindow, ipcMain, desktopCapturer, screen } = require('electron');
|
||||
const path = require('path');
|
||||
const injector = require('./input/inject');
|
||||
|
||||
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:8090';
|
||||
const ENROLL_TOKEN = process.env.AGENT_ENROLL_TOKEN || '';
|
||||
|
||||
let win;
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 460,
|
||||
height: 560,
|
||||
resizable: false,
|
||||
title: 'Remote Access Agent',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
win.loadFile(path.join(__dirname, 'renderer', 'agent.html'));
|
||||
// Pass config to the renderer once loaded
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
win.webContents.send('config', { serverUrl: SERVER_URL, enrollToken: ENROLL_TOKEN });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Screen source for getDisplayMedia (Electron requires a handler) ----
|
||||
function registerDisplayMediaHandler() {
|
||||
const { session } = require('electron');
|
||||
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
|
||||
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
|
||||
// Default to the primary display; production would let the user pick.
|
||||
callback({ video: sources[0], audio: 'loopback' });
|
||||
});
|
||||
}, { useSystemPicker: false });
|
||||
}
|
||||
|
||||
// ---- IPC: renderer forwards remote input events here for OS injection ----
|
||||
ipcMain.on('inject-input', (_e, evt) => {
|
||||
injector.inject(evt);
|
||||
});
|
||||
ipcMain.on('session-ended', () => {
|
||||
injector.releaseAll();
|
||||
});
|
||||
ipcMain.handle('get-primary-size', () => {
|
||||
const { size } = screen.getPrimaryDisplay();
|
||||
return size;
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerDisplayMediaHandler();
|
||||
createWindow();
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
Generated
+2491
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "remote-access-agent",
|
||||
"version": "0.2.0",
|
||||
"description": "Native host agent — screen capture, WebRTC, consent, OS input injection",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"test:input": "node input/inject.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@nut-tree-fork/nut-js": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^31.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Secure bridge between the sandboxed renderer and the main process.
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('agent', {
|
||||
onConfig: (cb) => ipcRenderer.on('config', (_e, cfg) => cb(cfg)),
|
||||
injectInput: (evt) => ipcRenderer.send('inject-input', evt),
|
||||
sessionEnded: () => ipcRenderer.send('session-ended'),
|
||||
getPrimarySize: () => ipcRenderer.invoke('get-primary-size'),
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Remote Access Agent</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 1.5rem; }
|
||||
h1 { font-size: 1.1rem; }
|
||||
.status { background: #1e293b; padding: 0.7rem 1rem; border-radius: 8px; margin: 0.8rem 0; font-size: 0.9rem; }
|
||||
.status.on { background: #14532d; }
|
||||
.status.warn { background: #7c2d12; }
|
||||
.consent { background: #1e293b; border: 1px solid #3b82f6; border-radius: 10px; padding: 1.2rem; margin-top: 1rem; }
|
||||
.consent h2 { font-size: 1rem; margin: 0 0 0.5rem; }
|
||||
button { padding: 0.6rem 1.2rem; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
||||
.grant { background: #22c55e; color: #052e16; }
|
||||
.deny { background: #ef4444; color: #fff; margin-left: 0.5rem; }
|
||||
.muted { color: #94a3b8; font-size: 0.82rem; }
|
||||
.indicator { position: fixed; bottom: 0; left: 0; right: 0; background: #b91c1c; color: #fff; text-align: center; padding: 0.4rem; font-size: 0.85rem; display: none; }
|
||||
.indicator.show { display: block; }
|
||||
#log { font-family: monospace; font-size: 0.72rem; color: #64748b; height: 120px; overflow-y: auto; margin-top: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🛡️ Remote Access Agent</h1>
|
||||
<div id="status" class="status">Starting…</div>
|
||||
<div id="consentBox"></div>
|
||||
<p class="muted">This machine accepts remote support sessions. You'll be asked to approve each connection unless an unattended-access policy is set.</p>
|
||||
<div id="log"></div>
|
||||
<div id="indicator" class="indicator">● A technician is currently viewing/controlling this screen</div>
|
||||
|
||||
<script src="agent.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,135 @@
|
||||
// Agent renderer: connects to the signaling server, handles consent,
|
||||
// captures the screen, and streams it over WebRTC to the technician's viewer.
|
||||
// Remote input events arrive on the data channel and are handed to the main
|
||||
// process (via the preload bridge) for OS-level injection.
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const consentBox = document.getElementById('consentBox');
|
||||
const indicator = document.getElementById('indicator');
|
||||
const logEl = document.getElementById('log');
|
||||
|
||||
let cfg = null;
|
||||
let ws = null;
|
||||
let pc = null;
|
||||
let localStream = null;
|
||||
let currentSessionId = null;
|
||||
|
||||
const log = (t) => { const d = document.createElement('div'); d.textContent = t; logEl.appendChild(d); logEl.scrollTop = logEl.scrollHeight; };
|
||||
const setStatus = (t, cls = '') => { statusEl.textContent = t; statusEl.className = 'status ' + cls; };
|
||||
|
||||
window.agent.onConfig((c) => {
|
||||
cfg = c;
|
||||
if (!cfg.enrollToken) {
|
||||
setStatus('No enroll token. Set AGENT_ENROLL_TOKEN and restart.', 'warn');
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
});
|
||||
|
||||
function wsUrl() {
|
||||
const u = new URL(cfg.serverUrl);
|
||||
const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${u.host}/ws`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
setStatus('Connecting to server…');
|
||||
ws = new WebSocket(wsUrl());
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'agent-hello', enrollToken: cfg.enrollToken }));
|
||||
};
|
||||
ws.onmessage = (e) => handle(JSON.parse(e.data));
|
||||
ws.onclose = () => { setStatus('Disconnected. Reconnecting in 3s…', 'warn'); setTimeout(connect, 3000); };
|
||||
}
|
||||
|
||||
async function handle(m) {
|
||||
switch (m.type) {
|
||||
case 'agent-registered':
|
||||
setStatus(`Online as "${m.name}". Waiting for sessions.`, 'on');
|
||||
log(`registered: ${m.name}`);
|
||||
break;
|
||||
|
||||
case 'session-request':
|
||||
if (m.unattended) { log(`unattended session ${m.sessionId} — auto-granting`); grant(m.sessionId); }
|
||||
else showConsent(m);
|
||||
break;
|
||||
|
||||
case 'start-stream':
|
||||
currentSessionId = m.sessionId;
|
||||
await startStreaming();
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
if (m.candidate && pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate));
|
||||
break;
|
||||
|
||||
case 'session-ended':
|
||||
teardown();
|
||||
break;
|
||||
case 'error':
|
||||
setStatus('Server: ' + m.message, 'warn');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function showConsent(m) {
|
||||
consentBox.innerHTML = `
|
||||
<div class="consent">
|
||||
<h2>Allow remote support?</h2>
|
||||
<p class="muted"><b>${escapeHtml(m.technician)}</b> is requesting to view and control this PC.</p>
|
||||
<button class="grant" id="grantBtn">Allow</button>
|
||||
<button class="deny" id="denyBtn">Deny</button>
|
||||
</div>`;
|
||||
document.getElementById('grantBtn').onclick = () => { consentBox.innerHTML = ''; grant(m.sessionId); };
|
||||
document.getElementById('denyBtn').onclick = () => { consentBox.innerHTML = ''; deny(m.sessionId); };
|
||||
}
|
||||
|
||||
const grant = (sessionId) => ws.send(JSON.stringify({ type: 'consent', sessionId, granted: true }));
|
||||
const deny = (sessionId) => ws.send(JSON.stringify({ type: 'consent', sessionId, granted: false }));
|
||||
|
||||
async function startStreaming() {
|
||||
setStatus('Sharing screen with technician…', 'on');
|
||||
indicator.classList.add('show');
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 30 } }, audio: false });
|
||||
} catch (err) {
|
||||
log('getDisplayMedia failed: ' + err.message);
|
||||
setStatus('Screen capture failed.', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
|
||||
|
||||
// Viewer creates the input data channel; we receive it here.
|
||||
pc.ondatachannel = (ev) => {
|
||||
const ch = ev.channel;
|
||||
ch.onmessage = (msg) => {
|
||||
let evt; try { evt = JSON.parse(msg.data); } catch { return; }
|
||||
window.agent.injectInput(evt); // -> main process -> OS injection
|
||||
};
|
||||
};
|
||||
|
||||
pc.onicecandidate = (ev) => {
|
||||
if (ev.candidate) ws.send(JSON.stringify({ type: 'ice-candidate', sessionId: currentSessionId, candidate: ev.candidate }));
|
||||
};
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
ws.send(JSON.stringify({ type: 'offer', sessionId: currentSessionId, sdp: pc.localDescription }));
|
||||
log('sent offer for session ' + currentSessionId);
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
indicator.classList.remove('show');
|
||||
window.agent.sessionEnded();
|
||||
if (localStream) { localStream.getTracks().forEach((t) => t.stop()); localStream = null; }
|
||||
if (pc) { pc.close(); pc = null; }
|
||||
currentSessionId = null;
|
||||
setStatus('Session ended. Waiting for sessions.', 'on');
|
||||
}
|
||||
|
||||
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
Reference in New Issue
Block a user