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
6 changes: 3 additions & 3 deletions apps/desktop/src/main/ipc/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ vi.mock('../input/injector', () => ({
enabled: false,
backend: 'nut-js',
backendSupported: true,
stats: { received: 0, injected: 0, rejected: 0, errors: 0 },
stats: { received: 0, injected: 0, rejected: 0, errors: 0, coalesced: 0 },
heldButtons: 0,
heldKeys: 0,
}),
Expand Down Expand Up @@ -100,7 +100,7 @@ describe('IPC Input Handlers', () => {
enabled: true,
backend: 'nut-js',
backendSupported: true,
stats: { received: 0, injected: 0, rejected: 0, errors: 0 },
stats: { received: 0, injected: 0, rejected: 0, errors: 0, coalesced: 0 },
heldButtons: 0,
heldKeys: 0,
});
Expand Down Expand Up @@ -135,7 +135,7 @@ describe('IPC Input Handlers', () => {
enabled: true,
backend: 'nut-js',
backendSupported: true,
stats: { received: 1, injected: 1, rejected: 0, errors: 0 },
stats: { received: 1, injected: 1, rejected: 0, errors: 0, coalesced: 0 },
heldButtons: 0,
heldKeys: 0,
});
Expand Down
136 changes: 76 additions & 60 deletions apps/desktop/src/renderer/components/control/InputCapture.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,58 +99,41 @@ describe('InputCapture pointer lock', () => {
vi.clearAllMocks();
});

it('sends real coordinates when there is no pointer lock', () => {
// The guest's complaint that drove this: their real cursor walks out of the
// video the moment they push toward an edge, so they cannot reach a menu bar,
// a corner, or a window's controls. Clicking the picture takes the pointer.
it('captures the pointer when the guest clicks the picture', () => {
const browser = installBrowserApis();
const onInputEvent = vi.fn();
const { container } = renderCapture(onInputEvent);
const { container } = renderCapture(vi.fn());

act(() => {
clickAt(container, 250, 250);
});

expect(browser.lockRequests).toHaveLength(0);
expect(onInputEvent).toHaveBeenCalledWith(
expect.objectContaining({ action: 'down', x: 0.25, y: 0.5 })
);
expect(browser.lockRequests).toHaveLength(1);
});

// The regression. Fullscreen is granted, the lock is refused — Chromium does
// exactly this during the cooldown after an Escape — and the click must still
// carry where the guest actually clicked.
it('keeps using real coordinates when the lock is refused', async () => {
const browser = installBrowserApis();
// That first click is how the guest reaches for the window, not for whatever
// happens to be under the pointer at the time.
it('does not forward the click that takes control', () => {
installBrowserApis();
const onInputEvent = vi.fn();
const { container, getByRole } = renderCapture(onInputEvent);

await act(async () => {
getByRole('button').click();
await browser.grantFullscreen();
});

act(() => {
browser.denyLock();
});
const { container } = renderCapture(onInputEvent);

onInputEvent.mockClear();
act(() => {
clickAt(container, 250, 250);
});

expect(onInputEvent).toHaveBeenCalledWith(
expect.objectContaining({ action: 'down', x: 0.25, y: 0.5 })
);
expect(onInputEvent).not.toHaveBeenCalled();
});

it('uses the virtual position once the lock is granted', async () => {
it('uses the virtual position once the lock is granted', () => {
const browser = installBrowserApis();
const onInputEvent = vi.fn();
const { container, getByRole } = renderCapture(onInputEvent);
const { container } = renderCapture(onInputEvent);

await act(async () => {
getByRole('button').click();
await browser.grantFullscreen();
});
act(() => {
clickAt(container, 250, 250);
browser.grantLock(container);
});

Expand All @@ -172,61 +155,94 @@ describe('InputCapture pointer lock', () => {
);
});

// Escape releases the lock without leaving fullscreen. The lifecycle effect
// used to see "fullscreen, granted, unlocked" on the next render and ask for
// the lock straight back, so Escape appeared to do nothing.
it('does not grab the pointer back after the guest presses Escape', async () => {
// A refusal must not leave the guest unable to do anything. Chromium refuses
// during the cooldown after an Escape, and some embeddings refuse outright.
it('falls back to absolute coordinates when the lock is refused', () => {
vi.useFakeTimers();
try {
const browser = installBrowserApis();
const onInputEvent = vi.fn();
const { container } = renderCapture(onInputEvent);

act(() => {
clickAt(container, 250, 250);
browser.denyLock();
});

// The component waits briefly before concluding the lock is not coming.
act(() => {
vi.advanceTimersByTime(1500);
});

onInputEvent.mockClear();
act(() => {
clickAt(container, 250, 250);
});

expect(onInputEvent).toHaveBeenCalledWith(
expect.objectContaining({ action: 'down', x: 0.25, y: 0.5 })
);
} finally {
vi.useRealTimers();
}
});

// Escape releases the lock. The lifecycle effect used to see "granted, not
// locked" on the next render and ask for it straight back, so Escape appeared
// to do nothing.
it('does not grab the pointer back after the guest presses Escape', () => {
const browser = installBrowserApis();
const onInputEvent = vi.fn();
const { container, getByRole } = renderCapture(onInputEvent);
const { container } = renderCapture(vi.fn());

await act(async () => {
getByRole('button').click();
await browser.grantFullscreen();
});
act(() => {
clickAt(container, 250, 250);
browser.grantLock(container);
});

const requestsBefore = browser.lockRequests.length;

act(() => {
// Escape: the browser drops the lock and leaves fullscreen alone.
document.exitPointerLock();
});

expect(browser.lockRequests).toHaveLength(requestsBefore);
});

it('re-captures when the guest clicks again after releasing', () => {
const browser = installBrowserApis();
const { container } = renderCapture(vi.fn());

// And the guest's real cursor drives coordinates again.
onInputEvent.mockClear();
act(() => {
clickAt(container, 250, 250);
browser.grantLock(container);
document.exitPointerLock();
});
expect(onInputEvent).toHaveBeenCalledWith(
expect.objectContaining({ action: 'down', x: 0.25, y: 0.5 })
);

const requestsBefore = browser.lockRequests.length;
act(() => {
clickAt(container, 250, 250);
});

expect(browser.lockRequests.length).toBe(requestsBefore + 1);
});

it('re-captures the pointer when the guest asks for it again', async () => {
it('goes back to asking for control when control is revoked', () => {
const browser = installBrowserApis();
const { container, getByRole } = renderCapture(vi.fn());
const { container, rerender } = renderCapture(vi.fn());

await act(async () => {
getByRole('button').click();
await browser.grantFullscreen();
});
act(() => {
clickAt(container, 250, 250);
browser.grantLock(container);
document.exitPointerLock();
});

const requestsBefore = browser.lockRequests.length;
act(() => {
getByRole('button').click();
rerender(
<InputCapture enabled controlState="view-only" onInputEvent={vi.fn()} allowFullscreen>
<video />
</InputCapture>
);
});

expect(browser.lockRequests.length).toBe(requestsBefore + 1);
// Still fullscreen: the click asked for the pointer, not for the exit.
expect(document.fullscreenElement).not.toBeNull();
expect(document.pointerLockElement).toBeNull();
});
});
80 changes: 75 additions & 5 deletions apps/desktop/src/renderer/components/control/InputCapture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,28 @@ export function InputCapture({
onMove: handlePointerMove,
});

// The guest has asked to drive, by clicking into the picture.
//
// Capture is deliberately not tied to fullscreen. Without the pointer held,
// the guest's real cursor walks straight out of the video the moment they
// push toward an edge — so they cannot reach a menu bar, a corner, or a
// window's controls, which is most of what taking control is *for*. Making
// that conditional on entering fullscreen first put the fix behind a step
// nobody found.
const [captureRequested, setCaptureRequested] = useState(false);
// The browser refused the lock. Rather than leaving the guest unable to do
// anything, fall back to plain absolute coordinates — worse at the edges,
// but working.
const [lockDenied, setLockDenied] = useState(false);

// Wanting the lock and having it are different states, and conflating them is
// a silent failure: a request can be denied (no user gesture, or Chromium's
// cooldown after an Escape) and then the guest sees their real cursor over a
// fullscreen video while every click is sent from a virtual position they
// are not steering. So the lock must be *granted* before coordinates come
// from anywhere but the event.
const wantsLock = controlState === 'granted' && isFullscreen && !wasReleasedByUser;
// cooldown after an Escape) and then the guest sees their real cursor over
// the video while every click is sent from a virtual position they are not
// steering. So the lock must be *granted* before coordinates come from
// anywhere but the event.
const wantsLock =
controlState === 'granted' && captureRequested && !wasReleasedByUser && !lockDenied;

const { isCapturing, startCapture, stopCapture } = useRemoteControl({
enabled,
Expand Down Expand Up @@ -84,6 +99,53 @@ export function InputCapture({
}
}, [isCapturing]);

// Clicking the picture takes control of the pointer.
//
// Capture phase, so this runs before useRemoteControl's own mousedown on the
// same container and can stop the click that *acquires* control from also
// being sent to the host — the guest is reaching for the window, not for
// whatever happens to be under it.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (controlState !== 'granted' || !enabled) return;

const onMouseDown = (event: MouseEvent) => {
if (isLocked || lockDenied) return;
event.stopPropagation();
event.preventDefault();
// Clears the "they pressed Escape" latch, so asking again works.
resetPosition();
setCaptureRequested(true);
lock(container);
};

container.addEventListener('mousedown', onMouseDown, { capture: true });
return () => {
container.removeEventListener('mousedown', onMouseDown, { capture: true });
};
}, [controlState, enabled, isLocked, lockDenied, lock, resetPosition]);

// A refusal is not permanent, but it must not be retried on a loop either.
// Record it so coordinates stay absolute and the guest keeps working.
useEffect(() => {
if (!captureRequested || isLocked) return;
const timer = setTimeout(() => {
if (!document.pointerLockElement) setLockDenied(true);
}, 1000);
return () => {
clearTimeout(timer);
};
}, [captureRequested, isLocked]);

// Control ending resets the whole interaction, so the next grant starts from
// "click to take control" rather than from whatever state this was left in.
useEffect(() => {
if (controlState === 'granted') return;
setCaptureRequested(false);
setLockDenied(false);
}, [controlState]);

// Pointer lock lifecycle: request when fullscreen and control is granted,
// release when either ends.
//
Expand Down Expand Up @@ -173,6 +235,14 @@ export function InputCapture({
<div className="pointer-events-none absolute inset-0 rounded-lg ring-2 ring-green-500/50" />
)}

{controlState === 'granted' && !isLocked && !lockDenied && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<div className="rounded-lg bg-black/70 px-4 py-2 text-sm font-medium text-white backdrop-blur">
Click to control · Esc to release
</div>
</div>
)}

{allowFullscreen && controlState === 'granted' && (
<button
data-pairux-local-control
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/session/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ function SessionViewerContent({
controlState={controlState}
onInputEvent={sendInput}
onCursorMove={sendCursorPosition}
allowFullscreen
className="h-full"
>
<VideoViewer
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/view/[sessionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ function GuestViewerContent({
controlState={controlState}
onInputEvent={sendInput}
onCursorMove={sendCursorPosition}
allowFullscreen
className="h-full"
>
<VideoViewer
Expand Down
Loading
Loading