Skip to content

Commit b96e733

Browse files
committed
Support async Bluetooth pointer movement
1 parent f04d570 commit b96e733

10 files changed

Lines changed: 339 additions & 6 deletions

src/main/input/command-executor.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,42 @@ describe('DesktopCommandExecutor', () => {
235235
]);
236236
});
237237

238+
it('coalesces no-response pointer movement commands', async () => {
239+
const { adapter, executor } = createExecutor();
240+
adapter.mouseMoveDelayMs = 10;
241+
242+
await Promise.all([
243+
executor.execute(command('mouse.move', { dx: 1, dy: 0 }, { responseMode: 'none' })),
244+
executor.execute(command('mouse.move', { dx: 2, dy: 0 }, { responseMode: 'none' })),
245+
executor.execute(command('mouse.move', { dx: 3, dy: 0 }, { responseMode: 'none' }))
246+
]);
247+
248+
expect(adapter.maxActiveMouseMoves).toBe(1);
249+
expect(adapter.calls.length).toBeLessThan(3);
250+
expect(adapter.calls).toEqual([
251+
{ method: 'moveMouseBy', args: [{ dx: 6, dy: 0 }] }
252+
]);
253+
});
254+
255+
it('keeps drag actions ordered around no-response pointer movement', async () => {
256+
const { adapter, executor } = createExecutor();
257+
adapter.mouseMoveDelayMs = 10;
258+
259+
await Promise.all([
260+
executor.execute(command('mouse.dragStart', { button: 'left' })),
261+
executor.execute(command('mouse.move', { dx: 1, dy: 0 }, { responseMode: 'none' })),
262+
executor.execute(command('mouse.move', { dx: 2, dy: 0 }, { responseMode: 'none' })),
263+
executor.execute(command('mouse.dragEnd', { button: 'left' }))
264+
]);
265+
266+
expect(adapter.maxActiveMouseMoves).toBe(1);
267+
expect(adapter.calls).toEqual([
268+
{ method: 'setMouseButtonDown', args: ['left', true] },
269+
{ method: 'moveMouseBy', args: [{ dx: 3, dy: 0 }] },
270+
{ method: 'setMouseButtonDown', args: ['left', false] }
271+
]);
272+
});
273+
238274
it('maps drag start and end to held mouse button actions', async () => {
239275
const { adapter, executor, overlay } = createExecutor();
240276

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

333369
function command<TType extends CommandRequest['type']>(
334370
type: TType,
335-
payload: Extract<CommandRequest, { type: TType }>['payload']
371+
payload: Extract<CommandRequest, { type: TType }>['payload'],
372+
overrides: Partial<Extract<CommandRequest, { type: TType }>> = {}
336373
): Extract<CommandRequest, { type: TType }> {
337374
return {
338375
version: PROTOCOL_VERSION,
@@ -341,6 +378,7 @@ function command<TType extends CommandRequest['type']>(
341378
timestamp: 1,
342379
type,
343380
payload,
344-
auth: 'proof'
381+
auth: 'proof',
382+
...overrides
345383
} as Extract<CommandRequest, { type: TType }>;
346384
}

src/main/input/command-executor.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
MAX_POINTER_DELTA,
44
MAX_SCROLL_DELTA,
55
MAX_SHORTCUT_KEYS,
6-
MAX_TEXT_LENGTH
6+
MAX_TEXT_LENGTH,
7+
PROTOCOL_VERSION
78
} from '../../shared/protocol';
89
import type { DesktopInputAdapter } from './desktop-input-adapter';
910
import { DesktopInputError } from './desktop-input-adapter';
@@ -21,13 +22,19 @@ export type CursorOverlayNotifier = {
2122
export class DesktopCommandExecutor {
2223
private pointerActionQueue: Promise<void> = Promise.resolve();
2324
private activeDragButton: MouseButton | null = null;
25+
private pendingRealtimeMove: { dx: number; dy: number } | null = null;
26+
private realtimeMoveFlush: Promise<CommandExecutionResult> | null = null;
2427

2528
constructor(
2629
private readonly adapter: DesktopInputAdapter,
2730
private readonly cursorOverlay?: CursorOverlayNotifier
2831
) {}
2932

3033
async execute(command: CommandRequest): Promise<CommandExecutionResult> {
34+
if (command.type === 'mouse.move' && command.responseMode === 'none') {
35+
return this.enqueueCoalescedMouseMove(command);
36+
}
37+
3138
if (isPointerAction(command)) {
3239
return this.enqueuePointerAction(command);
3340
}
@@ -60,6 +67,56 @@ export class DesktopCommandExecutor {
6067
return result;
6168
}
6269

70+
private enqueueCoalescedMouseMove(command: CommandRequest & { type: 'mouse.move' }): Promise<CommandExecutionResult> {
71+
this.pendingRealtimeMove = addMouseDeltas(this.pendingRealtimeMove, command.payload);
72+
73+
if (this.realtimeMoveFlush) {
74+
return Promise.resolve({ ok: true });
75+
}
76+
77+
const result = this.pointerActionQueue.then(() => this.flushRealtimeMouseMoves());
78+
this.realtimeMoveFlush = result;
79+
this.pointerActionQueue = result.then(
80+
() => undefined,
81+
() => undefined
82+
);
83+
84+
return result;
85+
}
86+
87+
private async flushRealtimeMouseMoves(): Promise<CommandExecutionResult> {
88+
try {
89+
while (this.pendingRealtimeMove) {
90+
const payload = this.pendingRealtimeMove;
91+
this.pendingRealtimeMove = null;
92+
const result = await this.executeNow({
93+
version: PROTOCOL_VERSION,
94+
id: 'realtime-move',
95+
deviceId: 'realtime',
96+
timestamp: Date.now(),
97+
type: 'mouse.move',
98+
payload,
99+
auth: '',
100+
responseMode: 'none'
101+
});
102+
if (!result.ok) {
103+
return result;
104+
}
105+
}
106+
return { ok: true };
107+
} finally {
108+
this.realtimeMoveFlush = null;
109+
if (this.pendingRealtimeMove) {
110+
const result = this.pointerActionQueue.then(() => this.flushRealtimeMouseMoves());
111+
this.realtimeMoveFlush = result;
112+
this.pointerActionQueue = result.then(
113+
() => undefined,
114+
() => undefined
115+
);
116+
}
117+
}
118+
}
119+
63120
private async executeNow(command: CommandRequest): Promise<CommandExecutionResult> {
64121
try {
65122
if (isMouseCommand(command)) {
@@ -171,6 +228,20 @@ function unsafe(message: string): CommandExecutionResult {
171228
return { ok: false, code: 'unsafe_payload', message };
172229
}
173230

231+
function addMouseDeltas(
232+
current: { dx: number; dy: number } | null,
233+
next: { dx: number; dy: number }
234+
): { dx: number; dy: number } {
235+
return {
236+
dx: clampDelta((current?.dx ?? 0) + next.dx),
237+
dy: clampDelta((current?.dy ?? 0) + next.dy)
238+
};
239+
}
240+
241+
function clampDelta(value: number): number {
242+
return Math.max(-MAX_POINTER_DELTA, Math.min(MAX_POINTER_DELTA, value));
243+
}
244+
174245
function isPointerAction(
175246
command: CommandRequest
176247
): command is CommandRequest & { type: 'mouse.move' | 'mouse.dragStart' | 'mouse.dragEnd' } {

src/main/input/pointer-profile.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ describe('createPointerMovementProfile', () => {
2121
small: 32,
2222
medium: 85,
2323
large: 187
24+
},
25+
capabilities: {
26+
noAckMouseMove: true
2427
}
2528
});
2629
});

src/main/input/pointer-profile.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export function createPointerMovementProfile(input: {
3131
small: toLogicalDelta(TARGET_NATIVE_DELTAS.small, scaleFactor, maxDelta),
3232
medium: toLogicalDelta(TARGET_NATIVE_DELTAS.medium, scaleFactor, maxDelta),
3333
large: toLogicalDelta(TARGET_NATIVE_DELTAS.large, scaleFactor, maxDelta)
34+
},
35+
capabilities: {
36+
noAckMouseMove: true
3437
}
3538
};
3639
}

src/main/pairing/auth.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import type { PingCommand } from '../../shared/protocol';
2+
import type { MouseMoveCommand, PingCommand } from '../../shared/protocol';
33
import { PROTOCOL_VERSION } from '../../shared/protocol';
44
import { createCommandAuthProof, CommandAuthValidator, COMMAND_TIMESTAMP_TOLERANCE_MS } from './auth';
55
import { MemoryPairingStore } from './pairing-store';
@@ -70,6 +70,31 @@ describe('CommandAuthValidator', () => {
7070
expect((await store.load()).pairedDevices[0].lastSeenAt).toBeNull();
7171
});
7272

73+
it('canonicalizes missing response mode as ack', () => {
74+
const implicitAck = createCommand();
75+
const explicitAck = createCommand({ responseMode: 'ack' });
76+
77+
expect(createCommandAuthProof(implicitAck, token)).toBe(createCommandAuthProof(explicitAck, token));
78+
});
79+
80+
it('includes no-response mode in command auth proofs', async () => {
81+
const ackMove = createMouseMoveCommand();
82+
const noAckMove = createMouseMoveCommand({ responseMode: 'none' });
83+
84+
expect(createCommandAuthProof(noAckMove, token)).not.toBe(createCommandAuthProof(ackMove, token));
85+
});
86+
87+
it('rejects response mode tampering after signing', async () => {
88+
const store = createStore();
89+
const validator = new CommandAuthValidator(store, () => now);
90+
const command = createMouseMoveCommand();
91+
92+
await expect(validator.validate({ ...command, responseMode: 'none' })).resolves.toEqual({
93+
ok: false,
94+
reason: 'invalid_auth'
95+
});
96+
});
97+
7398
it('rejects expired timestamps', async () => {
7499
const store = createStore();
75100
const validator = new CommandAuthValidator(store, () => now);
@@ -109,3 +134,21 @@ describe('CommandAuthValidator', () => {
109134
});
110135
});
111136
});
137+
138+
function createMouseMoveCommand(overrides: Partial<MouseMoveCommand> = {}): MouseMoveCommand {
139+
const command = {
140+
version: PROTOCOL_VERSION,
141+
id: 'move-1',
142+
deviceId: 'android-1',
143+
timestamp: now,
144+
type: 'mouse.move',
145+
payload: { dx: 12, dy: -6 },
146+
auth: ''
147+
} satisfies MouseMoveCommand;
148+
149+
const merged = { ...command, ...overrides } as MouseMoveCommand;
150+
return {
151+
...merged,
152+
auth: overrides.auth ?? createCommandAuthProof(merged, token)
153+
};
154+
}

src/main/pairing/auth.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHmac, timingSafeEqual } from 'node:crypto';
2-
import type { CommandRequest } from '../../shared/protocol';
2+
import type { CommandRequest, CommandResponseMode } from '../../shared/protocol';
33
import { validateProtocolRequest } from '../../shared/protocol';
44
import type { PairingStore } from './pairing-store';
55
import { findPairedDevice } from './pairing-store';
@@ -70,10 +70,15 @@ function canonicalCommandString(command: CommandRequest): string {
7070
command.deviceId,
7171
command.timestamp,
7272
command.type,
73-
stableStringify(command.payload)
73+
stableStringify(command.payload),
74+
commandResponseMode(command)
7475
].join('\n');
7576
}
7677

78+
function commandResponseMode(command: CommandRequest): CommandResponseMode {
79+
return command.responseMode ?? 'ack';
80+
}
81+
7782
function stableStringify(value: unknown): string {
7883
if (Array.isArray(value)) {
7984
return `[${value.map(stableStringify).join(',')}]`;

src/main/transport/remote-session-manager.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,47 @@ describe('RemoteSessionManager', () => {
4343
]);
4444
});
4545

46+
it('executes authenticated no-response mouse movement without sending an ack', async () => {
47+
const handled: MouseMoveCommand[] = [];
48+
const manager = createManager({
49+
onCommand: (command) => {
50+
handled.push(command as MouseMoveCommand);
51+
return { ok: true };
52+
}
53+
});
54+
const connection = createConnection();
55+
manager.addConnection(connection);
56+
const command = createMouseMoveCommand({ responseMode: 'none' });
57+
58+
await manager.handleMessage(connection.id, JSON.stringify(command));
59+
60+
expect(sentResponses(connection)).toEqual([]);
61+
expect(handled).toEqual([command]);
62+
expect(manager.getAuthenticatedClients()).toEqual([
63+
expect.objectContaining({
64+
id: connection.id,
65+
deviceId: 'android-1',
66+
transport: 'bluetooth'
67+
})
68+
]);
69+
});
70+
71+
it('suppresses adapter failure responses for no-response mouse movement', async () => {
72+
const onError = vi.fn();
73+
const manager = createManager({
74+
onError,
75+
onCommand: () => ({ ok: false, code: 'adapter_failure', message: 'Move failed.' })
76+
});
77+
const connection = createConnection();
78+
manager.addConnection(connection);
79+
const command = createMouseMoveCommand({ responseMode: 'none' });
80+
81+
await manager.handleMessage(connection.id, JSON.stringify(command));
82+
83+
expect(sentResponses(connection)).toEqual([]);
84+
expect(onError).toHaveBeenCalledWith('Move failed.');
85+
});
86+
4687
it('acks authenticated heartbeat pings without executing desktop commands', async () => {
4788
const onCommand = vi.fn();
4889
const manager = createManager({ onCommand });
@@ -81,6 +122,46 @@ describe('RemoteSessionManager', () => {
81122
expect(onCommand).not.toHaveBeenCalled();
82123
});
83124

125+
it('rejects unauthenticated no-response movement with a structured auth error', async () => {
126+
const onCommand = vi.fn();
127+
const manager = createManager({ onCommand });
128+
const connection = createConnection();
129+
manager.addConnection(connection);
130+
131+
await manager.handleMessage(
132+
connection.id,
133+
JSON.stringify(createMouseMoveCommand({ auth: 'invalid', responseMode: 'none' }))
134+
);
135+
136+
expect(sentResponses(connection)).toEqual([
137+
expect.objectContaining({
138+
type: 'error',
139+
ok: false,
140+
error: expect.objectContaining({ code: 'invalid_auth' })
141+
})
142+
]);
143+
expect(onCommand).not.toHaveBeenCalled();
144+
});
145+
146+
it('rejects unsupported no-response command types during validation', async () => {
147+
const onCommand = vi.fn();
148+
const manager = createManager({ onCommand });
149+
const connection = createConnection();
150+
manager.addConnection(connection);
151+
const command = createPingCommand({ responseMode: 'none' } as Partial<PingCommand>);
152+
153+
await manager.handleMessage(connection.id, JSON.stringify(command));
154+
155+
expect(sentResponses(connection)).toEqual([
156+
expect.objectContaining({
157+
type: 'error',
158+
ok: false,
159+
error: expect.objectContaining({ code: 'invalid_payload' })
160+
})
161+
]);
162+
expect(onCommand).not.toHaveBeenCalled();
163+
});
164+
84165
it('keeps pairing requests pending until they are accepted', async () => {
85166
const store = new MemoryPairingStore({ desktopId: 'desktop-1', pairedDevices: [] });
86167
const approvalManager = new PairingApprovalManager(store, () => now);

0 commit comments

Comments
 (0)