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
42 changes: 40 additions & 2 deletions src/main/input/command-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,42 @@ describe('DesktopCommandExecutor', () => {
]);
});

it('coalesces no-response pointer movement commands', async () => {
const { adapter, executor } = createExecutor();
adapter.mouseMoveDelayMs = 10;

await Promise.all([
executor.execute(command('mouse.move', { dx: 1, dy: 0 }, { responseMode: 'none' })),
executor.execute(command('mouse.move', { dx: 2, dy: 0 }, { responseMode: 'none' })),
executor.execute(command('mouse.move', { dx: 3, dy: 0 }, { responseMode: 'none' }))
]);

expect(adapter.maxActiveMouseMoves).toBe(1);
expect(adapter.calls.length).toBeLessThan(3);
expect(adapter.calls).toEqual([
{ method: 'moveMouseBy', args: [{ dx: 6, dy: 0 }] }
]);
});

it('keeps drag actions ordered around no-response pointer movement', async () => {
const { adapter, executor } = createExecutor();
adapter.mouseMoveDelayMs = 10;

await Promise.all([
executor.execute(command('mouse.dragStart', { button: 'left' })),
executor.execute(command('mouse.move', { dx: 1, dy: 0 }, { responseMode: 'none' })),
executor.execute(command('mouse.move', { dx: 2, dy: 0 }, { responseMode: 'none' })),
executor.execute(command('mouse.dragEnd', { button: 'left' }))
]);

expect(adapter.maxActiveMouseMoves).toBe(1);
expect(adapter.calls).toEqual([
{ method: 'setMouseButtonDown', args: ['left', true] },
{ method: 'moveMouseBy', args: [{ dx: 3, dy: 0 }] },
{ method: 'setMouseButtonDown', args: ['left', false] }
]);
});

it('maps drag start and end to held mouse button actions', async () => {
const { adapter, executor, overlay } = createExecutor();

Expand Down Expand Up @@ -332,7 +368,8 @@ function createExecutor(): { adapter: FakeInputAdapter; overlay: FakeCursorOverl

function command<TType extends CommandRequest['type']>(
type: TType,
payload: Extract<CommandRequest, { type: TType }>['payload']
payload: Extract<CommandRequest, { type: TType }>['payload'],
overrides: Partial<Extract<CommandRequest, { type: TType }>> = {}
): Extract<CommandRequest, { type: TType }> {
return {
version: PROTOCOL_VERSION,
Expand All @@ -341,6 +378,7 @@ function command<TType extends CommandRequest['type']>(
timestamp: 1,
type,
payload,
auth: 'proof'
auth: 'proof',
...overrides
} as Extract<CommandRequest, { type: TType }>;
}
73 changes: 72 additions & 1 deletion src/main/input/command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
MAX_POINTER_DELTA,
MAX_SCROLL_DELTA,
MAX_SHORTCUT_KEYS,
MAX_TEXT_LENGTH
MAX_TEXT_LENGTH,
PROTOCOL_VERSION
} from '../../shared/protocol';
import type { DesktopInputAdapter } from './desktop-input-adapter';
import { DesktopInputError } from './desktop-input-adapter';
Expand All @@ -21,13 +22,19 @@ export type CursorOverlayNotifier = {
export class DesktopCommandExecutor {
private pointerActionQueue: Promise<void> = Promise.resolve();
private activeDragButton: MouseButton | null = null;
private pendingRealtimeMove: { dx: number; dy: number } | null = null;
private realtimeMoveFlush: Promise<CommandExecutionResult> | null = null;

constructor(
private readonly adapter: DesktopInputAdapter,
private readonly cursorOverlay?: CursorOverlayNotifier
) {}

async execute(command: CommandRequest): Promise<CommandExecutionResult> {
if (command.type === 'mouse.move' && command.responseMode === 'none') {
return this.enqueueCoalescedMouseMove(command);
}

if (isPointerAction(command)) {
return this.enqueuePointerAction(command);
}
Expand Down Expand Up @@ -60,6 +67,56 @@ export class DesktopCommandExecutor {
return result;
}

private enqueueCoalescedMouseMove(command: CommandRequest & { type: 'mouse.move' }): Promise<CommandExecutionResult> {
this.pendingRealtimeMove = addMouseDeltas(this.pendingRealtimeMove, command.payload);

if (this.realtimeMoveFlush) {
return Promise.resolve({ ok: true });
}

const result = this.pointerActionQueue.then(() => this.flushRealtimeMouseMoves());
this.realtimeMoveFlush = result;
this.pointerActionQueue = result.then(
() => undefined,
() => undefined
);

return result;
}

private async flushRealtimeMouseMoves(): Promise<CommandExecutionResult> {
try {
while (this.pendingRealtimeMove) {
const payload = this.pendingRealtimeMove;
this.pendingRealtimeMove = null;
const result = await this.executeNow({
version: PROTOCOL_VERSION,
id: 'realtime-move',
deviceId: 'realtime',
timestamp: Date.now(),
type: 'mouse.move',
payload,
auth: '',
responseMode: 'none'
});
if (!result.ok) {
return result;
}
}
return { ok: true };
} finally {
this.realtimeMoveFlush = null;
if (this.pendingRealtimeMove) {
const result = this.pointerActionQueue.then(() => this.flushRealtimeMouseMoves());
this.realtimeMoveFlush = result;
this.pointerActionQueue = result.then(
() => undefined,
() => undefined
);
}
}
}

private async executeNow(command: CommandRequest): Promise<CommandExecutionResult> {
try {
if (isMouseCommand(command)) {
Expand Down Expand Up @@ -171,6 +228,20 @@ function unsafe(message: string): CommandExecutionResult {
return { ok: false, code: 'unsafe_payload', message };
}

function addMouseDeltas(
current: { dx: number; dy: number } | null,
next: { dx: number; dy: number }
): { dx: number; dy: number } {
return {
dx: clampDelta((current?.dx ?? 0) + next.dx),
dy: clampDelta((current?.dy ?? 0) + next.dy)
};
}

function clampDelta(value: number): number {
return Math.max(-MAX_POINTER_DELTA, Math.min(MAX_POINTER_DELTA, value));
}

function isPointerAction(
command: CommandRequest
): command is CommandRequest & { type: 'mouse.move' | 'mouse.dragStart' | 'mouse.dragEnd' } {
Expand Down
3 changes: 3 additions & 0 deletions src/main/input/pointer-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ describe('createPointerMovementProfile', () => {
small: 32,
medium: 85,
large: 187
},
capabilities: {
noAckMouseMove: true
}
});
});
Expand Down
3 changes: 3 additions & 0 deletions src/main/input/pointer-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export function createPointerMovementProfile(input: {
small: toLogicalDelta(TARGET_NATIVE_DELTAS.small, scaleFactor, maxDelta),
medium: toLogicalDelta(TARGET_NATIVE_DELTAS.medium, scaleFactor, maxDelta),
large: toLogicalDelta(TARGET_NATIVE_DELTAS.large, scaleFactor, maxDelta)
},
capabilities: {
noAckMouseMove: true
}
};
}
Expand Down
45 changes: 44 additions & 1 deletion src/main/pairing/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { PingCommand } from '../../shared/protocol';
import type { MouseMoveCommand, PingCommand } from '../../shared/protocol';
import { PROTOCOL_VERSION } from '../../shared/protocol';
import { createCommandAuthProof, CommandAuthValidator, COMMAND_TIMESTAMP_TOLERANCE_MS } from './auth';
import { MemoryPairingStore } from './pairing-store';
Expand Down Expand Up @@ -70,6 +70,31 @@ describe('CommandAuthValidator', () => {
expect((await store.load()).pairedDevices[0].lastSeenAt).toBeNull();
});

it('canonicalizes missing response mode as ack', () => {
const implicitAck = createCommand();
const explicitAck = createCommand({ responseMode: 'ack' });

expect(createCommandAuthProof(implicitAck, token)).toBe(createCommandAuthProof(explicitAck, token));
});

it('includes no-response mode in command auth proofs', async () => {
const ackMove = createMouseMoveCommand();
const noAckMove = createMouseMoveCommand({ responseMode: 'none' });

expect(createCommandAuthProof(noAckMove, token)).not.toBe(createCommandAuthProof(ackMove, token));
});

it('rejects response mode tampering after signing', async () => {
const store = createStore();
const validator = new CommandAuthValidator(store, () => now);
const command = createMouseMoveCommand();

await expect(validator.validate({ ...command, responseMode: 'none' })).resolves.toEqual({
ok: false,
reason: 'invalid_auth'
});
});

it('rejects expired timestamps', async () => {
const store = createStore();
const validator = new CommandAuthValidator(store, () => now);
Expand Down Expand Up @@ -109,3 +134,21 @@ describe('CommandAuthValidator', () => {
});
});
});

function createMouseMoveCommand(overrides: Partial<MouseMoveCommand> = {}): MouseMoveCommand {
const command = {
version: PROTOCOL_VERSION,
id: 'move-1',
deviceId: 'android-1',
timestamp: now,
type: 'mouse.move',
payload: { dx: 12, dy: -6 },
auth: ''
} satisfies MouseMoveCommand;

const merged = { ...command, ...overrides } as MouseMoveCommand;
return {
...merged,
auth: overrides.auth ?? createCommandAuthProof(merged, token)
};
}
9 changes: 7 additions & 2 deletions src/main/pairing/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { CommandRequest } from '../../shared/protocol';
import type { CommandRequest, CommandResponseMode } from '../../shared/protocol';
import { validateProtocolRequest } from '../../shared/protocol';
import type { PairingStore } from './pairing-store';
import { findPairedDevice } from './pairing-store';
Expand Down Expand Up @@ -70,10 +70,15 @@ function canonicalCommandString(command: CommandRequest): string {
command.deviceId,
command.timestamp,
command.type,
stableStringify(command.payload)
stableStringify(command.payload),
commandResponseMode(command)
].join('\n');
}

function commandResponseMode(command: CommandRequest): CommandResponseMode {
return command.responseMode ?? 'ack';
}

function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
Expand Down
81 changes: 81 additions & 0 deletions src/main/transport/remote-session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,47 @@ describe('RemoteSessionManager', () => {
]);
});

it('executes authenticated no-response mouse movement without sending an ack', async () => {
const handled: MouseMoveCommand[] = [];
const manager = createManager({
onCommand: (command) => {
handled.push(command as MouseMoveCommand);
return { ok: true };
}
});
const connection = createConnection();
manager.addConnection(connection);
const command = createMouseMoveCommand({ responseMode: 'none' });

await manager.handleMessage(connection.id, JSON.stringify(command));

expect(sentResponses(connection)).toEqual([]);
expect(handled).toEqual([command]);
expect(manager.getAuthenticatedClients()).toEqual([
expect.objectContaining({
id: connection.id,
deviceId: 'android-1',
transport: 'bluetooth'
})
]);
});

it('suppresses adapter failure responses for no-response mouse movement', async () => {
const onError = vi.fn();
const manager = createManager({
onError,
onCommand: () => ({ ok: false, code: 'adapter_failure', message: 'Move failed.' })
});
const connection = createConnection();
manager.addConnection(connection);
const command = createMouseMoveCommand({ responseMode: 'none' });

await manager.handleMessage(connection.id, JSON.stringify(command));

expect(sentResponses(connection)).toEqual([]);
expect(onError).toHaveBeenCalledWith('Move failed.');
});

it('acks authenticated heartbeat pings without executing desktop commands', async () => {
const onCommand = vi.fn();
const manager = createManager({ onCommand });
Expand Down Expand Up @@ -81,6 +122,46 @@ describe('RemoteSessionManager', () => {
expect(onCommand).not.toHaveBeenCalled();
});

it('rejects unauthenticated no-response movement with a structured auth error', async () => {
const onCommand = vi.fn();
const manager = createManager({ onCommand });
const connection = createConnection();
manager.addConnection(connection);

await manager.handleMessage(
connection.id,
JSON.stringify(createMouseMoveCommand({ auth: 'invalid', responseMode: 'none' }))
);

expect(sentResponses(connection)).toEqual([
expect.objectContaining({
type: 'error',
ok: false,
error: expect.objectContaining({ code: 'invalid_auth' })
})
]);
expect(onCommand).not.toHaveBeenCalled();
});

it('rejects unsupported no-response command types during validation', async () => {
const onCommand = vi.fn();
const manager = createManager({ onCommand });
const connection = createConnection();
manager.addConnection(connection);
const command = createPingCommand({ responseMode: 'none' } as Partial<PingCommand>);

await manager.handleMessage(connection.id, JSON.stringify(command));

expect(sentResponses(connection)).toEqual([
expect.objectContaining({
type: 'error',
ok: false,
error: expect.objectContaining({ code: 'invalid_payload' })
})
]);
expect(onCommand).not.toHaveBeenCalled();
});

it('keeps pairing requests pending until they are accepted', async () => {
const store = new MemoryPairingStore({ desktopId: 'desktop-1', pairedDevices: [] });
const approvalManager = new PairingApprovalManager(store, () => now);
Expand Down
Loading