Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/framed-checkbox-forward-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prosopo/procaptcha-common": patch
---

The framed checkbox forwards its mouse, press and touch input to the host page. Input that arrives before the detector is listening, such as a tap the moment the page loads, is kept in the page's early behaviour capture so it is not lost when the frame is redrawn.
199 changes: 189 additions & 10 deletions packages/procaptcha-common/src/components/framedCheckbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { type Component, Teardown } from "../dom/component.js";
import { applyStyles } from "../dom/element.js";
import type { Activation } from "../events/activation.js";
import { noteInputMethod, watchInputMethod } from "../events/inputModality.js";
import { addPageBehaviour } from "../pageBehaviour.js";
import { type CheckboxProps, mountCheckbox } from "./checkbox.js";

/** The page the bundle ships beside its chunks to host the checkbox. */
Expand All @@ -34,18 +35,53 @@ const FRAME_HEIGHT_PX = 58;
/** Everything the frame needs to draw the checkbox. Functions do not cross. */
export type CheckboxFrameProps = Omit<CheckboxProps, "onChange">;

export type CheckboxFrameParentMessage = {
pf: string;
t: "props";
props: CheckboxFrameProps;
export type CheckboxFrameParentMessage =
| { pf: string; t: "props"; props: CheckboxFrameProps }
| { pf: string; t: "offset"; left: number; top: number };

/** A touch the frame forwards, already in the page's coordinates. */
export type ForwardedTouch = {
x: number;
y: number;
id: number;
force?: number;
rx?: number;
ry?: number;
rot?: number;
};

type ForwardedMouseInput = {
type: (typeof MOUSE_INPUT)[number];
x: number;
y: number;
button: number;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
};

type ForwardedTouchInput = {
type: (typeof TOUCH_INPUT)[number];
touches: ForwardedTouch[];
changed: ForwardedTouch[];
};

/**
* Input inside the frame never reaches the page's own behaviour collection, so
* the frame forwards it in page coordinates.
*/
export type CheckboxFrameInputMessage = { pf: string; t: "input" } & (
| ForwardedMouseInput
| ForwardedTouchInput
);

export type CheckboxFrameMessage =
| { pf: string; t: "ready" }
| { pf: string; t: "size"; w: number; h: number }
| { pf: string; t: "drawn" }
| ({ pf: string; t: "activate" } & Activation)
| { pf: string; t: "pointer"; type: string; pointerType: string };
| { pf: string; t: "pointer"; type: string; pointerType: string }
| CheckboxFrameInputMessage;

const createNonce = (): string =>
Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte: number) =>
Expand Down Expand Up @@ -126,6 +162,35 @@ export const mountFramedCheckbox = (
teardown.add(() => clearTimeout(timer));
teardown.addEventListener(frame, "error", dropFrame);

// The frame adds its own position on the page to the input it forwards.
const postOffset = (): void => {
const rect = frame.getBoundingClientRect();
const message: CheckboxFrameParentMessage = {
pf: nonce,
t: "offset",
left: rect.left,
top: rect.top,
};
frame.contentWindow?.postMessage(message, src.origin);
};
let offsetQueued = false;
const queueOffset = (): void => {
if (offsetQueued || !ready) {
return;
}
offsetQueued = true;
requestAnimationFrame(() => {
offsetQueued = false;
postOffset();
});
};
const viewportChange: AddEventListenerOptions = {
capture: true,
passive: true,
};
teardown.addEventListener(window, "scroll", queueOffset, viewportChange);
teardown.addEventListener(window, "resize", queueOffset, viewportChange);

const showFrame = (): void => {
shown = true;
clearTimeout(timer);
Expand All @@ -136,6 +201,7 @@ export const mountFramedCheckbox = (
});
standIn?.destroy();
standIn = undefined;
postOffset();
};

const postProps = (): void => {
Expand Down Expand Up @@ -172,6 +238,7 @@ export const mountFramedCheckbox = (
const data = message.data;
if ("ready" === data.t) {
ready = true;
postOffset();
postProps();
} else if ("size" === data.t) {
frame.style.width = `${data.w}px`;
Expand All @@ -180,6 +247,8 @@ export const mountFramedCheckbox = (
showFrame();
} else if ("activate" === data.t && shown) {
activate(data);
} else if ("input" === data.t) {
recordForwardedInput(data);
}
});

Expand All @@ -199,6 +268,64 @@ export const mountFramedCheckbox = (
};
};

/**
* The detector only starts listening for the frame's input once it has loaded,
* and a tap before then would be lost with the frame when the widget next
* redraws, so the page's early capture keeps it.
*/
const recordForwardedInput = (input: CheckboxFrameInputMessage): void => {
const timestamp = Date.now();
if ("touches" in input) {
const touch = input.touches[0] ?? input.changed[0];
if (touch) {
addPageBehaviour("touch", {
x: touch.x,
y: touch.y,
timestamp,
eventType: input.type,
touchCount: input.touches.length,
force: touch.force,
touchId: touch.id,
radiusX: touch.rx,
radiusY: touch.ry,
rotationAngle: touch.rot,
});
}
return;
}
if ("mousemove" === input.type) {
addPageBehaviour("mouse", { x: input.x, y: input.y, timestamp });
return;
}
addPageBehaviour("press", {
x: input.x,
y: input.y,
timestamp,
eventType: input.type,
button: input.button,
targetElement: "IFRAME",
ctrlKey: input.ctrlKey,
shiftKey: input.shiftKey,
altKey: input.altKey,
});
};

const MOUSE_INPUT = [
"mousemove",
"mousedown",
"mouseup",
"click",
"dblclick",
"contextmenu",
] as const;

const TOUCH_INPUT = [
"touchstart",
"touchmove",
"touchend",
"touchcancel",
] as const;

const POINTER_EVENTS = [
"pointerover",
"pointerout",
Expand Down Expand Up @@ -236,6 +363,7 @@ export const runCheckboxFrame = (): void => {
};

let checkbox: Component<CheckboxProps> | undefined;
let offset = { left: 0, top: 0 };

// The frame page lays its body out inline-block, so the body's box is the
// checkbox's size rather than the frame's.
Expand All @@ -247,11 +375,14 @@ export const runCheckboxFrame = (): void => {
addEventListener(
"message",
(event: MessageEvent<CheckboxFrameParentMessage>) => {
if (
event.source !== window.parent ||
event.data?.pf !== nonce ||
"props" !== event.data.t
) {
if (event.source !== window.parent || event.data?.pf !== nonce) {
return;
}
if ("offset" === event.data.t) {
offset = { left: event.data.left, top: event.data.top };
return;
}
if ("props" !== event.data.t) {
return;
}
// Without a matching color-scheme Chrome paints the frame's canvas
Expand Down Expand Up @@ -297,5 +428,53 @@ export const runCheckboxFrame = (): void => {
);
}

const forwardTouch = (touch: Touch): ForwardedTouch => ({
x: touch.clientX + offset.left,
y: touch.clientY + offset.top,
id: touch.identifier,
force: touch.force,
rx: touch.radiusX,
ry: touch.radiusY,
rot: touch.rotationAngle,
});
for (const type of MOUSE_INPUT) {
addEventListener(
type,
(event: MouseEvent) => {
if (event.isTrusted) {
post({
pf: nonce,
t: "input",
type,
x: event.clientX + offset.left,
y: event.clientY + offset.top,
button: event.button,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
});
}
},
{ capture: true, passive: true },
);
}
for (const type of TOUCH_INPUT) {
addEventListener(
type,
(event: TouchEvent) => {
if (event.isTrusted) {
post({
pf: nonce,
t: "input",
type,
touches: Array.from(event.touches, forwardTouch),
changed: Array.from(event.changedTouches, forwardTouch),
});
}
},
{ capture: true, passive: true },
);
}

post({ pf: nonce, t: "ready" });
};
22 changes: 21 additions & 1 deletion packages/procaptcha-common/src/pageBehaviour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface PageBehaviour {

interface PageBehaviourCapture {
behaviour: PageBehaviour;
recording: boolean;
stop: () => void;
}

Expand Down Expand Up @@ -167,9 +168,11 @@ export const startPageBehaviourCapture = (): void => {
}
window.addEventListener("scroll", onScroll, LISTENER_OPTIONS);

captureHost[CAPTURE_KEY] = {
const capture: PageBehaviourCapture = {
behaviour,
recording: true,
stop: () => {
capture.recording = false;
window.removeEventListener("mousemove", onMouseMove, LISTENER_OPTIONS);
for (const type of PRESS_EVENTS) {
window.removeEventListener(type, onPress, LISTENER_OPTIONS);
Expand All @@ -180,6 +183,23 @@ export const startPageBehaviourCapture = (): void => {
window.removeEventListener("scroll", onScroll, LISTENER_OPTIONS);
},
};
captureHost[CAPTURE_KEY] = capture;
};

/**
* Records input the page's listeners cannot see, such as taps inside the
* framed checkbox, for as long as the capture is still recording.
*/
export const addPageBehaviour = <K extends keyof PageBehaviour>(
kind: K,
point: PageBehaviour[K][number],
): void => {
const capture = captureHost[CAPTURE_KEY];
if (!capture?.recording) {
return;
}
const points: PageBehaviour[K][number][] = capture.behaviour[kind];
pushCapped(points, point, PAGE_BEHAVIOUR_LIMITS[kind]);
};

/**
Expand Down
Loading
Loading