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:
2026-11-08 23:27:42 -05:00
parent 9bc8d5213c
commit ece0313d02
19 changed files with 3135 additions and 324 deletions
+227 -9
View File
@@ -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();