// 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 };