Skip to content

Commit bbb4e21

Browse files
committed
Support no-ack Bluetooth control commands
1 parent 380b91a commit bbb4e21

7 files changed

Lines changed: 253 additions & 32 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,25 @@ describe('DesktopCommandExecutor', () => {
140140
expect(overlay.hideCount).toBe(6);
141141
});
142142

143+
it('executes non-movement no-response commands without coalescing', async () => {
144+
const { adapter, executor, overlay } = createExecutor();
145+
146+
await executor.execute(command('mouse.click', { button: 'left' }, { responseMode: 'none' }));
147+
await executor.execute(command('mouse.scroll', { dx: 0, dy: -3 }, { responseMode: 'none' }));
148+
await executor.execute(command('keyboard.key', { key: 'Enter' }, { responseMode: 'none' }));
149+
await executor.execute(command('window.control', { action: 'switchNext' }, { responseMode: 'none' }));
150+
151+
expect(adapter.calls).toEqual([
152+
{ method: 'clickMouse', args: ['left'] },
153+
{ method: 'scrollMouse', args: [{ dx: 0, dy: -3 }] },
154+
{ method: 'pressKey', args: ['Enter'] },
155+
{ method: 'controlWindow', args: ['switchNext'] }
156+
]);
157+
expect(overlay.events).toEqual(['click']);
158+
expect(overlay.activeCount).toBe(2);
159+
expect(overlay.hideCount).toBe(2);
160+
});
161+
143162
it('passes empty committed text to the adapter', async () => {
144163
const { adapter, executor } = createExecutor();
145164

src/main/input/pointer-profile.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MAX_POINTER_DELTA, type PointerMovementProfile } from '../../shared/protocol';
1+
import { MAX_POINTER_DELTA, NO_ACK_CONTROL_COMMAND_TYPES, type PointerMovementProfile } from '../../shared/protocol';
22

33
type Point = { x: number; y: number };
44
type Bounds = { x: number; y: number; width: number; height: number };
@@ -33,7 +33,8 @@ export function createPointerMovementProfile(input: {
3333
large: toLogicalDelta(TARGET_NATIVE_DELTAS.large, scaleFactor, maxDelta)
3434
},
3535
capabilities: {
36-
noAckMouseMove: true
36+
noAckMouseMove: true,
37+
noAckCommands: [...NO_ACK_CONTROL_COMMAND_TYPES]
3738
}
3839
};
3940
}

src/main/pairing/auth.test.ts

Lines changed: 28 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 { MouseMoveCommand, PingCommand } from '../../shared/protocol';
2+
import type { MouseClickCommand, 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';
@@ -80,8 +80,11 @@ describe('CommandAuthValidator', () => {
8080
it('includes no-response mode in command auth proofs', async () => {
8181
const ackMove = createMouseMoveCommand();
8282
const noAckMove = createMouseMoveCommand({ responseMode: 'none' });
83+
const ackClick = createMouseClickCommand();
84+
const noAckClick = createMouseClickCommand({ responseMode: 'none' });
8385

8486
expect(createCommandAuthProof(noAckMove, token)).not.toBe(createCommandAuthProof(ackMove, token));
87+
expect(createCommandAuthProof(noAckClick, token)).not.toBe(createCommandAuthProof(ackClick, token));
8588
});
8689

8790
it('rejects response mode tampering after signing', async () => {
@@ -93,6 +96,12 @@ describe('CommandAuthValidator', () => {
9396
ok: false,
9497
reason: 'invalid_auth'
9598
});
99+
100+
const clickCommand = createMouseClickCommand();
101+
await expect(validator.validate({ ...clickCommand, responseMode: 'none' })).resolves.toEqual({
102+
ok: false,
103+
reason: 'invalid_auth'
104+
});
96105
});
97106

98107
it('rejects expired timestamps', async () => {
@@ -152,3 +161,21 @@ function createMouseMoveCommand(overrides: Partial<MouseMoveCommand> = {}): Mous
152161
auth: overrides.auth ?? createCommandAuthProof(merged, token)
153162
};
154163
}
164+
165+
function createMouseClickCommand(overrides: Partial<MouseClickCommand> = {}): MouseClickCommand {
166+
const command = {
167+
version: PROTOCOL_VERSION,
168+
id: 'click-1',
169+
deviceId: 'android-1',
170+
timestamp: now,
171+
type: 'mouse.click',
172+
payload: { button: 'left' },
173+
auth: ''
174+
} satisfies MouseClickCommand;
175+
176+
const merged = { ...command, ...overrides } as MouseClickCommand;
177+
return {
178+
...merged,
179+
auth: overrides.auth ?? createCommandAuthProof(merged, token)
180+
};
181+
}

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

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import { describe, expect, it, vi } from 'vitest';
22
import {
33
PROTOCOL_VERSION,
44
validateProtocolResponse,
5+
type CommandRequest,
6+
type KeyboardKeyCommand,
7+
type MouseClickCommand,
58
type MouseMoveCommand,
69
type PairingCompleteResponse,
710
type DisconnectingCommand,
8-
type PingCommand
11+
type PingCommand,
12+
type WindowControlCommand
913
} from '../../shared/protocol';
1014
import { createCommandAuthProof, CommandAuthValidator } from '../pairing/auth';
1115
import { PairingApprovalManager } from '../pairing/pairing-approval-manager';
@@ -84,6 +88,46 @@ describe('RemoteSessionManager', () => {
8488
expect(onError).toHaveBeenCalledWith('Move failed.');
8589
});
8690

91+
it('executes authenticated no-response control commands without sending an ack', async () => {
92+
const handled: CommandRequest[] = [];
93+
const manager = createManager({
94+
onCommand: (command) => {
95+
handled.push(command);
96+
return { ok: true };
97+
}
98+
});
99+
const connection = createConnection();
100+
manager.addConnection(connection);
101+
const commands = [
102+
createMouseClickCommand({ responseMode: 'none' }),
103+
createKeyboardKeyCommand({ responseMode: 'none' }),
104+
createWindowControlCommand({ responseMode: 'none' })
105+
];
106+
107+
for (const command of commands) {
108+
await manager.handleMessage(connection.id, JSON.stringify(command));
109+
}
110+
111+
expect(sentResponses(connection)).toEqual([]);
112+
expect(handled).toEqual(commands);
113+
});
114+
115+
it('suppresses adapter failure responses for no-response control commands', async () => {
116+
const onError = vi.fn();
117+
const manager = createManager({
118+
onError,
119+
onCommand: () => ({ ok: false, code: 'adapter_failure', message: 'Click failed.' })
120+
});
121+
const connection = createConnection();
122+
manager.addConnection(connection);
123+
const command = createMouseClickCommand({ responseMode: 'none' });
124+
125+
await manager.handleMessage(connection.id, JSON.stringify(command));
126+
127+
expect(sentResponses(connection)).toEqual([]);
128+
expect(onError).toHaveBeenCalledWith('Click failed.');
129+
});
130+
87131
it('acks authenticated heartbeat pings without executing desktop commands', async () => {
88132
const onCommand = vi.fn();
89133
const manager = createManager({ onCommand });
@@ -316,6 +360,57 @@ function createMouseMoveCommand(overrides: Partial<MouseMoveCommand> = {}): Mous
316360
};
317361
}
318362

363+
function createMouseClickCommand(overrides: Partial<MouseClickCommand> = {}): MouseClickCommand {
364+
const command = {
365+
version: PROTOCOL_VERSION,
366+
id: 'click-1',
367+
deviceId: 'android-1',
368+
timestamp: now,
369+
type: 'mouse.click',
370+
payload: { button: 'left' },
371+
auth: ''
372+
} satisfies MouseClickCommand;
373+
const merged = { ...command, ...overrides } as MouseClickCommand;
374+
return {
375+
...merged,
376+
auth: overrides.auth ?? createCommandAuthProof(merged, token)
377+
};
378+
}
379+
380+
function createKeyboardKeyCommand(overrides: Partial<KeyboardKeyCommand> = {}): KeyboardKeyCommand {
381+
const command = {
382+
version: PROTOCOL_VERSION,
383+
id: 'key-1',
384+
deviceId: 'android-1',
385+
timestamp: now,
386+
type: 'keyboard.key',
387+
payload: { key: 'Enter' },
388+
auth: ''
389+
} satisfies KeyboardKeyCommand;
390+
const merged = { ...command, ...overrides } as KeyboardKeyCommand;
391+
return {
392+
...merged,
393+
auth: overrides.auth ?? createCommandAuthProof(merged, token)
394+
};
395+
}
396+
397+
function createWindowControlCommand(overrides: Partial<WindowControlCommand> = {}): WindowControlCommand {
398+
const command = {
399+
version: PROTOCOL_VERSION,
400+
id: 'window-1',
401+
deviceId: 'android-1',
402+
timestamp: now,
403+
type: 'window.control',
404+
payload: { action: 'switchNext' },
405+
auth: ''
406+
} satisfies WindowControlCommand;
407+
const merged = { ...command, ...overrides } as WindowControlCommand;
408+
return {
409+
...merged,
410+
auth: overrides.auth ?? createCommandAuthProof(merged, token)
411+
};
412+
}
413+
319414
function createDisconnectingCommand(overrides: Partial<DisconnectingCommand> = {}): DisconnectingCommand {
320415
const command = {
321416
version: PROTOCOL_VERSION,

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
createErrorResponse,
44
createPairingCompleteResponse,
55
createPointerProfileResponse,
6+
NO_ACK_CONTROL_COMMAND_TYPES,
67
parseProtocolRequest,
78
type CommandResponseMode,
89
type CommandRequest,
@@ -303,5 +304,9 @@ function commandResponseMode(command: CommandRequest): CommandResponseMode {
303304
}
304305

305306
function shouldSuppressAck(command: CommandRequest): boolean {
306-
return command.type === 'mouse.move' && commandResponseMode(command) === 'none';
307+
return isNoAckControlCommand(command) && commandResponseMode(command) === 'none';
308+
}
309+
310+
function isNoAckControlCommand(command: CommandRequest): boolean {
311+
return (NO_ACK_CONTROL_COMMAND_TYPES as readonly CommandRequest['type'][]).includes(command.type);
307312
}

src/shared/protocol.test.ts

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createPairingCompleteResponse,
66
createPointerProfileResponse,
77
MAX_POINTER_DELTA,
8+
NO_ACK_CONTROL_COMMAND_TYPES,
89
parseProtocolRequest,
910
PROTOCOL_VERSION,
1011
validateProtocolRequest,
@@ -45,15 +46,33 @@ describe('protocol request validation', () => {
4546
}
4647
});
4748

48-
it('accepts no-response mode only for mouse movement', () => {
49-
expect(
50-
validateProtocolRequest({
51-
...baseCommand,
52-
type: 'mouse.move',
53-
payload: { dx: 12, dy: -6 },
54-
responseMode: 'none'
55-
})
56-
).toMatchObject({ ok: true });
49+
it('accepts no-response mode only for user control commands', () => {
50+
const payloads = {
51+
'mouse.move': { dx: 12, dy: -6 },
52+
'mouse.click': { button: 'left' },
53+
'mouse.doubleClick': { button: 'middle' },
54+
'mouse.rightClick': {},
55+
'mouse.scroll': { dx: 0, dy: -3 },
56+
'mouse.dragStart': { button: 'left' },
57+
'mouse.dragEnd': { button: 'left' },
58+
'keyboard.key': { key: 'Enter' },
59+
'keyboard.shortcut': { keys: ['Ctrl', 'C'] },
60+
'keyboard.typeText': { text: 'Hello' },
61+
'media.control': { action: 'playPause' },
62+
'window.control': { action: 'switchNext' }
63+
};
64+
65+
for (const type of NO_ACK_CONTROL_COMMAND_TYPES) {
66+
expect(
67+
validateProtocolRequest({
68+
...baseCommand,
69+
type,
70+
payload: payloads[type],
71+
responseMode: 'none'
72+
})
73+
).toMatchObject({ ok: true });
74+
}
75+
5776
expect(
5877
validateProtocolRequest({
5978
...baseCommand,
@@ -62,22 +81,16 @@ describe('protocol request validation', () => {
6281
responseMode: 'ack'
6382
})
6483
).toMatchObject({ ok: true });
65-
expect(
66-
validateProtocolRequest({
67-
...baseCommand,
68-
type: 'mouse.scroll',
69-
payload: { dx: 0, dy: -3 },
70-
responseMode: 'none'
71-
})
72-
).toMatchObject({ ok: false, error: 'invalid_payload' });
73-
expect(
74-
validateProtocolRequest({
75-
...baseCommand,
76-
type: 'keyboard.key',
77-
payload: { key: 'Enter' },
78-
responseMode: 'none'
79-
})
80-
).toMatchObject({ ok: false, error: 'invalid_payload' });
84+
for (const command of [
85+
{ type: 'connection.ping', payload: {} },
86+
{ type: 'connection.disconnecting', payload: {} },
87+
{ type: 'pointer.profile', payload: {} }
88+
]) {
89+
expect(validateProtocolRequest({ ...baseCommand, ...command, responseMode: 'none' })).toMatchObject({
90+
ok: false,
91+
error: 'invalid_payload'
92+
});
93+
}
8194
expect(
8295
validateProtocolRequest({
8396
...baseCommand,
@@ -307,14 +320,44 @@ describe('protocol response validation', () => {
307320
large: 252
308321
},
309322
capabilities: {
310-
noAckMouseMove: true
323+
noAckMouseMove: true,
324+
noAckCommands: [...NO_ACK_CONTROL_COMMAND_TYPES]
311325
}
312326
});
313327

314328
expect(response.payload.capabilities.noAckMouseMove).toBe(true);
329+
expect(response.payload.capabilities.noAckCommands).toEqual([...NO_ACK_CONTROL_COMMAND_TYPES]);
315330
expect(validateProtocolResponse(response)).toMatchObject({ ok: true });
316331
});
317332

333+
it('accepts backward-compatible pointer profile capabilities', () => {
334+
const baseProfile = {
335+
version: PROTOCOL_VERSION,
336+
id: 'profile-1',
337+
type: 'pointer.profile',
338+
ok: true,
339+
payload: {
340+
displayId: '0:0:1280:720:1.5',
341+
scaleFactor: 1.5,
342+
bounds: { x: 0, y: 0, width: 1280, height: 720 },
343+
maxDelta: MAX_POINTER_DELTA,
344+
recommendedDeltas: { small: 50, medium: 130, large: 252 }
345+
},
346+
error: null
347+
};
348+
349+
expect(validateProtocolResponse(baseProfile)).toMatchObject({ ok: true });
350+
expect(
351+
validateProtocolResponse({
352+
...baseProfile,
353+
payload: {
354+
...baseProfile.payload,
355+
capabilities: { noAckMouseMove: true }
356+
}
357+
})
358+
).toMatchObject({ ok: true });
359+
});
360+
318361
it('rejects malformed pointer profile responses', () => {
319362
expect(
320363
validateProtocolResponse({

0 commit comments

Comments
 (0)