Skip to content

fix(control): map clicks to the picture, not the letterboxed element around it - #61

Merged
mrprecioustech merged 1 commit into
masterfrom
fix/letterboxed-coordinate-mapping
Aug 11, 2026
Merged

fix(control): map clicks to the picture, not the letterboxed element around it#61
mrprecioustech merged 1 commit into
masterfrom
fix/letterboxed-coordinate-mapping

Conversation

@mrprecioustech

Copy link
Copy Markdown
Contributor

Found while auditing the remote-control path end to end after #59. This one has a wider blast radius than anything in that PR: it affects every click in every session, on both viewers.

The bug

The remote screen renders object-contain, so the browser letterboxes it whenever its aspect ratio differs from the viewer's window — the <video> element fills the container, but the picture is centred inside it with dead space on two sides.

Coordinates were normalized against the element:

const rect = container.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;

So every click carried an offset and a scale error, both proportional to how much dead space there was.

Concretely: a 16:9 host screen watched in a 1000x1000 area letterboxes to 1000x562.5, with 218.75px bars top and bottom. A guest clicking the very top of the host's screen — reaching for a menu bar, the single most common thing to aim at — put the pointer at y = 0.219, a fifth of the way down the screen instead.

Open a side panel and it gets worse. Happen to be watching at the host's aspect ratio and it looks perfect. That combination is why this presents as flaky rather than broken, and why it survived this long.

The fix

The geometry already existed and was already correct. getContainRect was written for the camera bubble, which had to be positioned against the picture for exactly this reason — it just was never used by the input path. It moves to @pairux/shared-types so there is one implementation, since an overlay drawn against one rectangle and clicks mapped against another is its own bug.

Three places were reading the wrong rectangle:

  • Click and move coordinates, in both the web and desktop viewers. The one that made control feel unusable.
  • Pointer-lock movement, which scaled deltas by the element, so the virtual pointer crossed one axis too slowly by whatever fraction the bars took — the drift that reads as "the cursor doesn't go where I push it".
  • The remote cursor overlay, which positioned by percentage of the container. Worse than it sounds: the cursor and the click disagreed, so the host watched a pointer hover over one thing and click another.

Positions inside the letterbox bars clamp to the nearest edge of the picture rather than being dropped, for the same reason coordinates are clamped everywhere else — the screen edges have to stay reachable.

Notes

  • useVideoContentRect re-measures on resize, on loadedmetadata, and on the video's resize event, since videoWidth is 0 until the stream reports it and the shape changes when the host switches which screen they share.
  • Before metadata arrives, getContainRect falls back to the full box — identical to the old behaviour, so there is no window where this is worse than before.
  • The web test setup gains a ResizeObserver stand-in; jsdom does not implement it.

lint 0 errors, typecheck 9/9, build 7/7. 1443 tests pass, +8 covering the mapping — including the menu-bar case above stated as a regression test.

Still not run on real hardware, same as #59.

🤖 Generated with Claude Code

…around it

The remote screen is rendered `object-contain`, so the browser letterboxes it
whenever its aspect ratio differs from the viewer's window: the <video> element
fills the container, but the picture is centred inside it with dead space on
two sides. Coordinates were normalized against the element.

Every click therefore carried an offset and a scale error, both proportional to
how much dead space there was. A 16:9 host screen watched in a square-ish
window letterboxes to a fifth of the height in bars — so a guest clicking the
very top of the host's screen, reaching for a menu bar, put the pointer a fifth
of the way down it instead. Open a side panel and it gets worse; happen to
match the host's aspect ratio and it looks perfect. That combination is why
this reads as flaky rather than broken.

The geometry already existed and was already correct: `getContainRect` was
written for the camera bubble, which had to be positioned against the picture
for exactly the same reason. It just was not used by the input path. It moves
to @pairux/shared-types so there is one implementation, since an overlay drawn
against one rectangle and clicks mapped against another is its own bug.

Three places were reading the wrong rectangle:

- Click and move coordinates, in both viewers. This is the one that made
  control feel unusable.
- Pointer-lock movement, which scaled deltas by the element, so the virtual
  pointer crossed one axis too slowly by whatever fraction the bars took.
- The remote cursor overlay, which drew the guest's cursor by percentage of the
  container. That one is worse than it sounds: the cursor and the click
  disagreed, so the host watched a pointer hover over one thing and click
  another.

Positions inside the letterbox bars clamp to the nearest edge of the picture
rather than being dropped, for the same reason coordinates are clamped
everywhere else — the screen edges have to stay reachable.

The web test setup gains a ResizeObserver stand-in, which jsdom does not
implement and the overlay's measurement needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

169 finding(s)

HIGH/CRITICAL: 16 | MEDIUM: 47 | LOW: 106

Severity Rule Location
HIGH sh-eval-expansion .githooks/pre-commit:33
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:691
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:715
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:813
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:815
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:894
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:910
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1064
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1066
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1128
HIGH secret-generic-credential apps/livekit/fly.toml:12
HIGH sh-remote-script-execution apps/livekit/setup-livekit-server.sh:93
HIGH sh-remote-script-execution apps/turn/deploy-droplet.sh:62
HIGH secret-generic-credential apps/turn/fly.toml:11
HIGH secret-generic-credential docs/API.md:648
HIGH secret-generic-credential docs/API.md:654
MEDIUM insecure-temp-file .githooks/commit-msg:19
MEDIUM insecure-temp-file .githooks/post-commit:22
MEDIUM insecure-temp-file apps/desktop/src/main/streaming/index.test.ts:64
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:370
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:393
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:402
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:438
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:48
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:73
MEDIUM js-unescaped-html-sink apps/web/src/app/c/[handle]/page.tsx:191
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:165
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:128
MEDIUM js-unescaped-html-sink apps/web/src/app/live/page.tsx:145
MEDIUM js-unescaped-html-sink apps/web/src/app/page.tsx:122
MEDIUM js-unescaped-html-sink apps/web/src/app/pricing/page.tsx:284
MEDIUM js-open-redirect apps/web/src/app/pricing/UpgradeButton.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/u/[username]/page.tsx:282
MEDIUM manifest-install-lifecycle-script package.json:54
MEDIUM sql-template-interpolation packages/ai-core/src/prompts.ts:36
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:81
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:96
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:112
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:133
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:160
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:189
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:214
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:237
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:273
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:298
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:324
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:346
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:154
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:160
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:208

…and 119 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@mrprecioustech
mrprecioustech merged commit e2fb1e0 into master Aug 11, 2026
13 checks passed
@mrprecioustech
mrprecioustech deleted the fix/letterboxed-coordinate-mapping branch August 11, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant