feat: relative mouse on the HID interface, and the verifier to prove it
The board now enumerates as a boot keyboard plus a relative mouse on the same HID interface. The pointer is captured, never grabbed: Pointer Lock in the page, so Esc (the browser's own, never forwarded) always frees it, and every frame restates the whole button byte so a frame lost in the WiFi cannot leave a button held. Losing the lock, the tab or the link runs the same release funnel as a stuck key, and boot protocol or `mouse off` refuses mouse frames with E11 rather than sending bytes a boot interface would misparse as keys. src/mouse.h owns the report, src/output.h the queues and the funnel; the page, relayctl.py and watch_target.py carry the button bits and the evdev decoding. The verifier's own tables are pinned now — REL_WHEEL is 8, not 2, and the first version guessed from the report's field order, so a working wheel looked dead while the summary said no scroll happened. LLM-Generated: true
This commit is contained in:
+227
-9
@@ -37,11 +37,33 @@ function makeEl(tagName) {
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
|
||||
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
|
||||
// Element handlers are reachable too: the mouse pad is clicked, and a real click is
|
||||
// what the browser turns into a Pointer Lock request.
|
||||
fire(t, ev) {
|
||||
for (const fn of (this.listeners[t] || [])) fn(ev || { preventDefault() {} });
|
||||
},
|
||||
getAttribute() { return null; }, setAttribute() {}, focus() {},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
// Document- and window-level listeners are recorded rather than dropped. Every
|
||||
// fail-safe this page has is triggered by something the browser does to the document --
|
||||
// blur, visibilitychange, pointerlockchange -- so a shim that swallows them cannot
|
||||
// prove them.
|
||||
function makeListeners() {
|
||||
const map = {};
|
||||
return {
|
||||
map,
|
||||
add(t, fn) { (map[t] = map[t] || []).push(fn); },
|
||||
fire(t, ev) {
|
||||
for (const fn of (map[t] || [])) fn(ev || { preventDefault() {}, target: null });
|
||||
},
|
||||
};
|
||||
}
|
||||
const docListeners = makeListeners();
|
||||
const winListeners = makeListeners();
|
||||
|
||||
// The shim hands out an element for any id, which is far less work than a real DOM.
|
||||
// The weakness in that is obvious, so it is compensated for: every id the script asks
|
||||
// for is recorded and checked against the ids that are actually in the markup at the
|
||||
@@ -70,16 +92,26 @@ class FakeWebSocket {
|
||||
FakeWebSocket.OPEN = 1;
|
||||
FakeWebSocket.instances = [];
|
||||
|
||||
const sandbox = {
|
||||
document: {
|
||||
getElementById: byId,
|
||||
createElement: t => makeEl(t),
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() {},
|
||||
body: makeEl("body"),
|
||||
hidden: false,
|
||||
const doc = {
|
||||
getElementById: byId,
|
||||
createElement: t => makeEl(t),
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: docListeners.add,
|
||||
body: makeEl("body"),
|
||||
hidden: false,
|
||||
// Pointer Lock, as much of it as the page touches. exitPointerLock fires the change
|
||||
// event because a real browser does, which is what makes the release path testable.
|
||||
pointerLockElement: null,
|
||||
exitPointerLock() {
|
||||
if (!doc.pointerLockElement) return;
|
||||
doc.pointerLockElement = null;
|
||||
docListeners.fire("pointerlockchange", {});
|
||||
},
|
||||
window: { addEventListener() {} },
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
document: doc,
|
||||
window: { addEventListener: winListeners.add },
|
||||
location: { protocol: "http:", host: "keyrelay.local" },
|
||||
sessionStorage: { getItem: () => null, setItem() {} },
|
||||
WebSocket: FakeWebSocket,
|
||||
@@ -95,6 +127,11 @@ const expose = `
|
||||
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
|
||||
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect,
|
||||
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
|
||||
takeMouse, dropMouse, flushMouse, mouseFrame, paintMouse, refreshMouseUi,
|
||||
MOUSE_DELTA_MAX, MOUSE_FRAME_MS, MOUSE_MAX_FRAMES, MOUSE_WHEEL_PX,
|
||||
get mCaptured() { return mCaptured; }, set mCaptured(v) { mCaptured = v; },
|
||||
get mButtons() { return mButtons; },
|
||||
get pendX() { return pendX; }, get pendY() { return pendY; },
|
||||
get ws() { return ws; },
|
||||
get armed() { return armed; }, set armed(v) { armed = v; },
|
||||
get authed() { return authed; }, set authed(v) { authed = v; },
|
||||
@@ -131,7 +168,29 @@ const checkEq = (a, b, label) => {
|
||||
}
|
||||
};
|
||||
const frames = () => sentFrames.filter(f => f.startsWith("P="));
|
||||
const mframes = () => sentFrames.filter(f => f.startsWith("M="));
|
||||
const textOf = f => Buffer.from(f.slice(f.indexOf(":") + 1), "hex").toString();
|
||||
|
||||
// The pad the page asks for Pointer Lock, wired the way a browser wires it: the request
|
||||
// sets pointerLockElement and fires pointerlockchange.
|
||||
function givePointerLock() {
|
||||
const pad = byId("mPad");
|
||||
pad.requestPointerLock = () => {
|
||||
doc.pointerLockElement = pad;
|
||||
docListeners.fire("pointerlockchange", {});
|
||||
return Promise.resolve();
|
||||
};
|
||||
return pad;
|
||||
}
|
||||
function takePointerLockAway() {
|
||||
doc.pointerLockElement = null;
|
||||
docListeners.fire("pointerlockchange", {});
|
||||
}
|
||||
const move = (dx, dy) => docListeners.fire("mousemove", { movementX: dx, movementY: dy });
|
||||
const btn = (b, down) => docListeners.fire(down ? "mousedown" : "mouseup",
|
||||
{ button: b, preventDefault() {} });
|
||||
const scroll = (dy, dx, mode) => docListeners.fire("wheel",
|
||||
{ deltaY: dy, deltaX: dx || 0, deltaMode: mode || 0, preventDefault() {} });
|
||||
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
console.log("the log stays readable");
|
||||
@@ -264,6 +323,165 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
|
||||
await p;
|
||||
checkEq(frames().length, 1, "a disarm mid-run stops the run");
|
||||
|
||||
console.log("the mouse");
|
||||
givePointerLock();
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.authed = true;
|
||||
|
||||
// A capture with nothing behind it must not produce a single frame.
|
||||
x.setArmed(false, "harness: disarmed");
|
||||
sentFrames.length = 0;
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(mframes().length, 0, "a disarmed bridge is not offered the mouse");
|
||||
checkEq(x.mCaptured, false, "and the pointer is not taken");
|
||||
|
||||
x.setArmed(true, "harness: armed");
|
||||
sentFrames.length = 0;
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(x.mCaptured, true, "capture takes the pointer when the bridge is armed");
|
||||
checkEq(mframes().length, 0, "taking the pointer sends no movement nobody made");
|
||||
|
||||
move(30, -4);
|
||||
sentFrames.length = 0;
|
||||
x.flushMouse();
|
||||
checkEq(mframes().length, 1, "one flush of one small movement is one frame");
|
||||
checkEq(mframes()[0], "M=00:30:-4:0:0", "the button byte first, then this frame's movement");
|
||||
|
||||
sentFrames.length = 0;
|
||||
x.flushMouse();
|
||||
checkEq(mframes().length, 0, "a frame that says nothing is not sent at all");
|
||||
|
||||
// A fast flick: the report carries one signed byte per axis, so the tab splits it.
|
||||
move(300, -260);
|
||||
sentFrames.length = 0;
|
||||
x.flushMouse();
|
||||
const big = mframes();
|
||||
const sum = i => big.reduce((a, f) => a + Number(f.split(":")[i]), 0);
|
||||
checkEq(sum(1), 300, "300 px of X arrives as 300 px, split across reports");
|
||||
checkEq(sum(2), -260, "and so does the Y");
|
||||
check(big.every(f => [1, 2, 3, 4].every(i => Math.abs(Number(f.split(":")[i]))
|
||||
<= x.MOUSE_DELTA_MAX)),
|
||||
"and no single report asks for more than one signed byte");
|
||||
|
||||
// Order is the whole difference between a click and a drag.
|
||||
move(12, 0);
|
||||
sentFrames.length = 0;
|
||||
btn(0, true);
|
||||
const order = sentFrames.filter(f => f.startsWith("M="));
|
||||
checkEq(order.length, 2, "a press with movement behind it is two frames");
|
||||
checkEq(order[0], "M=00:12:0:0:0", "the movement first, under the old button state");
|
||||
checkEq(order[1], "M=01:0:0:0:0", "then the button, so the target sees press then move");
|
||||
checkEq(x.mButtons, 0x01, "left is held");
|
||||
|
||||
sentFrames.length = 0;
|
||||
btn(0, false);
|
||||
checkEq(mframes()[0], "M=00:0:0:0:0", "release restates the whole button byte as nothing");
|
||||
checkEq(x.mButtons, 0, "and the tab agrees");
|
||||
|
||||
// Two buttons at once, and letting go of one: the byte that goes out is the state,
|
||||
// not the change. (DOM button 0 = left -> 0x01, 2 = right -> 0x02, 1 = middle -> 0x04.)
|
||||
btn(0, true);
|
||||
btn(2, true);
|
||||
sentFrames.length = 0;
|
||||
btn(0, false);
|
||||
checkEq(mframes()[0], "M=02:0:0:0:0", "right stays down when the left button goes up");
|
||||
|
||||
sentFrames.length = 0;
|
||||
scroll(100, 0); // one detent's worth of pixels
|
||||
scroll(100, 0);
|
||||
scroll(25, 0); // not yet a detent: carried, not dropped
|
||||
x.flushMouse();
|
||||
checkEq(mframes()[0], "M=02:0:0:2:0", "wheel travels in detents and the remainder waits");
|
||||
|
||||
sentFrames.length = 0;
|
||||
scroll(3, 0, 1); // deltaMode 1: lines, one detent each
|
||||
x.flushMouse();
|
||||
checkEq(mframes()[0], "M=02:0:0:3:0", "a line is a detent, whatever the pixel density");
|
||||
|
||||
// The fail-safes. Each one has to let go of the buttons, whatever else it does.
|
||||
btn(2, false);
|
||||
btn(0, true);
|
||||
checkEq(x.mButtons, 0x01, "holding left again for the release tests");
|
||||
sentFrames.length = 0;
|
||||
takePointerLockAway();
|
||||
checkEq(mframes()[0], "M=00:0:0:0:0",
|
||||
"losing the pointer lock (Esc, focus, the browser) releases every button");
|
||||
checkEq(x.mCaptured, false, "and the tab stops capturing");
|
||||
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
btn(1, true);
|
||||
sentFrames.length = 0;
|
||||
doc.pointerLockElement = null; // the browser already dropped it; blur follows
|
||||
winListeners.fire("blur", {});
|
||||
check(mframes().includes("M=00:0:0:0:0"),
|
||||
"a blur releases the buttons too");
|
||||
checkEq(x.mCaptured, false, "and ends the capture");
|
||||
|
||||
// The stay-armed-on-blur option is an exception for the keyboard and must not become
|
||||
// one for the cursor: a pointer nobody is looking at is not a pointer being aimed.
|
||||
byId("keepArmed").checked = true;
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
btn(0, true);
|
||||
sentFrames.length = 0;
|
||||
winListeners.fire("blur", {});
|
||||
check(mframes().includes("M=00:0:0:0:0"),
|
||||
"stay-armed-on-blur keeps the keyboard armed and still releases the mouse");
|
||||
checkEq(x.mCaptured, false, "the capture does not survive it either");
|
||||
byId("keepArmed").checked = false;
|
||||
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
btn(0, true);
|
||||
sentFrames.length = 0;
|
||||
x.setArmed(false, "harness: disarmed with a button held");
|
||||
checkEq(x.mCaptured, false, "disarm ends the capture");
|
||||
checkEq(x.mButtons, 0, "and the tab's own button state");
|
||||
// The board releases the buttons itself when it disarms (src/output.h), so the tab
|
||||
// sends an M= frame to a bridge that has already answered: silence here is correct.
|
||||
checkEq(mframes().length, 0,
|
||||
"disarm does not beg a bridge that has already let go");
|
||||
|
||||
// A board that cannot report a mouse: refused up front, and a running capture ended.
|
||||
x.setArmed(true, "harness: armed");
|
||||
sentFrames.length = 0;
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(x.mCaptured, false, "a board with no mouse interface does not take the pointer");
|
||||
checkEq(mframes().length, 0, "and gets no frames");
|
||||
checkEq(byId("mCapture").disabled, true, "the button says so by being disabled");
|
||||
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":1}');
|
||||
checkEq(byId("mCapture").disabled, false,
|
||||
"the button comes back when the board says it can report a mouse");
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(x.mCaptured, true, "and it captures again once the board can report one");
|
||||
sentFrames.length = 0;
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
|
||||
checkEq(x.mCaptured, false,
|
||||
"a capture that the board can no longer honour is ended, not left hanging");
|
||||
|
||||
// Every frame the tab can emit has to be one the firmware parses: five fields, a hex
|
||||
// button byte, signed decimal movements, and short enough for one WebSocket message.
|
||||
const shape = /^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$/;
|
||||
let malformed = 0, tooLong = 0;
|
||||
for (const f of mframes()) {
|
||||
if (!shape.test(f)) malformed++;
|
||||
if (f.length > 256) tooLong++;
|
||||
}
|
||||
checkEq(malformed, 0, "every mouse frame this tab emitted parses");
|
||||
checkEq(tooLong, 0, "and every one fits in one WebSocket message");
|
||||
|
||||
x.mCaptured = false;
|
||||
x.setArmed(false, "harness: done");
|
||||
|
||||
console.log("element ids");
|
||||
const markup = fs.readFileSync(headerPath, "utf8");
|
||||
const present = new Set();
|
||||
|
||||
Reference in New Issue
Block a user