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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pairux/desktop",
"version": "0.9.54",
"version": "0.9.55",
"private": true,
"description": "PairUX Desktop - Screen sharing with remote control",
"author": "PairUX Team <hello@pairux.com>",
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/src/main/input/injector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,12 +301,14 @@ describe('Input Injector', () => {
});

describe('mouse scroll events', () => {
it('should handle scroll down event', async () => {
// DOM WheelEvent: positive deltaY is a scroll *down*. Both backends had
// this backwards, so every remote scroll went the wrong way.
it('scrolls down for positive deltaY', async () => {
const event: MouseScrollEvent = {
type: 'mouse',
action: 'scroll',
deltaX: 0,
deltaY: -120,
deltaY: 120,
x: 0.5,
y: 0.5,
};
Expand All @@ -315,14 +317,15 @@ describe('Input Injector', () => {

expect(mouse.setPosition).toHaveBeenCalled();
expect(mouse.scrollDown).toHaveBeenCalled();
expect(mouse.scrollUp).not.toHaveBeenCalled();
});

it('should handle scroll up event', async () => {
it('scrolls up for negative deltaY', async () => {
const event: MouseScrollEvent = {
type: 'mouse',
action: 'scroll',
deltaX: 0,
deltaY: 120,
deltaY: -120,
x: 0.5,
y: 0.5,
};
Expand Down
16 changes: 4 additions & 12 deletions apps/desktop/src/renderer/components/capture/CapturePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -344,20 +344,12 @@ export function CapturePreview({
};
}, [hasPendingControlRequests]);

const inputScreenSize = useMemo(() => {
if (!stream) return undefined;
const tracks = stream.getVideoTracks();
const track = tracks.length > 0 ? tracks[0] : undefined;
const settings = track?.getSettings();
const width = settings?.width;
const height = settings?.height;
if (!width || !height) return undefined;
return { width, height };
}, [stream]);

// No screenSize is passed on purpose: the injection backend reads the host's
// real screen geometry from the OS itself. The capture track's dimensions are
// the *encoded stream* size, which is a different number in different units,
// and using it put every remote click in the wrong place. See useInputInjection.
const { injectEvent, diagnostics: inputDiagnostics } = useInputInjection({
enabled: Boolean(participantWithControl) || grantedViewerId !== null,
screenSize: inputScreenSize,
});
const remoteInputCountRef = useRef(0);
const showWaylandInputDiagnostics =
Expand Down
32 changes: 11 additions & 21 deletions apps/desktop/src/renderer/hooks/useInputInjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,32 +182,22 @@ describe('useInputInjection', () => {
});
});

describe('screen size updates', () => {
it('should update screen size when screenSize prop changes', async () => {
const { rerender } = renderHook(
({ screenSize }) => useInputInjection({ enabled: false, screenSize }),
{ initialProps: { screenSize: { width: 1920, height: 1080 } } }
);
// Regression: the hook used to forward the capture track's dimensions as the
// injection screen size. Those are encoded-stream pixels, not the host's
// screen geometry, and on a Retina Mac (logical 1440x900, stream 2880x1800)
// that mapped the centre of the screen to its bottom-right corner and put
// everything past halfway off the display, so clicks did nothing. The backend
// reads its own geometry from the OS; the renderer must not override it.
describe('screen size', () => {
it('never sends a screen size to the backend', async () => {
renderHook(() => useInputInjection({ enabled: true }));

await act(async () => {
await vi.runAllTimersAsync();
});

expect(mockElectronAPI.invoke).toHaveBeenCalledWith('input:updateScreenSize', {
width: 1920,
height: 1080,
});

rerender({ screenSize: { width: 2560, height: 1440 } });

await act(async () => {
await vi.runAllTimersAsync();
});

expect(mockElectronAPI.invoke).toHaveBeenCalledWith('input:updateScreenSize', {
width: 2560,
height: 1440,
});
const channels = mockElectronAPI.invoke.mock.calls.map(([channel]) => channel);
expect(channels).not.toContain('input:updateScreenSize');
});
});

Expand Down
29 changes: 11 additions & 18 deletions apps/desktop/src/renderer/hooks/useInputInjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import type { InputInjectionDiagnostics } from '../../preload/api';
interface UseInputInjectionOptions {
/** Whether injection should be active */
enabled: boolean;
/** Screen dimensions for coordinate mapping */
screenSize?: { width: number; height: number };
/** Callback when emergency stop is triggered */
onEmergencyStop?: () => void;
}
Expand All @@ -36,7 +34,6 @@ interface UseInputInjectionReturn {
*/
export function useInputInjection({
enabled,
screenSize,
onEmergencyStop,
}: UseInputInjectionOptions): UseInputInjectionReturn {
const [isEnabled, setIsEnabled] = useState(false);
Expand Down Expand Up @@ -86,21 +83,17 @@ export function useInputInjection({
void init();
}, []);

// Update screen size when it changes
useEffect(() => {
if (!isInitialized || !screenSize) return;

const updateSize = async () => {
try {
await window.electronAPI.invoke('input:updateScreenSize', screenSize);
} catch (error) {
console.error('[useInputInjection] Failed to update screen size:', error);
}
};

void updateSize();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isInitialized, screenSize?.width, screenSize?.height]);
// Deliberately does not tell the backend a screen size.
//
// Remote coordinates are normalized 0-1, so the only thing needed to place
// them is the host's own screen geometry — which each backend already reads
// from its own OS API in that API's units (nut-js from screen.width(), the
// Wayland backend from the compositor). Feeding it anything else mixes units.
//
// This used to pass the capture track's dimensions, which are neither: the
// track reports encoded pixels, so a Retina Mac (logical 1440x900, stream
// 2880x1800) mapped the centre of the screen to its bottom-right corner and
// everything past halfway fell off the display entirely.

// Enable/disable injection based on prop
useEffect(() => {
Expand Down
25 changes: 10 additions & 15 deletions apps/desktop/src/renderer/hooks/useRemoteControl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect, useState } from 'react';
import { useCallback, useMemo, useRef, useEffect, useState } from 'react';
import type {
InputEvent,
MouseMoveEvent,
Expand All @@ -8,6 +8,8 @@ import type {
MouseButton,
ControlStateUI,
} from '@pairux/shared-types';
import { modifiersFromDomEvent } from '@pairux/shared-types';
import { getAccelPlatform } from '@/lib/viewerPlatform';

interface UseRemoteControlOptions {
enabled: boolean;
Expand Down Expand Up @@ -45,6 +47,9 @@ export function useRemoteControl({
onCursorMove,
}: UseRemoteControlOptions): UseRemoteControlReturn {
const [isCapturing, setIsCapturing] = useState(false);
// Read once: it cannot change while the app runs. Kept out of module scope so
// importing this hook has no side effects.
const viewerPlatform = useMemo(() => getAccelPlatform(), []);
// Buttons/keys this viewer has sent a "down" for. Every one of them must get
// an "up", or the host is left mid-drag with a stuck button.
const heldButtonsRef = useRef<Set<MouseButton>>(new Set());
Expand Down Expand Up @@ -228,19 +233,14 @@ export function useRemoteControl({
action: 'down',
key: event.key,
code: event.code,
modifiers: {
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
},
modifiers: modifiersFromDomEvent(event, viewerPlatform),
};

heldKeysRef.current.add(inputEvent.code);
onInputEvent(inputEvent);
event.preventDefault();
},
[canSendInput, onInputEvent]
[canSendInput, onInputEvent, viewerPlatform]
);

// Handle key up
Expand All @@ -253,18 +253,13 @@ export function useRemoteControl({
action: 'up',
key: event.key,
code: event.code,
modifiers: {
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
},
modifiers: modifiersFromDomEvent(event, viewerPlatform),
};

heldKeysRef.current.delete(inputEvent.code);
onInputEvent(inputEvent);
},
[canSendInput, onInputEvent]
[canSendInput, onInputEvent, viewerPlatform]
);

// Handle context menu (right-click)
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/renderer/lib/viewerPlatform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest';
import { accelPlatformFor, getAccelPlatform } from './viewerPlatform';

describe('accelPlatformFor', () => {
it('treats darwin as the Cmd platform', () => {
expect(accelPlatformFor('darwin')).toBe('darwin');
});

it('treats everything else as the Ctrl platform', () => {
expect(accelPlatformFor('linux')).toBe('other');
expect(accelPlatformFor('win32')).toBe('other');
});

// Never throws on render: a wrong modifier beats a blank screen.
it('falls back to the Ctrl platform when the platform is unknown', () => {
expect(accelPlatformFor(undefined)).toBe('other');
expect(accelPlatformFor(null)).toBe('other');
});
});

describe('getAccelPlatform', () => {
afterEach(() => {
delete (globalThis as { electronAPI?: unknown }).electronAPI;
});

it('reads the platform exposed by preload', () => {
(globalThis as { electronAPI?: unknown }).electronAPI = { platform: 'darwin' };
expect(getAccelPlatform()).toBe('darwin');
});

it('does not throw when preload is absent', () => {
expect(getAccelPlatform()).toBe('other');
});
});
26 changes: 26 additions & 0 deletions apps/desktop/src/renderer/lib/viewerPlatform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Which modifier this machine uses for keyboard shortcuts.
*
* Needed when acting as a viewer, because the host may be on a different OS:
* "the shortcut key" is Cmd on macOS and Ctrl everywhere else, and only the
* viewer knows which one the user actually pressed. See `modifiersFromDomEvent`.
*/

export type AccelPlatform = 'darwin' | 'other';

/** Pure so the mapping is testable without a window. */
export function accelPlatformFor(platform: string | undefined | null): AccelPlatform {
return platform === 'darwin' ? 'darwin' : 'other';
}

/**
* Read the platform from preload rather than sniffing the user agent.
*
* Falls back to 'other' outside Electron, which is also the right answer for a
* plain browser on anything but a Mac — and a wrong shortcut modifier is a
* better failure than throwing on render.
*/
export function getAccelPlatform(): AccelPlatform {
const api = (globalThis as { electronAPI?: { platform?: string } }).electronAPI;
return accelPlatformFor(api?.platform);
}
2 changes: 1 addition & 1 deletion apps/installer/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pairux/installer",
"version": "0.9.54",
"version": "0.9.55",
"private": true,
"description": "PairUX Desktop App Installer Service",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/livekit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pairux/livekit",
"version": "0.9.54",
"version": "0.9.55",
"private": true,
"description": "PairUX LiveKit SFU Server",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/turn/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pairux/turn",
"version": "0.9.54",
"version": "0.9.55",
"private": true,
"description": "PairUX TURN/STUN Server (coturn)",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pairux/web",
"version": "0.9.54",
"version": "0.9.55",
"private": true,
"type": "module",
"scripts": {
Expand Down
25 changes: 10 additions & 15 deletions apps/web/src/hooks/useRemoteControl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect, useState } from 'react';
import { useCallback, useMemo, useRef, useEffect, useState } from 'react';
import type {
InputEvent,
MouseMoveEvent,
Expand All @@ -8,6 +8,8 @@ import type {
MouseButton,
ControlStateUI,
} from '@pairux/shared-types';
import { modifiersFromDomEvent } from '@pairux/shared-types';
import { getAccelPlatform } from '@/lib/viewerPlatform';

interface UseRemoteControlOptions {
enabled: boolean;
Expand Down Expand Up @@ -45,6 +47,9 @@ export function useRemoteControl({
onCursorMove,
}: UseRemoteControlOptions): UseRemoteControlReturn {
const [isCapturing, setIsCapturing] = useState(false);
// Read once: it cannot change while the app runs. Kept out of module scope so
// importing this hook has no side effects.
const viewerPlatform = useMemo(() => getAccelPlatform(), []);
// Buttons/keys this viewer has sent a "down" for. Every one of them must get
// an "up", or the host is left mid-drag with a stuck button.
const heldButtonsRef = useRef<Set<MouseButton>>(new Set());
Expand Down Expand Up @@ -228,19 +233,14 @@ export function useRemoteControl({
action: 'down',
key: event.key,
code: event.code,
modifiers: {
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
},
modifiers: modifiersFromDomEvent(event, viewerPlatform),
};

heldKeysRef.current.add(inputEvent.code);
onInputEvent(inputEvent);
event.preventDefault();
},
[canSendInput, onInputEvent]
[canSendInput, onInputEvent, viewerPlatform]
);

// Handle key up
Expand All @@ -253,18 +253,13 @@ export function useRemoteControl({
action: 'up',
key: event.key,
code: event.code,
modifiers: {
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
},
modifiers: modifiersFromDomEvent(event, viewerPlatform),
};

heldKeysRef.current.delete(inputEvent.code);
onInputEvent(inputEvent);
},
[canSendInput, onInputEvent]
[canSendInput, onInputEvent, viewerPlatform]
);

// Handle context menu (right-click)
Expand Down
Loading
Loading