diff --git a/TODO.md b/TODO.md index b704f6b..a18ec65 100644 --- a/TODO.md +++ b/TODO.md @@ -234,7 +234,6 @@ - [x] **Overlay Indicators** - [x] "Control Active" visual indicator - - [x] Remote cursor visualization - [x] Recording/sharing indicator ### Platform-Specific diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 98d7bbe..2a57554 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,7 +31,6 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", - "dbus-next": "^0.10.2", "dotenv": "^17.2.3", "linkify-react": "^4.3.2", "linkifyjs": "^4.3.2", diff --git a/apps/desktop/src/main/input/injector.test.ts b/apps/desktop/src/main/input/injector.test.ts index a560f07..e7a6039 100644 --- a/apps/desktop/src/main/input/injector.test.ts +++ b/apps/desktop/src/main/input/injector.test.ts @@ -212,7 +212,7 @@ describe('Input Injector', () => { expect(mouse.setPosition).toHaveBeenCalledWith({ x: 960, y: 540 }); }); - it('positions the pointer at the remote cursor when a click lands', async () => { + it('positions the shared host pointer before a click lands', async () => { await injectInput({ type: 'mouse', action: 'move', x: 0.25, y: 0.75 }); await injectInput({ type: 'mouse', diff --git a/apps/desktop/src/main/input/injector.ts b/apps/desktop/src/main/input/injector.ts index 1565f45..091175e 100644 --- a/apps/desktop/src/main/input/injector.ts +++ b/apps/desktop/src/main/input/injector.ts @@ -21,13 +21,8 @@ function getInjector(): RemoteInputInjector { injector ??= new RemoteInputInjector({ // Platform facts come from the app's Electron-aware detection. selection, - // PairUX is a remote-control application: while a guest has control, - // their pointer must drive the host's *real* cursor continuously. The - // library's two-cursor mode is useful for annotations, but on Wayland it - // can only approximate a second cursor and makes normal host navigation - // feel disconnected from the mouse. Control revocation and the emergency - // stop hotkey remain the host's immediate way to take the cursor back. - virtualCursor: false, + // Remote input always drives the host's one real pointer. The host and + // guest take turns by simply moving that shared cursor. // Keep remote input one pixel off the screen edge on Linux: GNOME's // Activities hot-corner fires from the corner pixel, so a guest brushing // it would take over the host's desktop. One pixel is enough to miss the @@ -124,8 +119,7 @@ export async function emergencyStop(): Promise { /** * Shut down injection on quit. * - * On Wayland this also unloads the KWin helper script; left behind it would - * keep pushing the cursor position to a DBus name that has gone away. + * Backends use this to release held input and close any OS resources. */ export async function disposeInputInjector(): Promise { if (!injector) return; diff --git a/apps/desktop/src/main/ipc/input.test.ts b/apps/desktop/src/main/ipc/input.test.ts index 693b469..7ecd7fe 100644 --- a/apps/desktop/src/main/ipc/input.test.ts +++ b/apps/desktop/src/main/ipc/input.test.ts @@ -95,6 +95,24 @@ describe('IPC Input Handlers', () => { }); describe('input:enable handler', () => { + it('refuses control when the global emergency shortcut cannot be registered', () => { + vi.mocked(globalShortcut.register).mockReturnValueOnce(false); + vi.mocked(getInjectionDiagnostics).mockReturnValueOnce({ + enabled: true, + backend: 'nut-js', + backendSupported: true, + stats: { received: 0, injected: 0, rejected: 0, errors: 0, coalesced: 0 }, + heldButtons: 0, + heldKeys: 0, + }); + const handler = mockIpcMainHandlers.get('input:enable')!; + + const result = handler(); + + expect(disableInjection).toHaveBeenCalled(); + expect(result).toMatchObject({ success: false, enabled: false }); + }); + it('should enable injection and register emergency shortcut', () => { vi.mocked(getInjectionDiagnostics).mockReturnValueOnce({ enabled: true, diff --git a/apps/desktop/src/main/ipc/input.ts b/apps/desktop/src/main/ipc/input.ts index cfb2c44..ad8e5c3 100644 --- a/apps/desktop/src/main/ipc/input.ts +++ b/apps/desktop/src/main/ipc/input.ts @@ -4,7 +4,6 @@ import { ipcMain, globalShortcut, app } from 'electron'; import type { InputEvent } from '@pairux/shared-types'; -import { showRemoteCursor, hideRemoteCursor, destroyRemoteCursor } from '../overlay/cursorOverlay'; import { reportDaemonState } from '../daemon'; import { getTailscaleState, checkTailnetPath } from '../daemon/tailscale'; import { @@ -24,13 +23,12 @@ let emergencyShortcutRegistered = false; /** * Register emergency revoke hotkey (Ctrl+Shift+Escape) */ -function registerEmergencyShortcut(): void { - if (emergencyShortcutRegistered) return; +function registerEmergencyShortcut(): boolean { + if (emergencyShortcutRegistered) return true; const registered = globalShortcut.register('CommandOrControl+Shift+Escape', () => { console.log('[IPC:Input] Emergency revoke hotkey triggered'); void (async () => { - destroyRemoteCursor(); await emergencyStop(); // Notify renderer const { BrowserWindow } = await import('electron'); @@ -44,8 +42,10 @@ function registerEmergencyShortcut(): void { if (registered) { emergencyShortcutRegistered = true; console.log('[IPC:Input] Emergency shortcut registered (Ctrl+Shift+Escape)'); + return true; } else { console.warn('[IPC:Input] Failed to register emergency shortcut'); + return false; } } @@ -73,10 +73,16 @@ export function registerInputHandlers(): void { // Enable input injection (when control is granted to a viewer) ipcMain.handle('input:enable', () => { - enableInjection(); - registerEmergencyShortcut(); + const injectionEnabled = enableInjection(); + const emergencyStopReady = injectionEnabled && registerEmergencyShortcut(); + if (injectionEnabled && !emergencyStopReady) { + // Direct control owns the host's real pointer. Never start it unless the + // host has a verified, global way to stop a stuck guest input stream. + disableInjection(); + } const diagnostics = getInjectionDiagnostics(); - return { success: true, ...diagnostics }; + const enabled = emergencyStopReady && diagnostics.enabled; + return { success: enabled, ...diagnostics, enabled }; }); // Disable input injection (when control is revoked) @@ -91,17 +97,6 @@ export function registerInputHandlers(): void { return getInjectionDiagnostics(); }); - // Update screen size (when capture source changes) - // Paint the guest's cursor on the host's desktop, outside the app window. - ipcMain.handle( - 'overlay:remoteCursor', - (_event, args: { x: number; y: number; name: string; visible: boolean }) => { - if (args.visible) showRemoteCursor(args.x, args.y, args.name); - else hideRemoteCursor(); - return { success: true }; - } - ); - // The renderer owns capture/session state; main mirrors it so the daemon's // HTTP endpoints can answer without a round trip. ipcMain.handle( @@ -149,11 +144,6 @@ export function registerInputHandlers(): void { return checkTailnetPath(args.ip); }); - ipcMain.handle('overlay:clearRemoteCursor', () => { - destroyRemoteCursor(); - return { success: true }; - }); - ipcMain.handle('input:updateScreenSize', (_event, args: { width: number; height: number }) => { updateScreenSize(args.width, args.height); return { success: true }; @@ -192,8 +182,6 @@ export function registerInputHandlers(): void { event.preventDefault(); unregisterEmergencyShortcut(); - destroyRemoteCursor(); - // Bounded, so a wedged backend cannot make the app unquittable. const deadline = new Promise((resolve) => setTimeout(resolve, 2000)); void Promise.race([disposeInputInjector(), deadline]) diff --git a/apps/desktop/src/main/overlay/cursorOverlay.test.ts b/apps/desktop/src/main/overlay/cursorOverlay.test.ts deleted file mode 100644 index bc8112b..0000000 --- a/apps/desktop/src/main/overlay/cursorOverlay.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { canShowDesktopOverlay } from './cursorOverlay'; - -// The overlay is a fullscreen, always-on-top window over the host's real -// desktop. If it fails to be inert it takes input the host cannot get back, -// which is the most damaging failure this app has. So the question is not -// "does click-through usually work" but "can we show it without taking focus -// at all" — and on Wayland Electron documents showInactive() as unsupported. -describe('canShowDesktopOverlay', () => { - it('refuses Wayland, where showInactive is unsupported', () => { - expect(canShowDesktopOverlay('wayland')).toBe(false); - }); - - it('allows the display servers whose window APIs Electron supports', () => { - expect(canShowDesktopOverlay('x11')).toBe(true); - expect(canShowDesktopOverlay('macos')).toBe(true); - expect(canShowDesktopOverlay('windows')).toBe(true); - }); - - // An unknown display server is only reported on Linux when neither - // WAYLAND_DISPLAY nor DISPLAY is set — a headless or unusual session rather - // than a compositor known to mishandle the window. - it('allows an unknown display server', () => { - expect(canShowDesktopOverlay('unknown')).toBe(true); - }); -}); diff --git a/apps/desktop/src/main/overlay/cursorOverlay.ts b/apps/desktop/src/main/overlay/cursorOverlay.ts deleted file mode 100644 index d9fccbe..0000000 --- a/apps/desktop/src/main/overlay/cursorOverlay.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Draws a remote participant's cursor on the host's actual desktop. - * - * The in-app overlay can only paint inside the PairUX window, so a guest's - * cursor vanished the moment it left the video preview. This is a transparent, - * click-through, always-on-top window covering the screen, so the second cursor - * is visible wherever the guest points. - * - * SAFETY: a fullscreen always-on-top window that failed to be click-through - * would lock the user out of their own desktop. So it is created hidden, - * made click-through and non-focusable before it is ever shown, exists only - * while a guest holds control, and is destroyed by emergency revoke. - */ - -import { BrowserWindow, screen } from 'electron'; -import { detectDisplayServer } from '../platform'; - -let overlay: BrowserWindow | null = null; - -/** - * Whether a desktop-wide overlay can be shown safely on this display server. - * - * False on Wayland, because Electron documents the operations this window is - * built out of as unsupported there: - * - * - `showInactive()` — "Not supported on Wayland (Linux)". This is the one - * that matters. It is how the overlay appears *without* taking focus; with - * it unavailable there is no way to put a fullscreen always-on-top window - * on screen and be sure it has not grabbed the user's input. - * - `setPosition()` — "Not supported on Wayland (Linux)", and `getBounds()` - * reports `{ x: 0, y: 0 }`, so the window cannot be reliably placed. - * - the `level` argument to `setAlwaysOnTop` is documented macOS/Windows only. - * - * More generally: "On Wayland (Linux) it is generally not possible to - * programmatically resize windows after creation, or to position, move, focus, - * or blur windows without user input." - * - * The failure mode if we show it anyway is the worst one this app has — a - * fullscreen window over the host's desktop that takes input they cannot get - * back. The in-app cursor still draws inside the PairUX window, so the guest's - * pointer stays visible where the video is; only the desktop-wide overlay is - * given up. - * - * https://www.electronjs.org/docs/latest/api/browser-window - */ -export function canShowDesktopOverlay(displayServer: string): boolean { - return displayServer !== 'wayland'; -} - -/** Escape hatch, in case a compositor mishandles a click-through window. */ -function isDisabled(): boolean { - if (process.env.PAIRUX_DISABLE_CURSOR_OVERLAY === '1') return true; - return !canShowDesktopOverlay(detectDisplayServer()); -} - -function buildHtml(): string { - // Self-contained: no file to package, nothing to load over a protocol. - return ` - - - - - - -
- - - -
-
- - -`; -} - -function create(): BrowserWindow | null { - if (isDisabled()) return null; - - const display = screen.getPrimaryDisplay(); - const { x, y, width, height } = display.bounds; - - const win = new BrowserWindow({ - x, - y, - width, - height, - // Created hidden: nothing is shown until it is provably inert. - show: false, - frame: false, - transparent: true, - hasShadow: false, - resizable: false, - movable: false, - minimizable: false, - maximizable: false, - fullscreenable: false, - skipTaskbar: true, - focusable: false, - alwaysOnTop: true, - acceptFirstMouse: false, - webPreferences: { nodeIntegration: false, contextIsolation: true }, - }); - - // Before showing: never take a single click from the user. - win.setIgnoreMouseEvents(true, { forward: false }); - win.setAlwaysOnTop(true, 'screen-saver'); - win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - - void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildHtml())}`); - - win.on('closed', () => { - overlay = null; - }); - - return win; -} - -/** - * Move the guest's cursor to a point on the host's screen. - * - * Coordinates are normalized 0-1 so callers never deal with display geometry. - */ -export function showRemoteCursor(x: number, y: number, name: string): void { - if (isDisabled()) return; - - overlay ??= create(); - if (!overlay || overlay.isDestroyed()) return; - - const { width, height } = screen.getPrimaryDisplay().bounds; - const px = Math.round(Math.min(1, Math.max(0, x)) * width); - const py = Math.round(Math.min(1, Math.max(0, y)) * height); - - if (!overlay.isVisible()) { - // showInactive: never steal focus from what the host is doing. - overlay.showInactive(); - } - - overlay.webContents - .executeJavaScript( - `window.postMessage(${JSON.stringify({ type: 'cursor', visible: true, x: px, y: py, name })}, '*')`, - true - ) - .catch(() => { - // The window can go away mid-flight; nothing to recover. - }); -} - -export function hideRemoteCursor(): void { - if (!overlay || overlay.isDestroyed()) return; - - overlay.webContents - .executeJavaScript( - `window.postMessage(${JSON.stringify({ type: 'cursor', visible: false })}, '*')`, - true - ) - .catch(() => { - // Ignore — the window is being torn down. - }); -} - -/** Remove the overlay entirely. Used when control ends and on revoke. */ -export function destroyRemoteCursor(): void { - if (overlay && !overlay.isDestroyed()) { - overlay.destroy(); - } - overlay = null; -} diff --git a/apps/desktop/src/preload/api.ts b/apps/desktop/src/preload/api.ts index cfe1fff..27f59a5 100644 --- a/apps/desktop/src/preload/api.ts +++ b/apps/desktop/src/preload/api.ts @@ -242,16 +242,6 @@ export interface IPCChannels { return: { success: boolean }; }; - 'overlay:remoteCursor': { - args: { x: number; y: number; name: string; visible: boolean }; - return: { success: boolean }; - }; - - 'overlay:clearRemoteCursor': { - args: undefined; - return: { success: boolean }; - }; - 'webrtc:setIpPolicy': { args: { allowPrivate: boolean }; return: { success: boolean; policy: string }; diff --git a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx index c5eb4b9..68de0af 100644 --- a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx +++ b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx @@ -25,7 +25,6 @@ import type { CaptureSource, Session, InputMessage, - CursorPositionMessage, SessionParticipant, } from '@pairux/shared-types'; import { @@ -56,13 +55,7 @@ import { useWebRTCHostSFUAPI } from '@/hooks/useWebRTCHostSFUAPI'; import { useAutoStopServerStream } from '@/hooks/useAutoStopServerStream'; import { useAudioMixer } from '@/hooks/useAudioMixer'; import { useInputInjection } from '@/hooks/useInputInjection'; -import { - SharingIndicator, - RecordingIndicator, - ControlActiveIndicator, - RemoteCursorsContainer, - useRemoteCursors, -} from '@/components/overlay'; +import { SharingIndicator, RecordingIndicator, ControlActiveIndicator } from '@/components/overlay'; // A control request the host has not answered yet. Expires so a request the // host ignored does not sit in the UI forever. @@ -257,9 +250,6 @@ export function CapturePreview({ ); const canManageParticipantControl = Boolean(session); - // Remote cursors for showing viewer cursor positions - const { cursors: remoteCursors, updateCursor, removeCursor } = useRemoteCursors(); - // Determine mode from whichever session exists (joined OR auto-created by // quick-share). Both host hooks are always called; only the selection below // switches, and hosting doesn't start until a session exists, so the flip @@ -438,11 +428,6 @@ export function CapturePreview({ onViewerLeft: (viewerId: string) => { console.log('[CapturePreview] Viewer left:', viewerId); if (shouldChimeRef.current()) playLeaveSound(); - void getElectronAPI() - .invoke('overlay:clearRemoteCursor', undefined) - .catch(() => { - // Best-effort. - }); // A departing viewer's control ends with them, which also releases // anything they were still holding down on this machine. setGrantedViewerId((prev) => (prev === viewerId ? null : prev)); @@ -466,48 +451,6 @@ export function CapturePreview({ } void injectEvent(input.event); }, - onCursorUpdate: (viewerId: string, cursor: CursorPositionMessage) => { - // Show where the participant is pointing even when they are not - // driving. There is only one real system cursor, so this overlay is - // what makes two people working at once legible. - const api = getElectronAPI(); - - if (!cursor.visible) { - removeCursor(viewerId); - void api - .invoke('overlay:remoteCursor', { x: 0, y: 0, name: '', visible: false }) - .catch(() => { - // Overlay is best-effort. - }); - return; - } - - // Also paint it on the desktop itself, so the guest's cursor is visible - // wherever they point — not just inside this window's video preview. - void api - .invoke('overlay:remoteCursor', { - x: cursor.x, - y: cursor.y, - name: participantNameRef.current(viewerId), - visible: true, - }) - .catch(() => { - // Overlay is best-effort; never let it disturb the session. - }); - - // Cursor messages are normalized 0-1; the overlay scales from source - // pixels, so convert or every cursor lands in the top-left corner. - const source = sourceDimensionsRef.current; - updateCursor({ - participantId: viewerId, - displayName: participantNameRef.current(viewerId), - position: { - x: cursor.x * source.width, - y: cursor.y * source.height, - timestamp: Date.now(), - }, - }); - }, }), [ session?.id, @@ -519,8 +462,6 @@ export function CapturePreview({ refreshSession, handleControlRequested, handleTailnetHello, - updateCursor, - removeCursor, ] ); @@ -1819,13 +1760,6 @@ export function CapturePreview({ )} - {/* Remote cursors */} - - {/* Loom-style camera bubble (optional) */} {camera.isEnabled && camera.stream && ( void; - onCursorMove?: ((x: number, y: number, visible: boolean) => void) | undefined; className?: string; /** When true, shows a fullscreen toggle and enables pointer lock. */ allowFullscreen?: boolean; @@ -30,22 +29,14 @@ export function InputCapture({ enabled, controlState, onInputEvent, - onCursorMove, className = '', allowFullscreen = false, }: InputCaptureProps) { const containerRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); - const handlePointerMove = useCallback( - (x: number, y: number, visible: boolean) => { - onCursorMove?.(x, y, visible); - }, - [onCursorMove] - ); - const { isLocked, wasReleasedByUser, positionRef, lock, unlock, resetPosition } = usePointerLock({ - onMove: handlePointerMove, + onMove: () => undefined, }); // The guest has asked to drive, by clicking into the picture. @@ -76,7 +67,6 @@ export function InputCapture({ controlState, containerRef, onInputEvent, - onCursorMove, // Only once the lock is actually held. Under lock the event's clientX/Y // stop advancing, so the virtual position is the only real coordinate; // outside it, the event is. diff --git a/apps/desktop/src/renderer/components/overlay/OverlayIndicators.test.tsx b/apps/desktop/src/renderer/components/overlay/OverlayIndicators.test.tsx index e51c14c..88d809b 100644 --- a/apps/desktop/src/renderer/components/overlay/OverlayIndicators.test.tsx +++ b/apps/desktop/src/renderer/components/overlay/OverlayIndicators.test.tsx @@ -165,7 +165,7 @@ describe('OverlayIndicators', () => { it('displays participant name', () => { render(); - expect(screen.getByText('Test User has control')).toBeInTheDocument(); + expect(screen.getByText(/Test User has control/)).toBeInTheDocument(); }); it('has monitor icon', () => { diff --git a/apps/desktop/src/renderer/components/overlay/OverlayIndicators.tsx b/apps/desktop/src/renderer/components/overlay/OverlayIndicators.tsx index 3665628..c79309e 100644 --- a/apps/desktop/src/renderer/components/overlay/OverlayIndicators.tsx +++ b/apps/desktop/src/renderer/components/overlay/OverlayIndicators.tsx @@ -100,7 +100,9 @@ export function ControlActiveIndicator({ participant }: ControlActiveIndicatorPr data-testid="control-active-indicator" > - {participant.display_name} has control + + {participant.display_name} has control · Ctrl+Shift+Esc to stop + ); } diff --git a/apps/desktop/src/renderer/components/overlay/RemoteCursor.test.tsx b/apps/desktop/src/renderer/components/overlay/RemoteCursor.test.tsx deleted file mode 100644 index 4047fea..0000000 --- a/apps/desktop/src/renderer/components/overlay/RemoteCursor.test.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; -import { renderHook } from '@testing-library/react'; -import { - RemoteCursor, - RemoteCursorsContainer, - useRemoteCursors, - getCursorColor, - type RemoteCursorData, -} from './RemoteCursor'; - -describe('RemoteCursor', () => { - const mockCursor: RemoteCursorData = { - participantId: 'participant-1', - displayName: 'Test User', - position: { - x: 500, - y: 300, - timestamp: Date.now(), - }, - }; - - const containerDimensions = { width: 800, height: 600 }; - const sourceDimensions = { width: 1920, height: 1080 }; - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('getCursorColor', () => { - it('returns a color from the predefined palette', () => { - const color = getCursorColor('participant-1'); - expect(color).toMatch(/^#[0-9A-Fa-f]{6}$/); - }); - - it('returns consistent color for same participant ID', () => { - const color1 = getCursorColor('participant-1'); - const color2 = getCursorColor('participant-1'); - expect(color1).toBe(color2); - }); - - it('returns different colors for different IDs (usually)', () => { - const colors = new Set(); - for (let i = 0; i < 20; i++) { - colors.add(getCursorColor(`participant-${String(i)}`)); - } - // Should have multiple different colors - expect(colors.size).toBeGreaterThan(1); - }); - }); - - describe('RemoteCursor component', () => { - it('renders cursor element', () => { - render( - - ); - - expect(screen.getByTestId('remote-cursor')).toBeInTheDocument(); - }); - - it('displays participant name when showLabel is true', () => { - render( - - ); - - expect(screen.getByText('Test User')).toBeInTheDocument(); - }); - - it('hides participant name when showLabel is false', () => { - render( - - ); - - expect(screen.queryByText('Test User')).not.toBeInTheDocument(); - }); - - it('sets correct participant ID attribute', () => { - render( - - ); - - const cursor = screen.getByTestId('remote-cursor'); - expect(cursor).toHaveAttribute('data-participant-id', 'participant-1'); - }); - - it('uses custom color when provided', () => { - const cursorWithColor: RemoteCursorData = { - ...mockCursor, - color: '#FF0000', - }; - - render( - - ); - - // The cursor should be rendered (color is applied via style) - expect(screen.getByTestId('remote-cursor')).toBeInTheDocument(); - }); - - it('hides cursor when position is stale (older than 5 seconds)', () => { - const staleCursor: RemoteCursorData = { - ...mockCursor, - position: { - ...mockCursor.position, - timestamp: Date.now() - 6000, // 6 seconds ago - }, - }; - - render( - - ); - - expect(screen.queryByTestId('remote-cursor')).not.toBeInTheDocument(); - }); - - it('shows cursor when position is fresh', () => { - const freshCursor: RemoteCursorData = { - ...mockCursor, - position: { - ...mockCursor.position, - timestamp: Date.now() - 1000, // 1 second ago - }, - }; - - render( - - ); - - expect(screen.getByTestId('remote-cursor')).toBeInTheDocument(); - }); - }); - - describe('RemoteCursorsContainer', () => { - it('renders nothing when cursors array is empty', () => { - render( - - ); - - expect(screen.queryByTestId('remote-cursors-container')).not.toBeInTheDocument(); - }); - - it('renders container when cursors are present', () => { - render( - - ); - - expect(screen.getByTestId('remote-cursors-container')).toBeInTheDocument(); - }); - - it('renders multiple cursors', () => { - const cursors: RemoteCursorData[] = [ - mockCursor, - { - participantId: 'participant-2', - displayName: 'Another User', - position: { x: 100, y: 100, timestamp: Date.now() }, - }, - ]; - - render( - - ); - - const cursorElements = screen.getAllByTestId('remote-cursor'); - expect(cursorElements).toHaveLength(2); - }); - - it('passes showLabels prop to individual cursors', () => { - render( - - ); - - expect(screen.queryByText('Test User')).not.toBeInTheDocument(); - }); - }); - - describe('useRemoteCursors hook', () => { - it('starts with empty cursors array', () => { - const { result } = renderHook(() => useRemoteCursors()); - - expect(result.current.cursors).toEqual([]); - }); - - it('adds cursor with updateCursor', () => { - const { result } = renderHook(() => useRemoteCursors()); - - act(() => { - result.current.updateCursor(mockCursor); - }); - - expect(result.current.cursors).toHaveLength(1); - expect(result.current.cursors[0]).toEqual(mockCursor); - }); - - it('updates existing cursor with same participantId', () => { - const { result } = renderHook(() => useRemoteCursors()); - - act(() => { - result.current.updateCursor(mockCursor); - }); - - const updatedCursor: RemoteCursorData = { - ...mockCursor, - position: { x: 200, y: 200, timestamp: Date.now() }, - }; - - act(() => { - result.current.updateCursor(updatedCursor); - }); - - expect(result.current.cursors).toHaveLength(1); - expect(result.current.cursors[0].position.x).toBe(200); - }); - - it('removes cursor with removeCursor', () => { - const { result } = renderHook(() => useRemoteCursors()); - - act(() => { - result.current.updateCursor(mockCursor); - }); - - expect(result.current.cursors).toHaveLength(1); - - act(() => { - result.current.removeCursor('participant-1'); - }); - - expect(result.current.cursors).toHaveLength(0); - }); - - it('clears all cursors with clearCursors', () => { - const { result } = renderHook(() => useRemoteCursors()); - - act(() => { - result.current.updateCursor(mockCursor); - result.current.updateCursor({ - participantId: 'participant-2', - displayName: 'User 2', - position: { x: 0, y: 0, timestamp: Date.now() }, - }); - }); - - expect(result.current.cursors).toHaveLength(2); - - act(() => { - result.current.clearCursors(); - }); - - expect(result.current.cursors).toHaveLength(0); - }); - - it('handles multiple cursors from different participants', () => { - const { result } = renderHook(() => useRemoteCursors()); - - act(() => { - result.current.updateCursor(mockCursor); - result.current.updateCursor({ - participantId: 'participant-2', - displayName: 'User 2', - position: { x: 100, y: 100, timestamp: Date.now() }, - }); - result.current.updateCursor({ - participantId: 'participant-3', - displayName: 'User 3', - position: { x: 200, y: 200, timestamp: Date.now() }, - }); - }); - - expect(result.current.cursors).toHaveLength(3); - }); - }); -}); diff --git a/apps/desktop/src/renderer/components/overlay/RemoteCursor.tsx b/apps/desktop/src/renderer/components/overlay/RemoteCursor.tsx deleted file mode 100644 index 0325d08..0000000 --- a/apps/desktop/src/renderer/components/overlay/RemoteCursor.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { useEffect, useState, useRef, useCallback } from 'react'; - -export interface CursorPosition { - x: number; - y: number; - timestamp: number; -} - -export interface RemoteCursorData { - participantId: string; - displayName: string; - position: CursorPosition; - color?: string; -} - -interface RemoteCursorProps { - /** Cursor data including position and participant info */ - cursor: RemoteCursorData; - /** Container dimensions for scaling cursor position */ - containerDimensions: { width: number; height: number }; - /** Original screen dimensions the cursor was captured from */ - sourceDimensions: { width: number; height: number }; - /** Whether to show the participant name */ - showLabel?: boolean; - /** Animation smoothing (0-1, higher = smoother) */ - smoothing?: number; -} - -// Predefined colors for cursors -const CURSOR_COLORS = [ - '#3B82F6', // blue - '#10B981', // green - '#F59E0B', // amber - '#EF4444', // red - '#8B5CF6', // purple - '#EC4899', // pink - '#06B6D4', // cyan - '#F97316', // orange -]; - -/** - * Get a consistent color for a participant based on their ID - */ -export function getCursorColor(participantId: string): string { - let hash = 0; - for (let i = 0; i < participantId.length; i++) { - hash = participantId.charCodeAt(i) + ((hash << 5) - hash); - } - return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; -} - -/** - * RemoteCursor component displays a remote user's cursor position - */ -export function RemoteCursor({ - cursor, - containerDimensions, - sourceDimensions, - showLabel = true, - smoothing = 0.15, -}: RemoteCursorProps) { - const [displayPosition, setDisplayPosition] = useState({ x: 0, y: 0 }); - const animationRef = useRef(undefined); - const targetRef = useRef({ x: 0, y: 0 }); - - const color = cursor.color ?? getCursorColor(cursor.participantId); - - // Scale cursor position from source dimensions to container dimensions - const scalePosition = useCallback( - (pos: CursorPosition) => { - const scaleX = containerDimensions.width / sourceDimensions.width; - const scaleY = containerDimensions.height / sourceDimensions.height; - return { - x: pos.x * scaleX, - y: pos.y * scaleY, - }; - }, - [containerDimensions, sourceDimensions] - ); - - // Update target position when cursor data changes - useEffect(() => { - const scaled = scalePosition(cursor.position); - targetRef.current = scaled; - }, [cursor.position, scalePosition]); - - // Smooth animation loop - useEffect(() => { - const animate = () => { - setDisplayPosition((prev) => ({ - x: prev.x + (targetRef.current.x - prev.x) * smoothing, - y: prev.y + (targetRef.current.y - prev.y) * smoothing, - })); - animationRef.current = requestAnimationFrame(animate); - }; - - animationRef.current = requestAnimationFrame(animate); - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current); - } - }; - }, [smoothing]); - - // Hide cursor if position is stale (older than 5 seconds) - const isStale = Date.now() - cursor.position.timestamp > 5000; - if (isStale) { - return null; - } - - return ( -
- {/* Cursor pointer SVG */} - - - - - {/* Participant label */} - {showLabel && ( -
- {cursor.displayName} -
- )} -
- ); -} - -interface RemoteCursorsContainerProps { - /** Array of cursor data from remote participants */ - cursors: RemoteCursorData[]; - /** Container dimensions for scaling */ - containerDimensions: { width: number; height: number }; - /** Source screen dimensions */ - sourceDimensions: { width: number; height: number }; - /** Whether to show labels */ - showLabels?: boolean; -} - -/** - * Container for rendering multiple remote cursors - */ -export function RemoteCursorsContainer({ - cursors, - containerDimensions, - sourceDimensions, - showLabels = true, -}: RemoteCursorsContainerProps) { - if (cursors.length === 0) { - return null; - } - - return ( -
- {cursors.map((cursor) => ( - - ))} -
- ); -} - -/** - * Hook for managing remote cursor positions - */ -export function useRemoteCursors() { - const [cursors, setCursors] = useState>(new Map()); - - /** - * Update a cursor position - */ - const updateCursor = (data: RemoteCursorData) => { - setCursors((prev) => { - const next = new Map(prev); - next.set(data.participantId, data); - return next; - }); - }; - - /** - * Remove a cursor (e.g., when participant leaves) - */ - const removeCursor = (participantId: string) => { - setCursors((prev) => { - const next = new Map(prev); - next.delete(participantId); - return next; - }); - }; - - /** - * Clear all cursors - */ - const clearCursors = () => { - setCursors(new Map()); - }; - - /** - * Get cursors as array for rendering - */ - const getCursorsArray = (): RemoteCursorData[] => { - return Array.from(cursors.values()); - }; - - return { - cursors: getCursorsArray(), - updateCursor, - removeCursor, - clearCursors, - }; -} diff --git a/apps/desktop/src/renderer/components/overlay/cursorScaling.test.ts b/apps/desktop/src/renderer/components/overlay/cursorScaling.test.ts deleted file mode 100644 index 12d0a06..0000000 --- a/apps/desktop/src/renderer/components/overlay/cursorScaling.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -/** - * RemoteCursor scales a cursor by containerDimensions / sourceDimensions, so it - * expects SOURCE PIXELS. Cursor messages travel normalized 0-1, and feeding - * those straight in put every cursor in the top-left corner: 0.5 * (1280/1920) - * is a third of a pixel. - * - * This pins the conversion CapturePreview performs before handing a position to - * the overlay. - */ -function toSourcePixels( - normalized: { x: number; y: number }, - source: { width: number; height: number } -): { x: number; y: number } { - return { x: normalized.x * source.width, y: normalized.y * source.height }; -} - -function scaleToContainer( - sourcePixels: { x: number; y: number }, - source: { width: number; height: number }, - container: { width: number; height: number } -): { x: number; y: number } { - return { - x: sourcePixels.x * (container.width / source.width), - y: sourcePixels.y * (container.height / source.height), - }; -} - -describe('remote cursor coordinate conversion', () => { - const source = { width: 1920, height: 1080 }; - const container = { width: 1280, height: 720 }; - - it('puts a centred cursor at the centre of the container', () => { - const px = toSourcePixels({ x: 0.5, y: 0.5 }, source); - const onScreen = scaleToContainer(px, source, container); - - expect(onScreen.x).toBeCloseTo(640, 5); - expect(onScreen.y).toBeCloseTo(360, 5); - }); - - it('keeps the corners at the corners', () => { - const topLeft = scaleToContainer(toSourcePixels({ x: 0, y: 0 }, source), source, container); - expect(topLeft).toEqual({ x: 0, y: 0 }); - - const bottomRight = scaleToContainer(toSourcePixels({ x: 1, y: 1 }, source), source, container); - expect(bottomRight.x).toBeCloseTo(1280, 5); - expect(bottomRight.y).toBeCloseTo(720, 5); - }); - - // The exact regression: normalized values used as if they were pixels. - it('would collapse into the top-left without the conversion', () => { - const wrong = scaleToContainer({ x: 0.5, y: 0.5 }, source, container); - - expect(wrong.x).toBeLessThan(1); - expect(wrong.y).toBeLessThan(1); - }); -}); diff --git a/apps/desktop/src/renderer/components/overlay/index.ts b/apps/desktop/src/renderer/components/overlay/index.ts index e41cf2b..fdee111 100644 --- a/apps/desktop/src/renderer/components/overlay/index.ts +++ b/apps/desktop/src/renderer/components/overlay/index.ts @@ -5,12 +5,3 @@ export { SharingIndicator, type OverlayIndicatorsProps, } from './OverlayIndicators'; - -export { - RemoteCursor, - RemoteCursorsContainer, - useRemoteCursors, - getCursorColor, - type RemoteCursorData, - type CursorPosition, -} from './RemoteCursor'; diff --git a/apps/desktop/src/renderer/hooks/useInputInjection.ts b/apps/desktop/src/renderer/hooks/useInputInjection.ts index d8cab51..457e731 100644 --- a/apps/desktop/src/renderer/hooks/useInputInjection.ts +++ b/apps/desktop/src/renderer/hooks/useInputInjection.ts @@ -44,7 +44,7 @@ export function useInputInjection({ // IPC handlers may run concurrently. Keep every OS injection in the order // it arrived, especially move -> down -> up. Without this queue, a button // down that is waiting for a pending move batch can be overtaken by its up, - // leaving the virtual mouse button held on the host desktop. + // leaving a mouse button held on the host desktop. const injectionQueue = useRef>(Promise.resolve()); const enqueueInjection = useCallback( diff --git a/apps/desktop/src/renderer/hooks/useRemoteControl.ts b/apps/desktop/src/renderer/hooks/useRemoteControl.ts index 1bbd3a2..e5d41ce 100644 --- a/apps/desktop/src/renderer/hooks/useRemoteControl.ts +++ b/apps/desktop/src/renderer/hooks/useRemoteControl.ts @@ -21,7 +21,6 @@ interface UseRemoteControlOptions { controlState: ControlStateUI; containerRef: React.RefObject; onInputEvent: (event: InputEvent) => void; - onCursorMove?: ((x: number, y: number, visible: boolean) => void) | undefined; /** * Under pointer lock there is no cursor position to read from the event — * the browser reports movement deltas instead. When set, every click and @@ -56,7 +55,6 @@ export function useRemoteControl({ controlState, containerRef, onInputEvent, - onCursorMove, pointerLockPosition, }: UseRemoteControlOptions): UseRemoteControlReturn { const [isCapturing, setIsCapturing] = useState(false); @@ -67,11 +65,9 @@ export function useRemoteControl({ // an "up", or the host is left mid-drag with a stuck button. const heldButtonsRef = useRef>(new Set()); const heldKeysRef = useRef>(new Set()); - const lastCursorUpdateRef = useRef(0); // Pointer events fire before mouse events in Chromium. Storing the last // pointer event's timestamp lets the mouse handler skip a double-fire. const lastPointerEventRef = useRef(0); - const cursorThrottleMs = 16; // ~60fps throttle for cursor updates // Check if we can send input (enabled, granted control, and capturing) const canSendInput = enabled && controlState === 'granted' && isCapturing; @@ -120,15 +116,6 @@ export function useRemoteControl({ const coords = getRelativeCoords(event); if (!coords) return; - // Always update cursor position (even when view-only). - // Skip when locked: the pointer-lock movement handler already does this, - // and double-firing produces a cursor that jitters between two sources. - const now = Date.now(); - if (!pointerLockPosition?.current && now - lastCursorUpdateRef.current >= cursorThrottleMs) { - lastCursorUpdateRef.current = now; - onCursorMove?.(coords.x, coords.y, true); - } - // Only send input event if we have control if (!canSendInput) return; @@ -141,7 +128,7 @@ export function useRemoteControl({ onInputEvent(inputEvent); }, - [getRelativeCoords, canSendInput, onCursorMove, onInputEvent, pointerLockPosition] + [getRelativeCoords, canSendInput, onInputEvent] ); // Handle mouse down @@ -259,11 +246,6 @@ export function useRemoteControl({ heldKeysRef.current.clear(); }, [onInputEvent]); - // Handle mouse leave (cursor left the container) - const handleMouseLeave = useCallback(() => { - onCursorMove?.(0, 0, false); - }, [onCursorMove]); - // Handle key down const handleKeyDown = useCallback( (event: globalThis.KeyboardEvent) => { @@ -356,8 +338,7 @@ export function useRemoteControl({ const stopCapture = useCallback(() => { releaseHeldInput(); setIsCapturing(false); - onCursorMove?.(0, 0, false); - }, [onCursorMove, releaseHeldInput]); + }, [releaseHeldInput]); // Attach/detach event listeners useEffect(() => { @@ -375,7 +356,6 @@ export function useRemoteControl({ container.addEventListener('pointerdown', handlePointerDown); container.addEventListener('pointerup', handlePointerUp); container.addEventListener('wheel', handleWheel, { passive: false }); - container.addEventListener('mouseleave', handleMouseLeave); container.addEventListener('contextmenu', handleContextMenu); // Keyboard events on document (when container is focused) @@ -394,7 +374,6 @@ export function useRemoteControl({ container.removeEventListener('pointerdown', handlePointerDown); container.removeEventListener('pointerup', handlePointerUp); container.removeEventListener('wheel', handleWheel); - container.removeEventListener('mouseleave', handleMouseLeave); container.removeEventListener('contextmenu', handleContextMenu); document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keyup', handleKeyUp); @@ -410,7 +389,6 @@ export function useRemoteControl({ handleMouseDown, handleMouseUp, handleWheel, - handleMouseLeave, handleContextMenu, handlePointerDown, handlePointerUp, diff --git a/apps/desktop/src/renderer/hooks/useWebRTCHostAPI.ts b/apps/desktop/src/renderer/hooks/useWebRTCHostAPI.ts index 8ddc0d2..995b307 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCHostAPI.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCHostAPI.ts @@ -13,7 +13,6 @@ import type { ConnectionState, NetworkQuality, InputMessage, - CursorPositionMessage, ControlMessage, KickMessage, MuteMessage, @@ -85,7 +84,6 @@ interface UseWebRTCHostAPIOptions { onViewerLeft?: (viewerId: string) => void; onControlRequest?: (viewerId: string) => void; onInputReceived?: (viewerId: string, input: InputMessage) => void; - onCursorUpdate?: (viewerId: string, cursor: CursorPositionMessage) => void; /** A peer reporting its tailnet addresses (diagnostic only). */ onTailnetHello?: (viewerId: string, ips: string[], isReply: boolean) => void; } @@ -123,7 +121,6 @@ export function useWebRTCHostAPI({ onViewerLeft, onControlRequest, onInputReceived, - onCursorUpdate, onTailnetHello, }: UseWebRTCHostAPIOptions): UseWebRTCHostAPIReturn { const [isHosting, setIsHosting] = useState(false); @@ -155,11 +152,9 @@ export function useWebRTCHostAPI({ localStreamRef.current = localStream; const onControlRequestRef = useRef(onControlRequest); const onInputReceivedRef = useRef(onInputReceived); - const onCursorUpdateRef = useRef(onCursorUpdate); const onTailnetHelloRef = useRef(onTailnetHello); onControlRequestRef.current = onControlRequest; onInputReceivedRef.current = onInputReceived; - onCursorUpdateRef.current = onCursorUpdate; onTailnetHelloRef.current = onTailnetHello; // Sessions that disallow control must never surface a request or forward an // input event, even if a viewer sends one anyway. @@ -354,10 +349,7 @@ export function useWebRTCHostAPI({ // Handle data channel messages const handleDataChannelMessage = useCallback((viewerId: string, event: MessageEvent) => { try { - const message = JSON.parse(event.data) as - | ControlMessage - | InputMessage - | CursorPositionMessage; + const message = JSON.parse(event.data) as ControlMessage | InputMessage; if ('type' in message) { switch (message.type) { @@ -386,9 +378,6 @@ export function useWebRTCHostAPI({ if (!allowControlRef.current) return; onInputReceivedRef.current?.(viewerId, message); break; - case 'cursor': - onCursorUpdateRef.current?.(viewerId, message); - break; } } } catch { diff --git a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts index 3388c7e..f40cd4f 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts @@ -23,7 +23,6 @@ import type { NetworkQuality, InputMessage, ControlMessage, - CursorPositionMessage, KickMessage, MuteMessage, } from '@pairux/shared-types'; @@ -63,7 +62,6 @@ interface UseWebRTCHostSFUAPIOptions { onViewerLeft?: (viewerId: string) => void; onControlRequest?: (viewerId: string) => void; onInputReceived?: (viewerId: string, input: InputMessage) => void; - onCursorUpdate?: (viewerId: string, cursor: CursorPositionMessage) => void; /** A peer reporting its tailnet addresses (diagnostic only). */ onTailnetHello?: (viewerId: string, ips: string[], isReply: boolean) => void; } @@ -102,7 +100,6 @@ export function useWebRTCHostSFUAPI({ onViewerLeft, onControlRequest, onInputReceived, - onCursorUpdate, onTailnetHello, }: UseWebRTCHostSFUAPIOptions): UseWebRTCHostSFUAPIReturn { const [isHosting, setIsHosting] = useState(false); @@ -124,14 +121,12 @@ export function useWebRTCHostSFUAPI({ const onControlRequestRef = useRef(onControlRequest); const onInputReceivedRef = useRef(onInputReceived); - const onCursorUpdateRef = useRef(onCursorUpdate); const onTailnetHelloRef = useRef(onTailnetHello); const onViewerJoinedRef = useRef(onViewerJoined); const onViewerLeftRef = useRef(onViewerLeft); onControlRequestRef.current = onControlRequest; onInputReceivedRef.current = onInputReceived; - onCursorUpdateRef.current = onCursorUpdate; onTailnetHelloRef.current = onTailnetHello; onViewerJoinedRef.current = onViewerJoined; onViewerLeftRef.current = onViewerLeft; @@ -160,7 +155,7 @@ export function useWebRTCHostSFUAPI({ try { const text = decoder.decode(payload); - const message = JSON.parse(text) as ControlMessage | InputMessage | CursorPositionMessage; + const message = JSON.parse(text) as ControlMessage | InputMessage; if ('type' in message) { switch (message.type) { @@ -189,9 +184,6 @@ export function useWebRTCHostSFUAPI({ if (!allowControlRef.current) return; onInputReceivedRef.current?.(viewerId, message); break; - case 'cursor': - onCursorUpdateRef.current?.(viewerId, message); - break; } } } catch { diff --git a/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts b/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts index 054ee99..4c4bb92 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts @@ -18,7 +18,6 @@ import type { InputEvent, ControlMessage, ControlStateUI, - CursorPositionMessage, KickMessage, MuteMessage, } from '@pairux/shared-types'; @@ -54,7 +53,6 @@ interface UseWebRTCViewerAPIOptions { onStreamReady?: (stream: MediaStream) => void; onStreamEnded?: () => void; onControlStateChange?: (state: ControlStateUI) => void; - onCursorUpdate?: (cursor: CursorPositionMessage) => void; onKicked?: (reason?: string) => void; onPresenceChange?: () => void; } @@ -72,7 +70,6 @@ interface UseWebRTCViewerAPIReturn { requestControl: () => void; releaseControl: () => void; sendInput: (event: InputEvent) => void; - sendCursorPosition: (x: number, y: number, visible: boolean) => void; micEnabled: boolean; hasMic: boolean; toggleMic: () => void; @@ -84,7 +81,6 @@ export function useWebRTCViewerAPI({ onStreamReady, onStreamEnded, onControlStateChange, - onCursorUpdate, onKicked, onPresenceChange, }: UseWebRTCViewerAPIOptions): UseWebRTCViewerAPIReturn { @@ -130,13 +126,11 @@ export function useWebRTCViewerAPI({ // Callback refs to avoid circular dependencies const handleConnectionFailureRef = useRef<(() => Promise) | undefined>(undefined); const onControlStateChangeRef = useRef(onControlStateChange); - const onCursorUpdateRef = useRef(onCursorUpdate); const onKickedRef = useRef(onKicked); const onPresenceChangeRef = useRef(onPresenceChange); const disconnectRef = useRef<(() => void) | undefined>(undefined); onControlStateChangeRef.current = onControlStateChange; - onCursorUpdateRef.current = onCursorUpdate; onKickedRef.current = onKicked; onPresenceChangeRef.current = onPresenceChange; @@ -179,11 +173,7 @@ export function useWebRTCViewerAPI({ // Handle incoming data channel messages const handleDataChannelMessage = useCallback((event: MessageEvent) => { try { - const message = JSON.parse(event.data) as - | ControlMessage - | CursorPositionMessage - | KickMessage - | MuteMessage; + const message = JSON.parse(event.data) as ControlMessage | KickMessage | MuteMessage; if ('type' in message) { switch (message.type) { @@ -215,9 +205,6 @@ export function useWebRTCViewerAPI({ setControlState('view-only'); onControlStateChangeRef.current?.('view-only'); break; - case 'cursor': - onCursorUpdateRef.current?.(message); - break; case 'kick': setError('You were removed from the session'); disconnectRef.current?.(); @@ -317,24 +304,6 @@ export function useWebRTCViewerAPI({ [controlState] ); - // Send cursor position - const sendCursorPosition = useCallback( - (x: number, y: number, visible: boolean) => { - const dc = dataChannelRef.current; - if (dc?.readyState !== 'open') return; - - const message: CursorPositionMessage = { - type: 'cursor', - participantId, - x, - y, - visible, - }; - dc.send(JSON.stringify(message)); - }, - [participantId] - ); - // Collect WebRTC stats for UI display const collectStats = useCallback(async () => { const pc = peerConnectionRef.current; @@ -1008,7 +977,6 @@ export function useWebRTCViewerAPI({ requestControl, releaseControl, sendInput, - sendCursorPosition, micEnabled, hasMic, toggleMic, diff --git a/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.ts b/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.ts index f51bdfc..dd7f3e5 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.ts @@ -26,7 +26,6 @@ import type { InputEvent, ControlMessage, ControlStateUI, - CursorPositionMessage, KickMessage, MuteMessage, } from '@pairux/shared-types'; @@ -40,7 +39,6 @@ interface UseWebRTCViewerSFUAPIOptions { onStreamReady?: (stream: MediaStream) => void; onStreamEnded?: () => void; onControlStateChange?: (state: ControlStateUI) => void; - onCursorUpdate?: (cursor: CursorPositionMessage) => void; onKicked?: (reason?: string) => void; onPresenceChange?: () => void; } @@ -58,7 +56,6 @@ interface UseWebRTCViewerSFUAPIReturn { requestControl: () => void; releaseControl: () => void; sendInput: (event: InputEvent) => void; - sendCursorPosition: (x: number, y: number, visible: boolean) => void; micEnabled: boolean; hasMic: boolean; toggleMic: () => void; @@ -85,7 +82,6 @@ export function useWebRTCViewerSFUAPI({ onStreamReady, onStreamEnded, onControlStateChange, - onCursorUpdate, onKicked, onPresenceChange, }: UseWebRTCViewerSFUAPIOptions): UseWebRTCViewerSFUAPIReturn { @@ -108,7 +104,6 @@ export function useWebRTCViewerSFUAPI({ const prevStatsTimestampRef = useRef(0); const onControlStateChangeRef = useRef(onControlStateChange); - const onCursorUpdateRef = useRef(onCursorUpdate); const onKickedRef = useRef(onKicked); const onPresenceChangeRef = useRef(onPresenceChange); const onStreamReadyRef = useRef(onStreamReady); @@ -116,7 +111,6 @@ export function useWebRTCViewerSFUAPI({ const disconnectRef = useRef<(() => void) | undefined>(undefined); onControlStateChangeRef.current = onControlStateChange; - onCursorUpdateRef.current = onCursorUpdate; onKickedRef.current = onKicked; onPresenceChangeRef.current = onPresenceChange; onStreamReadyRef.current = onStreamReady; @@ -133,14 +127,10 @@ export function useWebRTCViewerSFUAPI({ }, []); const handleDataReceived = useCallback( - (payload: Uint8Array, participant?: RemoteParticipant) => { + (payload: Uint8Array, _participant?: RemoteParticipant) => { try { const text = decoder.decode(payload); - const message = JSON.parse(text) as - | ControlMessage - | CursorPositionMessage - | KickMessage - | MuteMessage; + const message = JSON.parse(text) as ControlMessage | KickMessage | MuteMessage; if ('type' in message) { switch (message.type) { @@ -167,11 +157,6 @@ export function useWebRTCViewerSFUAPI({ setControlState('view-only'); onControlStateChangeRef.current?.('view-only'); break; - case 'cursor': - if (participant?.identity !== participantId) { - onCursorUpdateRef.current?.(message); - } - break; case 'kick': setError('You were removed from the session'); disconnectRef.current?.(); @@ -243,23 +228,6 @@ export function useWebRTCViewerSFUAPI({ [controlState, dataChannelReady, sendData] ); - // Send cursor position (lossy) - const sendCursorPosition = useCallback( - (x: number, y: number, visible: boolean) => { - if (!dataChannelReady) return; - - const message: CursorPositionMessage = { - type: 'cursor', - participantId, - x, - y, - visible, - }; - sendData(message, false); - }, - [participantId, dataChannelReady, sendData] - ); - // Collect stats const collectStats = useCallback(async () => { const room = roomRef.current; @@ -554,7 +522,6 @@ export function useWebRTCViewerSFUAPI({ requestControl, releaseControl, sendInput, - sendCursorPosition, micEnabled, hasMic, toggleMic, diff --git a/apps/desktop/src/renderer/lib/containRect.ts b/apps/desktop/src/renderer/lib/containRect.ts index 6c3945b..ba89ca7 100644 --- a/apps/desktop/src/renderer/lib/containRect.ts +++ b/apps/desktop/src/renderer/lib/containRect.ts @@ -10,8 +10,8 @@ // The implementation lives in @pairux/shared-types, because the input path // needs the same rectangle and the two must not be allowed to disagree: -// an overlay drawn against one and clicks mapped against the other put the -// remote cursor somewhere the click does not land. +// any element drawn against one and clicks mapped against the other drift +// apart. export { getContainRect, type ContainRect as Rect } from '@pairux/shared-types'; /** Clamp a value to the inclusive [min, max] range. */ diff --git a/apps/desktop/src/renderer/routes/viewer.test.tsx b/apps/desktop/src/renderer/routes/viewer.test.tsx index 2cf6556..5e39121 100644 --- a/apps/desktop/src/renderer/routes/viewer.test.tsx +++ b/apps/desktop/src/renderer/routes/viewer.test.tsx @@ -47,7 +47,6 @@ const mockP2PHookResult: { qualityMetrics: null; networkQuality: string; sendInput: ReturnType; - sendCursorPosition: ReturnType; } = { connectionState: 'connected', remoteStream: null, @@ -64,7 +63,6 @@ const mockP2PHookResult: { qualityMetrics: null, networkQuality: 'good', sendInput: vi.fn(), - sendCursorPosition: vi.fn(), }; const mockSFUHookResult = { diff --git a/apps/desktop/src/renderer/routes/viewer.tsx b/apps/desktop/src/renderer/routes/viewer.tsx index 8f91eb1..88d7553 100644 --- a/apps/desktop/src/renderer/routes/viewer.tsx +++ b/apps/desktop/src/renderer/routes/viewer.tsx @@ -41,7 +41,6 @@ interface ViewerHookResult { requestControl: () => void; releaseControl: () => void; sendInput: (event: InputEvent) => void; - sendCursorPosition: (x: number, y: number, visible: boolean) => void; micEnabled: boolean; hasMic: boolean; toggleMic: () => void; @@ -304,7 +303,6 @@ function ViewerContent({ session, participants, userId, hookResult }: ViewerCont requestControl, releaseControl, sendInput, - sendCursorPosition, micEnabled, hasMic, toggleMic, @@ -462,7 +460,6 @@ function ViewerContent({ session, participants, userId, hookResult }: ViewerCont enabled={allowControl} controlState={controlState} onInputEvent={sendInput} - onCursorMove={sendCursorPosition} allowFullscreen className="flex flex-1 flex-col" > diff --git a/apps/mobile/src/hooks/useWebRTCHost.ts b/apps/mobile/src/hooks/useWebRTCHost.ts index 40a25fc..c8dec33 100644 --- a/apps/mobile/src/hooks/useWebRTCHost.ts +++ b/apps/mobile/src/hooks/useWebRTCHost.ts @@ -15,7 +15,6 @@ import type { ConnectionState, NetworkQuality, InputMessage, - CursorPositionMessage, ControlMessage, KickMessage, MuteMessage, @@ -100,7 +99,6 @@ interface UseWebRTCHostOptions { onViewerLeft?: (viewerId: string) => void; onControlRequest?: (viewerId: string) => void; onInputReceived?: (viewerId: string, input: InputMessage) => void; - onCursorUpdate?: (viewerId: string, cursor: CursorPositionMessage) => void; } interface UseWebRTCHostReturn { @@ -130,7 +128,6 @@ export function useWebRTCHost({ onViewerLeft, onControlRequest, onInputReceived, - onCursorUpdate, }: UseWebRTCHostOptions): UseWebRTCHostReturn { const [isHosting, setIsHosting] = useState(false); const [error, setError] = useState(null); @@ -154,10 +151,8 @@ export function useWebRTCHost({ localStreamRef.current = localStream; const onControlRequestRef = useRef(onControlRequest); const onInputReceivedRef = useRef(onInputReceived); - const onCursorUpdateRef = useRef(onCursorUpdate); onControlRequestRef.current = onControlRequest; onInputReceivedRef.current = onInputReceived; - onCursorUpdateRef.current = onCursorUpdate; // Send signal via API const sendSignal = useCallback( @@ -258,7 +253,7 @@ export function useWebRTCHost({ // Handle data channel messages from viewer const handleDataChannelMessage = useCallback((viewerId: string, data: string) => { try { - const message = JSON.parse(data) as ControlMessage | InputMessage | CursorPositionMessage; + const message = JSON.parse(data) as ControlMessage | InputMessage; if ('type' in message) { switch (message.type) { @@ -277,9 +272,6 @@ export function useWebRTCHost({ case 'input': onInputReceivedRef.current?.(viewerId, message); break; - case 'cursor': - onCursorUpdateRef.current?.(viewerId, message); - break; } } } catch { diff --git a/apps/mobile/src/hooks/useWebRTCViewer.test.ts b/apps/mobile/src/hooks/useWebRTCViewer.test.ts index 0015cef..5d36402 100644 --- a/apps/mobile/src/hooks/useWebRTCViewer.test.ts +++ b/apps/mobile/src/hooks/useWebRTCViewer.test.ts @@ -71,7 +71,6 @@ describe('useWebRTCViewer', () => { expect(typeof result.current.requestControl).toBe('function'); expect(typeof result.current.releaseControl).toBe('function'); expect(typeof result.current.sendInput).toBe('function'); - expect(typeof result.current.sendCursorPosition).toBe('function'); expect(typeof result.current.toggleMic).toBe('function'); }); diff --git a/apps/mobile/src/hooks/useWebRTCViewer.ts b/apps/mobile/src/hooks/useWebRTCViewer.ts index d831200..b3caa45 100644 --- a/apps/mobile/src/hooks/useWebRTCViewer.ts +++ b/apps/mobile/src/hooks/useWebRTCViewer.ts @@ -18,7 +18,6 @@ import type { InputEvent, ControlMessage, ControlStateUI, - CursorPositionMessage, KickMessage, MuteMessage, } from '@pairux/shared-types'; @@ -73,7 +72,6 @@ interface UseWebRTCViewerOptions { onStreamReady?: (stream: MediaStream) => void; onStreamEnded?: () => void; onControlStateChange?: (state: ControlStateUI) => void; - onCursorUpdate?: (cursor: CursorPositionMessage) => void; onKicked?: (reason?: string) => void; } @@ -90,7 +88,6 @@ interface UseWebRTCViewerReturn { requestControl: () => void; releaseControl: () => void; sendInput: (event: InputEvent) => void; - sendCursorPosition: (x: number, y: number, visible: boolean) => void; micEnabled: boolean; hasMic: boolean; toggleMic: () => void; @@ -102,7 +99,6 @@ export function useWebRTCViewer({ onStreamReady, onStreamEnded, onControlStateChange, - onCursorUpdate, onKicked, }: UseWebRTCViewerOptions): UseWebRTCViewerReturn { const [connectionState, setConnectionState] = useState('idle'); @@ -134,12 +130,10 @@ export function useWebRTCViewer({ const handleConnectionFailureRef = useRef<(() => Promise) | undefined>(undefined); const onControlStateChangeRef = useRef(onControlStateChange); - const onCursorUpdateRef = useRef(onCursorUpdate); const onKickedRef = useRef(onKicked); const disconnectRef = useRef<(() => void) | undefined>(undefined); onControlStateChangeRef.current = onControlStateChange; - onCursorUpdateRef.current = onCursorUpdate; onKickedRef.current = onKicked; const calculateNetworkQuality = useCallback((metrics: QualityMetrics): NetworkQuality => { @@ -178,11 +172,7 @@ export function useWebRTCViewer({ // Handle data channel messages const handleDataChannelMessage = useCallback((data: string) => { try { - const message = JSON.parse(data) as - | ControlMessage - | CursorPositionMessage - | KickMessage - | MuteMessage; + const message = JSON.parse(data) as ControlMessage | KickMessage | MuteMessage; if ('type' in message) { switch (message.type) { @@ -194,9 +184,6 @@ export function useWebRTCViewer({ setControlState('view-only'); onControlStateChangeRef.current?.('view-only'); break; - case 'cursor': - onCursorUpdateRef.current?.(message); - break; case 'kick': setError('You were removed from the session'); disconnectRef.current?.(); @@ -291,23 +278,6 @@ export function useWebRTCViewer({ [controlState] ); - const sendCursorPosition = useCallback( - (x: number, y: number, visible: boolean) => { - const dc = dataChannelRef.current; - if (dc?.readyState !== 'open') return; - - const message: CursorPositionMessage = { - type: 'cursor', - participantId, - x, - y, - visible, - }; - dc.send(JSON.stringify(message)); - }, - [participantId] - ); - // Collect stats for UI const collectStats = useCallback(async () => { const pc = peerConnectionRef.current; @@ -834,7 +804,6 @@ export function useWebRTCViewer({ requestControl, releaseControl, sendInput, - sendCursorPosition, micEnabled, hasMic, toggleMic, diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx index 7f6eef2..6dc46ab 100644 --- a/apps/web/src/app/changelog/page.tsx +++ b/apps/web/src/app/changelog/page.tsx @@ -10,15 +10,14 @@ export const metadata: Metadata = { const releases = [ { - version: '0.9.36', - date: 'July 2026', - title: 'Two cursors, and remote control that stays out of your way', + version: '0.9.70', + date: 'August 2026', + title: 'One shared host pointer for remote control', changes: [ - 'Two independent cursors: a guest can point and click while you keep working — their movement never takes over your pointer', - 'Your pointer is returned to where you left it after a guest clicks (macOS, Windows, Linux/X11, and KDE Wayland)', - 'Fixed remote control freezing the host mouse until reboot', - 'Guests joining from the desktop app can now request control', - 'More reliable relay connections on networks with unreliable DNS', + 'Remote input now drives the host’s one real system pointer directly', + 'Host and guest can take turns naturally whenever the other is idle', + 'Removed the overlay cursor, cursor-position transport, pointer borrowing, restoration, and KDE cursor helper', + 'The host can always stop remote input with Ctrl+Shift+Escape', ], }, { diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index a5e7046..e073b68 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -65,7 +65,7 @@ const faqs = [ { question: 'Does the viewer see my cursor?', answer: - "Yes, your cursor is captured as part of the screen share. When the viewer is granted control, you'll also see their remote cursor as an overlay.", + "Yes, your cursor is captured as part of the screen share. When control is granted, the viewer drives the host's real system pointer; either person can use it when the other is idle.", }, { question: 'Can I share audio?', diff --git a/apps/web/src/app/features/page.tsx b/apps/web/src/app/features/page.tsx index 18bd7ae..7918275 100644 --- a/apps/web/src/app/features/page.tsx +++ b/apps/web/src/app/features/page.tsx @@ -184,8 +184,8 @@ const technicalFeatures = [ }, { icon: Pointer, - title: 'Multi-cursor', - description: 'See remote cursor position in real-time.', + title: 'Shared control', + description: 'Host and guest take turns using the host system pointer.', }, { icon: Keyboard, diff --git a/apps/web/src/app/session/[id]/page.test.tsx b/apps/web/src/app/session/[id]/page.test.tsx index 362718b..db8f3b1 100644 --- a/apps/web/src/app/session/[id]/page.test.tsx +++ b/apps/web/src/app/session/[id]/page.test.tsx @@ -19,7 +19,6 @@ const mockUseWebRTC = { requestControl: vi.fn(), releaseControl: vi.fn(), sendInput: vi.fn(), - sendCursorPosition: vi.fn(), // Microphone micEnabled: false, hasMic: true, diff --git a/apps/web/src/app/session/[id]/page.tsx b/apps/web/src/app/session/[id]/page.tsx index b3ca51b..8ecc4f4 100644 --- a/apps/web/src/app/session/[id]/page.tsx +++ b/apps/web/src/app/session/[id]/page.tsx @@ -18,7 +18,6 @@ import { import { Wifi, WifiOff, RefreshCw } from 'lucide-react'; import type { ConnectionState, - CursorPositionMessage, QualityMetrics, NetworkQuality, ControlStateUI, @@ -27,18 +26,12 @@ import type { import { VideoViewer } from '@/components/video'; import { useWebRTC } from '@/hooks/useWebRTC'; import { useWebRTCSFU } from '@/hooks/useWebRTCSFU'; -import { - ControlRequestButton, - ControlStatusIndicator, - InputCapture, - CursorOverlay, -} from '@/components/control'; +import { ControlRequestButton, ControlStatusIndicator, InputCapture } from '@/components/control'; import { ChatPanel } from '@/components/chat/ChatPanel'; import { SessionSettingsPanel } from '@/components/session/SessionSettingsPanel'; import { HostPresenceIndicator } from '@/components/session/HostPresenceIndicator'; import { useSessionPresence } from '@/hooks/useSessionPresence'; import { Logo } from '@/components/Logo'; -import { useVideoContentRect } from '@/hooks/useVideoContentRect'; type SidebarPanel = 'participants' | 'chat' | 'settings' | null; @@ -160,25 +153,11 @@ function P2PSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { // participantId with z.string().uuid(). useId() returns React opaque ids // (":r0:"), which the SFU token route rejects ("Invalid participant ID"). const [participantId] = useState(() => crypto.randomUUID()); - const [remoteCursors, setRemoteCursors] = useState>(new Map()); - - const handleCursorUpdate = useCallback((cursor: CursorPositionMessage) => { - setRemoteCursors((prev) => { - const next = new Map(prev); - if (cursor.visible) { - next.set(cursor.participantId, cursor); - } else { - next.delete(cursor.participantId); - } - return next; - }); - }, []); const hookResult = useWebRTC({ sessionId, participantId, useApiSignalPost: true, - onCursorUpdate: handleCursorUpdate, }); return ( @@ -186,7 +165,6 @@ function P2PSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { sessionId={sessionId} session={session} participantId={participantId} - remoteCursors={remoteCursors} {...hookResult} /> ); @@ -197,24 +175,10 @@ function SFUSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { // participantId with z.string().uuid(). useId() returns React opaque ids // (":r0:"), which the SFU token route rejects ("Invalid participant ID"). const [participantId] = useState(() => crypto.randomUUID()); - const [remoteCursors, setRemoteCursors] = useState>(new Map()); - - const handleCursorUpdate = useCallback((cursor: CursorPositionMessage) => { - setRemoteCursors((prev) => { - const next = new Map(prev); - if (cursor.visible) { - next.set(cursor.participantId, cursor); - } else { - next.delete(cursor.participantId); - } - return next; - }); - }, []); const hookResult = useWebRTCSFU({ sessionId, participantId, - onCursorUpdate: handleCursorUpdate, }); return ( @@ -222,7 +186,6 @@ function SFUSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { sessionId={sessionId} session={session} participantId={participantId} - remoteCursors={remoteCursors} {...hookResult} /> ); @@ -234,7 +197,6 @@ interface SessionViewerContentProps { sessionId: string; session: SessionData; participantId: string; - remoteCursors: Map; connectionState: ConnectionState; remoteStream: MediaStream | null; qualityMetrics: QualityMetrics | null; @@ -246,7 +208,6 @@ interface SessionViewerContentProps { requestControl: () => void; releaseControl: () => void; sendInput: (event: InputEvent) => void; - sendCursorPosition: (x: number, y: number, visible: boolean) => void; micEnabled: boolean; hasMic: boolean; toggleMic: () => void; @@ -256,7 +217,6 @@ function SessionViewerContent({ sessionId, session, participantId: _participantId, - remoteCursors, connectionState, remoteStream, qualityMetrics, @@ -268,7 +228,6 @@ function SessionViewerContent({ requestControl, releaseControl, sendInput, - sendCursorPosition, micEnabled, hasMic, toggleMic, @@ -278,7 +237,6 @@ function SessionViewerContent({ const videoContainerRef = useRef(null); // Cursors are normalized against the remote screen, which is letterboxed // inside the player, so the overlay needs the picture's rectangle. - const videoContentRect = useVideoContentRect(videoContainerRef); // Track host presence in real-time const { status: sessionStatus, currentHostId, hostOnline } = useSessionPresence(sessionId); @@ -353,7 +311,6 @@ function SessionViewerContent({ enabled={allowControl} controlState={controlState} onInputEvent={sendInput} - onCursorMove={sendCursorPosition} allowFullscreen className="h-full" > @@ -370,7 +327,6 @@ function SessionViewerContent({ className="h-full" /> - {/* Control bar */} diff --git a/apps/web/src/app/view/[sessionId]/page.test.tsx b/apps/web/src/app/view/[sessionId]/page.test.tsx index 5265e2a..49c43bf 100644 --- a/apps/web/src/app/view/[sessionId]/page.test.tsx +++ b/apps/web/src/app/view/[sessionId]/page.test.tsx @@ -25,7 +25,6 @@ const mockRequestControl = vi.fn(); const mockReleaseControl = vi.fn(); const mockReconnect = vi.fn(); const mockSendInput = vi.fn(); -const mockSendCursorPosition = vi.fn(); const mockWebRTCResult = { connectionState: 'connected' as const, @@ -39,7 +38,6 @@ const mockWebRTCResult = { requestControl: mockRequestControl, releaseControl: mockReleaseControl, sendInput: mockSendInput, - sendCursorPosition: mockSendCursorPosition, micEnabled: false, hasMic: true, toggleMic: mockToggleMic, @@ -73,7 +71,6 @@ vi.mock('@/components/control', () => ({ ControlRequestButton: () =>
ControlButton
, ControlStatusIndicator: () => null, InputCapture: ({ children }: { children: React.ReactNode }) =>
{children}
, - CursorOverlay: () => null, })); vi.mock('@/components/Logo', () => ({ diff --git a/apps/web/src/app/view/[sessionId]/page.tsx b/apps/web/src/app/view/[sessionId]/page.tsx index 6ae4548..e121b8b 100644 --- a/apps/web/src/app/view/[sessionId]/page.tsx +++ b/apps/web/src/app/view/[sessionId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, use, useRef, useCallback } from 'react'; +import { useState, useEffect, use, useRef } from 'react'; import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { @@ -20,7 +20,6 @@ import { HostPresenceIndicator } from '@/components/session/HostPresenceIndicato import { useSessionPresence } from '@/hooks/useSessionPresence'; import type { ConnectionState, - CursorPositionMessage, QualityMetrics, NetworkQuality, ControlStateUI, @@ -29,14 +28,8 @@ import type { import { VideoViewer } from '@/components/video'; import { useWebRTC } from '@/hooks/useWebRTC'; import { useWebRTCSFU } from '@/hooks/useWebRTCSFU'; -import { - ControlRequestButton, - ControlStatusIndicator, - InputCapture, - CursorOverlay, -} from '@/components/control'; +import { ControlRequestButton, ControlStatusIndicator, InputCapture } from '@/components/control'; import { Logo } from '@/components/Logo'; -import { useVideoContentRect } from '@/hooks/useVideoContentRect'; interface Participant { id: string; @@ -188,65 +181,21 @@ interface GuestViewerProps { } function P2PGuestViewer({ sessionId, session, participant, participantId }: GuestViewerProps) { - const [remoteCursors, setRemoteCursors] = useState>(new Map()); - - const handleCursorUpdate = useCallback((cursor: CursorPositionMessage) => { - setRemoteCursors((prev) => { - const next = new Map(prev); - if (cursor.visible) { - next.set(cursor.participantId, cursor); - } else { - next.delete(cursor.participantId); - } - return next; - }); - }, []); - const hookResult = useWebRTC({ sessionId, participantId, - onCursorUpdate: handleCursorUpdate, }); - return ( - - ); + return ; } function SFUGuestViewer({ sessionId, session, participant, participantId }: GuestViewerProps) { - const [remoteCursors, setRemoteCursors] = useState>(new Map()); - - const handleCursorUpdate = useCallback((cursor: CursorPositionMessage) => { - setRemoteCursors((prev) => { - const next = new Map(prev); - if (cursor.visible) { - next.set(cursor.participantId, cursor); - } else { - next.delete(cursor.participantId); - } - return next; - }); - }, []); - const hookResult = useWebRTCSFU({ sessionId, participantId, - onCursorUpdate: handleCursorUpdate, }); - return ( - - ); + return ; } // --- Shared viewer content (all JSX lives here) --- @@ -254,7 +203,6 @@ function SFUGuestViewer({ sessionId, session, participant, participantId }: Gues interface GuestViewerContentProps { session: SessionData; participant: Participant; - remoteCursors: Map; connectionState: ConnectionState; remoteStream: MediaStream | null; qualityMetrics: QualityMetrics | null; @@ -266,7 +214,6 @@ interface GuestViewerContentProps { requestControl: () => void; releaseControl: () => void; sendInput: (event: InputEvent) => void; - sendCursorPosition: (x: number, y: number, visible: boolean) => void; micEnabled: boolean; hasMic: boolean; toggleMic: () => void; @@ -275,7 +222,6 @@ interface GuestViewerContentProps { function GuestViewerContent({ session, participant, - remoteCursors, connectionState, remoteStream, qualityMetrics, @@ -287,7 +233,6 @@ function GuestViewerContent({ requestControl, releaseControl, sendInput, - sendCursorPosition, micEnabled, hasMic, toggleMic, @@ -295,7 +240,6 @@ function GuestViewerContent({ const videoContainerRef = useRef(null); // Cursors are normalized against the remote screen, which is letterboxed // inside the player, so the overlay needs the picture's rectangle. - const videoContentRect = useVideoContentRect(videoContainerRef); const [speakerMuted, setSpeakerMuted] = useState(false); const allowControl = session.settings.allowControl ?? false; const activeParticipants = session.session_participants.filter((p) => p.role !== 'left'); @@ -361,7 +305,6 @@ function GuestViewerContent({ enabled={allowControl} controlState={controlState} onInputEvent={sendInput} - onCursorMove={sendCursorPosition} allowFullscreen className="h-full" > @@ -378,7 +321,6 @@ function GuestViewerContent({ className="h-full" /> - {/* Control bar */} diff --git a/apps/web/src/components/control/CursorOverlay.tsx b/apps/web/src/components/control/CursorOverlay.tsx deleted file mode 100644 index 27cc272..0000000 --- a/apps/web/src/components/control/CursorOverlay.tsx +++ /dev/null @@ -1,142 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import type { CursorPositionMessage } from '@pairux/shared-types'; - -interface Cursor { - participantId: string; - x: number; - y: number; - visible: boolean; - displayName?: string | undefined; - color?: string | undefined; -} - -interface CursorOverlayProps { - cursors: Map; - participantNames?: Map; - className?: string; - /** - * The picture's rectangle inside this overlay, when it is known. - * - * Cursor coordinates are normalized against the remote screen, and the - * remote screen is letterboxed inside the player whenever the aspect ratios - * differ. Positioning by percentage of the overlay therefore draws the - * cursor somewhere the guest is not pointing, while their clicks land where - * they aimed — the cursor and the click disagree, which is worse than having - * no cursor. Falls back to the full overlay when null, which is correct - * before the stream reports its dimensions. - */ - contentRect?: { x: number; y: number; width: number; height: number } | null; -} - -// Generate consistent color from participant ID -function getColorFromId(id: string): string { - const colors = [ - 'rgb(239, 68, 68)', // red - 'rgb(249, 115, 22)', // orange - 'rgb(234, 179, 8)', // yellow - 'rgb(34, 197, 94)', // green - 'rgb(20, 184, 166)', // teal - 'rgb(59, 130, 246)', // blue - 'rgb(139, 92, 246)', // violet - 'rgb(236, 72, 153)', // pink - ]; - - // Simple hash function to get consistent color - let hash = 0; - for (let i = 0; i < id.length; i++) { - const char = id.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; - } - - const index = Math.abs(hash) % colors.length; - const color = colors[index]; - return color ?? 'rgb(59, 130, 246)'; // fallback blue -} - -function CursorIcon({ color }: { color: string }) { - return ( - - - - ); -} - -export function CursorOverlay({ - cursors, - participantNames, - className = '', - contentRect = null, -}: CursorOverlayProps) { - // Convert map to array of cursors with colors - const cursorList = useMemo(() => { - const list: Cursor[] = []; - cursors.forEach((cursor, id) => { - if (cursor.visible) { - list.push({ - participantId: id, - x: cursor.x, - y: cursor.y, - visible: cursor.visible, - displayName: participantNames?.get(id), - color: getColorFromId(id), - }); - } - }); - return list; - }, [cursors, participantNames]); - - if (cursorList.length === 0) { - return null; - } - - return ( -
- {cursorList.map((cursor) => ( -
- - {cursor.displayName && ( -
- {cursor.displayName} -
- )} -
- ))} -
- ); -} diff --git a/apps/web/src/components/control/InputCapture.tsx b/apps/web/src/components/control/InputCapture.tsx index 9fa1c51..0a0c0f0 100644 --- a/apps/web/src/components/control/InputCapture.tsx +++ b/apps/web/src/components/control/InputCapture.tsx @@ -11,7 +11,6 @@ interface InputCaptureProps { enabled: boolean; controlState: ControlStateUI; onInputEvent: (event: InputEvent) => void; - onCursorMove?: ((x: number, y: number, visible: boolean) => void) | undefined; className?: string; /** When true, shows a fullscreen toggle and enables pointer lock. */ allowFullscreen?: boolean; @@ -32,22 +31,14 @@ export function InputCapture({ enabled, controlState, onInputEvent, - onCursorMove, className = '', allowFullscreen = false, }: InputCaptureProps) { const containerRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); - const handlePointerMove = useCallback( - (x: number, y: number, visible: boolean) => { - onCursorMove?.(x, y, visible); - }, - [onCursorMove] - ); - const { isLocked, wasReleasedByUser, positionRef, lock, unlock, resetPosition } = usePointerLock({ - onMove: handlePointerMove, + onMove: () => undefined, }); // The guest has asked to drive, by clicking into the picture. @@ -78,7 +69,6 @@ export function InputCapture({ controlState, containerRef, onInputEvent, - onCursorMove, // Only once the lock is actually held. Under lock the event's clientX/Y // stop advancing, so the virtual position is the only real coordinate; // outside it, the event is. diff --git a/apps/web/src/components/control/index.ts b/apps/web/src/components/control/index.ts index ea5dc66..2b6004a 100644 --- a/apps/web/src/components/control/index.ts +++ b/apps/web/src/components/control/index.ts @@ -1,4 +1,3 @@ export { ControlRequestButton } from './ControlRequestButton'; export { ControlStatusIndicator } from './ControlStatusIndicator'; export { InputCapture } from './InputCapture'; -export { CursorOverlay } from './CursorOverlay'; diff --git a/apps/web/src/hooks/useRemoteControl.ts b/apps/web/src/hooks/useRemoteControl.ts index 796878e..e84eea3 100644 --- a/apps/web/src/hooks/useRemoteControl.ts +++ b/apps/web/src/hooks/useRemoteControl.ts @@ -21,7 +21,6 @@ interface UseRemoteControlOptions { controlState: ControlStateUI; containerRef: React.RefObject; onInputEvent: (event: InputEvent) => void; - onCursorMove?: ((x: number, y: number, visible: boolean) => void) | undefined; pointerLockPosition?: React.RefObject<{ x: number; y: number } | null> | undefined; } @@ -50,7 +49,6 @@ export function useRemoteControl({ controlState, containerRef, onInputEvent, - onCursorMove, pointerLockPosition, }: UseRemoteControlOptions): UseRemoteControlReturn { const [isCapturing, setIsCapturing] = useState(false); @@ -61,11 +59,9 @@ export function useRemoteControl({ // an "up", or the host is left mid-drag with a stuck button. const heldButtonsRef = useRef>(new Set()); const heldKeysRef = useRef>(new Set()); - const lastCursorUpdateRef = useRef(0); // Pointer events fire before mouse events in Chromium. Storing the last // pointer event's timestamp lets the mouse handler skip a double-fire. const lastPointerEventRef = useRef(0); - const cursorThrottleMs = 16; // ~60fps throttle for cursor updates // Check if we can send input (enabled, granted control, and capturing) const canSendInput = enabled && controlState === 'granted' && isCapturing; @@ -114,14 +110,6 @@ export function useRemoteControl({ const coords = getRelativeCoords(event); if (!coords) return; - // Always update cursor position (even when view-only). - // Skip when locked: the pointer-lock movement handler already does this. - const now = Date.now(); - if (!pointerLockPosition?.current && now - lastCursorUpdateRef.current >= cursorThrottleMs) { - lastCursorUpdateRef.current = now; - onCursorMove?.(coords.x, coords.y, true); - } - // Only send input event if we have control if (!canSendInput) return; @@ -134,7 +122,7 @@ export function useRemoteControl({ onInputEvent(inputEvent); }, - [getRelativeCoords, canSendInput, onCursorMove, onInputEvent, pointerLockPosition] + [getRelativeCoords, canSendInput, onInputEvent] ); // Handle mouse down @@ -252,11 +240,6 @@ export function useRemoteControl({ heldKeysRef.current.clear(); }, [onInputEvent]); - // Handle mouse leave (cursor left the container) - const handleMouseLeave = useCallback(() => { - onCursorMove?.(0, 0, false); - }, [onCursorMove]); - // Handle key down const handleKeyDown = useCallback( (event: globalThis.KeyboardEvent) => { @@ -349,8 +332,7 @@ export function useRemoteControl({ const stopCapture = useCallback(() => { releaseHeldInput(); setIsCapturing(false); - onCursorMove?.(0, 0, false); - }, [onCursorMove, releaseHeldInput]); + }, [releaseHeldInput]); // Attach/detach event listeners useEffect(() => { @@ -368,7 +350,6 @@ export function useRemoteControl({ container.addEventListener('pointerdown', handlePointerDown); container.addEventListener('pointerup', handlePointerUp); container.addEventListener('wheel', handleWheel, { passive: false }); - container.addEventListener('mouseleave', handleMouseLeave); container.addEventListener('contextmenu', handleContextMenu); // Keyboard events on document (when container is focused) @@ -387,7 +368,6 @@ export function useRemoteControl({ container.removeEventListener('pointerdown', handlePointerDown); container.removeEventListener('pointerup', handlePointerUp); container.removeEventListener('wheel', handleWheel); - container.removeEventListener('mouseleave', handleMouseLeave); container.removeEventListener('contextmenu', handleContextMenu); document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keyup', handleKeyUp); @@ -403,7 +383,6 @@ export function useRemoteControl({ handleMouseDown, handleMouseUp, handleWheel, - handleMouseLeave, handleContextMenu, handlePointerDown, handlePointerUp, diff --git a/apps/web/src/hooks/useVideoContentRect.ts b/apps/web/src/hooks/useVideoContentRect.ts index 96f5422..ab61761 100644 --- a/apps/web/src/hooks/useVideoContentRect.ts +++ b/apps/web/src/hooks/useVideoContentRect.ts @@ -7,10 +7,8 @@ import { getContainRect, type ContainRect } from '@pairux/shared-types'; * Anything drawn over the video has to be positioned against the picture * rather than the element: `object-contain` letterboxes the stream whenever * its aspect ratio differs from the window's, and a percentage of the - * container is not a percentage of the picture. A remote cursor placed that - * way drifts from the point the guest is actually aiming at — and since clicks - * land on the aimed point, the cursor ends up pointing at one thing while the - * click hits another, which is worse than not drawing it at all. + * container is not a percentage of the picture. Positioning content against + * the wrong rectangle makes it drift from the picture it belongs to. * * Recomputed on resize and once the stream's dimensions are known, since * `videoWidth` is 0 until metadata arrives. diff --git a/apps/web/src/hooks/useWebRTC.test.ts b/apps/web/src/hooks/useWebRTC.test.ts index ddb504b..52a5dc5 100644 --- a/apps/web/src/hooks/useWebRTC.test.ts +++ b/apps/web/src/hooks/useWebRTC.test.ts @@ -149,7 +149,6 @@ describe('useWebRTC', () => { expect('requestControl' in result.current).toBe(true); expect('releaseControl' in result.current).toBe(true); expect('sendInput' in result.current).toBe(true); - expect('sendCursorPosition' in result.current).toBe(true); }); describe('microphone capture', () => { diff --git a/apps/web/src/hooks/useWebRTC.ts b/apps/web/src/hooks/useWebRTC.ts index 0da64f9..371af3f 100644 --- a/apps/web/src/hooks/useWebRTC.ts +++ b/apps/web/src/hooks/useWebRTC.ts @@ -10,7 +10,6 @@ import type { InputEvent, ControlMessage, ControlStateUI, - CursorPositionMessage, KickMessage, MuteMessage, } from '@pairux/shared-types'; @@ -47,7 +46,6 @@ interface UseWebRTCOptions { onStreamReady?: (stream: MediaStream) => void; onStreamEnded?: () => void; onControlStateChange?: (state: ControlStateUI) => void; - onCursorUpdate?: (cursor: CursorPositionMessage) => void; onKicked?: (reason?: string) => void; } @@ -65,7 +63,6 @@ interface UseWebRTCReturn { requestControl: () => void; releaseControl: () => void; sendInput: (event: InputEvent) => void; - sendCursorPosition: (x: number, y: number, visible: boolean) => void; // Microphone micEnabled: boolean; hasMic: boolean; @@ -79,7 +76,6 @@ export function useWebRTC({ onStreamReady, onStreamEnded, onControlStateChange, - onCursorUpdate, onKicked, }: UseWebRTCOptions): UseWebRTCReturn { const [connectionState, setConnectionState] = useState('idle'); @@ -139,13 +135,11 @@ export function useWebRTC({ // Use refs to avoid circular dependencies in callbacks const handleConnectionFailureRef = useRef<(() => Promise) | undefined>(undefined); const onControlStateChangeRef = useRef(onControlStateChange); - const onCursorUpdateRef = useRef(onCursorUpdate); const onKickedRef = useRef(onKicked); const disconnectRef = useRef<(() => void) | undefined>(undefined); // Keep refs updated onControlStateChangeRef.current = onControlStateChange; - onCursorUpdateRef.current = onCursorUpdate; onKickedRef.current = onKicked; // Calculate network quality from metrics @@ -165,11 +159,7 @@ export function useWebRTC({ // Handle incoming data channel messages const handleDataChannelMessage = useCallback((event: MessageEvent) => { try { - const message = JSON.parse(event.data) as - | ControlMessage - | CursorPositionMessage - | KickMessage - | MuteMessage; + const message = JSON.parse(event.data) as ControlMessage | KickMessage | MuteMessage; if ('type' in message) { switch (message.type) { @@ -181,9 +171,6 @@ export function useWebRTC({ setControlState('view-only'); onControlStateChangeRef.current?.('view-only'); break; - case 'cursor': - onCursorUpdateRef.current?.(message); - break; case 'kick': // Host kicked this viewer setError('You were removed from the session'); @@ -284,25 +271,6 @@ export function useWebRTC({ [controlState] ); - // Send cursor position to host - const sendCursorPosition = useCallback( - (x: number, y: number, visible: boolean) => { - const dc = dataChannelRef.current; - if (dc?.readyState !== 'open') return; - - const message: CursorPositionMessage = { - type: 'cursor', - participantId, - x, - y, - visible, - }; - - dc.send(JSON.stringify(message)); - }, - [participantId] - ); - // Collect WebRTC stats const collectStats = useCallback(async () => { const pc = peerConnectionRef.current; @@ -738,7 +706,6 @@ export function useWebRTC({ requestControl, releaseControl, sendInput, - sendCursorPosition, // Microphone micEnabled, hasMic, diff --git a/apps/web/src/hooks/useWebRTCHost.ts b/apps/web/src/hooks/useWebRTCHost.ts index 98a6013..08606c3 100644 --- a/apps/web/src/hooks/useWebRTCHost.ts +++ b/apps/web/src/hooks/useWebRTCHost.ts @@ -6,7 +6,6 @@ import type { SignalMessage, InputMessage, ControlMessage, - CursorPositionMessage, ControlStateUI, NetworkQuality, KickMessage, @@ -98,7 +97,6 @@ interface UseWebRTCHostOptions { onViewerLeft?: (viewerId: string) => void; onControlRequest?: (viewerId: string) => void; onInputReceived?: (viewerId: string, input: InputMessage) => void; - onCursorUpdate?: (viewerId: string, cursor: CursorPositionMessage) => void; } interface UseWebRTCHostReturn { @@ -130,7 +128,6 @@ export function useWebRTCHost({ onViewerLeft, onControlRequest, onInputReceived, - onCursorUpdate, }: UseWebRTCHostOptions): UseWebRTCHostReturn { const [isHosting, setIsHosting] = useState(false); const [error, setError] = useState(null); @@ -152,13 +149,11 @@ export function useWebRTCHost({ const pendingCandidatesRef = useRef>(new Map()); const onControlRequestRef = useRef(onControlRequest); const onInputReceivedRef = useRef(onInputReceived); - const onCursorUpdateRef = useRef(onCursorUpdate); // Keep refs updated localStreamRef.current = localStream; onControlRequestRef.current = onControlRequest; onInputReceivedRef.current = onInputReceived; - onCursorUpdateRef.current = onCursorUpdate; // Calculate network quality from stats const calculateNetworkQuality = useCallback( @@ -246,10 +241,7 @@ export function useWebRTCHost({ // Handle data channel messages from a viewer const handleDataChannelMessage = useCallback((viewerId: string, event: MessageEvent) => { try { - const message = JSON.parse(event.data) as - | ControlMessage - | InputMessage - | CursorPositionMessage; + const message = JSON.parse(event.data) as ControlMessage | InputMessage; if ('type' in message) { switch (message.type) { @@ -269,9 +261,6 @@ export function useWebRTCHost({ case 'input': onInputReceivedRef.current?.(viewerId, message); break; - case 'cursor': - onCursorUpdateRef.current?.(viewerId, message); - break; } } } catch { diff --git a/apps/web/src/hooks/useWebRTCHostSFU.ts b/apps/web/src/hooks/useWebRTCHostSFU.ts index f472015..a532eb7 100644 --- a/apps/web/src/hooks/useWebRTCHostSFU.ts +++ b/apps/web/src/hooks/useWebRTCHostSFU.ts @@ -12,7 +12,6 @@ import type { NetworkQuality, InputMessage, ControlMessage, - CursorPositionMessage, KickMessage, MuteMessage, } from '@pairux/shared-types'; @@ -48,7 +47,6 @@ interface UseWebRTCHostSFUOptions { onViewerLeft?: (viewerId: string) => void; onControlRequest?: (viewerId: string) => void; onInputReceived?: (viewerId: string, input: InputMessage) => void; - onCursorUpdate?: (viewerId: string, cursor: CursorPositionMessage) => void; } interface UseWebRTCHostSFUReturn { @@ -79,7 +77,6 @@ export function useWebRTCHostSFU({ onViewerLeft, onControlRequest, onInputReceived, - onCursorUpdate, }: UseWebRTCHostSFUOptions): UseWebRTCHostSFUReturn { const [isHosting, setIsHosting] = useState(false); const [error, setError] = useState(null); @@ -96,13 +93,11 @@ export function useWebRTCHostSFU({ const onControlRequestRef = useRef(onControlRequest); const onInputReceivedRef = useRef(onInputReceived); - const onCursorUpdateRef = useRef(onCursorUpdate); const onViewerJoinedRef = useRef(onViewerJoined); const onViewerLeftRef = useRef(onViewerLeft); onControlRequestRef.current = onControlRequest; onInputReceivedRef.current = onInputReceived; - onCursorUpdateRef.current = onCursorUpdate; onViewerJoinedRef.current = onViewerJoined; onViewerLeftRef.current = onViewerLeft; @@ -126,7 +121,7 @@ export function useWebRTCHostSFU({ try { const text = decoder.decode(payload); - const message = JSON.parse(text) as ControlMessage | InputMessage | CursorPositionMessage; + const message = JSON.parse(text) as ControlMessage | InputMessage; if ('type' in message) { switch (message.type) { @@ -145,9 +140,6 @@ export function useWebRTCHostSFU({ case 'input': onInputReceivedRef.current?.(viewerId, message); break; - case 'cursor': - onCursorUpdateRef.current?.(viewerId, message); - break; } } } catch { diff --git a/apps/web/src/hooks/useWebRTCSFU.ts b/apps/web/src/hooks/useWebRTCSFU.ts index eb276dd..f726f83 100644 --- a/apps/web/src/hooks/useWebRTCSFU.ts +++ b/apps/web/src/hooks/useWebRTCSFU.ts @@ -15,7 +15,6 @@ import type { InputEvent, ControlMessage, ControlStateUI, - CursorPositionMessage, KickMessage, MuteMessage, } from '@pairux/shared-types'; @@ -31,7 +30,6 @@ interface UseWebRTCSFUOptions { onStreamReady?: (stream: MediaStream) => void; onStreamEnded?: () => void; onControlStateChange?: (state: ControlStateUI) => void; - onCursorUpdate?: (cursor: CursorPositionMessage) => void; onKicked?: (reason?: string) => void; } @@ -48,7 +46,6 @@ interface UseWebRTCSFUReturn { requestControl: () => void; releaseControl: () => void; sendInput: (event: InputEvent) => void; - sendCursorPosition: (x: number, y: number, visible: boolean) => void; micEnabled: boolean; hasMic: boolean; toggleMic: () => void; @@ -75,7 +72,6 @@ export function useWebRTCSFU({ onStreamReady, onStreamEnded, onControlStateChange, - onCursorUpdate, onKicked, }: UseWebRTCSFUOptions): UseWebRTCSFUReturn { const [connectionState, setConnectionState] = useState('idle'); @@ -94,28 +90,22 @@ export function useWebRTCSFU({ const remoteMediaStreamRef = useRef(null); const onControlStateChangeRef = useRef(onControlStateChange); - const onCursorUpdateRef = useRef(onCursorUpdate); const onKickedRef = useRef(onKicked); const onStreamReadyRef = useRef(onStreamReady); const onStreamEndedRef = useRef(onStreamEnded); const disconnectRef = useRef<(() => void) | undefined>(undefined); onControlStateChangeRef.current = onControlStateChange; - onCursorUpdateRef.current = onCursorUpdate; onKickedRef.current = onKicked; onStreamReadyRef.current = onStreamReady; onStreamEndedRef.current = onStreamEnded; // Handle incoming data messages from LiveKit const handleDataReceived = useCallback( - (payload: Uint8Array, participant?: RemoteParticipant) => { + (payload: Uint8Array, _participant?: RemoteParticipant) => { try { const text = decoder.decode(payload); - const message = JSON.parse(text) as - | ControlMessage - | CursorPositionMessage - | KickMessage - | MuteMessage; + const message = JSON.parse(text) as ControlMessage | KickMessage | MuteMessage; if ('type' in message) { switch (message.type) { @@ -127,12 +117,6 @@ export function useWebRTCSFU({ setControlState('view-only'); onControlStateChangeRef.current?.('view-only'); break; - case 'cursor': - // Ignore cursor updates from ourselves - if (participant?.identity !== participantId) { - onCursorUpdateRef.current?.(message); - } - break; case 'kick': setError('You were removed from the session'); disconnectRef.current?.(); @@ -213,23 +197,6 @@ export function useWebRTCSFU({ [controlState, dataChannelReady, sendData] ); - // Send cursor position (lossy - high frequency, okay to drop) - const sendCursorPosition = useCallback( - (x: number, y: number, visible: boolean) => { - if (!dataChannelReady) return; - - const message: CursorPositionMessage = { - type: 'cursor', - participantId, - x, - y, - visible, - }; - sendData(message, false); // lossy for cursor updates - }, - [participantId, dataChannelReady, sendData] - ); - // Collect stats const collectStats = useCallback(async () => { const room = roomRef.current; @@ -518,7 +485,6 @@ export function useWebRTCSFU({ requestControl, releaseControl, sendInput, - sendCursorPosition, micEnabled, hasMic, toggleMic, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3d31feb..b3e082a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -191,7 +191,6 @@ sequenceDiagram | `JoinPage` | Session join flow | | `ViewerCanvas` | Render remote screen | | `InputCapture` | Capture mouse/keyboard for remote control | -| `CursorOverlay` | Show multi-cursor positions | | `ControlRequestUI` | Request/status of control | ## Network Architecture diff --git a/docs/FEATURES.md b/docs/FEATURES.md index fa742c0..0f83c8e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -462,71 +462,23 @@ interface KeyboardEventData { - Cannot be overridden by remote input - Works even if app is not focused (global hotkey) -### 5.5 Simultaneous Input (two cursors) +### 5.5 Shared Host Pointer -**Design**: Both host and viewer work at the same time, each with their own -cursor. Neither hands control over, and the host's pointer is never taken away. +**Design**: The host and viewer share the host's one real system pointer. -**How**: every OS has exactly one system pointer and none lets a normal process -create a second, so the viewer's cursor is not a real one: +**How**: Every remote movement, click, scroll, and drag is injected directly +into the host operating system. When the viewer is idle, the host immediately +uses the same pointer normally. There is no separate cursor, pointer borrowing, +restoration, or compositor integration. -- viewer movement only advances a tracked position, drawn as their cursor — - the host's pointer does not move for it -- the real pointer is borrowed for the instant a viewer click or scroll lands, - then returned to where the host left it -- during a drag it stays with the viewer until they release - -**Conflict resolution**: there is nothing to resolve for movement, since the two -cursors are independent. A viewer click briefly borrows the real pointer; if the -host clicks at the same moment, the OS orders them like any two events. - -**Platform note**: returning the pointer requires reading its position, which -macOS, Windows, X11 and KDE/Wayland (via the KWin helper) allow. Other Wayland -compositors leave the pointer where the click landed. +**Conflict resolution**: Input that arrives at the same moment is ordered by the +operating system. The emergency revoke hotkey always releases held input and +stops injection. See `docs/REMOTE-CONTROL.md` and `@profullstack/remote-input`. --- -## 6. Multi-Cursor Display - -### 6.1 Cursor Rendering - -**Host Side**: - -- Native OS cursor (always visible) -- No overlay needed - -**Viewer Side**: - -- Remote cursor rendered as overlay -- Shows host cursor position -- Own cursor for control input - -### 6.2 Cursor Appearance - -| Participant | Cursor Style | Label | -| -------------------- | ---------------- | ---------- | -| Host | Native OS cursor | None | -| Viewer (viewing) | Arrow with color | Name badge | -| Viewer (controlling) | Arrow with color | Name badge | - -**Cursor Colors**: Assigned automatically from palette - -- Viewer 1: Blue (#3B82F6) -- Viewer 2: Green (#10B981) -- Viewer 3: Purple (#8B5CF6) -- Viewer 4: Orange (#F59E0B) - -### 6.3 Cursor Position Sync - -- Positions sent via DataChannel -- Update rate: 60 Hz max -- Throttled to reduce bandwidth -- Interpolation for smooth movement - ---- - ## 7. Connection Management ### 7.1 Connection States @@ -702,7 +654,6 @@ stateDiagram-v2 **Display**: - Fit to window / Original size -- Show remote cursor **Input**: diff --git a/docs/LINUX-SETUP.md b/docs/LINUX-SETUP.md index 0554e81..6cfd297 100644 --- a/docs/LINUX-SETUP.md +++ b/docs/LINUX-SETUP.md @@ -139,37 +139,12 @@ Wayland support requires PipeWire for screen capture. Input injection works via - ✅ Screen sharing via xdg-desktop-portal / PipeWire - ✅ Remote control (mouse + keyboard) via `ydotool` -- ✅ Two-cursor mode: a guest's movement never takes over the host's pointer — - this needs nothing Wayland-specific -- ✅ Restoring the host's pointer after a guest click, **on KDE**, via the KWin - helper below -- ⚠️ On non-KDE compositors a guest's click leaves the pointer where it landed. - Wayland gives clients no way to read the pointer position, so there is nothing - to restore to. GNOME support (a Shell extension) is not implemented yet. +- ✅ The host and guest share the one real system pointer. When the guest stops + moving, the host can immediately use that same pointer anywhere in the system. +- ✅ No KWin helper, pointer borrowing, or pointer restoration is used. - ⚠️ The in-app source picker is skipped on Wayland: the portal cannot enumerate sources with thumbnails, so the system picker is used instead -### Pointer restoration on KDE (KWin) - -Automatic on a KDE Wayland session — PairUX claims a DBus name and loads a small -KWin script that reports `workspace.cursorPos`, then unloads it on quit. The -report rate is capped, the script exists only while a guest holds control, and it -self-disables after repeated failures. Set `PAIRUX_WAYLAND_CURSOR_RESTORE=0` to -turn it off. The only requirement is `gdbus`: - -```bash -sudo apt install libglib2.0-bin # usually already present -``` - -Confirm it is working in the host's terminal output: - -``` -[RemoteInput] KWin cursor reporting active -``` - -If it says `Cursor reporting off: ...` instead, remote control still works — a -guest's click just leaves the pointer where it landed. - ### Required Packages (Wayland) ```bash diff --git a/docs/REMOTE-CONTROL.md b/docs/REMOTE-CONTROL.md index 76b723f..4acf435 100644 --- a/docs/REMOTE-CONTROL.md +++ b/docs/REMOTE-CONTROL.md @@ -128,60 +128,16 @@ revokes the first. --- -## Two cursors +## One shared host pointer -Both people keep a working cursor at the same time. Nobody hands control over, -and the host's pointer is never taken away. +PairUX drives the host's real system pointer for every guest movement, click, +scroll, and drag. There is no synthetic cursor, overlay cursor, pointer +borrowing, restoration, or compositor-specific cursor reader. -Every OS we support has exactly one system pointer, and none lets an ordinary -process create a second (X11's XInput2 MPX aside, which exists on neither -Wayland nor macOS). So the second cursor comes from _not spending the real one -on movement_: - -- remote movement only advances a tracked position, drawn by the host as the - participant's cursor (`RemoteCursorsContainer`, fed by `onCursorUpdate`) -- the real pointer is borrowed for the instant a remote click or scroll has to - land, then returned to where its owner left it -- during a drag it stays with the remote user until they release, since - restoring mid-drag would tear the drag apart - -This lives in `@profullstack/remote-input` (`virtualCursor`, on by default), so -every host gets it without app-side logic. - -Restoring the pointer means reading where it is, which is platform-dependent: - -| Host | Restores the local pointer | -| ----------------------- | ------------------------------------------------- | -| macOS | Yes | -| Windows | Yes | -| Linux / X11 | Yes | -| Linux / Wayland (KDE) | Yes, via the KWin helper below | -| Linux / Wayland (other) | No — the click leaves the pointer where it landed | - -### The KWin helper (Wayland) - -Wayland refuses to tell a client where the pointer is, so on KDE the -compositor is asked instead. A KWin script can only talk _outward_ over DBus, -and the bus rejects calls to a name nobody owns, so PairUX claims -`org.profullstack.RemoteInput`, exposes `SetCursorPos`, and installs plus loads -a script that pushes `workspace.cursorPos` to it. Distance-throttled, because -the signal fires on every motion event. - -Automatic — nothing for the user to install beyond `gdbus` -(`libglib2.0-bin`), which desktops already have. Readings older than two -seconds are discarded rather than used, and any failure falls back to leaving -the pointer where the click landed. The script is unloaded on quit so it cannot -outlive the app pushing at a dead name. - -Look for one of these in the host's terminal: - -``` -[RemoteInput] KWin cursor reporting active -[RemoteInput] Cursor reporting off: KWin would not load the helper () -``` - -GNOME's equivalent (`global.get_pointer()` via a Shell extension) is not -implemented yet. +The host and guest therefore take turns naturally: when the guest stops moving, +the host can use the same physical pointer anywhere in the desktop. Input that +arrives at the same moment is ordered by the operating system like any other +input events. ## Coordinates diff --git a/docs/WEBRTC-FLOW.md b/docs/WEBRTC-FLOW.md index 3d8fa12..40ccbda 100644 --- a/docs/WEBRTC-FLOW.md +++ b/docs/WEBRTC-FLOW.md @@ -607,7 +607,6 @@ type DataChannelMessage = | InputEventMessage | ControlRequestMessage | ControlResponseMessage - | CursorPositionMessage | PingMessage; interface InputEventMessage { @@ -626,13 +625,6 @@ interface ControlResponseMessage { granted: boolean; } -interface CursorPositionMessage { - type: 'cursor'; - x: number; // 0-1 relative - y: number; // 0-1 relative - visible: boolean; -} - interface PingMessage { type: 'ping' | 'pong'; timestamp: number; @@ -660,9 +652,6 @@ function handleMessage(event: MessageEvent): void { case 'control-request': handleControlRequest(message.requestId); break; - case 'cursor': - updateRemoteCursor(message.x, message.y); - break; case 'ping': sendPong(message.timestamp); break; diff --git a/packages/remote-input/README.md b/packages/remote-input/README.md index 92e5b56..0161b81 100644 --- a/packages/remote-input/README.md +++ b/packages/remote-input/README.md @@ -71,67 +71,27 @@ Mouse coordinates are normalized `0-1` relative to the shared surface, not pixels. The viewer never needs to know the host's resolution, DPI, or monitor layout — call `updateScreenSize()` on the host and the injector maps them. -## Two cursors on a one-cursor OS +## One shared host pointer -Every desktop OS we support has exactly one system pointer, and none of them -lets an ordinary process create a second one (X11's XInput2 MPX aside, which -does not exist on Wayland or macOS). Injecting remote movement into that single -pointer is what makes remote control feel like the local user's mouse has been -stolen. +PairUX remote control drives the host's real system cursor directly. The host and +guest can take turns naturally: when the guest stops moving, the host can move +and click the same cursor anywhere in the system. No cursor is borrowed, +restored, or rendered separately. -So by default (`virtualCursor: true`) remote movement never touches the local -pointer at all — it only advances a tracked position, which the host renders as -the remote participant's cursor. The real pointer is borrowed for the instant a -remote click or scroll has to land somewhere, then handed straight back to -where its owner left it. Both people keep a usable cursor at the same time. - -```ts -injector.getRemoteCursorPosition(); // { x, y } normalized — draw this -``` - -During a drag the pointer necessarily stays with the remote user until they -release, otherwise the drag would tear. - -Restoration needs to read where the local pointer is. X11 and macOS answer -directly. **Wayland refuses** — no protocol tells a client where the pointer -is — so there the compositor is asked instead. - -### Wayland (KDE) - -`KWinCursorProvider` closes the gap on KWin. Since a KWin script can only talk -_outward_ over DBus, and the bus rejects calls to a name nobody owns, the -provider claims `org.profullstack.RemoteInput`, exposes a `SetCursorPos` -method, then installs and loads a small script that pushes `workspace.cursorPos` -to it — distance-throttled, since the signal fires on every motion event. This -happens automatically; the user installs nothing. - -Enabled automatically on a KDE session running Wayland — the only environment -it targets. `PAIRUX_WAYLAND_CURSOR_RESTORE=0` forces it off if a compositor -misbehaves; `=1` forces it on for a KDE session that does not advertise itself -in `XDG_CURRENT_DESKTOP`. - -Requires `gdbus` (`libglib2.0-bin`, present on essentially every desktop). -Readings older than two seconds are discarded rather than used, so a -half-working helper can never fling the pointer somewhere its owner never left -it. If any part fails, `getCursorPosition()` returns null and behaviour falls -back to leaving the pointer where the click landed. - -GNOME's equivalent (`global.get_pointer()` via a Shell extension) is not -implemented yet. - -Pass `virtualCursor: false` for the old behaviour where remote input drives the -system cursor directly. +On Wayland, PairUX uses `ydotool` to inject into that one pointer. The host +can revoke control or use the emergency-stop hotkey if a guest disconnects +mid-drag. ## Platform support -| Platform | Backend | Two cursors | Requirements | -| ----------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | -| macOS | `nut-js` | Full — local pointer restored | Accessibility permission (see below) | -| Windows | `nut-js` | Full — local pointer restored | None. Admin only to drive elevated windows. | -| Linux / X11 | `nut-js` | Full — local pointer restored | None | -| Linux / Wayland (KDE) | `wayland-ydotool` | Movement remains virtual; a click leaves the pointer where it landed. Experimental restoration requires `PAIRUX_WAYLAND_CURSOR_RESTORE=1`. | `ydotool` + running `ydotoold` with `/dev/uinput` | -| Linux / Wayland (other) | `wayland-ydotool` | Partial — movement never hijacked, but a click leaves the pointer where it landed | `ydotool` + a running `ydotoold` with `/dev/uinput` | -| Linux / Wayland | `wayland-portal` | n/a | Diagnostic only — reports why control is unavailable | +| Platform | Backend | Pointer behavior | Requirements | +| ----------------------- | ----------------- | ------------------------------------------ | ---------------------------------------------------- | +| macOS | `nut-js` | Drives the shared system pointer directly. | Accessibility permission (see below) | +| Windows | `nut-js` | Drives the shared system pointer directly. | None. Admin only to drive elevated windows. | +| Linux / X11 | `nut-js` | Drives the shared system pointer directly. | None | +| Linux / Wayland (KDE) | `wayland-ydotool` | Drives the shared system pointer directly. | `ydotool` + running `ydotoold` with `/dev/uinput` | +| Linux / Wayland (other) | `wayland-ydotool` | Drives the shared system pointer directly. | `ydotool` + a running `ydotoold` with `/dev/uinput` | +| Linux / Wayland | `wayland-portal` | n/a | Diagnostic only — reports why control is unavailable | > This package injects into a real OS, so it runs only where one exists. A > browser cannot be the _controlled_ machine; a browser-based client can only diff --git a/packages/remote-input/package.json b/packages/remote-input/package.json index a022816..7699f7a 100644 --- a/packages/remote-input/package.json +++ b/packages/remote-input/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/remote-input", - "version": "0.2.4", + "version": "0.3.0", "description": "Cross-platform OS input injection for remote control (macOS, Windows, Linux X11 and Wayland)", "license": "MIT", "type": "module", @@ -56,8 +56,5 @@ }, "engines": { "node": ">=18" - }, - "optionalDependencies": { - "dbus-next": "^0.10.2" } } diff --git a/packages/remote-input/src/backends/nutjs.test.ts b/packages/remote-input/src/backends/nutjs.test.ts index 951eb08..2c0855a 100644 --- a/packages/remote-input/src/backends/nutjs.test.ts +++ b/packages/remote-input/src/backends/nutjs.test.ts @@ -118,15 +118,6 @@ describe('NutJsInputBackend pointer mapping', () => { expect(mouse.setPosition).toHaveBeenCalledWith({ x: 3200, y: 720 }); }); - it('reads the pointer back relative to the shared display', async () => { - const backend = new NutJsInputBackend(); - backend.updateScreenSize(1920, 1080); - backend.updateCaptureBounds({ x: 1920, y: 0, width: 2560, height: 1440 }); - mouse.getPosition.mockResolvedValueOnce({ x: 3200, y: 720 }); - - expect(await backend.getCursorPosition()).toEqual({ x: 0.5, y: 0.5 }); - }); - it('goes back to the primary display when the bounds are cleared', async () => { const backend = new NutJsInputBackend(); backend.updateScreenSize(1920, 1080); diff --git a/packages/remote-input/src/backends/nutjs.ts b/packages/remote-input/src/backends/nutjs.ts index 3e987f6..8eb10aa 100644 --- a/packages/remote-input/src/backends/nutjs.ts +++ b/packages/remote-input/src/backends/nutjs.ts @@ -124,21 +124,6 @@ export class NutJsInputBackend implements InputBackend { }; } - /** Normalized so the injector can restore it without knowing the screen. */ - async getCursorPosition(): Promise<{ x: number; y: number } | null> { - try { - const { mouse } = await getNut(); - const point = await mouse.getPosition(); - const { x, y, width, height } = this.surface(); - return { - x: Math.min(1, Math.max(0, (point.x - x) / width)), - y: Math.min(1, Math.max(0, (point.y - y) / height)), - }; - } catch { - return null; - } - } - async init(): Promise { const { screen } = await getNut(); this.screenWidth = await screen.width(); @@ -178,9 +163,8 @@ export class NutJsInputBackend implements InputBackend { // the window server asynchronously, so the press can be delivered before // the pointer has moved — the click lands wherever the pointer used to be. // - // It also puts a real gap between press and release. Two-cursor mode - // borrows the pointer, clicks, and hands it straight back, which without - // a delay is a sub-millisecond blip that many controls simply ignore. + // It also puts a real gap between press and release, so controls receive + // a genuine click even when the guest sends the events back to back. await settle(); if (isInputDebugEnabled()) { diff --git a/packages/remote-input/src/backends/waylandYdotool.test.ts b/packages/remote-input/src/backends/waylandYdotool.test.ts index 4689c41..615b31e 100644 --- a/packages/remote-input/src/backends/waylandYdotool.test.ts +++ b/packages/remote-input/src/backends/waylandYdotool.test.ts @@ -73,26 +73,6 @@ describe('WaylandYdotoolInputBackend', () => { await expect(backend.init()).resolves.toEqual({ screenWidth: 1920, screenHeight: 1080 }); }); - it('passes pointer-borrow reporter suppression through to KWin', () => { - const backend = new WaylandYdotoolInputBackend(vi.fn(), { - hasBinary: true, - hasSocket: true, - socketPath: '/run/ydotoold/socket', - }); - const cursorProvider = { - suspendUpdates: vi.fn(), - resumeUpdates: vi.fn(), - }; - (backend as unknown as { cursorProvider: typeof cursorProvider }).cursorProvider = - cursorProvider; - - backend.suspendCursorReporting(); - backend.resumeCursorReporting(); - - expect(cursorProvider.suspendUpdates).toHaveBeenCalledOnce(); - expect(cursorProvider.resumeUpdates).toHaveBeenCalledOnce(); - }); - it('emits mouse move command with absolute coordinates', async () => { const run = vi.fn(async (_command: string, _args: string[]) => undefined); const backend = new WaylandYdotoolInputBackend(run, { diff --git a/packages/remote-input/src/backends/waylandYdotool.ts b/packages/remote-input/src/backends/waylandYdotool.ts index 564856a..d99582e 100644 --- a/packages/remote-input/src/backends/waylandYdotool.ts +++ b/packages/remote-input/src/backends/waylandYdotool.ts @@ -1,6 +1,5 @@ import { execFileSync } from 'child_process'; import { existsSync } from 'fs'; -import { KWinCursorProvider } from '../wayland/kwinCursorProvider.js'; import { detectWaylandScreenSize, type ScreenSize } from '../wayland/screenSize.js'; import { resolveModifiers } from '../modifiers.js'; import { resolveKey } from '../keymap.js'; @@ -401,9 +400,6 @@ export class WaylandYdotoolInputBackend implements InputBackend { private readonly scrollY = new ScrollAccumulator(); /** The shared region of the desktop, or null for "the whole primary screen". */ private captureBounds: CaptureBounds | null = null; - // Wayland will not report the pointer, so ask the compositor instead. Only - // used to hand the local pointer back after a remote click borrows it. - private readonly cursorProvider = new KWinCursorProvider(); private readonly run: ExecRunner; private readonly startDaemon: DaemonStarter; private readonly probeAvailability: AvailabilityProbe; @@ -530,44 +526,6 @@ export class WaylandYdotoolInputBackend implements InputBackend { return { screenWidth: this.screenWidth, screenHeight: this.screenHeight }; } - /** - * Pointer position via the compositor, normalized 0-1. - * - * Null whenever KWin is not reporting — the injector then skips restoring - * rather than moving the pointer somewhere wrong. - */ - async dispose(): Promise { - await this.cursorProvider.stop(); - } - - /** - * Begin asking the compositor for the pointer position. - * - * Deliberately not called from init(): this installs a hook into KWin's input - * path, so it should only exist while someone actually has control. - */ - async startCursorReporting(): Promise { - await this.cursorProvider.start(); - } - - suspendCursorReporting(): void { - this.cursorProvider.suspendUpdates(); - } - - resumeCursorReporting(): void { - this.cursorProvider.resumeUpdates(); - } - - getCursorPosition(): Promise<{ x: number; y: number } | null> { - const point = this.cursorProvider.getPosition(); - if (!point) return Promise.resolve(null); - - return Promise.resolve({ - x: Math.min(1, Math.max(0, point.x / this.screenWidth)), - y: Math.min(1, Math.max(0, point.y / this.screenHeight)), - }); - } - updateScreenSize(width: number, height: number): void { this.screenWidth = width; this.screenHeight = height; diff --git a/packages/remote-input/src/index.ts b/packages/remote-input/src/index.ts index a702181..6b5ddd9 100644 --- a/packages/remote-input/src/index.ts +++ b/packages/remote-input/src/index.ts @@ -53,12 +53,6 @@ export { type ValidationResult, } from './safety.js'; -export { - KWinCursorProvider, - isKWinCursorRestoreEnabled, - type KWinCursorProviderOptions, -} from './wayland/kwinCursorProvider.js'; - export { NutJsInputBackend } from './backends/nutjs.js'; export { WaylandPortalInputBackend } from './backends/waylandPortal.js'; export { WaylandYdotoolInputBackend } from './backends/waylandYdotool.js'; diff --git a/packages/remote-input/src/injector.test.ts b/packages/remote-input/src/injector.test.ts index 6aeaf44..b2f1cd9 100644 --- a/packages/remote-input/src/injector.test.ts +++ b/packages/remote-input/src/injector.test.ts @@ -25,8 +25,6 @@ function makeInjector(backend: InputBackend, maxEventsPerSecond?: number): Remot }, createBackend: () => backend, logger: silentLogger, - // virtualCursor off by default for these non-cursor tests - virtualCursor: false, ...(maxEventsPerSecond === undefined ? {} : { maxEventsPerSecond }), }; return new RemoteInputInjector(options); @@ -95,7 +93,6 @@ describe('RemoteInputInjector', () => { selection: { kind: 'nut-js', platform: 'linux', displayServer: 'x11' }, createBackend: () => backend, logger: silentLogger, - virtualCursor: false, onRejected, }); @@ -375,7 +372,6 @@ describe('RemoteInputInjector held-input safety', () => { selection: { kind: 'nut-js', platform: 'linux', displayServer: 'x11' }, createBackend: () => backend, logger: silentLogger, - virtualCursor: false, holdTimeoutMs: 1000, }); injector.enable(); @@ -399,7 +395,6 @@ describe('RemoteInputInjector held-input safety', () => { selection: { kind: 'nut-js', platform: 'linux', displayServer: 'x11' }, createBackend: () => backend, logger: silentLogger, - virtualCursor: false, holdTimeoutMs: 1000, }); injector.enable(); @@ -436,7 +431,6 @@ describe('RemoteInputInjector held-input safety', () => { selection: { kind: 'nut-js', platform: 'linux', displayServer: 'x11' }, createBackend: () => backend, logger: silentLogger, - virtualCursor: false, holdTimeoutMs: 1000, maxHoldMs: 5000, }); @@ -471,7 +465,6 @@ describe('RemoteInputInjector held-input safety', () => { selection: { kind: 'nut-js', platform: 'linux', displayServer: 'x11' }, createBackend: () => backend, logger: silentLogger, - virtualCursor: false, holdTimeoutMs: 1000, maxHoldMs: 5000, }); @@ -503,7 +496,6 @@ describe('RemoteInputInjector held-input safety', () => { selection: { kind: 'nut-js', platform: 'linux', displayServer: 'x11' }, createBackend: () => backend, logger: silentLogger, - virtualCursor: false, holdTimeoutMs: 1000, maxHoldMs: 5000, }); @@ -540,215 +532,6 @@ describe('RemoteInputInjector held-input safety', () => { }); }); -// Remote mouse movement must never spend the local pointer — that is what -// made control feel like it had been stolen. Moves are tracked rather than -// injected; clicks briefly borrow the pointer and hand it back. The one -// exception is a drag, where the single real cursor has to follow the motion. -describe('RemoteInputInjector two-cursor mode', () => { - const moves = (backend: InputBackend) => - vi.mocked(backend.inject).mock.calls.filter(([e]) => 'action' in e && e.action === 'move'); - - function twoCursorInjector(backend: InputBackend) { - return new RemoteInputInjector({ - selection: { kind: 'nut-js', platform: 'darwin', displayServer: 'macos' }, - createBackend: () => backend, - logger: silentLogger, - }); - } - - /** A host that can report its pointer, i.e. macOS/Windows/X11. */ - function reportingBackend(overrides: Partial = {}): InputBackend { - return fakeBackend({ - getCursorPosition: vi.fn().mockResolvedValue({ x: 0.9, y: 0.9 }), - ...overrides, - }); - } - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('does not move the local pointer for remote movement', async () => { - const backend = reportingBackend(); - const injector = twoCursorInjector(backend); - injector.enable(); - - await injector.inject({ type: 'mouse', action: 'move', x: 0.1, y: 0.2 }); - await injector.inject({ type: 'mouse', action: 'move', x: 0.3, y: 0.4 }); - - expect(backend.inject).not.toHaveBeenCalled(); - expect(injector.getRemoteCursorPosition()).toEqual({ x: 0.3, y: 0.4 }); - }); - - it('borrows the pointer for a click and hands it straight back', async () => { - const backend = reportingBackend(); - const injector = twoCursorInjector(backend); - injector.enable(); - - await injector.inject({ type: 'mouse', action: 'move', x: 0.2, y: 0.2 }); - await injector.inject({ type: 'mouse', action: 'down', button: 'left', x: 0.2, y: 0.2 }); - await injector.inject({ type: 'mouse', action: 'up', button: 'left', x: 0.2, y: 0.2 }); - - // Last movement returns the local pointer to where its owner left it. - const restore = moves(backend).at(-1); - expect(restore?.[0]).toMatchObject({ x: 0.9, y: 0.9 }); - }); - - it('pauses a compositor cursor reporter until the borrowed pointer is restored', async () => { - const backend = reportingBackend({ - suspendCursorReporting: vi.fn(), - resumeCursorReporting: vi.fn(), - }); - const injector = twoCursorInjector(backend); - injector.enable(); - - await injector.inject({ type: 'mouse', action: 'click', button: 'left', x: 0.2, y: 0.2 }); - - expect(backend.suspendCursorReporting).toHaveBeenCalledOnce(); - expect(backend.resumeCursorReporting).toHaveBeenCalledOnce(); - }); - - // Restoring between down and up would tear the drag apart, so the borrowed - // pointer is only handed back once every button is released. - it('holds the borrowed pointer until the drag ends', async () => { - const backend = reportingBackend(); - const injector = twoCursorInjector(backend); - injector.enable(); - - await injector.inject({ type: 'mouse', action: 'down', button: 'left', x: 0.2, y: 0.2 }); - await injector.inject({ type: 'mouse', action: 'move', x: 0.5, y: 0.5 }); - - // No restore yet — the only move so far is the drag motion itself. - expect(moves(backend).map(([e]) => e)).toEqual([expect.objectContaining({ x: 0.5, y: 0.5 })]); - - await injector.inject({ type: 'mouse', action: 'up', button: 'left', x: 0.5, y: 0.5 }); - expect(moves(backend).at(-1)?.[0]).toMatchObject({ x: 0.9, y: 0.9 }); - }); - - // A drag that only sends down-then-up is not a drag: text selection, canvas - // apps, HTML5 drag-and-drop and file managers all need the motion between. - it('injects motion while a button is held so drags do not tear', async () => { - const backend = reportingBackend(); - const injector = twoCursorInjector(backend); - injector.enable(); - - // Before the press: virtual, nothing reaches the OS. - await injector.inject({ type: 'mouse', action: 'move', x: 0.1, y: 0.1 }); - expect(moves(backend)).toHaveLength(0); - - await injector.inject({ type: 'mouse', action: 'down', button: 'left', x: 0.2, y: 0.2 }); - - // During the press: every move reaches the OS, in order. - await injector.inject({ type: 'mouse', action: 'move', x: 0.4, y: 0.4 }); - await injector.inject({ type: 'mouse', action: 'move', x: 0.6, y: 0.6 }); - - expect(moves(backend).map(([e]) => e)).toEqual([ - expect.objectContaining({ x: 0.4, y: 0.4 }), - expect.objectContaining({ x: 0.6, y: 0.6 }), - ]); - - // After release: virtual again (past the pointer restore). - await injector.inject({ type: 'mouse', action: 'up', button: 'left', x: 0.6, y: 0.6 }); - vi.mocked(backend.inject).mockClear(); - await injector.inject({ type: 'mouse', action: 'move', x: 0.8, y: 0.8 }); - expect(moves(backend)).toHaveLength(0); - }); - - it('keeps movement virtual when the host cannot report the pointer', async () => { - const backend = fakeBackend(); - delete (backend as { getCursorPosition?: unknown }).getCursorPosition; - const injector = twoCursorInjector(backend); - injector.enable(); - - await injector.inject({ type: 'mouse', action: 'move', x: 0.3, y: 0.4 }); - - expect(moves(backend)).toHaveLength(0); - }); - - // The method can exist and still refuse to answer (KWin not reporting). A - // click then cannot be restored, but remote movement must remain virtual. - it('does not hijack movement once a position read comes back empty', async () => { - const backend = fakeBackend({ getCursorPosition: vi.fn().mockResolvedValue(null) }); - const injector = twoCursorInjector(backend); - injector.enable(); - - // Before anything is known, movement stays virtual. - await injector.inject({ type: 'mouse', action: 'move', x: 0.1, y: 0.1 }); - expect(moves(backend)).toHaveLength(0); - - // A click reveals the pointer cannot be read. - await injector.inject({ type: 'mouse', action: 'down', button: 'left', x: 0.2, y: 0.2 }); - await injector.inject({ type: 'mouse', action: 'up', button: 'left', x: 0.2, y: 0.2 }); - - // From here a later click cannot restore the local pointer, but movement - // must not continuously warp it away from the host. - await injector.inject({ type: 'mouse', action: 'move', x: 0.7, y: 0.8 }); - expect(moves(backend)).toHaveLength(0); - }); - - // A host that *can* report the pointer must keep both cursors, or the fix - // above would quietly take two-cursor mode away from everyone. - it('keeps the cursors apart when the host can report the pointer', async () => { - const backend = reportingBackend(); - const injector = twoCursorInjector(backend); - injector.enable(); - - await injector.inject({ type: 'mouse', action: 'down', button: 'left', x: 0.2, y: 0.2 }); - await injector.inject({ type: 'mouse', action: 'up', button: 'left', x: 0.2, y: 0.2 }); - vi.mocked(backend.inject).mockClear(); - - await injector.inject({ type: 'mouse', action: 'move', x: 0.7, y: 0.8 }); - - expect(moves(backend)).toHaveLength(0); - }); - - it('drives the system cursor directly when virtualCursor is off', async () => { - const backend = fakeBackend(); - const injector = new RemoteInputInjector({ - selection: { kind: 'nut-js', platform: 'darwin', displayServer: 'macos' }, - createBackend: () => backend, - logger: silentLogger, - virtualCursor: false, - }); - injector.enable(); - - await injector.inject({ type: 'mouse', action: 'move', x: 0.3, y: 0.4 }); - - expect(backend.inject).toHaveBeenCalledWith( - expect.objectContaining({ action: 'move', x: 0.3, y: 0.4 }) - ); - }); - - it('hands the pointer back when stuck input is released', async () => { - const backend = reportingBackend(); - const injector = twoCursorInjector(backend); - injector.enable(); - - await injector.inject({ type: 'mouse', action: 'down', button: 'left', x: 0.2, y: 0.2 }); - await injector.releaseAll('viewer disconnected'); - - expect(moves(backend).at(-1)?.[0]).toMatchObject({ x: 0.9, y: 0.9 }); - }); - - it('hands the pointer back after a scroll, which borrows it too', async () => { - const backend = reportingBackend(); - const injector = twoCursorInjector(backend); - injector.enable(); - - await injector.inject({ - type: 'mouse', - action: 'scroll', - deltaX: 0, - deltaY: -100, - x: 0.3, - y: 0.3, - }); - - expect(backend.inject).toHaveBeenCalledWith(expect.objectContaining({ action: 'scroll' })); - expect(moves(backend).at(-1)?.[0]).toMatchObject({ x: 0.9, y: 0.9 }); - }); -}); - // Compositor hot-corners (GNOME's Activities corner) fire from the corner // pixel, so a guest brushing it hijacks the host's desktop. Hosts that need it // opt in to a pixel inset; everyone else gets the guest's exact coordinates, @@ -940,7 +723,7 @@ describe('move coalescing', () => { expect(xs).toContain(0.3); }); - it('reports and injects where the viewer stopped after coalescing', async () => { + it('injects where the viewer stopped after coalescing', async () => { const { backend, release, injected } = blockingBackend(); const injector = makeInjector(backend); injector.enable(); @@ -951,8 +734,6 @@ describe('move coalescing', () => { await drain(release, [inFlight, dropped]); - // The overlay still has to draw the guest's cursor where they left it. - expect(injector.getRemoteCursorPosition().x).toBe(0.9); expect(injected.some((event) => 'x' in event && event.x === 0.9)).toBe(true); }); diff --git a/packages/remote-input/src/injector.ts b/packages/remote-input/src/injector.ts index 49ee10e..efbe333 100644 --- a/packages/remote-input/src/injector.ts +++ b/packages/remote-input/src/injector.ts @@ -13,7 +13,6 @@ import { type InputBackendSelection, } from './factory.js'; import { InputRateLimiter, validateInputEvent, type RejectionReason } from './safety.js'; -import { isInputDebugEnabled } from './debug.js'; import type { CaptureBounds, InputBackend, @@ -51,17 +50,6 @@ export interface RemoteInputInjectorOptions { * and it is deliberately never reset. */ maxHoldMs?: number; - /** - * Keep the local and remote cursors independent (default true). - * - * Remote movement then drives only a tracked position rather than the real - * pointer, which is borrowed just long enough to place a click and handed - * back. Set false to have remote input drive the system cursor directly. - * - * A drag is the exception in either mode: once a button is held, the motion - * has to reach the OS or the drag tears (see `dispatch`). - */ - virtualCursor?: boolean; /** * Inset, in pixels, applied to injected pointer coordinates so remote input * cannot land exactly on a screen edge or corner. Defaults to 0 (no inset). @@ -103,15 +91,9 @@ export class RemoteInputInjector { private readonly holdTimeoutMs: number; private readonly maxHoldMs: number; - // Two-cursor mode: remote movement never moves the local pointer, so both - // people keep a usable cursor at the same time. - private readonly virtualCursor: boolean; private readonly edgeMarginPx: number; /** Last known host screen size, used to convert `edgeMarginPx` to 0-1. */ private screenSize: { width: number; height: number } | null = null; - private remotePosition = { x: 0.5, y: 0.5 }; - /** Where the local pointer was before a remote click borrowed it. */ - private borrowedFrom: { x: number; y: number } | null = null; // Every async operation that results in an OS-level button press or release // chains through this promise. disable() and emergencyStop() wait on it so @@ -130,7 +112,6 @@ export class RemoteInputInjector { this.rateLimiter = new InputRateLimiter(options.maxEventsPerSecond ?? 1000); this.holdTimeoutMs = options.holdTimeoutMs ?? 5000; this.maxHoldMs = options.maxHoldMs ?? 30_000; - this.virtualCursor = options.virtualCursor ?? true; this.edgeMarginPx = Math.max(0, options.edgeMarginPx ?? 0); this.onRejected = options.onRejected; this.logger = options.logger ?? console; @@ -185,15 +166,6 @@ export class RemoteInputInjector { this.enabled = true; this.rateLimiter.reset(); - // Start cursor reporting so we can restore the host pointer after remote - // clicks. A failed report only means click restoration is unavailable; it - // must never make virtual movement take over the host's cursor. - if (this.virtualCursor) { - void backend.startCursorReporting?.().catch((error: unknown) => { - this.logger.warn('[RemoteInput] Cursor reporting could not start', { error }); - }); - } - this.logger.log('[RemoteInput] Injection enabled', { backend: backend.name }); return true; } @@ -256,8 +228,6 @@ export class RemoteInputInjector { } } - await this.restoreLocalPointer(); - for (const key of keys) { try { await backend.inject({ @@ -276,20 +246,10 @@ export class RemoteInputInjector { /** * Put the event on the OS. * - * In two-cursor mode (`virtualCursor`, the default) remote *movement* only - * advances a tracked position; the host's pointer is left alone so both - * people keep a usable cursor. The real pointer is borrowed for the instant - * a click or scroll needs to land somewhere, then handed back. - * - * A drag is the exception. There is only one real cursor, so once a remote - * button is held the motion has to reach the OS — otherwise a drag becomes - * "press at A, teleport, release at B" and anything that tracks intermediate - * motion (text selection, canvas apps, HTML5 drag-and-drop, file managers) - * never sees the drag at all. The pointer is handed back once everything is - * released. - * - * With `virtualCursor: false` every mouse event drives the system cursor - * directly, which is the classic single-cursor remote-control behaviour. + * Every guest mouse event drives the host's one real system pointer. The + * host and guest take turns naturally: whichever person moves last owns the + * pointer until the other person acts. PairUX never creates, borrows, or + * restores a second cursor. */ private async dispatch(event: InputEvent): Promise { // Keyboard events go straight to the OS; they never move the cursor. @@ -302,63 +262,7 @@ export class RemoteInputInjector { return; } - const backend = this.getBackend(); - const dragging = this.heldButtons.size > 0; - - if (event.action === 'move') { - this.remotePosition = { x: event.x, y: event.y }; - - // Virtual movement must remain virtual even if cursor reporting fails. - // On that host a click cannot be restored, but constantly warping the - // host's pointer makes their UI unusable for the entire control session. - if (this.virtualCursor && !dragging) return; - - await backend.inject(this.withEdgeMargin(event)); - return; - } - - // Click / scroll: inject at the remote position. - this.remotePosition = { x: event.x, y: event.y }; - - // Borrow the real pointer, unless a previous press already borrowed it. - if (!dragging) { - const reported = (await backend.getCursorPosition?.()) ?? null; - // A null here means the compositor will not report the pointer, so this - // click cannot later restore it. Movement nevertheless remains virtual. - if (reported) { - // A compositor reporter sees the ydotool motion required to land this - // click. Freeze it before injecting so the synthetic position cannot - // replace the host's origin before we restore it. - backend.suspendCursorReporting?.(); - this.borrowedFrom ??= reported; - } - } - - await backend.inject(this.withEdgeMargin(event)); - - // Hold the pointer in place for the duration of a drag. Held state is - // recorded after dispatch, so fold this event in to see what remains down. - const remaining = new Set(this.heldButtons); - if (event.action === 'down') remaining.add(event.button); - else if (event.action === 'up') remaining.delete(event.button); - - if (isInputDebugEnabled()) { - // The two-cursor bookkeeping around a click. A restore firing between a - // down and its up would yank the pointer mid-click and is invisible from - // the backend's own trace, so it is recorded here. - this.logger.log('[RemoteInput:debug] click dispatch', { - action: event.action, - dragging, - heldBefore: [...this.heldButtons], - remainingAfter: [...remaining], - borrowedFrom: this.borrowedFrom, - willRestore: remaining.size === 0, - }); - } - - if (remaining.size > 0) return; - - await this.restoreLocalPointer(); + await this.getBackend().inject(this.withEdgeMargin(event)); } /** @@ -382,30 +286,6 @@ export class RemoteInputInjector { return { ...event, x, y }; } - private async restoreLocalPointer(): Promise { - const origin = this.borrowedFrom; - this.borrowedFrom = null; - if (!origin) return; - - try { - await this.getBackend().inject({ - type: 'mouse', - action: 'move', - x: origin.x, - y: origin.y, - }); - } catch (error) { - this.logger.warn('[RemoteInput] Could not restore local pointer', { error }); - } finally { - this.getBackend().resumeCursorReporting?.(); - } - } - - /** Where the remote participant's cursor currently sits, normalized 0-1. */ - getRemoteCursorPosition(): { x: number; y: number } { - return { ...this.remotePosition }; - } - private clearHoldWatchdog(): void { if (this.holdWatchdog !== null) { clearTimeout(this.holdWatchdog); @@ -540,7 +420,6 @@ export class RemoteInputInjector { // once the in-flight move completes, so the cursor always settles where // the viewer stopped. this.pendingCoalescedMove = event; - this.remotePosition = { x: event.x, y: event.y }; this.stats.coalesced += 1; await new Promise((resolve) => this.pendingCoalescedMoveResolvers.push(resolve)); return; diff --git a/packages/remote-input/src/types.ts b/packages/remote-input/src/types.ts index efe2904..cc6693f 100644 --- a/packages/remote-input/src/types.ts +++ b/packages/remote-input/src/types.ts @@ -126,38 +126,7 @@ export interface InputBackend { updateCaptureBounds?: (bounds: CaptureBounds | null) => void; inject: (event: InputEvent) => Promise; emergencyStop: () => Promise; - /** - * Where the local pointer is right now, normalized 0-1, or null when the - * platform will not say. - * - * Needed to put the local user's pointer back after a remote click borrows - * it. X11 and macOS can answer; Wayland gives clients no way to query the - * pointer, so those backends omit this and lose exact restoration. - */ - getCursorPosition?: () => Promise<{ x: number; y: number } | null>; - /** - * Release OS resources on shutdown. - * - * The Wayland backend installs a helper into the compositor, which would - * otherwise keep pushing to a DBus name that no longer exists once the app - * has quit. - */ dispose?: () => Promise; - /** - * Begin any optional machinery needed to report the cursor position. - * - * Separate from init() because on Wayland this installs a hook into the - * compositor's input path, which should only be present while a remote - * participant actually holds control. - */ - startCursorReporting?: () => Promise; - /** - * Stop an optional cursor reporter from mistaking PairUX's own synthetic - * pointer motion for the host user's position while a click is borrowed. - */ - suspendCursorReporting?: () => void; - /** Resume optional cursor reporting after a borrowed click has been restored. */ - resumeCursorReporting?: () => void; } export interface InputStats { diff --git a/packages/remote-input/src/wayland/kwinCursorProvider.test.ts b/packages/remote-input/src/wayland/kwinCursorProvider.test.ts deleted file mode 100644 index 15416a6..0000000 --- a/packages/remote-input/src/wayland/kwinCursorProvider.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { - KWinCursorProvider, - buildKWinScript, - isKWinCursorRestoreEnabled, -} from './kwinCursorProvider.js'; - -const silent = { log: () => {}, warn: () => {} }; - -describe('buildKWinScript', () => { - const script = buildKWinScript(); - - // The script's only way out is callDBus, and the bus rejects calls to a name - // nobody owns — so these must match what the provider claims. - it('pushes to the name and path the provider owns', () => { - expect(script).toContain("'org.profullstack.RemoteInput'"); - expect(script).toContain("'/org/profullstack/RemoteInput'"); - expect(script).toContain("'SetCursorPos'"); - }); - - it('reads the pointer from the compositor', () => { - expect(script).toContain('workspace.cursorPos'); - }); - - // This runs in KWin's input path. cursorPosChanged fires on every motion - // event, so the DBus rate must be capped by TIME — a per-distance cap scales - // with mouse speed and can flood the compositor badly enough to freeze the - // whole desktop. - it('caps the report rate by elapsed time, not distance', () => { - expect(script).toContain('MIN_INTERVAL_MS'); - expect(script).toMatch(/now - lastSent < MIN_INTERVAL_MS/); - expect(script).not.toMatch(/Math\.abs\(p\.x - lastX\)/); - }); - - // A name that is not owned would otherwise fail on every single motion event. - it('gives up after repeated DBus failures', () => { - expect(script).toContain('MAX_FAILURES'); - expect(script).toMatch(/stopped = true/); - }); - - // KWin generations differ; a missing signal must not throw inside KWin. - it('guards against the notify signal being absent', () => { - expect(script).toContain("typeof workspace.cursorPosChanged !== 'undefined'"); - }); - - it('reports once at load so a still pointer is known immediately', () => { - expect(script).toMatch(/\nreport\(\);/); - }); -}); - -describe('KWinCursorProvider', () => { - // Restoring the pointer is a comfort; the only way to do it puts our code in - // the compositor's input path, where a mistake costs the user their desktop. - // So it stays off until explicitly asked for. - it('does nothing where the helper does not apply', async () => { - process.env.PAIRUX_WAYLAND_CURSOR_RESTORE = '0'; - const provider = new KWinCursorProvider({ logger: silent }); - await expect(provider.start()).resolves.toBe(false); - expect(provider.isAvailable).toBe(false); - }); - - it('reports no position before the compositor has said anything', () => { - const provider = new KWinCursorProvider({ logger: silent }); - expect(provider.getPosition()).toBeNull(); - expect(provider.isAvailable).toBe(false); - }); - - // Restoring the pointer to a stale reading would move it somewhere the user - // never left it, which is worse than not restoring at all. - it('discards a reading that has gone stale', () => { - const provider = new KWinCursorProvider({ logger: silent }); - const withPosition = provider as unknown as { - position: { x: number; y: number; at: number } | null; - }; - - withPosition.position = { x: 100, y: 200, at: Date.now() }; - expect(provider.getPosition()).toEqual({ x: 100, y: 200 }); - - withPosition.position = { x: 100, y: 200, at: Date.now() - 10_000 }; - expect(provider.getPosition()).toBeNull(); - }); - - it('ignores synthetic movement while a pointer borrow is active', () => { - const provider = new KWinCursorProvider({ logger: silent }); - const state = provider as unknown as { - position: { x: number; y: number; at: number } | null; - ignoreUpdatesUntil: number; - }; - - provider.suspendUpdates(); - expect(state.ignoreUpdatesUntil).toBe(Number.POSITIVE_INFINITY); - - provider.resumeUpdates(); - expect(state.ignoreUpdatesUntil).toBeGreaterThan(Date.now()); - }); -}); - -describe('isKWinCursorRestoreEnabled', () => { - const saved = { ...process.env }; - - afterEach(() => { - process.env = { ...saved }; - }); - - function env(vars: Record): void { - delete process.env.PAIRUX_WAYLAND_CURSOR_RESTORE; - delete process.env.XDG_SESSION_TYPE; - delete process.env.WAYLAND_DISPLAY; - delete process.env.XDG_CURRENT_DESKTOP; - for (const [k, v] of Object.entries(vars)) { - if (v !== undefined) process.env[k] = v; - } - } - - it('is off by default, including on KDE Wayland', () => { - env({ XDG_SESSION_TYPE: 'wayland', XDG_CURRENT_DESKTOP: 'KDE' }); - expect(isKWinCursorRestoreEnabled()).toBe(false); - }); - - it('is off where the helper does not apply', () => { - env({ XDG_SESSION_TYPE: 'x11', XDG_CURRENT_DESKTOP: 'KDE' }); - expect(isKWinCursorRestoreEnabled()).toBe(false); - - env({ XDG_SESSION_TYPE: 'wayland', XDG_CURRENT_DESKTOP: 'GNOME' }); - expect(isKWinCursorRestoreEnabled()).toBe(false); - }); - - // An escape hatch matters: this hooks the compositor's input path. - it('can be forced off', () => { - env({ - XDG_SESSION_TYPE: 'wayland', - XDG_CURRENT_DESKTOP: 'KDE', - PAIRUX_WAYLAND_CURSOR_RESTORE: '0', - }); - expect(isKWinCursorRestoreEnabled()).toBe(false); - }); - - it('can be forced on for a session that does not advertise itself', () => { - env({ PAIRUX_WAYLAND_CURSOR_RESTORE: '1' }); - expect(isKWinCursorRestoreEnabled()).toBe(true); - }); -}); diff --git a/packages/remote-input/src/wayland/kwinCursorProvider.ts b/packages/remote-input/src/wayland/kwinCursorProvider.ts deleted file mode 100644 index e4a02a6..0000000 --- a/packages/remote-input/src/wayland/kwinCursorProvider.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Reads the pointer position on KDE/Wayland. - * - * Wayland deliberately refuses to tell a client where the pointer is, which - * leaves remote control unable to hand the local user's pointer back after - * borrowing it for a click. Only the compositor knows, so we ask KWin. - * - * KWin scripts can only talk *outward* over DBus (`callDBus`), and the session - * bus rejects calls to a name nobody owns — so this owns a name and exposes a - * method the script pushes into. The script is installed and loaded - * automatically, so the user does nothing. - * - * Everything here fails soft: if glib's gdbus is missing, KWin refuses the - * script, or the DBus name cannot be claimed, `getPosition()` simply returns - * null and callers fall back to not restoring the pointer. - */ - -import { execFile } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import { homedir, tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; - -const run = promisify(execFile); - -const DBUS_NAME = 'org.profullstack.RemoteInput'; -const DBUS_PATH = '/org/profullstack/RemoteInput'; -const SCRIPT_NAME = 'pairux-cursor-reporter'; - -/** Ignore a stale reading rather than restoring the pointer somewhere wrong. */ -const POSITION_MAX_AGE_MS = 2000; - -/** - * Floor on how often the compositor may report, in ms. - * - * The reporting hook sits in KWin's input path, so this is a safety limit, not - * a tuning knob: without it the DBus rate scales with mouse speed and can stall - * the whole desktop. - */ -const REPORT_INTERVAL_MS = 100; - -/** - * On automatically where it applies: a KDE session on Wayland. - * - * This is the only environment the helper targets, and enabling it by hand is - * not something a user should have to discover. It puts a hook in the - * compositor's input path, so the rails around it matter more than the switch: - * the report rate is capped, it exists only while a guest holds control, and it - * gives up after repeated failures. - * - * It is deliberately opt-in: KWin cannot identify whether a position change - * came from the host's mouse or from PairUX's synthetic ydotool event. A wrong - * restore is worse than no restore because it can repeatedly steal the host's - * pointer. Set PAIRUX_WAYLAND_CURSOR_RESTORE=1 only to try the experimental - * restore path; =0 is accepted for explicitness. - */ -export function isKWinCursorRestoreEnabled(): boolean { - const override = process.env.PAIRUX_WAYLAND_CURSOR_RESTORE; - return override === '1'; -} - -/** - * KWin scripting has lived at two interface names; try both rather than - * pinning to one KWin generation. - */ -const SCRIPTING_INTERFACES = ['org.kde.kwin.Scripting', 'org.kde.KWin.Scripting']; - -/** - * Pushes the pointer position to us whenever it moves far enough to matter. - * - * Throttled by distance because the signal fires on every motion event and we - * only need a position accurate enough to restore to — not a full motion feed. - */ -export function buildKWinScript(): string { - return `// Installed by PairUX. Reports the pointer position so remote control can -// hand the local pointer back after borrowing it for a click. -// -// This runs inside the compositor's input path, so it must stay cheap and -// strictly rate-limited: cursorPosChanged fires on every motion event, and one -// DBus call per event would scale with mouse speed and can stall KWin. -var MIN_INTERVAL_MS = ${String(REPORT_INTERVAL_MS)}; -var MAX_FAILURES = 5; - -var lastSent = 0; -var failures = 0; -var stopped = false; - -function report() { - if (stopped) { - return; - } - - var now = Date.now(); - if (now - lastSent < MIN_INTERVAL_MS) { - return; - } - lastSent = now; - - var p = workspace.cursorPos; - try { - callDBus( - '${DBUS_NAME}', - '${DBUS_PATH}', - '${DBUS_NAME}', - 'SetCursorPos', - Math.round(p.x), - Math.round(p.y) - ); - failures = 0; - } catch (e) { - failures = failures + 1; - if (failures >= MAX_FAILURES) { - stopped = true; - print('pairux: giving up cursor reporting after repeated DBus failures'); - } - } -} - -report(); - -if (typeof workspace.cursorPosChanged !== 'undefined') { - workspace.cursorPosChanged.connect(report); - print('pairux: cursor reporter attached (rate limit ' + MIN_INTERVAL_MS + 'ms)'); -} else { - print('pairux: cursorPosChanged unavailable, cursor reporting inactive'); -} -`; -} - -function describe(error: unknown): string { - if (error instanceof Error) { - // gdbus puts the useful part on stderr. - const stderr = (error as Error & { stderr?: string }).stderr; - return (stderr ?? error.message).trim().split('\n')[0] ?? 'unknown error'; - } - return String(error); -} - -interface Logger { - log: (message: string, ...rest: unknown[]) => void; - warn: (message: string, ...rest: unknown[]) => void; -} - -export interface KWinCursorProviderOptions { - logger?: Logger; -} - -export class KWinCursorProvider { - private position: { x: number; y: number; at: number } | null = null; - /** Ignore KWin notifications caused by our own move/click/restore cycle. */ - private ignoreUpdatesUntil = 0; - private started = false; - private available = false; - private bus: { disconnect: () => void } | null = null; - private readonly logger: Logger; - - constructor(options: KWinCursorProviderOptions = {}) { - this.logger = options.logger ?? console; - } - - get isAvailable(): boolean { - return this.available; - } - - /** Idempotent; safe to call even where none of this can work. */ - async start(): Promise { - if (this.started) return this.available; - this.started = true; - - if (!isKWinCursorRestoreEnabled()) { - this.logger.log( - '[RemoteInput] Wayland pointer restore is off (set PAIRUX_WAYLAND_CURSOR_RESTORE=1 to try it). ' + - 'Remote clicks will leave the pointer where they land.' - ); - return false; - } - - try { - await this.serveDBus(); - } catch (error) { - this.logger.warn( - '[RemoteInput] Cursor reporting off: could not claim the DBus name ' + - `(${describe(error)}). Remote clicks will leave the pointer where they land.` - ); - return false; - } - - try { - const scriptPath = await this.writeScript(); - await this.loadScript(scriptPath); - } catch (error) { - this.logger.warn( - `[RemoteInput] Cursor reporting off: KWin would not load the helper (${describe(error)}). ` + - 'Remote clicks will leave the pointer where they land.' - ); - return false; - } - - this.available = true; - this.logger.log('[RemoteInput] KWin cursor reporting active'); - return true; - } - - /** - * Latest pointer position in device pixels, or null when unknown or stale. - * - * Callers normalize; this provider has no view of the screen size. - */ - getPosition(): { x: number; y: number } | null { - if (!this.position) return null; - if (Date.now() - this.position.at > POSITION_MAX_AGE_MS) return null; - return { x: this.position.x, y: this.position.y }; - } - - /** - * A borrowed click produces compositor cursor notifications of its own. - * Ignore them until after its restore has settled, or the next click would - * "restore" the host pointer to PairUX's previous synthetic position. - */ - suspendUpdates(): void { - this.ignoreUpdatesUntil = Number.POSITIVE_INFINITY; - } - - resumeUpdates(): void { - // ydotool and KWin are asynchronous. Keep ignoring the synthetic restore - // notification long enough for it to arrive before accepting host motion. - this.ignoreUpdatesUntil = Date.now() + 150; - } - - async stop(): Promise { - for (const iface of SCRIPTING_INTERFACES) { - try { - await this.callScripting(iface, 'unloadScript', [SCRIPT_NAME]); - break; - } catch { - // Nothing loaded, or a different KWin generation — either is fine. - } - } - - this.bus?.disconnect(); - this.bus = null; - this.available = false; - this.started = false; - } - - private async serveDBus(): Promise { - // Optional dependency: absent on a server install, present on a desktop. - const dbus = (await import('dbus-next')) as unknown as { - sessionBus: () => { - requestName: (name: string, flags?: number) => Promise; - export: (path: string, iface: unknown) => void; - disconnect: () => void; - }; - interface: { - Interface: new (name: string) => object; - }; - }; - - const { Interface } = dbus.interface; - const record = (x: number, y: number): void => { - if (Date.now() < this.ignoreUpdatesUntil) return; - this.position = { x, y, at: Date.now() }; - }; - - class CursorInterface extends Interface { - // eslint-disable-next-line @typescript-eslint/naming-convention - SetCursorPos(x: number, y: number): void { - record(x, y); - } - } - - ( - CursorInterface as unknown as { - configureMembers: (config: unknown) => void; - } - ).configureMembers({ - methods: { - SetCursorPos: { inSignature: 'ii', outSignature: '' }, - }, - }); - - const bus = dbus.sessionBus(); - await bus.requestName(DBUS_NAME); - bus.export(DBUS_PATH, new CursorInterface(DBUS_NAME)); - this.bus = bus; - } - - private async writeScript(): Promise { - const base = - process.env.XDG_DATA_HOME ?? (homedir() ? join(homedir(), '.local', 'share') : tmpdir()); - const dir = join(base, 'pairux'); - await fs.mkdir(dir, { recursive: true }); - - const scriptPath = join(dir, `${SCRIPT_NAME}.js`); - await fs.writeFile(scriptPath, buildKWinScript(), 'utf8'); - return scriptPath; - } - - private async loadScript(scriptPath: string): Promise { - let lastError: unknown = null; - - for (const iface of SCRIPTING_INTERFACES) { - try { - // Replace any copy left behind by an earlier run. - try { - await this.callScripting(iface, 'unloadScript', [SCRIPT_NAME]); - } catch { - // Usually "not loaded" — expected on a clean start. - } - - await this.callScripting(iface, 'loadScript', [scriptPath, SCRIPT_NAME]); - await this.callScripting(iface, 'start', []); - this.logger.log('[RemoteInput] KWin cursor reporter loaded', { iface, scriptPath }); - return; - } catch (error) { - lastError = error; - } - } - - throw lastError instanceof Error - ? lastError - : new Error(`KWin scripting interface not reachable: ${describe(lastError)}`); - } - - private async callScripting(iface: string, method: string, args: string[]): Promise { - const { stdout } = await run( - 'gdbus', - [ - 'call', - '--session', - '--dest', - 'org.kde.KWin', - '--object-path', - '/Scripting', - '--method', - `${iface}.${method}`, - ...args, - ], - { timeout: 5000 } - ); - return stdout.trim(); - } -} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 5419aba..5d312f0 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -73,7 +73,6 @@ export type { MuteMessage, TailnetHelloMessage, ControlMessage, - CursorPositionMessage, PingMessage, PongMessage, ConnectionState, diff --git a/packages/shared-types/src/signaling.ts b/packages/shared-types/src/signaling.ts index 0be471b..3050e49 100644 --- a/packages/shared-types/src/signaling.ts +++ b/packages/shared-types/src/signaling.ts @@ -102,15 +102,6 @@ export type ControlMessage = | MuteMessage | TailnetHelloMessage; -// Cursor position message (for multi-cursor overlay) -export interface CursorPositionMessage { - type: 'cursor'; - participantId: string; - x: number; // 0-1 relative position - y: number; // 0-1 relative position - visible: boolean; -} - // Ping/pong for latency measurement export interface PingMessage { type: 'ping'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c77bc3..fb9f618 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,9 +98,6 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 - dbus-next: - specifier: ^0.10.2 - version: 0.10.2 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -462,10 +459,6 @@ importers: '@nut-tree-fork/nut-js': specifier: '>=4.2.0' version: 4.2.6(encoding@0.1.13) - optionalDependencies: - dbus-next: - specifier: ^0.10.2 - version: 0.10.2 devDependencies: typescript: specifier: ^5.7.0 @@ -2017,7 +2010,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.27': resolution: {integrity: sha512-MZ3s68+OFQZWljAiCdwL+dL+xZudpkmhq0A2Qb4+p6MNp386WJyAYineXtiLJVww/8ohIfUxOL10vnaPdDVl4w==} @@ -2644,10 +2637,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nornagon/put@0.0.8': - resolution: {integrity: sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==} - engines: {node: '>=0.3.0'} - '@npmcli/agent@3.0.0': resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2675,16 +2664,19 @@ packages: '@nut-tree-fork/libnut-darwin@2.7.5': resolution: {integrity: sha512-LbqtPtMPTJUcg4XoPP2jsU1wc8flBcGyKTerKsIfK9cD7nBHROnO0QksbrsbSWEpLym8T8fRtuU7XEY83l6Z2Q==} engines: {node: '>=10.15.3'} + cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut-linux@2.7.5': resolution: {integrity: sha512-uxaXEcRKnFObAljsoR6tLOBUU1dJ2sctloG6gFgCBGN7+k6Jdv6jZfOuNjd/fpdq2C5WPMm0rtn9EE7h5J3Jcg==} engines: {node: '>=10.15.3'} + cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut-win32@2.7.5': resolution: {integrity: sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw==} engines: {node: '>=10.15.3'} + cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut@4.2.6': @@ -2698,6 +2690,7 @@ packages: '@nut-tree-fork/nut-js@4.2.6': resolution: {integrity: sha512-aI/WCX7gE1HFGPH3EZP/UWqpNMM1NMoM/EkXqp7pKMgXFCi8e5+o5p+jd/QOYpmALv9bQg7+s69nI7FONbMqDg==} engines: {node: '>=16'} + cpu: [x64, arm64] os: [linux, darwin, win32] '@nut-tree-fork/provider-interfaces@4.2.6': @@ -3623,10 +3616,6 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} - ansi-regex@2.1.1: - resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} - engines: {node: '>=0.10.0'} - ansi-regex@4.1.1: resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} engines: {node: '>=6'} @@ -3682,9 +3671,6 @@ packages: dmg-builder: 26.4.0 electron-builder-squirrel-windows: 26.4.0 - aproba@1.2.0: - resolution: {integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==} - aproba@2.1.0: resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} @@ -3703,10 +3689,6 @@ packages: resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} engines: {node: '>= 10'} - are-we-there-yet@1.1.7: - resolution: {integrity: sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==} - deprecated: This package is no longer supported. - are-we-there-yet@3.0.1: resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -3769,9 +3751,6 @@ packages: asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} - asn1@0.2.6: - resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} - assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -3823,12 +3802,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - aws-sign2@0.7.0: - resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} - - aws4@1.13.2: - resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} - babel-core@7.0.0-bridge.0: resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==} peerDependencies: @@ -3926,9 +3899,6 @@ packages: resolution: {integrity: sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==} hasBin: true - bcrypt-pbkdf@1.0.2: - resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} - better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} @@ -4127,9 +4097,6 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} - caseless@0.12.0: - resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} - centra@2.7.0: resolution: {integrity: sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==} @@ -4238,10 +4205,6 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - code-point-at@1.1.0: - resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} - engines: {node: '>=0.10.0'} - color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -4407,10 +4370,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - dashdash@1.14.1: - resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} - engines: {node: '>=0.10'} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -4427,9 +4386,6 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} - dbus-next@0.10.2: - resolution: {integrity: sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==} - debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -4610,15 +4566,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - duplexer@0.1.2: - resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ecc-jsbn@0.1.2: - resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} - ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -4851,9 +4801,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - event-stream@3.3.4: - resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} - event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -5023,9 +4970,6 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -5034,10 +4978,6 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true - extsprintf@1.3.0: - resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} - engines: {'0': node >=0.6.0} - extsprintf@1.4.1: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} @@ -5171,13 +5111,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - forever-agent@0.6.1: - resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} - - form-data@2.5.6: - resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} - engines: {node: '>= 0.12'} - form-data@3.0.4: resolution: {integrity: sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==} engines: {node: '>= 6'} @@ -5201,9 +5134,6 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} - from@0.1.7: - resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} - fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -5261,10 +5191,6 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - gauge@2.7.4: - resolution: {integrity: sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==} - deprecated: This package is no longer supported. - gauge@4.0.4: resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -5317,9 +5243,6 @@ packages: resolution: {integrity: sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg==} engines: {node: '>=6'} - getpass@0.1.7: - resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} - gifwrap@0.10.1: resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} @@ -5375,15 +5298,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - har-schema@2.0.0: - resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} - engines: {node: '>=4'} - - har-validator@5.1.5: - resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} - engines: {node: '>=6'} - deprecated: this library is no longer supported - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -5434,10 +5348,6 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hexy@0.2.11: - resolution: {integrity: sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==} - hasBin: true - hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -5475,10 +5385,6 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} - http-signature@1.2.0: - resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} - engines: {node: '>=0.8', npm: '>=1.3.7'} - http2-wrapper@1.0.3: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} @@ -5671,10 +5577,6 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} - is-fullwidth-code-point@1.0.0: - resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==} - engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -5764,9 +5666,6 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} - is-typedarray@1.0.0: - resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} - is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -5819,9 +5718,6 @@ packages: isomorphic-fetch@3.0.0: resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} - isstream@0.1.2: - resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5937,12 +5833,6 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true - jsbi@2.0.5: - resolution: {integrity: sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ==} - - jsbn@0.1.1: - resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} - jsc-android@250231.0.0: resolution: {integrity: sha512-rS46PvsjYmdmuz1OAWXY/1kCYG7pnf1TBqeTiOJr1iDz7s5DLxxC9n/ZMknLDxzYzNVfI7R95MH10emSSG1Wuw==} @@ -5981,9 +5871,6 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -6004,10 +5891,6 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - jsprim@1.4.2: - resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} - engines: {node: '>=0.6.0'} - jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -6269,9 +6152,6 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} - long@4.0.0: - resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} - loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -6335,9 +6215,6 @@ packages: resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - map-stream@0.1.0: - resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} - marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -6581,9 +6458,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nan@2.28.0: - resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -6682,11 +6556,6 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true - node-gyp@7.1.2: - resolution: {integrity: sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ==} - engines: {node: '>= 10.12.0'} - hasBin: true - node-gyp@9.4.1: resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} engines: {node: ^12.13 || ^14.13 || >=16} @@ -6698,11 +6567,6 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nopt@5.0.0: - resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} - engines: {node: '>=6'} - hasBin: true - nopt@6.0.0: resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -6733,10 +6597,6 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} - npmlog@4.1.2: - resolution: {integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==} - deprecated: This package is no longer supported. - npmlog@6.0.2: resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -6745,16 +6605,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} - number-is-nan@1.0.1: - resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} - engines: {node: '>=0.10.0'} - nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} - oauth-sign@0.9.0: - resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} - ob1@0.81.5: resolution: {integrity: sha512-iNpbeXPLmaiT9I5g16gFFFjsF3sGxLpYG2EGP3dfFB4z+l9X60mp/yRzStHhMtuNt8qmf7Ww80nOPQHngHhnIQ==} engines: {node: '>=18.18'} @@ -6973,9 +6826,6 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} - pause-stream@0.0.11: - resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} - pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} @@ -6987,9 +6837,6 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - performance-now@2.1.0: - resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} - phin@3.7.1: resolution: {integrity: sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==} engines: {node: '>= 8'} @@ -7244,9 +7091,6 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -7258,10 +7102,6 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true - qs@6.5.5: - resolution: {integrity: sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==} - engines: {node: '>=0.6'} - quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -7512,11 +7352,6 @@ packages: remove-trailing-slash@0.1.1: resolution: {integrity: sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA==} - request@2.88.2: - resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} - engines: {node: '>= 6'} - deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -7895,20 +7730,12 @@ packages: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} - split@0.3.3: - resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} - sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - sshpk@1.18.0: - resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} - engines: {node: '>=0.10.0'} - hasBin: true - ssri@10.0.6: resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -7958,17 +7785,10 @@ packages: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} - stream-combiner@0.0.4: - resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} - strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} - string-width@1.0.2: - resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==} - engines: {node: '>=0.10.0'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -8002,10 +7822,6 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@3.0.1: - resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} - engines: {node: '>=0.10.0'} - strip-ansi@5.2.0: resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} engines: {node: '>=6'} @@ -8181,9 +7997,6 @@ packages: throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} - through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - timm@1.7.1: resolution: {integrity: sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==} @@ -8252,10 +8065,6 @@ packages: resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} engines: {node: '>=10'} - tough-cookie@2.5.0: - resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} - engines: {node: '>=0.8'} - tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -8293,9 +8102,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - turbo-darwin-64@2.7.5: resolution: {integrity: sha512-nN3wfLLj4OES/7awYyyM7fkU8U8sAFxsXau2bYJwAWi6T09jd87DgHD8N31zXaJ7LcpyppHWPRI2Ov9MuZEwnQ==} cpu: [x64] @@ -8330,9 +8136,6 @@ packages: resolution: {integrity: sha512-7Imdmg37joOloTnj+DPrab9hIaQcDdJ5RwSzcauo/wMOSAgO+A/I/8b3hsGGs6PWQz70m/jkPgdqWsfNKtwwDQ==} hasBin: true - tweetnacl@0.14.5: - resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -8489,9 +8292,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - usocket@0.3.0: - resolution: {integrity: sha512-V/H02RNiaOCJZuPoKont/y12VJaImC6C5xW7OzPFjYu9qnig0yv9hyp9E7Wqjm6d8yZuZouH3NAfDATVMgh2SQ==} - utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} @@ -8505,11 +8305,6 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@3.4.0: - resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -8528,10 +8323,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - verror@1.10.0: - resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} - engines: {'0': node >=0.6.0} - verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -8814,10 +8605,6 @@ packages: xml-parse-from-string@1.0.1: resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==} - xml2js@0.4.23: - resolution: {integrity: sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==} - engines: {node: '>=4.0.0'} - xml2js@0.5.0: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} @@ -11555,8 +11342,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nornagon/put@0.0.8': {} - '@npmcli/agent@3.0.0': dependencies: agent-base: 7.1.4 @@ -12683,9 +12468,6 @@ snapshots: dependencies: type-fest: 0.21.3 - ansi-regex@2.1.1: - optional: true - ansi-regex@4.1.1: {} ansi-regex@5.0.1: {} @@ -12796,9 +12578,6 @@ snapshots: transitivePeerDependencies: - supports-color - aproba@1.2.0: - optional: true - aproba@2.1.0: {} arch@2.2.0: {} @@ -12839,12 +12618,6 @@ snapshots: tar-stream: 2.2.0 zip-stream: 4.1.1 - are-we-there-yet@1.1.7: - dependencies: - delegates: 1.0.0 - readable-stream: 2.3.8 - optional: true - are-we-there-yet@3.0.1: dependencies: delegates: 1.0.0 @@ -12934,11 +12707,6 @@ snapshots: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 - asn1@0.2.6: - dependencies: - safer-buffer: 2.1.2 - optional: true - assert-plus@1.0.0: optional: true @@ -12982,12 +12750,6 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - aws-sign2@0.7.0: - optional: true - - aws4@1.13.2: - optional: true - babel-core@7.0.0-bridge.0(@babel/core@7.28.6): dependencies: '@babel/core': 7.28.6 @@ -13129,11 +12891,6 @@ snapshots: baseline-browser-mapping@2.9.17: {} - bcrypt-pbkdf@1.0.2: - dependencies: - tweetnacl: 0.14.5 - optional: true - better-opn@3.0.2: dependencies: open: 8.4.2 @@ -13416,9 +13173,6 @@ snapshots: caniuse-lite@1.0.30001766: {} - caseless@0.12.0: - optional: true - centra@2.7.0: dependencies: follow-redirects: 1.15.11 @@ -13544,9 +13298,6 @@ snapshots: clsx@2.1.1: {} - code-point-at@1.1.0: - optional: true - color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -13715,11 +13466,6 @@ snapshots: csstype@3.2.3: {} - dashdash@1.14.1: - dependencies: - assert-plus: 1.0.0 - optional: true - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -13743,18 +13489,6 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - dbus-next@0.10.2: - dependencies: - '@nornagon/put': 0.0.8 - event-stream: 3.3.4 - hexy: 0.2.11 - jsbi: 2.0.5 - long: 4.0.0 - safe-buffer: 5.2.1 - xml2js: 0.4.23 - optionalDependencies: - usocket: 0.3.0 - debug@2.6.9: dependencies: ms: 2.0.0 @@ -13910,16 +13644,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - duplexer@0.1.2: {} - eastasianwidth@0.2.0: {} - ecc-jsbn@0.1.2: - dependencies: - jsbn: 0.1.1 - safer-buffer: 2.1.2 - optional: true - ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -14324,16 +14050,6 @@ snapshots: etag@1.8.1: {} - event-stream@3.3.4: - dependencies: - duplexer: 0.1.2 - from: 0.1.7 - map-stream: 0.1.0 - pause-stream: 0.0.11 - split: 0.3.3 - stream-combiner: 0.0.4 - through: 2.3.8 - event-target-shim@5.0.1: {} event-target-shim@6.0.2: {} @@ -14563,9 +14279,6 @@ snapshots: exponential-backoff@3.1.3: {} - extend@3.0.2: - optional: true - extendable-error@0.1.7: {} extract-zip@2.0.1: @@ -14578,9 +14291,6 @@ snapshots: transitivePeerDependencies: - supports-color - extsprintf@1.3.0: - optional: true - extsprintf@1.4.1: optional: true @@ -14726,19 +14436,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - forever-agent@0.6.1: - optional: true - - form-data@2.5.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - safe-buffer: 5.2.1 - optional: true - form-data@3.0.4: dependencies: asynckit: 0.4.0 @@ -14769,8 +14466,6 @@ snapshots: fresh@0.5.2: {} - from@0.1.7: {} - fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -14843,18 +14538,6 @@ snapshots: functions-have-names@1.2.3: {} - gauge@2.7.4: - dependencies: - aproba: 1.2.0 - console-control-strings: 1.1.0 - has-unicode: 2.0.1 - object-assign: 4.1.1 - signal-exit: 3.0.7 - string-width: 1.0.2 - strip-ansi: 3.0.1 - wide-align: 1.1.5 - optional: true - gauge@4.0.4: dependencies: aproba: 2.1.0 @@ -14914,11 +14597,6 @@ snapshots: getenv@1.0.0: {} - getpass@0.1.7: - dependencies: - assert-plus: 1.0.0 - optional: true - gifwrap@0.10.1: dependencies: image-q: 4.0.0 @@ -15007,15 +14685,6 @@ snapshots: graceful-fs@4.2.11: {} - har-schema@2.0.0: - optional: true - - har-validator@5.1.5: - dependencies: - ajv: 6.12.6 - har-schema: 2.0.0 - optional: true - has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -15058,8 +14727,6 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hexy@0.2.11: {} - hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -15105,13 +14772,6 @@ snapshots: transitivePeerDependencies: - supports-color - http-signature@1.2.0: - dependencies: - assert-plus: 1.0.0 - jsprim: 1.4.2 - sshpk: 1.18.0 - optional: true - http2-wrapper@1.0.3: dependencies: quick-lru: 5.1.1 @@ -15289,11 +14949,6 @@ snapshots: dependencies: call-bound: 1.0.4 - is-fullwidth-code-point@1.0.0: - dependencies: - number-is-nan: 1.0.1 - optional: true - is-fullwidth-code-point@3.0.0: {} is-function@1.0.2: {} @@ -15371,9 +15026,6 @@ snapshots: dependencies: which-typed-array: 1.1.20 - is-typedarray@1.0.0: - optional: true - is-unicode-supported@0.1.0: {} is-weakmap@2.0.2: {} @@ -15414,9 +15066,6 @@ snapshots: transitivePeerDependencies: - encoding - isstream@0.1.2: - optional: true - istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1: @@ -15584,11 +15233,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsbi@2.0.5: {} - - jsbn@0.1.1: - optional: true - jsc-android@250231.0.0: {} jsc-safe-url@0.2.4: {} @@ -15656,9 +15300,6 @@ snapshots: json-schema-traverse@1.0.0: {} - json-schema@0.4.0: - optional: true - json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: @@ -15682,14 +15323,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsprim@1.4.2: - dependencies: - assert-plus: 1.0.0 - extsprintf: 1.3.0 - json-schema: 0.4.0 - verror: 1.10.0 - optional: true - jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -15923,8 +15556,6 @@ snapshots: loglevel@1.9.2: {} - long@4.0.0: {} - loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -16014,8 +15645,6 @@ snapshots: map-obj@5.0.0: {} - map-stream@0.1.0: {} - marked@18.0.5: {} marky@1.3.0: {} @@ -16349,9 +15978,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nan@2.28.0: - optional: true - nanoid@3.3.11: {} nativewind@4.2.1(react-native-reanimated@3.16.7(@babel/core@7.28.6)(react-native@0.76.9(@babel/core@7.28.6)(@babel/preset-env@7.28.6(@babel/core@7.28.6))(@types/react@19.2.9)(encoding@0.1.13)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@4.14.1(react-native@0.76.9(@babel/core@7.28.6)(@babel/preset-env@7.28.6(@babel/core@7.28.6))(@types/react@19.2.9)(encoding@0.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.76.9(@babel/core@7.28.6)(@babel/preset-env@7.28.6(@babel/core@7.28.6))(@types/react@19.2.9)(encoding@0.1.13)(react@19.2.3))(react@19.2.3)(tailwindcss@3.4.19(tsx@4.21.0)): @@ -16453,20 +16079,6 @@ snapshots: transitivePeerDependencies: - supports-color - node-gyp@7.1.2: - dependencies: - env-paths: 2.2.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - nopt: 5.0.0 - npmlog: 4.1.2 - request: 2.88.2 - rimraf: 3.0.2 - semver: 7.8.5 - tar: 7.5.22 - which: 2.0.2 - optional: true - node-gyp@9.4.1: dependencies: env-paths: 2.2.1 @@ -16488,11 +16100,6 @@ snapshots: node-releases@2.0.27: {} - nopt@5.0.0: - dependencies: - abbrev: 1.1.1 - optional: true - nopt@6.0.0: dependencies: abbrev: 1.1.1 @@ -16520,14 +16127,6 @@ snapshots: dependencies: path-key: 3.1.1 - npmlog@4.1.2: - dependencies: - are-we-there-yet: 1.1.7 - console-control-strings: 1.1.0 - gauge: 2.7.4 - set-blocking: 2.0.0 - optional: true - npmlog@6.0.2: dependencies: are-we-there-yet: 3.0.1 @@ -16537,14 +16136,8 @@ snapshots: nullthrows@1.1.1: {} - number-is-nan@1.0.1: - optional: true - nwsapi@2.2.23: {} - oauth-sign@0.9.0: - optional: true - ob1@0.81.5: dependencies: flow-enums-runtime: 0.0.6 @@ -16761,19 +16354,12 @@ snapshots: pathval@2.0.1: {} - pause-stream@0.0.11: - dependencies: - through: 2.3.8 - pe-library@0.4.1: {} peek-readable@4.1.0: {} pend@1.2.0: {} - performance-now@2.1.0: - optional: true - phin@3.7.1: dependencies: centra: 2.7.0 @@ -16950,11 +16536,6 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 - psl@1.15.0: - dependencies: - punycode: 2.3.1 - optional: true - pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -16964,9 +16545,6 @@ snapshots: qrcode-terminal@0.11.0: {} - qs@6.5.5: - optional: true - quansync@0.2.11: {} query-selector-shadow-dom@1.0.1: {} @@ -17304,30 +16882,6 @@ snapshots: remove-trailing-slash@0.1.1: {} - request@2.88.2: - dependencies: - aws-sign2: 0.7.0 - aws4: 1.13.2 - caseless: 0.12.0 - combined-stream: 1.0.8 - extend: 3.0.2 - forever-agent: 0.6.1 - form-data: 2.5.6 - har-validator: 5.1.5 - http-signature: 1.2.0 - is-typedarray: 1.0.0 - isstream: 0.1.2 - json-stringify-safe: 5.0.1 - mime-types: 2.1.35 - oauth-sign: 0.9.0 - performance-now: 2.1.0 - qs: 6.5.5 - safe-buffer: 5.2.1 - tough-cookie: 2.5.0 - tunnel-agent: 0.6.0 - uuid: 3.4.0 - optional: true - require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -17772,28 +17326,11 @@ snapshots: split-on-first@1.1.0: {} - split@0.3.3: - dependencies: - through: 2.3.8 - sprintf-js@1.0.3: {} sprintf-js@1.1.3: optional: true - sshpk@1.18.0: - dependencies: - asn1: 0.2.6 - assert-plus: 1.0.0 - bcrypt-pbkdf: 1.0.2 - dashdash: 1.14.1 - ecc-jsbn: 0.1.2 - getpass: 0.1.7 - jsbn: 0.1.1 - safer-buffer: 2.1.2 - tweetnacl: 0.14.5 - optional: true - ssri@10.0.6: dependencies: minipass: 7.1.2 @@ -17833,19 +17370,8 @@ snapshots: stream-buffers@2.2.0: {} - stream-combiner@0.0.4: - dependencies: - duplexer: 0.1.2 - strict-uri-encode@2.0.0: {} - string-width@1.0.2: - dependencies: - code-point-at: 1.1.0 - is-fullwidth-code-point: 1.0.0 - strip-ansi: 3.0.1 - optional: true - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -17910,11 +17436,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - strip-ansi@3.0.1: - dependencies: - ansi-regex: 2.1.1 - optional: true - strip-ansi@5.2.0: dependencies: ansi-regex: 4.1.1 @@ -18122,8 +17643,6 @@ snapshots: throat@5.0.0: {} - through@2.3.8: {} - timm@1.7.1: {} tiny-async-pool@1.3.0: @@ -18176,12 +17695,6 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 - tough-cookie@2.5.0: - dependencies: - psl: 1.15.0 - punycode: 2.3.1 - optional: true - tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -18217,11 +17730,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - optional: true - turbo-darwin-64@2.7.5: optional: true @@ -18249,9 +17757,6 @@ snapshots: turbo-windows-64: 2.7.5 turbo-windows-arm64: 2.7.5 - tweetnacl@0.14.5: - optional: true - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -18399,13 +17904,6 @@ snapshots: dependencies: react: 19.2.3 - usocket@0.3.0: - dependencies: - bindings: 1.5.0 - nan: 2.28.0 - node-gyp: 7.1.2 - optional: true - utf8-byte-length@1.0.5: {} utif2@4.1.0: @@ -18416,9 +17914,6 @@ snapshots: utils-merge@1.0.1: {} - uuid@3.4.0: - optional: true - uuid@7.0.3: {} uuid@8.3.2: {} @@ -18427,13 +17922,6 @@ snapshots: vary@1.1.2: {} - verror@1.10.0: - dependencies: - assert-plus: 1.0.0 - core-util-is: 1.0.2 - extsprintf: 1.4.1 - optional: true - verror@1.10.1: dependencies: assert-plus: 1.0.0 @@ -18874,11 +18362,6 @@ snapshots: xml-parse-from-string@1.0.1: {} - xml2js@0.4.23: - dependencies: - sax: 1.6.0 - xmlbuilder: 11.0.1 - xml2js@0.5.0: dependencies: sax: 1.6.0