diff --git a/src/main/input/command-executor.test.ts b/src/main/input/command-executor.test.ts index d839e1f..51f00c8 100644 --- a/src/main/input/command-executor.test.ts +++ b/src/main/input/command-executor.test.ts @@ -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(); @@ -332,7 +368,8 @@ function createExecutor(): { adapter: FakeInputAdapter; overlay: FakeCursorOverl function command( type: TType, - payload: Extract['payload'] + payload: Extract['payload'], + overrides: Partial> = {} ): Extract { return { version: PROTOCOL_VERSION, @@ -341,6 +378,7 @@ function command( timestamp: 1, type, payload, - auth: 'proof' + auth: 'proof', + ...overrides } as Extract; } diff --git a/src/main/input/command-executor.ts b/src/main/input/command-executor.ts index 89b0046..eab7452 100644 --- a/src/main/input/command-executor.ts +++ b/src/main/input/command-executor.ts @@ -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'; @@ -21,6 +22,8 @@ export type CursorOverlayNotifier = { export class DesktopCommandExecutor { private pointerActionQueue: Promise = Promise.resolve(); private activeDragButton: MouseButton | null = null; + private pendingRealtimeMove: { dx: number; dy: number } | null = null; + private realtimeMoveFlush: Promise | null = null; constructor( private readonly adapter: DesktopInputAdapter, @@ -28,6 +31,10 @@ export class DesktopCommandExecutor { ) {} async execute(command: CommandRequest): Promise { + if (command.type === 'mouse.move' && command.responseMode === 'none') { + return this.enqueueCoalescedMouseMove(command); + } + if (isPointerAction(command)) { return this.enqueuePointerAction(command); } @@ -60,6 +67,56 @@ export class DesktopCommandExecutor { return result; } + private enqueueCoalescedMouseMove(command: CommandRequest & { type: 'mouse.move' }): Promise { + 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 { + 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 { try { if (isMouseCommand(command)) { @@ -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' } { diff --git a/src/main/input/pointer-profile.test.ts b/src/main/input/pointer-profile.test.ts index 509d439..4920a8f 100644 --- a/src/main/input/pointer-profile.test.ts +++ b/src/main/input/pointer-profile.test.ts @@ -21,6 +21,9 @@ describe('createPointerMovementProfile', () => { small: 32, medium: 85, large: 187 + }, + capabilities: { + noAckMouseMove: true } }); }); diff --git a/src/main/input/pointer-profile.ts b/src/main/input/pointer-profile.ts index 5bab526..4e04899 100644 --- a/src/main/input/pointer-profile.ts +++ b/src/main/input/pointer-profile.ts @@ -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 } }; } diff --git a/src/main/pairing/auth.test.ts b/src/main/pairing/auth.test.ts index fbb335e..e75cbf7 100644 --- a/src/main/pairing/auth.test.ts +++ b/src/main/pairing/auth.test.ts @@ -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'; @@ -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); @@ -109,3 +134,21 @@ describe('CommandAuthValidator', () => { }); }); }); + +function createMouseMoveCommand(overrides: Partial = {}): 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) + }; +} diff --git a/src/main/pairing/auth.ts b/src/main/pairing/auth.ts index ba577d6..32d66a9 100644 --- a/src/main/pairing/auth.ts +++ b/src/main/pairing/auth.ts @@ -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'; @@ -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(',')}]`; diff --git a/src/main/transport/remote-session-manager.test.ts b/src/main/transport/remote-session-manager.test.ts index 818d26f..3f2ff74 100644 --- a/src/main/transport/remote-session-manager.test.ts +++ b/src/main/transport/remote-session-manager.test.ts @@ -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 }); @@ -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); + + 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); diff --git a/src/main/transport/remote-session-manager.ts b/src/main/transport/remote-session-manager.ts index de4e057..17b2b11 100644 --- a/src/main/transport/remote-session-manager.ts +++ b/src/main/transport/remote-session-manager.ts @@ -4,6 +4,7 @@ import { createPairingCompleteResponse, createPointerProfileResponse, parseProtocolRequest, + type CommandResponseMode, type CommandRequest, type PairingApprovalRequest, type PointerMovementProfile @@ -182,6 +183,9 @@ export class RemoteSessionManager { const commandResult = await this.executeCommand(authResult.command); if (!commandResult.ok) { this.options.onError?.(commandResult.message); + if (shouldSuppressAck(authResult.command)) { + return; + } await sendResponse( connection, createErrorResponse(message.id, toProtocolCommandErrorCode(commandResult.code), commandResult.message) @@ -191,6 +195,9 @@ export class RemoteSessionManager { this.markConnectionSeen(connection.id, authResult.command.deviceId); this.markLastSeen(Date.now()); + if (shouldSuppressAck(authResult.command)) { + return; + } await sendResponse(connection, createAckResponse(message.id)); } @@ -290,3 +297,11 @@ export class RemoteSessionManager { this.options.onClientStatusChange?.(); } } + +function commandResponseMode(command: CommandRequest): CommandResponseMode { + return command.responseMode ?? 'ack'; +} + +function shouldSuppressAck(command: CommandRequest): boolean { + return command.type === 'mouse.move' && commandResponseMode(command) === 'none'; +} diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index 98a46ff..db1704f 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -45,6 +45,49 @@ describe('protocol request validation', () => { } }); + it('accepts no-response mode only for mouse movement', () => { + expect( + validateProtocolRequest({ + ...baseCommand, + type: 'mouse.move', + payload: { dx: 12, dy: -6 }, + responseMode: 'none' + }) + ).toMatchObject({ ok: true }); + expect( + validateProtocolRequest({ + ...baseCommand, + type: 'mouse.move', + payload: { dx: 12, dy: -6 }, + responseMode: 'ack' + }) + ).toMatchObject({ ok: true }); + expect( + validateProtocolRequest({ + ...baseCommand, + type: 'mouse.scroll', + payload: { dx: 0, dy: -3 }, + responseMode: 'none' + }) + ).toMatchObject({ ok: false, error: 'invalid_payload' }); + expect( + validateProtocolRequest({ + ...baseCommand, + type: 'keyboard.key', + payload: { key: 'Enter' }, + responseMode: 'none' + }) + ).toMatchObject({ ok: false, error: 'invalid_payload' }); + expect( + validateProtocolRequest({ + ...baseCommand, + type: 'mouse.move', + payload: { dx: 12, dy: -6 }, + responseMode: 'eventually' + }) + ).toMatchObject({ ok: false, error: 'invalid_payload' }); + }); + it('accepts pairing approval requests without command auth fields', () => { expect( validateProtocolRequest({ @@ -262,9 +305,13 @@ describe('protocol response validation', () => { small: 50, medium: 130, large: 252 + }, + capabilities: { + noAckMouseMove: true } }); + expect(response.payload.capabilities.noAckMouseMove).toBe(true); expect(validateProtocolResponse(response)).toMatchObject({ ok: true }); }); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 041cfa8..81cde0f 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -57,6 +57,8 @@ export type WindowControlAction = | 'minimizeFocused' | 'maximizeFocused'; +export type CommandResponseMode = 'ack' | 'none'; + export type PointerMovementProfile = { displayId: string; scaleFactor: number; @@ -72,6 +74,9 @@ export type PointerMovementProfile = { medium: number; large: number; }; + capabilities: { + noAckMouseMove: boolean; + }; }; export interface BaseRequestEnvelope { @@ -82,6 +87,7 @@ export interface BaseRequestEnvelope { type: TType; payload: TPayload; auth: string; + responseMode?: CommandResponseMode; } export type PairingApprovalRequest = { @@ -200,6 +206,7 @@ const commandTypes = new Set([ ]); const pairingTypes = new Set(['pairing.request']); +const commandResponseModes = new Set(['ack', 'none']); const mouseButtons = new Set(['left', 'right', 'middle']); const keyboardKeys = new Set([ 'Backspace', @@ -387,6 +394,9 @@ function validateCommandRequest(value: Record): ValidationResul if (!isNonEmptyString(value.deviceId)) return invalid('invalid_message', 'Device id is required.'); if (!isFiniteNumber(value.timestamp)) return invalid('invalid_message', 'Timestamp is required.'); if (!isNonEmptyString(value.auth)) return invalid('invalid_auth', 'Auth proof is required.'); + if (!isValidResponseMode(value.type as CommandRequest['type'], value.responseMode)) { + return invalid('invalid_payload', 'Response mode is invalid.'); + } const payload = value.payload as Record; const payloadOk = validateCommandPayload(value.type as CommandRequest['type'], payload); @@ -470,9 +480,26 @@ function validatePointerProfilePayload(payload: Record): Valida return invalid('invalid_payload', 'Recommended delta is invalid.'); } } + if ('capabilities' in payload) { + if (!isRecord(payload.capabilities)) { + return invalid('invalid_payload', 'Pointer capabilities are invalid.'); + } + if ( + 'noAckMouseMove' in payload.capabilities && + typeof payload.capabilities.noAckMouseMove !== 'boolean' + ) { + return invalid('invalid_payload', 'No-ack mouse move capability is invalid.'); + } + } return valid(); } +function isValidResponseMode(type: CommandRequest['type'], responseMode: unknown): boolean { + if (responseMode === undefined) return true; + if (!commandResponseModes.has(responseMode as CommandResponseMode)) return false; + return responseMode !== 'none' || type === 'mouse.move'; +} + function isFiniteBounds(value: Record): boolean { return ( isFiniteNumber(value.x) &&