Files
Sravan 94b8fac32f fix: guest pre-join, post-admit lobby, audio device menu, RC keyboard (0.1.15/batch74)
1. Guest pre-join redesigned: brand backdrop, live camera preview, mic/cam toggles
   applied on entry, initials avatar, name required, "host may admit you" note.
2. Post-admit bug: the guest stayed on "Waiting for the host…" forever — the lobby
   screen had replaced the call UI and meeting-joined never re-rendered it. Now it
   rebuilds the call on admission (_inLobby).
3. Audio devices: dropped the standalone headphones button. The MIC now has a ▾ caret
   opening one Teams-style menu with Speaker + Microphone sections (radio-selected);
   speaker uses setSinkId/LiveKit switchActiveDevice, mic switches the live input.
   Mobile gets a speakerphone toggle that prefers a connected BT/headset when off.
4. Remote-control keyboard:
   - Injector now maps the PHYSICAL key (KeyboardEvent.code) instead of the character,
     so Shift+1 types "!" etc. Character mapping was why typing "performed differently".
   - Keys reach the sharer ONLY while control is ENGAGED (window focused AND you clicked
     their screen). Minimised/unfocused/chat typing stays local. Esc or clicking away
     releases; modifiers are released on disengage so nothing sticks.
   - Explicit control icons: viewer gets a Control ON/OFF button (green when engaged) +
     an on-screen hint; the SHARER gets a control icon beside mic/chat to allow/stop
     access at a glance, synced with the consent dialog and banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:18:01 +05:30

145 lines
6.2 KiB
JavaScript

// 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 the PHYSICAL key (KeyboardEvent.code) to a nut-js Key. This is the correct way to drive a remote
// keyboard: press the same physical key the viewer pressed and let the remote OS apply its own modifier
// state. Mapping by CHARACTER (mapKey below) broke shifted keys — e.g. Shift+1 typed "1" instead of "!"
// and symbols came out wrong ("keyboard performs differently on the sharer's device").
const CODE_MAP = {
Backspace: 'Backspace', Tab: 'Tab', Enter: 'Enter', NumpadEnter: 'Enter', Escape: 'Escape', Space: 'Space',
ShiftLeft: 'LeftShift', ShiftRight: 'RightShift',
ControlLeft: 'LeftControl', ControlRight: 'RightControl',
AltLeft: 'LeftAlt', AltRight: 'RightAlt',
MetaLeft: 'LeftSuper', MetaRight: 'RightSuper',
CapsLock: 'CapsLock',
PageUp: 'PageUp', PageDown: 'PageDown', End: 'End', Home: 'Home',
ArrowLeft: 'Left', ArrowUp: 'Up', ArrowRight: 'Right', ArrowDown: 'Down',
Insert: 'Insert', Delete: 'Delete',
Minus: 'Minus', Equal: 'Equal', BracketLeft: 'LeftBracket', BracketRight: 'RightBracket',
Backslash: 'Backslash', Semicolon: 'Semicolon', Quote: 'Quote', Backquote: 'Grave',
Comma: 'Comma', Period: 'Period', Slash: 'Slash',
NumpadAdd: 'Add', NumpadSubtract: 'Subtract', NumpadMultiply: 'Multiply', NumpadDivide: 'Divide', NumpadDecimal: 'Decimal',
};
function mapCode(code) {
if (!nut || !code) return null;
const K = nut.Key;
const named = CODE_MAP[code];
if (named && K[named] !== undefined) return [K[named]];
let m;
if ((m = /^Key([A-Z])$/.exec(code)) && K[m[1]] !== undefined) return [K[m[1]]];
if ((m = /^Digit([0-9])$/.exec(code)) && K['Num' + m[1]] !== undefined) return [K['Num' + m[1]]];
if ((m = /^Numpad([0-9])$/.exec(code)) && K['NumPad' + m[1]] !== undefined) return [K['NumPad' + m[1]]];
if ((m = /^(F\d{1,2})$/.exec(code)) && K[m[1]] !== undefined) return [K[m[1]]];
return null;
}
// Map browser KeyboardEvent.key values to nut-js Key enum names. (Fallback when there's no usable code.)
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;
}
// Cache the screen size (this nut-js exposes screen.width()/height(), not getResolution()).
// Recomputed once per session (cleared in releaseAll) so a resolution change is picked up.
let _screen = null;
async function screenSize() {
if (!_screen) _screen = { w: await nut.screen.width(), h: await nut.screen.height() };
return _screen;
}
async function moveTo(xNorm, yNorm) {
if (!nut) return;
const { w, h } = await screenSize();
await nut.mouse.setPosition(new nut.Point(Math.round(xNorm * w), Math.round(yNorm * h)));
}
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': {
// Prefer the PHYSICAL key so the remote OS applies its own shift/altgr state (correct symbols).
const m = mapCode(evt.code) || mapKey(evt.key, evt.code);
if (!m) break;
if (m.type) { await nut.keyboard.type(m.type); break; } // last-resort: type the literal character
await nut.keyboard.pressKey(...m); m.forEach((k) => pressed.add(k));
break;
}
case 'keyup': {
const m = mapCode(evt.code) || 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();
_screen = null;
}
module.exports = { inject, releaseAll, available, mapKey };