@limrun/ui 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "next"
@@ -0,0 +1,34 @@
1
+ // Covers `withAuthenticationToken` only — the rest of <RemoteControl> is
2
+ // integration-tested (see vitest.config.ts).
3
+ //
4
+ // Assertions parse the result and read decoded parameter values, so they
5
+ // don't depend on whether a space serializes as `+` or `%20`.
6
+
7
+ import { describe, expect, test } from 'vitest';
8
+ import { withAuthenticationToken } from './remote-control';
9
+
10
+ describe('withAuthenticationToken', () => {
11
+ test('adds the token alongside query parameters already on the endpoint', () => {
12
+ const result = new URL(
13
+ withAuthenticationToken('wss://example.com/socket?transport=webrtc', 'instance-token'),
14
+ );
15
+
16
+ expect(result.searchParams.get('transport')).toBe('webrtc');
17
+ expect(result.searchParams.get('token')).toBe('instance-token');
18
+ });
19
+
20
+ test('keeps a token containing URL syntax as a single decoded value', () => {
21
+ const token = 'instance token&scope=preview';
22
+ const result = new URL(withAuthenticationToken('wss://example.com/socket?transport=webrtc', token));
23
+
24
+ expect(result.searchParams.get('token')).toBe(token);
25
+ expect(result.searchParams.get('transport')).toBe('webrtc');
26
+ expect(result.searchParams.get('scope')).toBeNull();
27
+ });
28
+
29
+ test('replaces an existing token instead of duplicating it', () => {
30
+ const result = new URL(withAuthenticationToken('wss://example.com/socket?token=stale', 'fresh-token'));
31
+
32
+ expect(result.searchParams.getAll('token')).toEqual(['fresh-token']);
33
+ });
34
+ });
@@ -437,6 +437,13 @@ const detectPlatform = (url: string): DevicePlatform => {
437
437
  return 'ios';
438
438
  };
439
439
 
440
+ // The endpoint may already carry query parameters, and the token may contain URL syntax.
441
+ export const withAuthenticationToken = (url: string, token: string): string => {
442
+ const endpoint = new URL(url);
443
+ endpoint.searchParams.set('token', token);
444
+ return endpoint.toString();
445
+ };
446
+
440
447
  type DeviceConfig = {
441
448
  videoBorderRadiusMultiplier: number;
442
449
  loadingLogo: string;
@@ -688,6 +695,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
688
695
  pointerId1: number;
689
696
  };
690
697
  const twoFingerStateRef = useRef<TwoFingerState | null>(null);
698
+ // Wheel-scroll drag state; lives for a single synthesized gesture.
699
+ const wheelStateRef = useRef<{ pos: PointerGeometry; endTimer: number } | null>(null);
700
+ // Latest wheel handlers, so the once-attached native listener and the
701
+ // pointer machinery always call the current render's closures.
702
+ const wheelCallbacksRef = useRef<{ handle: (event: WheelEvent) => void; end: () => void }>({
703
+ handle: () => {},
704
+ end: () => {},
705
+ });
691
706
 
692
707
  // Hover point for rendering two-finger indicators when Alt is held.
693
708
  // Only computed/set when Alt is held to avoid unnecessary re-renders.
@@ -789,8 +804,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
789
804
  };
790
805
 
791
806
  // Pointer ID used by inspect-driven taps. Distinct from human pointers
792
- // (-1 mouse, -2 alt-mirror) and our touch identifiers so they never
793
- // interfere with an in-progress drag.
807
+ // (-1 mouse, -2 alt-mirror), the synthesized wheel finger (-3), and our
808
+ // touch identifiers so they never interfere with an in-progress drag.
794
809
  const AX_TAP_POINTER_ID = -10;
795
810
 
796
811
  // Send a down+up tap at a viewport-space (clientX/Y) position. The point
@@ -854,6 +869,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
854
869
  // Fixed pointer IDs for Alt-simulated two-finger gestures
855
870
  const ALT_POINTER_ID_PRIMARY = -1;
856
871
  const ALT_POINTER_ID_MIRROR = -2;
872
+ // Synthesized finger for wheel-scroll drags.
873
+ const WHEEL_POINTER_ID = -3;
857
874
 
858
875
  // Helper to send a single-touch control message (used by both single-finger and Android two-finger paths)
859
876
  const sendSingleTouch = (
@@ -911,6 +928,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
911
928
 
912
929
  switch (eventType) {
913
930
  case 'down':
931
+ // A real pointer going down takes over from any synthesized
932
+ // wheel-scroll finger, so the tap isn't sent as a second finger.
933
+ if (pointerId !== WHEEL_POINTER_ID) {
934
+ wheelCallbacksRef.current.end();
935
+ }
914
936
  // For multi-touch: use ACTION_DOWN for first pointer, ACTION_POINTER_DOWN for additional pointers
915
937
  const currentPointerCount = activePointers.current.size;
916
938
  action = currentPointerCount === 0 ? AMOTION_EVENT.ACTION_DOWN : AMOTION_EVENT.ACTION_POINTER_DOWN;
@@ -1199,6 +1221,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1199
1221
  p1: { x: x1, y: y1, id: pointerId1 },
1200
1222
  });
1201
1223
 
1224
+ if (eventType === 'down') {
1225
+ // A real two-finger gesture takes over from any synthesized
1226
+ // wheel-scroll finger.
1227
+ wheelCallbacksRef.current.end();
1228
+ }
1229
+
1202
1230
  if (platform === 'ios') {
1203
1231
  // iOS: use special two-finger message (type=18)
1204
1232
  const action =
@@ -1554,6 +1582,130 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1554
1582
  }
1555
1583
  };
1556
1584
 
1585
+ // Mouse wheel / trackpad scrolling over the stream scrolls inside the
1586
+ // simulator. iOS has no scroll wheel, so we fake a finger: press down
1587
+ // where the cursor is, drag it as wheel events come in, lift when they
1588
+ // stop. If the fake finger reaches the screen edge before the user stops
1589
+ // scrolling, lift and press again to keep going.
1590
+ const WHEEL_END_QUIET_MS = 150;
1591
+ const endWheelGesture = () => {
1592
+ const state = wheelStateRef.current;
1593
+ if (!state) return;
1594
+ window.clearTimeout(state.endTimer);
1595
+ applyPointerEvent(WHEEL_POINTER_ID, 'up', state.pos);
1596
+ wheelStateRef.current = null;
1597
+ };
1598
+ const handleWheel = (event: WheelEvent) => {
1599
+ // Trackpad pinches arrive as ctrl+wheel; don't turn them into scrolls.
1600
+ if (event.ctrlKey) return;
1601
+ // If the user is already touching/dragging (or holding Alt for the
1602
+ // two-finger gesture), let that win - don't add a second fake finger.
1603
+ if (isAltHeldRef.current || twoFingerStateRef.current) return;
1604
+ const pointerCount = activePointers.current.size;
1605
+ if (pointerCount > 1 || (pointerCount === 1 && !activePointers.current.has(WHEEL_POINTER_ID))) return;
1606
+
1607
+ const ctx = computeVideoMappingContext();
1608
+ if (!ctx) return;
1609
+ // Outside the rendered video (frame, letterbox) let the page scroll.
1610
+ const relativeX = event.clientX - ctx.videoRect.left - ctx.offsetX;
1611
+ const relativeY = event.clientY - ctx.videoRect.top - ctx.offsetY;
1612
+ if (relativeX < 0 || relativeX > ctx.actualWidth || relativeY < 0 || relativeY > ctx.actualHeight) {
1613
+ return;
1614
+ }
1615
+ const anchor = mapClientPointToVideo(ctx, event.clientX, event.clientY);
1616
+ if (!anchor) return;
1617
+ event.preventDefault();
1618
+
1619
+ // deltaMode: 0=pixels, 1=lines, 2=pages. Deltas are CSS pixels; convert
1620
+ // to stream pixels so a tick scrolls the same visual distance at any
1621
+ // window size.
1622
+ const scaleX =
1623
+ (event.deltaMode === 1 ? 16
1624
+ : event.deltaMode === 2 ? ctx.actualWidth
1625
+ : 1) *
1626
+ (ctx.videoWidth / ctx.actualWidth);
1627
+ const scaleY =
1628
+ (event.deltaMode === 1 ? 16
1629
+ : event.deltaMode === 2 ? ctx.actualHeight
1630
+ : 1) *
1631
+ (ctx.videoHeight / ctx.actualHeight);
1632
+ const dx = -event.deltaX * scaleX;
1633
+ const dy = -event.deltaY * scaleY;
1634
+ const armEnd = (s: { endTimer: number }) => {
1635
+ window.clearTimeout(s.endTimer);
1636
+ s.endTimer = window.setTimeout(endWheelGesture, WHEEL_END_QUIET_MS);
1637
+ };
1638
+ if (dx === 0 && dy === 0) {
1639
+ // Some wheel events carry no movement (momentum tails); just keep
1640
+ // the gesture alive.
1641
+ if (wheelStateRef.current) armEnd(wheelStateRef.current);
1642
+ return;
1643
+ }
1644
+
1645
+ // Where to press the fake finger down: at the cursor, but nudged away
1646
+ // from the screen edges so the drag has room to travel.
1647
+ const pressPoint = (): PointerGeometry => ({
1648
+ videoWidth: anchor.videoWidth,
1649
+ videoHeight: anchor.videoHeight,
1650
+ videoX:
1651
+ Math.abs(dx) > Math.abs(dy) ?
1652
+ Math.min(Math.max(anchor.videoX, anchor.videoWidth * 0.2), anchor.videoWidth * 0.8)
1653
+ : anchor.videoX,
1654
+ videoY:
1655
+ Math.abs(dy) >= Math.abs(dx) ?
1656
+ Math.min(Math.max(anchor.videoY, anchor.videoHeight * 0.2), anchor.videoHeight * 0.8)
1657
+ : anchor.videoY,
1658
+ });
1659
+
1660
+ const advance = (p: PointerGeometry): PointerGeometry => ({
1661
+ ...p,
1662
+ videoX: Math.min(Math.max(p.videoX + dx, 0), p.videoWidth),
1663
+ videoY: Math.min(Math.max(p.videoY + dy, 0), p.videoHeight),
1664
+ });
1665
+
1666
+ let state = wheelStateRef.current;
1667
+ if (!state) {
1668
+ const start = pressPoint();
1669
+ applyPointerEvent(WHEEL_POINTER_ID, 'down', start);
1670
+ state = { pos: start, endTimer: 0 };
1671
+ wheelStateRef.current = state;
1672
+ } else {
1673
+ window.clearTimeout(state.endTimer);
1674
+ }
1675
+
1676
+ let pos = state.pos;
1677
+ let next = advance(pos);
1678
+ if (next.videoX === pos.videoX && next.videoY === pos.videoY) {
1679
+ // Finger reached the screen edge: lift it, press again with room to
1680
+ // travel, and move right away (a press that never moves would count
1681
+ // as a tap).
1682
+ applyPointerEvent(WHEEL_POINTER_ID, 'up', pos);
1683
+ pos = pressPoint();
1684
+ applyPointerEvent(WHEEL_POINTER_ID, 'down', pos);
1685
+ next = advance(pos);
1686
+ }
1687
+ applyPointerEvent(WHEEL_POINTER_ID, 'move', next);
1688
+ state.pos = next;
1689
+ armEnd(state);
1690
+ };
1691
+ useEffect(() => {
1692
+ wheelCallbacksRef.current = { handle: handleWheel, end: endWheelGesture };
1693
+ });
1694
+
1695
+ // React attaches onWheel passively, so preventDefault (needed to keep the
1696
+ // page from scrolling) requires a native non-passive listener. Cleanup
1697
+ // lifts an in-flight synthesized finger before the channel goes away.
1698
+ useEffect(() => {
1699
+ const container = containerRef.current;
1700
+ if (!container) return;
1701
+ const listener = (event: WheelEvent) => wheelCallbacksRef.current.handle(event);
1702
+ container.addEventListener('wheel', listener, { passive: false });
1703
+ return () => {
1704
+ wheelCallbacksRef.current.end();
1705
+ container.removeEventListener('wheel', listener);
1706
+ };
1707
+ }, []);
1708
+
1557
1709
  useEffect(() => {
1558
1710
  const handleKeyDown = (event: KeyboardEvent) => {
1559
1711
  if (event.key === 'Alt') {
@@ -2131,8 +2283,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2131
2283
  parsed.protocol === 'wss:' ? 'https:'
2132
2284
  : parsed.protocol === 'ws:' ? 'http:'
2133
2285
  : parsed.protocol;
2134
- parsed.searchParams.set('token', token);
2135
- probeUrl = parsed.toString();
2286
+ probeUrl = withAuthenticationToken(parsed.toString(), token);
2136
2287
  } catch {
2137
2288
  return false;
2138
2289
  }
@@ -2256,7 +2407,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2256
2407
  }, CONNECTION_SUCCESS_TIMEOUT_MS);
2257
2408
 
2258
2409
  try {
2259
- const ws = new WebSocket(`${url}?token=${token}`);
2410
+ const ws = new WebSocket(withAuthenticationToken(url, token));
2260
2411
  wsRef.current = ws;
2261
2412
 
2262
2413
  // Wait for WebSocket to connect
package/vitest.config.ts CHANGED
@@ -12,8 +12,9 @@ import { defineConfig } from 'vitest/config';
12
12
  //
13
13
  // The actual <RemoteControl> component is intentionally NOT under unit
14
14
  // test here — its WebRTC plumbing is integration-tested via the demo +
15
- // staging instance. These tests cover the smaller, pure-logic modules:
16
- // `core/ax-tree.ts` and `core/ax-fetcher.ts`.
15
+ // staging instance. These tests cover the smaller, pure-logic pieces:
16
+ // `core/ax-tree.ts`, `core/ax-fetcher.ts` and the URL helper exported from
17
+ // `components/remote-control.tsx`.
17
18
  export default defineConfig({
18
19
  test: {
19
20
  environment: 'jsdom',