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
32 changes: 27 additions & 5 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
listLogicalDevices,
logicalDeviceGroups,
pickLogicalDevice,
prepareAutoConnectClient,
type PulsarClient,
type SupportedClient,
} from "./device-clients";
Expand Down Expand Up @@ -3504,10 +3505,34 @@ async function loadKeyboardTrigger(client: KeyboardClient): Promise<void> {
}

/** Queue a device activation so only one keyboard/mouse switch runs at a time. */
function activateClient(target: SupportedClient | KeyboardClient): Promise<void> {
function activateClient(target: SupportedClient | KeyboardClient, autoConnect = false): Promise<void> {
const run = activationQueue.then(async () => {
activationInProgress = true;
try {
if (autoConnect && !isKeyboardClient(target)) {
if (target.device === activeDevice) {
await refreshSidebar();
return;
}
const prepared = await prepareAutoConnectClient(target);
if (!prepared) {
await refreshSidebar();
return;
}
// A probe opens the candidate; discard it if unplugged while it answered.
if (prepared !== target) {
const devices = await navigator.hid?.getDevices().catch(() => []) ?? [];
if (!devices.includes(prepared.device)) {
if (activeDevice !== prepared.device) await prepared.close().catch(() => undefined);
await refreshSidebar();
return;
}
}
target = prepared;
deviceStatusText = st("ctl.newDevice");
readStatus = st("ctl.reading", { name: statusNameForClient(target) });
emit();
}
if (isKeyboardClient(target)) await activateKeyboardClientNow(target);
else await activateClientNow(target);
} finally {
Expand Down Expand Up @@ -3611,10 +3636,7 @@ function handleHidConnect(event: HIDConnectionEvent): void {
return;
}

deviceStatusText = st("ctl.newDevice");
readStatus = st("ctl.reading", { name: statusNameForClient(client) });
emit();
void activateClient(client).catch((error: unknown) => {
void activateClient(client, true).catch((error: unknown) => {
deviceStatusText = st("ctl.connFailed");
readStatus = error instanceof Error ? error.message : st("ctl.unableRead");
toastForError("Connection failed", error);
Expand Down
66 changes: 65 additions & 1 deletion src/device/device-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import { describeHidDevice } from "./hid-diagnostics.ts";
import { createSupportedClient, listLogicalDevices, logicalDeviceGroups } from "./device-clients.ts";
import { createSupportedClient, listLogicalDevices, logicalDeviceGroups, prepareAutoConnectClient } from "./device-clients.ts";

function supportedWebHidDevice(options: {
vendorId?: number;
Expand Down Expand Up @@ -108,3 +108,67 @@ test("Bridge devices keep their native per-physical-device identity", () => {
assert.equal(logicalDeviceGroups(devices).length, 2);
assert.equal(listLogicalDevices(devices).length, 2);
});

function answeringBridgeRazer(key: string, answers: boolean, usages = [[0x01, 0x02]]): HIDDevice {
let reply = new Uint8Array(90);
const device = {
...bridgeDevice(key, "Razer Basilisk V3 Pro 35K"),
productId: 0x00cc,
collections: usages.map(([usagePage, usage]) => ({
usagePage, usage, inputReports: [], outputReports: [], featureReports: [], children: [],
})),
opened: false,
async open() { this.opened = true; },
async close() { this.opened = false; },
async sendFeatureReport(reportId: number, data: Uint8Array) {
assert.equal(reportId, 0);
assert.equal(data[6], 0x00);
assert.equal(data[7], 0x81, "automatic selection must only probe firmware");
if (!answers) throw new Error("HidD_SetFeature: Incorrect function");
reply = new Uint8Array(data);
reply[0] = 0x02;
},
async receiveFeatureReport() { return new DataView(reply.buffer); },
};
return device as unknown as HIDDevice;
}

test("automatic Bridge selection rejects a dead Razer sibling without closing the working interface", async (t) => {
Object.defineProperty(globalThis, "window", { configurable: true, value: { setTimeout } });
t.after(() => Reflect.deleteProperty(globalThis, "window"));
const working = answeringBridgeRazer("working", true);
await working.open();
const sibling = answeringBridgeRazer("sibling", false, [[0x01, 0x80], [0x0c, 0x01]]);
const candidate = createSupportedClient(sibling);
assert.ok(candidate);

assert.equal(await prepareAutoConnectClient(candidate), null);
assert.equal(sibling.opened, false);
assert.equal(working.opened, true);
});

test("automatic Bridge selection keeps answering Razer interfaces regardless of collection shape", async (t) => {
Object.defineProperty(globalThis, "window", { configurable: true, value: { setTimeout } });
t.after(() => Reflect.deleteProperty(globalThis, "window"));
for (const usages of [[[0x01, 0x02]], [[0x0c, 0x01]], []]) {
const device = answeringBridgeRazer("working", true, usages);
const candidate = createSupportedClient(device);
assert.ok(candidate);
const prepared = await prepareAutoConnectClient(candidate);
assert.ok(prepared);
assert.equal(prepared.device, device);
assert.equal(device.opened, true);
await prepared.close();
}
});

test("automatic selection leaves specialized Razer and WebHID clients on their existing paths", async () => {
for (const device of [
{ ...bridgeDevice("v4"), productId: 0x00e5 },
supportedWebHidDevice(),
]) {
const client = createSupportedClient(device as HIDDevice);
assert.ok(client);
assert.equal(await prepareAutoConnectClient(client), client);
}
});
9 changes: 8 additions & 1 deletion src/device/device-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
COBRA_PRODUCT_ID,
RazerCobraHidClient,
} from "@openmouse/protocol/drivers/razer/cobra-hid";
import { RazerHidClient } from "@openmouse/protocol/drivers/razer/hid";
import { RazerHidClient, razerProbeControlInterface } from "@openmouse/protocol/drivers/razer/hid";
import {
RazerViperHidClient,
VIPER_PRODUCT_ID,
Expand Down Expand Up @@ -52,6 +52,13 @@ export function createSupportedClient(device: HIDDevice): SupportedClient | null
return registryCreateSupportedClient(device) ?? bridgeRazerClient(device);
}

export async function prepareAutoConnectClient(client: SupportedClient): Promise<SupportedClient | null> {
if (!(client instanceof RazerHidClient)
|| (client.device as NativeBridgeDevice).openMouseTransport !== "bridge") return client;
// Bridge can expose a sibling with the same VID/PID that cannot answer commands.
return razerProbeControlInterface([client.device]);
}

export type { KeyboardClient };

export function isKeyboardClient(client: SupportedClient | KeyboardClient): client is KeyboardClient {
Expand Down
Loading