diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 24828223..acc2d772 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -4,11 +4,27 @@ import { join } from "node:path"; // JS raised from 2_800_000: posthog-js (anonymous analytics, src/platform/analytics.ts, // the no-external build so nothing is injected past Trusted Types) added about 385 kB; // measured 3_171_143 bytes with it. +// +// 2026-10-25 code split (issue #140): posthog, the driver registry and the +// secondary pages (settings, hardware test, games, dialogs) moved behind +// dynamic import() boundaries, so the .js total now spans the initial chunk +// plus lazy chunks. MAIN_CHUNK_BUDGET_BYTES bounds main-*.js — the bytes the +// first paint waits on — so new drivers landing in the registry grow the lazy +// chunk, not the initial load. The total budget is unchanged: shipped bytes +// still count, only later. const BUDGET_BYTES: Record = { ".css": 165_000, ".js": 3_200_000, }; +// main-*.js measured 1,511,619 bytes right after the split (posthog ~377 kB +// and the registry's 320 kB chunk are behind dynamic import() now). The +// remainder still holds the driver classes controller.ts references directly +// (instanceof checks and NEEDS_OPEN) — that is the follow-up slice for this +// issue. The budget keeps those honest: a driver that grows the initial load +// fails the check instead of silently riding along. +const MAIN_CHUNK_BUDGET_BYTES = 1_600_000; + const ASSETS = join("dist", "assets"); function bundles(): { name: string; ext: string; bytes: number }[] { @@ -35,7 +51,24 @@ const budgets = { const totals = new Map(); for (const { ext, bytes } of found) totals.set(ext, (totals.get(ext) ?? 0) + bytes); +// The initial chunk is everything first paint waits on: main-*.js plus its +// eager CSS. Lazy chunks (registry, posthog, secondary pages, locales) do not +// count toward it. +const mainBytes = found + .filter(({ name }) => /^main-.*\.js$/.test(name)) + .reduce((sum, { bytes }) => sum + bytes, 0); + let failed = false; +{ + const percent = Math.round((mainBytes / MAIN_CHUNK_BUDGET_BYTES) * 100); + const label = `MAIN ${String(mainBytes).padStart(7)} / ${MAIN_CHUNK_BUDGET_BYTES} bytes (${percent}%)`; + if (mainBytes > MAIN_CHUNK_BUDGET_BYTES) { + failed = true; + console.error(`over budget ${label}`); + } else { + console.log(`ok ${label}`); + } +} for (const [ext, budget] of Object.entries(budgets)) { const bytes = totals.get(ext) ?? 0; const percent = Math.round((bytes / budget) * 100); diff --git a/src/app/App.tsx b/src/app/App.tsx index abfacc4d..5b7d46e4 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,19 +1,13 @@ import { displayText } from "../i18n/index"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react"; import * as control from "../device/controller"; import { ensureDisplayLocale } from "../i18n/index"; import { interfaceThemeSlug } from "../features/settings/interface-preferences"; import { AppSidebar, type DesktopPage } from "./AppSidebar"; import { OverviewPage } from "../features/devices/OverviewPage"; -import { CaptureDialog } from "../features/capture/CaptureDialog"; -import { GamesPage } from "../features/games/GamesPage"; -import { HardwareTestPage } from "../features/hardware-test/HardwareTestPage"; -import { InterfaceSettings } from "../features/settings/InterfaceSettings"; import { NewsBanner } from "../features/news/NewsBanner"; import { PendingBar } from "./PendingBar"; import { HelpPanel } from "./HelpPanel"; -import { ShareProfileDialog } from "../features/profiles/ShareProfileDialog"; -import { WhatsNewDialog } from "../features/news/WhatsNewDialog"; import { ToastHost } from "./Toasts"; import { useBridgeBatteryReporter } from "../features/bridge/useBridgeBatteryReporter"; import { useBridgeProfileApplier } from "../features/bridge/useBridgeProfileApplier"; @@ -21,6 +15,22 @@ import { useControl } from "./useControl"; import { setSoundsEnabled } from "../platform/sound-manager"; import { UiLocaleContext } from "../shared/ui-locale"; +// Secondary pages and dialogs load on demand: the control panel's first paint +// is the device view, so nothing behind a tab or a button belongs in the +// initial bundle. Each boundary below removed its module from main.js. +const InterfaceSettings = lazy(() => + import("../features/settings/InterfaceSettings").then((m) => ({ default: m.InterfaceSettings }))); +const HardwareTestPage = lazy(() => + import("../features/hardware-test/HardwareTestPage").then((m) => ({ default: m.HardwareTestPage }))); +const GamesPage = lazy(() => + import("../features/games/GamesPage").then((m) => ({ default: m.GamesPage }))); +const CaptureDialog = lazy(() => + import("../features/capture/CaptureDialog").then((m) => ({ default: m.CaptureDialog }))); +const ShareProfileDialog = lazy(() => + import("../features/profiles/ShareProfileDialog").then((m) => ({ default: m.ShareProfileDialog }))); +const WhatsNewDialog = lazy(() => + import("../features/news/WhatsNewDialog").then((m) => ({ default: m.WhatsNewDialog }))); + const SIDEBAR_KEY = "openmouse-sidebar-collapsed-v1"; function initialSidebarCollapsed(): boolean { @@ -172,19 +182,21 @@ export function App(): ReactNode {
- {showingSettings ? ( - - ) : page === "hardware-test" ? ( - - ) : showingGames ? ( - - ) : ( - setCaptureOpen(true)} - onShareProfile={() => setShareProfileOpen(true)} - /> - )} + + {showingSettings ? ( + + ) : page === "hardware-test" ? ( + + ) : showingGames ? ( + + ) : ( + setCaptureOpen(true)} + onShareProfile={() => setShareProfileOpen(true)} + /> + )} +

{displayText(locale, snapshot.readStatus)}

@@ -192,9 +204,21 @@ export function App(): ReactNode {
- setCaptureOpen(false)} locale={locale} /> - setWhatsNewOpen(false)} locale={locale} /> - setShareProfileOpen(false)} snapshot={snapshot} /> + {captureOpen ? ( + + setCaptureOpen(false)} locale={locale} /> + + ) : null} + {whatsNewOpen ? ( + + setWhatsNewOpen(false)} locale={locale} /> + + ) : null} + {shareProfileOpen ? ( + + setShareProfileOpen(false)} snapshot={snapshot} /> + + ) : null} diff --git a/src/device/controller.ts b/src/device/controller.ts index 35cb8aaf..d57214de 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -2740,18 +2740,18 @@ function sidebarEntryForm(client: SupportedClient): "mouse" | "keyboard" { } /** One sidebar card per physical device, marking the active one. */ -function sidebarEntries(devices: HIDDevice[]): SidebarDevice[] { - const groups = logicalDeviceGroups(devices); +async function sidebarEntries(devices: HIDDevice[]): Promise { + const groups = await logicalDeviceGroups(devices); const entries: SidebarDevice[] = []; for (let index = 0; index < groups.length; index += 1) { const group = groups[index]; const activeMember = group.find((member) => member === activeDevice); - const device = activeMember ?? pickLogicalDevice(group); + const device = activeMember ?? await pickLogicalDevice(group); const transport = (device as HIDDevice & { openMouseTransport?: string }).openMouseTransport === "bridge" ? "bridge" : "webhid"; const selected = group.some((member) => member === activeDevice); - const client = createSupportedClient(device); + const client = await createSupportedClient(device); if (!client) { const keyboard = createKeyboardClient(device); if (!keyboard) continue; @@ -2777,10 +2777,10 @@ function sidebarEntries(devices: HIDDevice[]): SidebarDevice[] { ? EGG_WE_DISPLAY_NAME : client instanceof FinalmouseHidClient ? client.displayName() - : (device.productName ?? `${deviceBrand(client)} mouse`)); + : (device.productName ?? `${await deviceBrand(client)} mouse`)); const detail = status ? `${status.brand} · ${connectionText(interfacePreferences.locale, status.connectionType, "ctl.connected")}` - : `${deviceBrand(client)} · Available`; + : `${await deviceBrand(client)} · Available`; entries.push({ index, name, @@ -2808,7 +2808,7 @@ async function refreshSidebar(devices?: HIDDevice[]): Promise { return; } } - sidebarDevices = sidebarEntries(all); + sidebarDevices = await sidebarEntries(all); emit(); } @@ -2879,8 +2879,10 @@ export async function selectAuthorizedDevice(index: number): Promise { const group = groups[index]; if (!group) return; if (group.some((device) => device === activeDevice) && (latestDeviceStatus !== null || activeKeyboard !== null)) return; - const candidates = group - .map((device) => ({ client: createSupportedClient(device), score: clientSupportScore(device) })) + const candidates = (await Promise.all(group.map(async (device) => ({ + client: await createSupportedClient(device), + score: await clientSupportScore(device), + })))) .filter((entry): entry is { client: SupportedClient; score: number } => entry.client !== null) .sort((left, right) => right.score - left.score); if (candidates.length === 0) { @@ -3623,14 +3625,14 @@ function handleHidConnect(event: HIDConnectionEvent): void { void refreshSidebar(); return; } - const client = createSupportedClient(event.device); - if (!client) { - void refreshSidebar(); - return; - } + void (async () => { + const client = await createSupportedClient(event.device); + if (!client) { + void refreshSidebar(); + return; + } - if (isEggWeClient(client)) { - void (async () => { + if (isEggWeClient(client)) { const all = await navigator.hid?.getDevices() ?? []; await refreshSidebar(all); const result = await eggWeResolveConnect(event.device, eggWeClient(), activeDevice, all); @@ -3651,16 +3653,11 @@ function handleHidConnect(event: HIDConnectionEvent): void { : st("ctl.reading", { name: EGG_WE_DISPLAY_NAME }); emit(); await activateClient(result.client); - })().catch((error: unknown) => { - deviceStatusText = st("ctl.connFailed"); - readStatus = error instanceof Error ? error.message : st("ctl.unableRead"); - toastForError("Connection failed", error); - void refreshSidebar(); - }); - return; - } + return; + } - void activateClient(client, true).catch((error: unknown) => { + await activateClient(client, true); + })().catch((error: unknown) => { deviceStatusText = st("ctl.connFailed"); readStatus = error instanceof Error ? error.message : st("ctl.unableRead"); toastForError("Connection failed", error); @@ -3681,9 +3678,8 @@ function handleHidDisconnect(event: HIDConnectionEvent): void { void (async () => { const devices = (await navigator.hid?.getDevices() ?? []) .filter((device) => device !== event.device); - const logical = listLogicalDevices(devices); - const replacement = logical - .map(createSupportedClient) + const logical = await listLogicalDevices(devices); + const replacement = (await Promise.all(logical.map((device) => createSupportedClient(device)))) .find((client): client is SupportedClient => client !== null); if (replacement) { await activateClient(replacement); @@ -3724,8 +3720,11 @@ async function requestSupportedClient( if (weClient) return weClient; } - const ranked = devices - .map((device) => ({ device, client: createSupportedClient(device), score: clientSupportScore(device) })) + const ranked = (await Promise.all(devices.map(async (device) => ({ + device, + client: await createSupportedClient(device), + score: await clientSupportScore(device), + })))) .filter((entry) => entry.client !== null) .sort((left, right) => right.score - left.score); const best = ranked[0]; @@ -3877,16 +3876,15 @@ async function reconnectAuthorizedDevice(): Promise { // authorized, and multiple keyboards must respect the saved preference // too. Mice keep the first slot when nothing is preferred, which was the // behavior before keyboards joined the reconnect path. - const candidates = listLogicalDevices(devices) - .map((device) => { - const client = createSupportedClient(device) ?? createKeyboardClient(device); - return client === null ? null : { - client, - preferred: deviceStorageKey(device) === preferredKey, - keyboard: isKeyboardClient(client), - score: clientSupportScore(device), - }; - }) + const candidates = (await Promise.all((await listLogicalDevices(devices)).map(async (device) => { + const client = await createSupportedClient(device) ?? createKeyboardClient(device); + return client === null ? null : { + client, + preferred: deviceStorageKey(device) === preferredKey, + keyboard: isKeyboardClient(client), + score: await clientSupportScore(device), + }; + }))) .filter((entry): entry is { client: SupportedClient | KeyboardClient; preferred: boolean; keyboard: boolean; score: number } => entry !== null) .sort((left, right) => Number(right.preferred) - Number(left.preferred) || Number(left.keyboard) - Number(right.keyboard) diff --git a/src/device/device-clients.test.ts b/src/device/device-clients.test.ts index cb344ace..c12bd1ce 100644 --- a/src/device/device-clients.test.ts +++ b/src/device/device-clients.test.ts @@ -54,7 +54,7 @@ test("HID diagnostics include device IDs and report collections", () => { ); }); -test("native Bridge exposes Razer transports without widening WebHID", () => { +test("native Bridge exposes Razer transports without widening WebHID", async () => { const products = [ { productName: "Razer Viper Ultimate", productId: 0x007b }, { productName: "Razer DeathAdder V3 HyperSpeed", productId: 0x00c5 }, @@ -73,40 +73,40 @@ test("native Bridge exposes Razer transports without widening WebHID", () => { openMouseTransport: "bridge", } as unknown as HIDDevice; - assert.equal(createSupportedClient(browserDevice), null); - assert.notEqual(createSupportedClient(bridgeDevice), null); + assert.equal(await createSupportedClient(browserDevice), null); + assert.notEqual(await createSupportedClient(bridgeDevice), null); } }); -test("WebHID top-level collections of one mouse collapse to a single card", () => { +test("WebHID top-level collections of one mouse collapse to a single card", async () => { const devices = [ supportedWebHidDevice(), supportedWebHidDevice(), supportedWebHidDevice(), ]; - assert.equal(logicalDeviceGroups(devices).length, 1); - assert.equal(listLogicalDevices(devices).length, 1); + assert.equal((await logicalDeviceGroups(devices)).length, 1); + assert.equal((await listLogicalDevices(devices)).length, 1); }); -test("identical WebHID mice with distinct product names stay separate cards", () => { +test("identical WebHID mice with distinct product names stay separate cards", async () => { const devices = [ supportedWebHidDevice({ productName: "Mouse One" }), supportedWebHidDevice({ productName: "Mouse Two" }), ]; - const groups = logicalDeviceGroups(devices); + const groups = await logicalDeviceGroups(devices); assert.equal(groups.length, 2); - assert.equal(listLogicalDevices(devices).length, 2); + assert.equal((await listLogicalDevices(devices)).length, 2); }); -test("Bridge devices keep their native per-physical-device identity", () => { +test("Bridge devices keep their native per-physical-device identity", async () => { // Bridge already groups report paths on its side; two identical receivers // are still distinct physical devices and must not be merged again. const devices = [bridgeDevice("hid-1"), bridgeDevice("hid-2")]; - assert.equal(logicalDeviceGroups(devices).length, 2); - assert.equal(listLogicalDevices(devices).length, 2); + assert.equal((await logicalDeviceGroups(devices)).length, 2); + assert.equal((await listLogicalDevices(devices)).length, 2); }); function answeringBridgeRazer(key: string, answers: boolean, usages = [[0x01, 0x02]]): HIDDevice { @@ -139,7 +139,7 @@ test("automatic Bridge selection rejects a dead Razer sibling without closing th const working = answeringBridgeRazer("working", true); await working.open(); const sibling = answeringBridgeRazer("sibling", false, [[0x01, 0x80], [0x0c, 0x01]]); - const candidate = createSupportedClient(sibling); + const candidate = await createSupportedClient(sibling); assert.ok(candidate); assert.equal(await prepareAutoConnectClient(candidate), null); @@ -152,7 +152,7 @@ test("automatic Bridge selection keeps answering Razer interfaces regardless of t.after(() => Reflect.deleteProperty(globalThis, "window")); for (const usages of [[[0x01, 0x02]], [[0x0c, 0x01]], []]) { const device = answeringBridgeRazer("working", true, usages); - const candidate = createSupportedClient(device); + const candidate = await createSupportedClient(device); assert.ok(candidate); const prepared = await prepareAutoConnectClient(candidate); assert.ok(prepared); @@ -167,7 +167,7 @@ test("automatic selection leaves specialized Razer and WebHID clients on their e { ...bridgeDevice("v4"), productId: 0x00e5 }, supportedWebHidDevice(), ]) { - const client = createSupportedClient(device as HIDDevice); + const client = await createSupportedClient(device as HIDDevice); assert.ok(client); assert.equal(await prepareAutoConnectClient(client), client); } diff --git a/src/device/device-clients.ts b/src/device/device-clients.ts index db18a7d9..0606861c 100644 --- a/src/device/device-clients.ts +++ b/src/device/device-clients.ts @@ -1,44 +1,63 @@ -import { - eggWeMergeLogicalDevices, -} from "@openmouse/protocol/drivers/endgame/egg-we-control"; -import { collapseBoltPeers } from "@openmouse/protocol/drivers/logitech/bolt"; -import { - COBRA_PRODUCT_ID, - RazerCobraHidClient, -} from "@openmouse/protocol/drivers/razer/cobra-hid"; -import { RazerHidClient, razerProbeControlInterface } from "@openmouse/protocol/drivers/razer/hid"; -import { - RazerViperHidClient, - VIPER_PRODUCT_ID, -} from "@openmouse/protocol/drivers/razer/viper-hid"; -import { - RazerViperMiniHidClient, - VIPER_MINI_PRODUCT_ID, -} from "@openmouse/protocol/drivers/razer/viper-mini-hid"; -import { - RazerViperV4ProHidClient, - VIPER_V4_PRO_PRODUCTS, -} from "@openmouse/protocol/drivers/razer/viper-v4-pro-hid"; -import { - clientSupportScore as registryClientSupportScore, - createSupportedClient as registryCreateSupportedClient, - deviceBrand, - type PulsarClient, - type SupportedClient, -} from "@openmouse/protocol/drivers/registry"; -import { RAZER_PRODUCTS } from "@openmouse/protocol/razer-devices"; import { KochiKeyboardClient } from "./kochi-keyboard.ts"; import type { KeyboardClient } from "./keyboard-client.ts"; import { WuqueKeyboardClient } from "./wuque-keyboard.ts"; +import type { PulsarClient, SupportedClient } from "@openmouse/protocol/drivers/registry"; export { describeHidDevice } from "./hid-diagnostics.ts"; -export { deviceBrand, type PulsarClient, type SupportedClient }; +export { type PulsarClient, type SupportedClient }; type NativeBridgeDevice = HIDDevice & { openMouseTransport?: "bridge" }; +// The driver registry pulls in every vendor module (~1.2 MB minified) while a +// session only ever talks to one of them. Loading it behind a dynamic import +// keeps it out of the initial bundle; every caller here is already async, and +// the promise is shared so parallel scans never double-fetch. Once loaded, the +// registry's sync functions are used directly — no behavioural difference from +// the old static wiring, only when the bytes arrive. +type Registry = typeof import("@openmouse/protocol/drivers/registry"); -function bridgeRazerClient(device: HIDDevice): SupportedClient | null { - if ((device as NativeBridgeDevice).openMouseTransport !== "bridge" - || device.vendorId !== 0x1532) return null; +let registryPromise: Promise | null = null; + +function loadRegistry(): Promise { + registryPromise ??= import("@openmouse/protocol/drivers/registry"); + return registryPromise; +} + +type RazerBridgeModules = typeof import("@openmouse/protocol/drivers/razer/hid") + & typeof import("@openmouse/protocol/drivers/razer/cobra-hid") + & typeof import("@openmouse/protocol/drivers/razer/viper-hid") + & typeof import("@openmouse/protocol/drivers/razer/viper-mini-hid") + & typeof import("@openmouse/protocol/drivers/razer/viper-v4-pro-hid") + & typeof import("@openmouse/protocol/razer-devices"); + +let razerBridgePromise: Promise | null = null; + +function loadRazerBridge(): Promise { + razerBridgePromise ??= Promise.all([ + import("@openmouse/protocol/drivers/razer/hid"), + import("@openmouse/protocol/drivers/razer/cobra-hid"), + import("@openmouse/protocol/drivers/razer/viper-hid"), + import("@openmouse/protocol/drivers/razer/viper-mini-hid"), + import("@openmouse/protocol/drivers/razer/viper-v4-pro-hid"), + import("@openmouse/protocol/razer-devices"), + ]).then(([hid, cobra, viper, viperMini, viperV4Pro, products]) => ({ + ...hid, ...cobra, ...viper, ...viperMini, ...viperV4Pro, ...products, + })); + return razerBridgePromise; +} + +function isBridgeRazer(device: HIDDevice): boolean { + return (device as NativeBridgeDevice).openMouseTransport === "bridge" + && device.vendorId === 0x1532; +} + +function bridgeRazerClientFrom(device: HIDDevice, mods: RazerBridgeModules): SupportedClient | null { + const { + COBRA_PRODUCT_ID, RazerCobraHidClient, + RazerHidClient, RazerViperHidClient, VIPER_PRODUCT_ID, + RazerViperMiniHidClient, VIPER_MINI_PRODUCT_ID, + RazerViperV4ProHidClient, VIPER_V4_PRO_PRODUCTS, + RAZER_PRODUCTS, + } = mods; if (VIPER_V4_PRO_PRODUCTS.has(device.productId)) return new RazerViperV4ProHidClient(device); if (device.productId === COBRA_PRODUCT_ID) return new RazerCobraHidClient(device); if (device.productId === VIPER_PRODUCT_ID) return new RazerViperHidClient(device); @@ -47,14 +66,23 @@ function bridgeRazerClient(device: HIDDevice): SupportedClient | null { return null; } +async function bridgeRazerClient(device: HIDDevice): Promise { + if (!isBridgeRazer(device)) return null; + return bridgeRazerClientFrom(device, await loadRazerBridge()); +} + -export function createSupportedClient(device: HIDDevice): SupportedClient | null { - return registryCreateSupportedClient(device) ?? bridgeRazerClient(device); +export async function createSupportedClient(device: HIDDevice): Promise { + if (isBridgeRazer(device)) return bridgeRazerClient(device); + return (await loadRegistry()).createSupportedClient(device); } export async function prepareAutoConnectClient(client: SupportedClient): Promise { - if (!(client instanceof RazerHidClient) - || (client.device as NativeBridgeDevice).openMouseTransport !== "bridge") return client; + // Check the transport before loading the Razer modules: every non-bridge + // client takes this path on connect, and none of them is a Razer client. + if ((client.device as NativeBridgeDevice).openMouseTransport !== "bridge") return client; + const { RazerHidClient, razerProbeControlInterface } = await loadRazerBridge(); + if (!(client instanceof RazerHidClient)) return client; // Bridge can expose a sibling with the same VID/PID that cannot answer commands. return razerProbeControlInterface([client.device]); } @@ -75,20 +103,28 @@ export function createKeyboardClient(device: HIDDevice): KeyboardClient | null { return WuqueKeyboardClient.isSupported(device) ? new WuqueKeyboardClient(device) : null; } -export function clientSupportScore(device: HIDDevice): number { - if (bridgeRazerClient(device) !== null) return 10_000; +export async function clientSupportScore(device: HIDDevice): Promise { + if (await bridgeRazerClient(device) !== null) return 10_000; // A Wuque board exposes a boot keyboard, a consumer-control and a mouse // collection beside its vendor config interface. Only the vendor page answers // commands, so it has to win the pick for the physical device. if (createKeyboardClient(device) !== null) return 900; - return registryClientSupportScore(device); + return (await loadRegistry()).clientSupportScore(device); } -export function logicalDeviceGroups(devices: HIDDevice[] = []): HIDDevice[][] { +export async function logicalDeviceGroups(devices: HIDDevice[] = []): Promise { + const [registry, razer, { collapseBoltPeers }, { eggWeMergeLogicalDevices }] = await Promise.all([ + loadRegistry(), + loadRazerBridge(), + import("@openmouse/protocol/drivers/logitech/bolt"), + import("@openmouse/protocol/drivers/endgame/egg-we-control"), + ]); const merged = collapseBoltPeers( eggWeMergeLogicalDevices( devices, - (device) => createSupportedClient(device) !== null || createKeyboardClient(device) !== null, + (device) => registry.createSupportedClient(device) !== null + || (isBridgeRazer(device) && bridgeRazerClientFrom(device, razer) !== null) + || createKeyboardClient(device) !== null, ), ); const byPhysicalDevice = new Map(); @@ -120,11 +156,22 @@ function physicalDeviceKey(device: HIDDevice): string { return `${device.vendorId}:${device.productId}:${device.productName ?? ""}`; } -export function pickLogicalDevice(group: HIDDevice[]): HIDDevice { - return group.reduce((best, device) => (clientSupportScore(device) > clientSupportScore(best) ? device : best)); +export async function pickLogicalDevice(group: HIDDevice[]): Promise { + const scores = await Promise.all(group.map((device) => clientSupportScore(device))); + let bestIndex = 0; + for (let index = 1; index < group.length; index += 1) { + if (scores[index]! > scores[bestIndex]!) bestIndex = index; + } + return group[bestIndex]!; } /** Supported devices for the sidebar; multi-path drivers collapse via their module. */ -export function listLogicalDevices(devices: HIDDevice[] = []): HIDDevice[] { - return logicalDeviceGroups(devices).map(pickLogicalDevice); +export async function listLogicalDevices(devices: HIDDevice[] = []): Promise { + const groups = await logicalDeviceGroups(devices); + return Promise.all(groups.map((group) => pickLogicalDevice(group))); +} + +/** Brand label for a connected client; the registry answers once loaded. */ +export async function deviceBrand(client: SupportedClient): Promise { + return (await loadRegistry()).deviceBrand(client); } diff --git a/src/device/kochi-keyboard.test.ts b/src/device/kochi-keyboard.test.ts index f23ed046..1318b406 100644 --- a/src/device/kochi-keyboard.test.ts +++ b/src/device/kochi-keyboard.test.ts @@ -10,13 +10,13 @@ function device(usagePage = 0xffa0, usage = 1): HIDDevice { } as unknown as HIDDevice; } -test("HB68 is discovered as a keyboard and only its command collection is offered", () => { +test("HB68 is discovered as a keyboard and only its command collection is offered", async () => { const config = device(); const boot = device(1, 6); const stream = device(0xffa0, 2); assert.ok(createKeyboardClient(config) instanceof KochiKeyboardClient); - assert.equal(createSupportedClient(config), null); + assert.equal(await createSupportedClient(config), null); assert.equal(createKeyboardClient(boot), null); assert.equal(createKeyboardClient(stream), null); - assert.deepEqual(listLogicalDevices([boot, stream, config]), [config]); + assert.deepEqual(await listLogicalDevices([boot, stream, config]), [config]); assert.ok(HID_FILTERS.some(f => f.vendorId === 0x1ca2 && f.productId === 0x0901 && f.usagePage === 0xffa0 && f.usage === 1)); }); diff --git a/src/features/settings/InterfaceSettings.tsx b/src/features/settings/InterfaceSettings.tsx index 522be54c..5474b8ec 100644 --- a/src/features/settings/InterfaceSettings.tsx +++ b/src/features/settings/InterfaceSettings.tsx @@ -1,7 +1,7 @@ import { displayText } from "../../i18n/index"; -import { analyticsAvailable, analyticsEnabled, setAnalyticsEnabled, track } from "../../platform/analytics"; +import { analyticsAvailable, analyticsEnabled, ANALYTICS_READY_EVENT, setAnalyticsEnabled, track } from "../../platform/analytics"; import { Globe, RotateCcw } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import * as control from "../../device/controller"; import type { ControlSnapshot } from "../../device/types"; import { LOCALE_NAME_KEYS, t } from "../../i18n/index"; @@ -119,6 +119,16 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): const preferences = snapshot.preferences; const locale = preferences.locale; const [analyticsOn, setAnalyticsOn] = useState(analyticsEnabled); + // posthog loads in the background; the switch appears once it is ready. + const [analyticsReady, setAnalyticsReady] = useState(analyticsAvailable()); + useEffect(() => { + const update = (): void => { + setAnalyticsReady(analyticsAvailable()); + setAnalyticsOn(analyticsEnabled()); + }; + window.addEventListener(ANALYTICS_READY_EVENT, update); + return () => window.removeEventListener(ANALYTICS_READY_EVENT, update); + }, []); const set = (key: K) => (value: (typeof preferences)[K]): void => control.setPreference(key, value); @@ -195,7 +205,7 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): {toggle("interface-sounds", t(locale, "set.soundTitle"), t(locale, "set.soundDescription"), preferences.enabledSounds, set("enabledSounds"))} {toggle("interface-expand-sections", t(locale, "set.advancedEditors"), t(locale, "set.advancedEditorsBody"), preferences.expandSections, set("expandSections"))} {toggle("interface-show-experimental", t(locale, "set.experimentalTitle"), t(locale, "set.experimentalBody"), preferences.showExperimental, set("showExperimental"))} - {analyticsAvailable + {analyticsReady ? toggle("interface-analytics", t(locale, "set.analytics"), t(locale, "set.analyticsBody"), analyticsOn, (next) => { setAnalyticsEnabled(next); setAnalyticsOn(next); diff --git a/src/platform/analytics.ts b/src/platform/analytics.ts index d3562fdc..fe9fe2d5 100644 --- a/src/platform/analytics.ts +++ b/src/platform/analytics.ts @@ -1,16 +1,35 @@ -import posthogClient from "posthog-js/dist/module.no-external"; +type PosthogClient = typeof import("posthog-js/dist/module.no-external")["default"]; const posthogKey = import.meta.env.VITE_POSTHOG_KEY; const posthogHost = import.meta.env.VITE_POSTHOG_HOST; -// Without both variables (local dev, forks, previews) analytics is a no-op. -export const posthog = posthogKey && posthogHost ? posthogClient : undefined; +// posthog-js is the single heaviest dependency (measured ~377 kB minified), +// and it only matters once something worth counting happens. It loads in the +// background; events and exceptions raised before it arrives are queued and +// replayed, so the split changes when data starts flowing, never whether. +let posthog: PosthogClient | null = null; // Analytics must never take the app down: a failed init just leaves it silent. let initialised = false; -if (posthog) { + +const ANALYTICS_READY_EVENT = "openmouse-analytics-ready"; + +type QueuedEvent = + | { kind: "event"; event: string; props?: Record } + | { kind: "error"; error: unknown }; + +const queue: QueuedEvent[] = []; + +if (posthogKey && posthogHost) { + void boot(); +} else { + // Without both variables (local dev, forks, previews) analytics is a no-op. +} + +async function boot(): Promise { try { - posthog.init(posthogKey!, { + const { default: client } = await import("posthog-js/dist/module.no-external"); + client.init(posthogKey!, { api_host: posthogHost!, defaults: "2026-05-30", // Anonymous random id in localStorage only: counts users and return visits. @@ -25,18 +44,41 @@ if (posthog) { capture_exceptions: false, before_send: scrubExceptionEvent, }); - window.addEventListener("error", (event) => { - posthog.captureException(event.error ?? new Error(event.message)); - }); - window.addEventListener("unhandledrejection", (event) => { - posthog.captureException(event.reason instanceof Error ? event.reason : new Error(String(event.reason))); - }); + posthog = client; initialised = true; + flushQueue(); + try { + window.dispatchEvent(new Event(ANALYTICS_READY_EVENT)); + } catch { + } } catch { /* analytics is optional */ } } +function forwardError(error: unknown): void { + if (initialised && posthog) { + posthog.captureException(error instanceof Error ? error : new Error(String(error))); + } else if (posthogKey && posthogHost) { + queue.push({ kind: "error", error }); + } +} + +function flushQueue(): void { + for (const item of queue) { + if (item.kind === "event") posthog?.capture(item.event, item.props); + else forwardError(item.error); + } + queue.length = 0; +} + +window.addEventListener("error", (event) => { + forwardError(event.error ?? new Error(event.message)); +}); +window.addEventListener("unhandledrejection", (event) => { + forwardError(event.reason instanceof Error ? event.reason : new Error(String(event.reason))); +}); + /** Whether anonymous diagnostics are being sent (on unless the user opted out). */ export function analyticsEnabled(): boolean { return initialised && posthog ? !posthog.has_opted_out_capturing() : false; @@ -49,7 +91,10 @@ export function setAnalyticsEnabled(enabled: boolean): void { } /** Whether this build reports anything, so Settings can hide a dead switch. */ -export const analyticsAvailable = initialised; +export function analyticsAvailable(): boolean { + return initialised; +} +export { ANALYTICS_READY_EVENT }; /** Model-level device facts only: never serials, receiver ids or user text. */ export interface DeviceFacts { @@ -121,4 +166,5 @@ function scrubExceptionEvent(event: T): T { export function track(event: string, props?: Record): void { if (initialised) posthog?.capture(event, props); + else if (posthogKey && posthogHost) queue.push({ kind: "event", event, props }); }