From fde578f19cc97d07316194d07aa24e5dc6a539b8 Mon Sep 17 00:00:00 2001 From: jazzstack Date: Sun, 25 Oct 2026 23:17:58 +0530 Subject: [PATCH] perf(bundle): split posthog, the driver registry and secondary pages out of the initial chunk main-*.js measured 2.30 MB and the JS total sat at 99% of its budget, which issue #140 called: the next driver would force a sixth budget raise. Three dynamic import() boundaries fix the initial load without dropping a byte of functionality: - posthog-js (~377 kB) loads in the background. Analytics now queues events and uncaught errors raised before it arrives and replays them after init; builds without VITE_POSTHOG_KEY tree-shake the chunk entirely. The Settings switch appears via an analytics-ready event instead of a frozen module-load constant. - The driver registry (registry + one-shot vendor modules, 320 kB chunk) loads behind a shared dynamic import in device-clients.ts. Its callers in controller.ts were already async flows (sidebar refresh, connect, HID connect/disconnect); they now await the factory, and the sync semantics are preserved - the registry's own functions run synchronously once the module has landed, and the Bridge Razer/keyboard predicate in logicalDeviceGroups keeps its exact filter. Tests updated for the async API. - Settings, hardware test, games and the capture/share/what's-new dialogs load on demand via React.lazy; the dialogs mount only while open. main-*.js measured 1,511,619 bytes after the split (-34%). The size check now bounds the initial chunk separately (MAIN 1.6 MB), so driver growth must land in the lazy registry chunk or fail the build. The total .js budget is unchanged; locally it measured 2,810,356 bytes (88%) because production-only posthog is tree-shaken without VITE_POSTHOG_KEY. Lazy chunks follow the service worker's documented runtime-cache design, like locale chunks today. What still ships in main is the driver code controller.ts references directly (instanceof tables, NEEDS_OPEN) - the follow-up slice for #140. Refers to #140 --- build/check-bundle-size.ts | 33 +++++ src/app/App.tsx | 70 ++++++---- src/device/controller.ts | 78 ++++++----- src/device/device-clients.test.ts | 30 ++--- src/device/device-clients.ts | 139 +++++++++++++------- src/device/kochi-keyboard.test.ts | 6 +- src/features/settings/InterfaceSettings.tsx | 16 ++- src/platform/analytics.ts | 70 ++++++++-- 8 files changed, 300 insertions(+), 142 deletions(-) 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 }); }