Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@

- [x] **Overlay Indicators**
- [x] "Control Active" visual indicator
- [x] Remote cursor visualization
- [x] Recording/sharing indicator

### Platform-Specific
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/input/injector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 3 additions & 9 deletions apps/desktop/src/main/input/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -124,8 +119,7 @@ export async function emergencyStop(): Promise<void> {
/**
* 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<void> {
if (!injector) return;
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/main/ipc/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 13 additions & 25 deletions apps/desktop/src/main/ipc/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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<void>((resolve) => setTimeout(resolve, 2000));
void Promise.race([disposeInputInjector(), deadline])
Expand Down
26 changes: 0 additions & 26 deletions apps/desktop/src/main/overlay/cursorOverlay.test.ts

This file was deleted.

Loading
Loading