From c81bd7c002ce92a01ad6203ffe8c7327610fd201 Mon Sep 17 00:00:00 2001 From: Onyx Date: Tue, 20 Oct 2026 15:45:29 +0200 Subject: [PATCH] Add custom games, game profile parity and custom game images - Games page: add a game or an app by picking its executable or choosing a running app. Custom games are kept by Bridge, not in the browser. - Custom games can have a picture, uploaded or taken from a pasted link, shown on the tile and on the game's profile page. Hovering the picture offers "Change image" for custom games only. - Game profiles cover the Logitech settings of the device page: DPI X and Y, lift-off, report rate, bunny hop, HITS, Apply now and side button assignments, with Host profile parity and a default profile. - Strings are added to every locale. - A game profile keeps a report rate for wired and for wireless (like the onboard profiles), and applies the one for the connection in use. --- build/check-bundle-size.ts | 6 +- src/device/controller.ts | 374 +++++++++++++++++- src/device/game-profile-snapshot.test.ts | 43 ++ src/device/game-profile-snapshot.ts | 5 + src/device/pending-changes.test.ts | 18 + src/device/pending-changes.ts | 14 +- src/device/types.ts | 2 + src/features/bridge/bridge.ts | 40 ++ src/features/buttons/ButtonMap.tsx | 206 ++++++++-- .../buttons/logitech-commands.test.ts | 9 + src/features/buttons/logitech-commands.ts | 3 + src/features/devices/cards/DpiCard.tsx | 97 ++++- .../devices/cards/PerformanceCards.tsx | 53 ++- src/features/games/CustomGameDialog.tsx | 307 ++++++++++++++ src/features/games/CustomGameImage.tsx | 11 + src/features/games/GameProfilePanel.tsx | 172 +++++++- src/features/games/GameRequestDialog.tsx | 2 +- src/features/games/GamesPage.tsx | 106 ++++- src/features/games/game-image.test.ts | 17 + src/features/games/game-image.ts | 31 ++ src/features/superstrike/Superstrike.tsx | 4 +- src/i18n/index.ts | 20 + src/i18n/locales/ar.ts | 20 + src/i18n/locales/de.ts | 20 + src/i18n/locales/es.ts | 20 + src/i18n/locales/fr.ts | 20 + src/i18n/locales/ja.ts | 20 + src/i18n/locales/ko.ts | 20 + src/i18n/locales/pt.ts | 20 + src/i18n/locales/ru.ts | 20 + src/i18n/locales/tr.ts | 20 + src/i18n/locales/vi.ts | 20 + src/i18n/locales/zh.ts | 20 + src/styles/areas/buttons.css | 8 +- src/styles/areas/pages.css | 16 +- src/styles/controls.css | 2 +- 36 files changed, 1671 insertions(+), 115 deletions(-) create mode 100644 src/features/games/CustomGameDialog.tsx create mode 100644 src/features/games/CustomGameImage.tsx create mode 100644 src/features/games/game-image.test.ts create mode 100644 src/features/games/game-image.ts diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 24828223..50a54e76 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -4,9 +4,13 @@ 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. +// Raised again to 3_230_000 for custom games (dialog, Bridge calls), the game +// profile Apply now button, Host bunny hop, the side buttons card, custom game +// pictures, renaming a custom game and a report rate per connection, with their +// strings in 12 languages. Measured 3_209_919 after the last change. const BUDGET_BYTES: Record = { ".css": 165_000, - ".js": 3_200_000, + ".js": 3_230_000, }; const ASSETS = join("dist", "assets"); diff --git a/src/device/controller.ts b/src/device/controller.ts index 35cb8aaf..43ca7cca 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -33,6 +33,7 @@ import { pendingChangeBatches, pendingChanges, restorePendingChanges, + setStatusOverlay, stagePendingChange, stashPendingChanges, withPendingChanges, @@ -42,6 +43,8 @@ import { draftProfileSnapshot, editableProfileFields, matchingProfileFields, + mergeSnapshots, + pickSnapshot, type GameProfileSnapshot, } from "./game-profile-snapshot"; import { deviceImage } from "../shared/device-images"; @@ -375,7 +378,15 @@ let settingInProgress = false; // `touched` is every field the profile sets: its saved fields plus any a draft // edit has changed. A touched field stays in the profile even when its value // happens to match the mouse right now. -let gameProfileDraft: { stash: PendingChange[]; touched: Set } | null = null; +// `held` is what the mouse cannot take now (side buttons; a Host bunny hop while +// it is on Onboard): "Apply now" flashes and clears the staged changes, so these +// stay here and are laid over every preview. +let gameProfileDraft: { + stash: PendingStash; + touched: Set; + rates: { wireless?: number; wired?: number }; + held: Pick; +} | null = null; let lastRenderedStatusKey: string | null = null; let activeDevice: HIDDevice | null = null; let latestReceiverFirmware: { interfaceId: string; mcus: Array<{ mcu: number; version: string | null; raw: number[] }> } | null = null; @@ -489,7 +500,7 @@ function buildProfileView(): ProfileView { const rates = lastProfileFormat ? reportRateCapabilitiesFor(lastProfileFormat.id, latestDeviceStatus?.transportIds?.USB) : null; - return { + const view: ProfileView = { entry, summary: describeProfileEntry(entry), slotsAvailable: dpiSlotsAvailable(), @@ -505,6 +516,7 @@ function buildProfileView(): ProfileView { ratesLocked: lastProfileFormat?.writable !== true, bunnyHopSupported: bunnyHopSupported(), }; + return gameProfileDraft ? { ...view, entry: null, slotsAvailable: false, bunnyHopSupported: false } : view; } let artworkKey: string | null = null; @@ -630,6 +642,7 @@ function buildSnapshot(): ControlSnapshot { keys: changes.map((change) => change.key), }, gameProfileDraft: gameProfileDraft !== null, + gameProfileRates: { ...gameProfileDraft?.rates }, diagnostics: { ...diagnosticsView, downloadStatus: diagnosticDownloadStatus }, diagnosticsOpen, captureAvailable: logitechClient() !== null && latestDeviceStatus?.brand === "Logitech", @@ -1482,6 +1495,29 @@ function gameProfileFieldsOf(change: PendingChange): string[] | null { return editableProfileFields(base, preview); } +type PendingStash = { changes: PendingChange[]; analog: Map<0 | 1, AnalogTuning> }; + +// The HITS values waiting to be written sit in a map of their own, apart from +// the pending changes that name them, so both are set aside together. +function stashEverything(): PendingStash { + const analog = new Map(stagedAnalogButtons); + stagedAnalogButtons.clear(); + return { changes: stashPendingChanges(), analog }; +} + +function restoreEverything(stash: PendingStash): void { + restorePendingChanges(stash.changes); + stagedAnalogButtons.clear(); + for (const [button, tuning] of stash.analog) stagedAnalogButtons.set(button, tuning); +} + +function overlayHeldValues(status: MouseStatus): void { + const held = gameProfileDraft?.held; + if (!held) return; + if (held.hostButtons) status.hostButtons = structuredClone(held.hostButtons); + if (held.hostBunnyHopMs !== undefined) status.hostBunnyHopMs = held.hostBunnyHopMs; +} + /** * The Games page edits a game profile with the device page's own cards: every * `apply*` they call stages into a draft that is never flashed. The user's @@ -1490,8 +1526,9 @@ function gameProfileFieldsOf(change: PendingChange): string[] | null { */ export function beginGameProfileDraft(initial: GameProfileSnapshot): boolean { if (gameProfileDraft) endGameProfileDraft(); - if (settingInProgress || !latestDeviceStatus) return false; - gameProfileDraft = { stash: stashPendingChanges(), touched: new Set() }; + if (gameProfileDraft || settingInProgress || !latestDeviceStatus) return false; + gameProfileDraft = { stash: stashEverything(), touched: new Set(), rates: {}, held: {} }; + setStatusOverlay(overlayHeldValues); openGameProfileDraft(initial); emit(); return true; @@ -1512,7 +1549,14 @@ function openGameProfileDraft(initial: GameProfileSnapshot): void { export function gameProfileDraftSnapshot(): GameProfileSnapshot { if (!gameProfileDraft || !latestDeviceStatus) return {}; const preview = withPendingChanges(latestDeviceStatus); - return draftProfileSnapshot(latestDeviceStatus, preview, gameProfileDraft.touched); + const snapshot = draftProfileSnapshot(latestDeviceStatus, preview, gameProfileDraft.touched); + // The rate of the connection the mouse is not on can never be flashed, so the draft keeps both itself. + delete snapshot.pollingRateWirelessHz; + delete snapshot.pollingRateWiredHz; + const { wireless, wired } = gameProfileDraft.rates; + if (wireless !== undefined) snapshot.pollingRateWirelessHz = wireless; + if (wired !== undefined) snapshot.pollingRateWiredHz = wired; + return snapshot; } /** Replaces the draft's staged values with `snapshot` (the Clear/Revert paths). */ @@ -1520,15 +1564,28 @@ export function resetGameProfileDraft(snapshot: GameProfileSnapshot): void { if (!gameProfileDraft) return; clearPendingChanges(); gameProfileDraft.touched = new Set(); + gameProfileDraft.rates = {}; + gameProfileDraft.held = {}; openGameProfileDraft(snapshot); emit(); } +// Set when the draft was asked to end while "Apply now" was still writing it. +let endDraftWhenWritten = false; + export function endGameProfileDraft(): void { if (!gameProfileDraft) return; + // The writer drops the draft's changes by key when it finishes, which would + // take the user's own changes with it if they were put back first. + if (gameProfileWriting) { + endDraftWhenWritten = true; + return; + } const { stash } = gameProfileDraft; gameProfileDraft = null; - restorePendingChanges(stash); + endDraftWhenWritten = false; + setStatusOverlay(null); + restoreEverything(stash); emit(); } @@ -1547,14 +1604,77 @@ export async function flashGameProfileSnapshot( snapshot: GameProfileSnapshot, ): Promise<"written" | "unchanged" | "busy"> { if (gameProfileDraft || settingInProgress || !latestDeviceStatus) return "busy"; - const stash = stashPendingChanges(); + const stash = stashEverything(); applyGameProfileSnapshot(snapshot); const staged = hasPendingChanges(); - if (staged) await flashPendingChanges(); - restorePendingChanges(stash); + let landed = true; + recordDiagnosticCommand(`Game profile: write ${Object.keys(snapshot).join(", ") || "nothing"} (staged ${staged})`); + if (staged) { + gameProfileWriting = true; + try { + await flashPendingChanges(); + // A write that failed leaves its changes staged: that is not a write, and the caller should try again. + landed = !hasPendingChanges(); + } finally { + gameProfileWriting = false; + } + } + restoreEverything(stash); + if (!landed) return "busy"; return staged ? "written" : "unchanged"; } +// True while a game profile is being written, so writes that would also save to +// the mouse's stored profile (HITS tuning) stay live-only. +let gameProfileWriting = false; + +// What "Apply now" replaced, by mouse: it is written back to the mouse it came from. +const gameProfileRestores = new Map(); + +function activeDeviceKey(): string | null { + return activeDevice ? deviceStorageKey(activeDevice) : null; +} + +export async function applyGameProfileDraftNow(): Promise<"written" | "busy"> { + const key = activeDeviceKey(); + if (!gameProfileDraft || !key || settingInProgress || !latestDeviceStatus || !hasPendingChanges()) return "busy"; + const before = pickSnapshot(latestDeviceStatus, gameProfileDraftSnapshot()); + recordDiagnosticCommand(`Game profile: apply now (replacing ${Object.keys(before).join(", ") || "nothing"})`); + // Values from before the first apply win, so a second apply still restores the originals. + gameProfileRestores.set(key, mergeSnapshots(before, gameProfileRestores.get(key) ?? {})); + gameProfileWriting = true; + let written = false; + try { + await flashStagedChanges(); + written = !hasPendingChanges(); + } finally { + gameProfileWriting = false; + if (endDraftWhenWritten) endGameProfileDraft(); + } + return written ? "written" : "busy"; +} + +/** + * Puts back what "Apply now" replaced on the active mouse; call once the draft + * has ended. The saved values stay until a write lands, so a mouse that stayed + * busy is tried again by the next call. + */ +export async function restoreGameProfileApply(): Promise { + const key = activeDeviceKey(); + const restore = key ? gameProfileRestores.get(key) : undefined; + if (!key || !restore) return; + recordDiagnosticCommand(`Game profile: restore ${JSON.stringify(restore)}`); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (await flashGameProfileSnapshot(restore) !== "busy") { + gameProfileRestores.delete(key); + return; + } + await wait(500); + // The write goes to whichever mouse is active now, which must still be the one these values came from. + if (activeDeviceKey() !== key) return; + } +} + /** Writes that bypass staging must not reach the mouse while a game profile is being edited. */ function blockedByGameProfileDraft(): boolean { if (!gameProfileDraft) return false; @@ -1816,6 +1936,30 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void { else applyDpiValue(s.dpi); } if (s.pollingRateHz != null) applyPollingRate(s.pollingRateHz); + if (gameProfileDraft) { + if (s.pollingRateWirelessHz != null) applyGameProfilePollingRate("wireless", s.pollingRateWirelessHz); + if (s.pollingRateWiredHz != null) applyGameProfilePollingRate("wired", s.pollingRateWiredHz); + } else { + // The mouse only has the rate of the connection it is on, so only that one is applied. + const linkRate = status.connectionType === "Wireless" ? s.pollingRateWirelessHz : s.pollingRateWiredHz; + if (linkRate != null) applyPollingRate(linkRate); + } + if (typeof s.hostBunnyHopMs === "number") applyHostBunnyHopMs(s.hostBunnyHopMs); + if (s.hostButtons && hostBunnyHopSupported(status)) stageHostButtons(s.hostButtons); + + const hits = s.analogButtonTuning?.buttons; + const liveHits = status.analogButtonTuning?.buttons; + if (Array.isArray(hits) && hits.length === 2 && liveHits?.length === 2) { + analogTuning = { + mode: JSON.stringify(hits[0]) === JSON.stringify(hits[1]) ? "both" : "independent", + left: { ...hits[0] }, + right: { ...hits[1] }, + both: { ...hits[0] }, + }; + for (const button of [0, 1] as const) { + if (JSON.stringify(hits[button]) !== JSON.stringify(liveHits[button])) stageAnalogButton(button, { ...hits[button] }); + } + } if (s.gamingSurfaceMode) applyGamingSurfaceMode(s.gamingSurfaceMode); if (s.lightforceSwitchMode) applyLightforceSwitchMode(s.lightforceSwitchMode); @@ -2185,6 +2329,10 @@ async function flashPause(milliseconds = FLASH_STEP_DELAY_MS): Promise { export async function flashPendingChanges(): Promise { if (gameProfileDraft) return; + await flashStagedChanges(); +} + +async function flashStagedChanges(): Promise { const batches = pendingChangeBatches(); if (batches.length === 0) return; if (settingInProgress) { @@ -2667,6 +2815,16 @@ function applyStatusInner(deviceStatus: MouseStatus, statusKey?: string): void { }, }; } + if (hostBunnyHopSupported(deviceStatus)) { + deviceStatus = { ...deviceStatus, hostBunnyHopMs: savedHostBunnyHopMs(), hostButtons: {} }; + } + if (deviceStatus.connectionType) { + // The rate of the connection the mouse is on, so a game profile can set the other one apart. + deviceStatus = { + ...deviceStatus, + [deviceStatus.connectionType === "Wireless" ? "pollingRateWirelessHz" : "pollingRateWiredHz"]: deviceStatus.pollingRateHz, + }; + } latestDeviceStatus = deviceStatus; latestDiagnosticStatus = deviceStatus; // Battery samples are recorded at device-update cadence; renders read the @@ -4298,8 +4456,9 @@ async function writeStagedAnalogButtons(): Promise { for (const entry of staged) await client.setAnalogButtonTuning(entry.button, entry); // Then the profile, which is what the mouse loads at power-on. Live values // alone are gone after a power cycle. Host mode and profiles that cannot be - // written keep the live-only behavior. - if (lastDeviceMode === "Onboard" && lastProfileFormat?.writable === true) { + // written keep the live-only behavior, and so does a game profile: it must + // never rewrite the mouse's stored profile each time a game starts or stops. + if (!gameProfileWriting && lastDeviceMode === "Onboard" && lastProfileFormat?.writable === true) { await client.persistAnalogButtonTuning(staged); } stagedAnalogButtons.clear(); @@ -4323,7 +4482,6 @@ function stageAnalogButton(button: 0 | 1, tuning: AnalogTuning): void { } export function applyLogitechAnalogButton(button: 0 | 1): void { - if (blockedByGameProfileDraft()) return; if (!logitechClient()) return; stageAnalogButton(button, button === 0 ? analogTuning.left : analogTuning.right); } @@ -4450,7 +4608,6 @@ export function dismissMagneticCalibration(): void { } export function applyLogitechAnalogButtons(): void { - if (blockedByGameProfileDraft()) return; if (!logitechClient()) return; stageAnalogButton(0, analogTuning.both); stageAnalogButton(1, analogTuning.both); @@ -4639,6 +4796,81 @@ export function setDpiSlotAxis(index: number, axis: "x" | "y", value: number): v stageDpiSlots(); } +const DPI_UNLINKED_STORAGE_KEY = "openmouse.dpi-unlinked"; + +/** The per-device record kept in localStorage under `storageKey`, empty when missing or unreadable. */ +function storedRecord(storageKey: string): Record { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return {}; + } +} + +/** Identifies the device and profile the unlink choices belong to; two identical Bridge devices differ by session key. */ +export function dpiUnlinkKey(): string | null { + if (!activeDevice || editedProfile === null) return null; + const bridge = activeDevice as HIDDevice & { openMouseTransport?: string; key?: string }; + const device = bridge.openMouseTransport === "bridge" ? `bridge:${bridge.key ?? ""}` : deviceStorageKey(activeDevice); + return `${device}#${editedProfile}`; +} + +/** Slot rows whose X and Y were unlinked on purpose; the mouse cannot store that when the two are equal. */ +export function unlinkedDpiSlots(): number[] { + const key = dpiUnlinkKey(); + const saved = key ? storedRecord(DPI_UNLINKED_STORAGE_KEY)[key] : undefined; + return Array.isArray(saved) ? saved : []; +} + +const HOST_DPI_SLOTS_STORAGE_KEY = "openmouse.host-dpi-slots"; +export type HostDpiSlot = { x: number; y: number; unlinked?: boolean }; + +function isHostDpiSlot(value: unknown): value is HostDpiSlot { + const slot = value as Partial | null; + return typeof slot === "object" && slot !== null && Number.isFinite(slot.x) && Number.isFinite(slot.y) + && (slot.unlinked === undefined || typeof slot.unlinked === "boolean"); +} + +// Bridge hands out per-session device keys (hid-1, hid-2, ...) that can change +// on every page load, so the slots are keyed by the mouse's identity instead. +function hostDpiSlotsKey(): string | null { + return activeDevice ? deviceStorageKey(activeDevice) : null; +} + +export function loadHostDpiSlots(): HostDpiSlot[] | null { + const key = hostDpiSlotsKey(); + const saved = key ? storedRecord(HOST_DPI_SLOTS_STORAGE_KEY)[key] : undefined; + return Array.isArray(saved) && saved.length > 0 && saved.every(isHostDpiSlot) ? saved : null; +} + +export function saveHostDpiSlots(slots: HostDpiSlot[]): void { + const key = hostDpiSlotsKey(); + if (!key) return; + try { + localStorage.setItem(HOST_DPI_SLOTS_STORAGE_KEY, JSON.stringify({ ...storedRecord(HOST_DPI_SLOTS_STORAGE_KEY), [key]: slots })); + } catch { + // Storage can be blocked; the slots then simply reorder on refresh. + } +} + +/** Replaces the whole unlinked list for the active device and profile. */ +export function replaceDpiUnlinkedSlots(indices: number[]): void { + const key = dpiUnlinkKey(); + if (!key) return; + try { + localStorage.setItem(DPI_UNLINKED_STORAGE_KEY, JSON.stringify({ ...storedRecord(DPI_UNLINKED_STORAGE_KEY), [key]: [...new Set(indices)].sort((a, b) => a - b) })); + } catch { + } +} + +export function setDpiSlotUnlinked(index: number, unlinked: boolean): void { + const rows = new Set(unlinkedDpiSlots()); + if (unlinked) rows.add(index); + else rows.delete(index); + replaceDpiUnlinkedSlots([...rows]); +} + export function dpiAxisLockedAt(index: number): boolean { return dpiAxisLocks[index] ?? true; } @@ -5375,6 +5607,122 @@ export function applyBunnyHopMs(milliseconds: number): void { export { BUNNY_HOP_LIMITS }; +const HOST_BUNNY_HOP_STORAGE_KEY = "openmouse.host-bunny-hop"; +const HOST_BUNNY_HOP_KEY = "logitech-host-bunny-hop"; + +/** Whether the mouse has a Host mode bunny hop setting (the profile format that has bunny hop). */ +export function hostBunnyHopSupported(status: MouseStatus | null): boolean { + return status?.brand === "Logitech" && capabilitiesForFormat(status.onboardProfileFormat?.id).bunnyHop; +} + +/** + * The Host bunny hop time last written to this mouse. The mouse cannot report it + * back, and G HUB itself starts a Host session at off, so unknown reads as off. + */ +function savedHostBunnyHopMs(): number { + const value = activeDevice ? storedRecord(HOST_BUNNY_HOP_STORAGE_KEY)[deviceStorageKey(activeDevice)] : undefined; + return typeof value === "number" && validateBunnyHoppingMs(value) === null ? value : 0; +} + +function saveHostBunnyHopMs(milliseconds: number): void { + if (!activeDevice) return; + try { + localStorage.setItem( + HOST_BUNNY_HOP_STORAGE_KEY, + JSON.stringify({ ...storedRecord(HOST_BUNNY_HOP_STORAGE_KEY), [deviceStorageKey(activeDevice)]: milliseconds }), + ); + } catch { + // Storage can be blocked; the value then reads as off after a refresh. + } +} + +const HOST_BUTTONS_KEY = "logitech-host-buttons"; + +function stageHostButtons(buttons: NonNullable): void { + const holdInDraft = gameProfileDraft !== null && !settingInProgress; + stageChange({ + key: HOST_BUTTONS_KEY, + label: "Side buttons", + command: "Set the side button assignments", + progress: "Saving the side button assignments…", + preview: (status) => { status.hostButtons = structuredClone(buttons); }, + apply: async () => {}, + }); + // Bridge presses these keys while the game runs; the mouse has nothing to write. Held only once staging has + // marked the field as touched, which compares against what was held before. + if (holdInDraft && gameProfileDraft) { + gameProfileDraft.held.hostButtons = structuredClone(buttons); + emit(); + } +} + +/** Assigns a side button (bit 8 back, 16 front) a key or media key, or null for its normal click. */ +export function applyHostButton(bit: number, action: NonNullable[number] | null): void { + if (!latestDeviceStatus || !hostBunnyHopSupported(latestDeviceStatus)) return; + const next = { ...(withPendingChanges(latestDeviceStatus).hostButtons ?? {}) }; + if (action) next[bit] = action; + else delete next[bit]; + stageHostButtons(next); +} + +/** Sets bunny hop while the mouse runs from software (the Host profile); 0 turns it off. */ +export function applyHostBunnyHopMs(milliseconds: number): void { + if (!logitechClient()) return; + const invalid = validateBunnyHoppingMs(milliseconds); + if (invalid) { + setReadStatus(invalid); + return; + } + const holdInDraft = gameProfileDraft !== null && !settingInProgress; + stageChange({ + key: HOST_BUNNY_HOP_KEY, + label: milliseconds === 0 ? "Bunny hop off" : `Bunny hop ${milliseconds} ms`, + command: `Set Host mode bunny hop to ${milliseconds} ms`, + progress: milliseconds === 0 ? "Turning bunny hop off…" : `Setting bunny hop to ${milliseconds} ms…`, + preview: (status) => { status.hostBunnyHopMs = milliseconds; }, + apply: async () => { + const client = logitechClient(); + if (!client) throw new Error(st("ctl.gone")); + if (lastDeviceMode !== "Host") { + recordDiagnosticCommand("Bunny hop skipped: the mouse is not on the Host profile"); + return; + } + await client.setHostBunnyHopMs(milliseconds); + saveHostBunnyHopMs(milliseconds); + }, + }); + // Kept in the draft as well: on Onboard the mouse cannot take it, and after "Apply now" the staged change is gone. + // Held only once staging has marked the field as touched, which compares against what was held before. + if (holdInDraft && gameProfileDraft) { + gameProfileDraft.held.hostBunnyHopMs = milliseconds; + emit(); + } +} + +/** Sets the rate a game profile uses on one connection. */ +export function applyGameProfilePollingRate(link: "wireless" | "wired", hz: number): void { + if (!gameProfileDraft || !profileReportRateOptions(link).includes(hz)) return; + // The draft must not take a rate that staging refuses while a write is running. + if (settingInProgress) { + setReadStatus(st("ctl.waitFlash")); + return; + } + const field = link === "wireless" ? "pollingRateWirelessHz" : "pollingRateWiredHz"; + gameProfileDraft.rates[link] = hz; + stageChange({ + key: `polling-rate-${link}`, + label: `${hz.toLocaleString()} Hz ${link}`, + command: `Set the ${link} polling rate to ${hz.toLocaleString()} Hz`, + progress: `Setting ${hz.toLocaleString()} Hz…`, + preview: (status) => { status[field] = hz; }, + // "Apply now" flashes the draft as it is, and the mouse only has the rate of the connection it is on. + apply: async () => { + if ((latestDeviceStatus?.connectionType === "Wireless") !== (link === "wireless")) return; + await requireClientMethod("setPollingRate", "the polling rate").setPollingRate(hz); + }, + }); +} + export function profileReportRateOptions(link: "wireless" | "wired"): number[] { const format = lastProfileFormat; const rates = format ? reportRateCapabilitiesFor(format.id, latestDeviceStatus?.transportIds?.USB) : null; diff --git a/src/device/game-profile-snapshot.test.ts b/src/device/game-profile-snapshot.test.ts index 4844d5fe..65a2dee3 100644 --- a/src/device/game-profile-snapshot.test.ts +++ b/src/device/game-profile-snapshot.test.ts @@ -47,6 +47,41 @@ test("stored snapshots lose fields this build cannot apply", () => { assert.deepEqual(sanitizeSnapshot([1]), {}); }); +test("HITS tuning is part of a game profile", () => { + const tuning = (actuation: number): MouseStatus["analogButtonTuning"] => ({ + maxActuation: 10, + maxRapidTrigger: 10, + maxHaptics: 10, + buttons: [ + { actuation, rapidTrigger: 2, haptics: 3, rapidTriggerEnabled: true }, + { actuation: 4, rapidTrigger: 2, haptics: 3, rapidTriggerEnabled: false }, + ], + }); + const before = status({ analogButtonTuning: tuning(4) }); + const after = status({ analogButtonTuning: tuning(7) }); + assert.deepEqual(editableProfileFields(before, after), ["analogButtonTuning"]); + assert.deepEqual(snapshotDiff(before, after), { analogButtonTuning: tuning(7) }); + assert.deepEqual(sanitizeSnapshot({ analogButtonTuning: tuning(7) }), { analogButtonTuning: tuning(7) }); +}); + +test("side button assignments are part of a game profile", () => { + const before = status({ hostButtons: {} }); + const after = status({ hostButtons: { 16: { key: 0x68, modifiers: 0 }, 8: { key: 0x04, modifiers: 3 } } }); + assert.deepEqual(editableProfileFields(before, after), ["hostButtons"]); + assert.deepEqual(snapshotDiff(before, after), { hostButtons: after.hostButtons }); + assert.deepEqual(pickSnapshot(before, { hostButtons: after.hostButtons }), { hostButtons: {} }, "the prior is no assignments"); + assert.deepEqual(sanitizeSnapshot({ hostButtons: after.hostButtons }), { hostButtons: after.hostButtons }); +}); + +test("Host bunny hop is part of a game profile", () => { + const before = status({ hostBunnyHopMs: 0 }); + const after = status({ hostBunnyHopMs: 100 }); + assert.deepEqual(editableProfileFields(before, after), ["hostBunnyHopMs"]); + assert.deepEqual(snapshotDiff(before, after), { hostBunnyHopMs: 100 }); + assert.deepEqual(pickSnapshot(before, { hostBunnyHopMs: 100 }), { hostBunnyHopMs: 0 }); + assert.deepEqual(sanitizeSnapshot({ hostBunnyHopMs: 100 }), { hostBunnyHopMs: 100 }); +}); + test("the comparison key ignores field order", () => { assert.equal(snapshotKey({ dpi: 800, pollingRateHz: 1000 }), snapshotKey({ pollingRateHz: 1000, dpi: 800 })); assert.notEqual(snapshotKey({ dpi: 800 }), snapshotKey({ dpi: 1600 })); @@ -114,3 +149,11 @@ test("switching Games profiles restores previous controls and retains every orig assert.deepEqual(firstPrior, { lunafury: { lightningMode: 0, leftDebounceMs: 3 } }); assert.deepEqual(mergeSnapshots({ dpi: 800 }, { pollingRateHz: 2000, dpi: 1600 }), { dpi: 1600, pollingRateHz: 2000 }); }); + +test("a profile keeps a report rate for each connection and restores only the one in use", () => { + const wired = status({ connectionType: "Wired", pollingRateHz: 1000, pollingRateWiredHz: 1000 }); + const edited = { ...wired, pollingRateWirelessHz: 8000, pollingRateWiredHz: 2000 }; + const profile = snapshotDiff(wired, edited); + assert.deepEqual(profile, { pollingRateWirelessHz: 8000, pollingRateWiredHz: 2000 }); + assert.deepEqual(pickSnapshot(wired, profile), { pollingRateWiredHz: 1000 }, "only the connection in use has a value to put back"); +}); diff --git a/src/device/game-profile-snapshot.ts b/src/device/game-profile-snapshot.ts index 189a9d39..ce5b5152 100644 --- a/src/device/game-profile-snapshot.ts +++ b/src/device/game-profile-snapshot.ts @@ -20,6 +20,8 @@ export const GAME_PROFILE_FIELDS = [ "activeDpiStage", "dpiStageColors", "pollingRateHz", + "pollingRateWirelessHz", + "pollingRateWiredHz", "liftOffDistance", "liftOffScale", "asymmetricLiftOff", @@ -47,6 +49,9 @@ export const GAME_PROFILE_FIELDS = [ "hiResScroll", "invertScroll", "thumbWheelInverted", + "analogButtonTuning", + "hostBunnyHopMs", + "hostButtons", "hapticIntensity", "hapticEnabled", "hapticBatterySaving", diff --git a/src/device/pending-changes.test.ts b/src/device/pending-changes.test.ts index 507f2069..4bbf94e5 100644 --- a/src/device/pending-changes.test.ts +++ b/src/device/pending-changes.test.ts @@ -7,6 +7,7 @@ import { pendingChangeBatches, pendingChanges, restorePendingChanges, + setStatusOverlay, stagePendingChange, stashPendingChanges, withPendingChanges, @@ -145,3 +146,20 @@ test("stashed changes come back in their original order after another set was st assert.deepEqual(pendingChanges().map((entry) => entry.key), ["a", "b"]); clearPendingChanges(); }); + +test("an overlay shows on top of the status and of the staged changes, and outlives them", () => { + clearPendingChanges(); + const deviceStatus = status(); + stagePendingChange({ key: "dpi", label: "DPI 1600", command: "", progress: "", preview: (value) => { value.dpi = 1600; }, apply: async () => {} }); + setStatusOverlay((value) => { value.hostBunnyHopMs = 40; }); + + const shown = withPendingChanges(deviceStatus); + assert.equal(shown.dpi, 1600); + assert.equal(shown.hostBunnyHopMs, 40); + assert.equal(deviceStatus.hostBunnyHopMs, undefined, "the status itself is left alone"); + + clearPendingChanges(); + assert.equal(withPendingChanges(deviceStatus).hostBunnyHopMs, 40, "nothing is staged, the overlay still shows"); + setStatusOverlay(null); + assert.equal(withPendingChanges(deviceStatus), deviceStatus); +}); diff --git a/src/device/pending-changes.ts b/src/device/pending-changes.ts index e8109b39..d006aeeb 100644 --- a/src/device/pending-changes.ts +++ b/src/device/pending-changes.ts @@ -102,12 +102,24 @@ export function clearPendingChanges(): void { * refreshes cannot snap the controls back to the values still on the device. */ export function withPendingChanges(status: MouseStatus): MouseStatus { - if (changes.size === 0) return status; + if (changes.size === 0 && !overlay) return status; const preview = structuredClone(status); for (const change of changes.values()) change.preview?.(preview); + overlay?.(preview); return preview; } +let overlay: ((status: MouseStatus) => void) | null = null; + +/** + * Values the mouse cannot take right now (a game profile's side buttons, or a + * Host bunny hop while the mouse is on Onboard) still have to show as set, so a + * game profile draft puts them on top of every preview. `null` removes it. + */ +export function setStatusOverlay(next: ((status: MouseStatus) => void) | null): void { + overlay = next; +} + export function stashPendingChanges(): PendingChange[] { const stashed = [...changes.values()]; clearPendingChanges(); diff --git a/src/device/types.ts b/src/device/types.ts index 778ad4ac..0585eae6 100644 --- a/src/device/types.ts +++ b/src/device/types.ts @@ -331,6 +331,8 @@ export interface ControlSnapshot { pending: PendingView; /** True while the Games page is editing a game profile: edits are staged into a draft that is never flashed. */ gameProfileDraft: boolean; + /** The report rate a game profile being edited keeps for each connection. */ + gameProfileRates: { wireless?: number; wired?: number }; diagnostics: DiagnosticsView; diagnosticsOpen: boolean; captureAvailable: boolean; diff --git a/src/features/bridge/bridge.ts b/src/features/bridge/bridge.ts index f6e45b4b..1d87a8a3 100644 --- a/src/features/bridge/bridge.ts +++ b/src/features/bridge/bridge.ts @@ -90,6 +90,41 @@ export async function bridgeGames(signal?: AbortSignal): Promise { return bridgeRequest("/v1/games", undefined, signal); } +/** Games the user added by hand; `bridgeGames` already includes them. */ +export async function bridgeCustomGames(signal?: AbortSignal): Promise { + return bridgeRequest("/v1/custom-games", undefined, signal); +} + +/** Where Bridge serves a custom game's image; `version` makes the browser ask again after it changes. */ +export function customGameImageUrl(name: string, version = 0): string { + return `${BRIDGE_URL}/v1/custom-games/image?name=${encodeURIComponent(name)}&v=${version}`; +} + +export async function saveBridgeCustomGameImage(name: string, image: Blob): Promise { + await bridgeRequest(`/v1/custom-games/image?name=${encodeURIComponent(name)}`, { + method: "PUT", + headers: { "Content-Type": image.type || "application/octet-stream" }, + body: image, + }); +} + +/** The image a link points at, downloaded by Bridge because most sites block a page from doing it. */ +export async function bridgeImageFromUrl(link: string): Promise { + const response = await fetch(`${BRIDGE_URL}/v1/custom-games/image-from-url?url=${encodeURIComponent(link)}`, { + signal: AbortSignal.timeout(BRIDGE_NATIVE_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(await response.text()); + return response.blob(); +} + +export async function saveBridgeCustomGames(games: BridgeGame[]): Promise { + return bridgeRequest("/v1/custom-games", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(games), + }); +} + export async function saveBridgeProfiles(profiles: BridgeProfile[]): Promise { await bridgeRequest("/v1/profiles", { method: "PUT", @@ -98,6 +133,11 @@ export async function saveBridgeProfiles(profiles: BridgeProfile[]): Promise { + return bridgeRequest("/v1/default-profile", undefined, signal); +} + export async function saveBridgeDefaultProfile(profile: BridgeProfile): Promise { await bridgeRequest("/v1/default-profile", { method: "PUT", diff --git a/src/features/buttons/ButtonMap.tsx b/src/features/buttons/ButtonMap.tsx index 13b5f2d1..25bf0ab4 100644 --- a/src/features/buttons/ButtonMap.tsx +++ b/src/features/buttons/ButtonMap.tsx @@ -1,15 +1,16 @@ import { displayText } from "../../i18n/index"; import { matchesCommandQuery } from "./command-search"; import { useUiLocale } from "../../shared/ui-locale"; -import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { bridgeDefaultProfile, saveBridgeDefaultProfile, type BridgeProfile } from "../bridge/bridge"; +import { isBridgeHidActive, subscribeBridgeHidActive } from "../bridge/bridge-hid"; import * as control from "../../device/controller"; import type { LogitechButtonAction } from "@openmouse/protocol/drivers/logitech/onboard-profiles"; +import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; import type { ControlSnapshot } from "../../device/types"; import { buttonMapLayoutFor, type ButtonSpot } from "./logitech-button-map"; import { COMMAND_SECTIONS, bindingFromKeyEvent, conflictHints, describeAssignment, isAssigned, shortcutText, type CommandEntry } from "./logitech-commands"; -import { connectionText } from "../../i18n/index"; import { SetupCodeDialog } from "./SetupCodeDialog"; -import { BatteryIcon } from "../../shared/ui"; type Layer = "primary" | "g-shift"; @@ -17,6 +18,61 @@ type Layer = "primary" | "g-shift"; /** What each unlocked button does out of the box; G-Shift starts with nothing on them. */ const DEFAULT_ACTION: Record = { 2: "Middle click", 3: "Back", 4: "Forward" }; +const HOST_BUTTONS = [3, 4]; +const hostBit = (button: number): number => 1 << button; +type HostAction = NonNullable[number]; + +function hostCapable(snapshot: ControlSnapshot): boolean { + const status = snapshot.status; + return snapshot.traits.logitech + && status !== null + && control.hostBunnyHopSupported(status) + && buttonMapLayoutFor(status.name) !== null; +} + +/** Editing a game profile on a mouse that can hand its side buttons to Bridge. */ +function inHostMode(snapshot: ControlSnapshot): boolean { + return snapshot.gameProfileDraft && hostCapable(snapshot); +} + +function onHostPageWithBridge(snapshot: ControlSnapshot, bridgeActive: boolean): boolean { + return bridgeActive && !snapshot.gameProfileDraft && snapshot.editedProfile === "host" && hostCapable(snapshot); +} + +function useBridgeRunning(): boolean { + const [active, setActive] = useState(isBridgeHidActive()); + useEffect(() => subscribeBridgeHidActive(setActive), []); + return active; +} + +/** Keyboard shortcuts and the media keys Bridge can press; macros and mouse commands are not offered. */ +function hostSupported(command: CommandEntry): boolean { + return command.target.kind === "keyboard" || command.target.kind === "consumer"; +} + +function hostActionFor(command: CommandEntry): HostAction | null { + const target = command.target; + if (target.kind === "keyboard") return { key: target.key, modifiers: target.modifiers }; + if (target.kind === "consumer") return { consumer: target.usage }; + return null; +} + +function hostText(action: HostAction | undefined, button: number): string { + if (!action) return DEFAULT_ACTION[button] ?? "-"; + if ("key" in action) return shortcutText(action.key, action.modifiers); + const media = COMMAND_SECTIONS.flatMap((section) => section.entries) + .find((entry) => entry.target.kind === "consumer" && entry.target.usage === action.consumer); + return media?.label ?? `Media 0x${action.consumer.toString(16)}`; +} + +function isHostAssigned(command: CommandEntry, action: HostAction | undefined): boolean { + const wanted = hostActionFor(command); + if (!wanted || !action) return false; + return "key" in wanted && "key" in action + ? wanted.key === action.key && wanted.modifiers === action.modifiers + : "consumer" in wanted && "consumer" in action && wanted.consumer === action.consumer; +} + function hasButtonMap(snapshot: ControlSnapshot): boolean { const status = snapshot.status; return snapshot.traits.logitech @@ -52,7 +108,7 @@ function onHostProfile(snapshot: ControlSnapshot): boolean { } export function buttonMapAvailable(snapshot: ControlSnapshot): boolean { - return stableSnapshot(snapshot) !== null || onHostProfile(snapshot); + return stableSnapshot(snapshot) !== null || onHostProfile(snapshot) || inHostMode(snapshot); } function HostProfileNotice({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { @@ -63,7 +119,7 @@ function HostProfileNotice({ snapshot }: { snapshot: ControlSnapshot }): ReactNo

{displayText(__uiLocale, "Button assignments")}

-

{displayText(__uiLocale, "You are on the Host profile, which runs from software. Button assignments and Bunny Hop are stored in the mouse's onboard profiles, so open one to edit them.")}

+

{displayText(__uiLocale, "You are on the Host profile, which runs from software. Button assignments are stored in the mouse's onboard profiles, so open one to edit them. Bunny hop is set on this profile.")}

{target ? ( @@ -75,7 +131,72 @@ function HostProfileNotice({ snapshot }: { snapshot: ControlSnapshot }): ReactNo export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): ReactNode { const __uiLocale = useUiLocale(); - const snapshot = stableSnapshot(live) ?? live; + const bridgeActive = useBridgeRunning(); + const onHostPage = onHostPageWithBridge(live, bridgeActive); + const host = inHostMode(live) || onHostPage; + const snapshot = host ? live : stableSnapshot(live) ?? live; + const [saved, setSaved] = useState>({}); + const savedProfile = useRef(null); + // Until Bridge has answered, an edit would save a profile without the settings it already holds. + const [loaded, setLoaded] = useState(false); + useEffect(() => { + if (!onHostPage) return; + setLoaded(false); + const controller = new AbortController(); + let retry: ReturnType | undefined; + // Editing stays off until Bridge answers, so a failed read is tried again rather than left for good. + const load = (): void => { + void bridgeDefaultProfile(controller.signal).then((profile) => { + savedProfile.current = profile; + setSaved(profile?.settings.snapshot?.hostButtons ?? {}); + setLoaded(true); + }).catch(() => { + if (!controller.signal.aborted) retry = setTimeout(load, 5000); + }); + }; + load(); + return () => { + controller.abort(); + clearTimeout(retry); + }; + }, [onHostPage, live.status?.name]); + const hostReady = !onHostPage || loaded; + const hostButtons = live.gameProfileDraft ? live.status?.hostButtons ?? {} : saved; + // Several buttons change in one update: each would otherwise start from the same saved copy. + const updateHostButtons = (changes: Array<[number, HostAction | null]>): void => { + if (live.gameProfileDraft) { + for (const [bit, action] of changes) control.applyHostButton(bit, action); + return; + } + const name = live.status?.name; + if (!name || !loaded) return; + const next = { ...saved }; + for (const [bit, action] of changes) { + if (action) next[bit] = action; + else delete next[bit]; + } + const previous = { buttons: saved, profile: savedProfile.current }; + setSaved(next); + const base = savedProfile.current; + const profile: BridgeProfile = { + application: base?.application ?? { name: "Default", executable: "", path: "" }, + device: { id: `Logitech:${name}`, name }, + enabled: base?.enabled ?? true, + settings: { + dpi: base?.settings.dpi ?? null, + pollingRateHz: base?.settings.pollingRateHz ?? null, + snapshot: { ...(base?.settings.snapshot ?? {}), hostButtons: next }, + }, + }; + savedProfile.current = profile; + void saveBridgeDefaultProfile(profile).catch(() => { + // It never reached Bridge, so it must not read as saved. + setSaved(previous.buttons); + savedProfile.current = previous.profile; + control.pushToast("error", displayText(__uiLocale, "Could not save the side buttons. Is Bridge running?")); + }); + }; + const setHostButton = (bit: number, action: HostAction | null): void => updateHostButtons([[bit, action]]); const [layer, setLayer] = useState("primary"); const [selected, setSelected] = useState(null); const [query, setQuery] = useState(""); @@ -83,7 +204,10 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re const [recording, setRecording] = useState(false); const [sharing, setSharing] = useState(false); - const recordable = selected !== null && !(selected <= 1 && layer === "primary") && !live.settingInProgress; + const setHostButtonRef = useRef(setHostButton); + setHostButtonRef.current = setHostButton; + const recordable = selected !== null && !live.settingInProgress && hostReady + && (host ? HOST_BUTTONS.includes(selected) : !(selected <= 1 && layer === "primary")); useEffect(() => { if (!recording || !recordable || selected === null) return; const onKey = (event: KeyboardEvent): void => { @@ -94,24 +218,28 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re } const binding = bindingFromKeyEvent(event); if (!binding || binding.kind !== "keyboard") return; - void control.applyLogitechButtonAssignment(layer, selected, binding); - setPending((previous) => ({ ...previous, [`${layer}-${selected}`]: shortcutText(binding.key, binding.modifiers) })); + if (host) { + setHostButtonRef.current(hostBit(selected), { key: binding.key, modifiers: binding.modifiers }); + } else { + void control.applyLogitechButtonAssignment(layer, selected, binding); + setPending((previous) => ({ ...previous, [`${layer}-${selected}`]: shortcutText(binding.key, binding.modifiers) })); + } setRecording(false); }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [recording, recordable, selected, layer]); + }, [recording, recordable, selected, layer, host]); const status = snapshot.status; const entry = snapshot.profile.entry; - const estimate = status ? control.batteryEstimateParts(status, snapshot.preferences.locale) : null; const layout = status ? buttonMapLayoutFor(status.name) : null; - if (!status || !entry || !layout) return onHostProfile(live) ? : null; + if (!status || !(entry || host) || !layout) return onHostProfile(live) ? : null; - const assignments = layer === "primary" ? entry.buttonAssignments : entry.gShiftAssignments; + const assignments = entry ? (layer === "primary" ? entry.buttonAssignments : entry.gShiftAssignments) : []; const assignmentFor = (button: number) => assignments.find((assignment) => assignment.button === button); - const isLocked = (spot: ButtonSpot): boolean => spot.locked && layer === "primary"; + const isLocked = (spot: ButtonSpot): boolean => (host ? !HOST_BUTTONS.includes(spot.button) : spot.locked && layer === "primary"); const textFor = (spot: ButtonSpot): string => { + if (host) return hostText(hostButtons[hostBit(spot.button)], spot.button); const staged = snapshot.stagedProfileButtonAssignments.find((item) => item.layer === layer && item.button === spot.button); if (staged) return pending[`${layer}-${spot.button}`] ?? "Pending"; const assignment = assignmentFor(spot.button); @@ -119,11 +247,17 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re }; const selectedSpot = layout.spots.find((spot) => spot.button === selected) ?? null; - const canAssign = selectedSpot !== null && !isLocked(selectedSpot) && !live.settingInProgress; + const canAssign = selectedSpot !== null && !isLocked(selectedSpot) && !live.settingInProgress && hostReady; const current = selected !== null ? assignmentFor(selected) : undefined; + const hostCurrent = host && selected !== null ? hostButtons[hostBit(selected)] : undefined; const assign = (command: CommandEntry): void => { if (!canAssign || selected === null) return; + if (host) { + const action = hostActionFor(command); + if (action) setHostButton(hostBit(selected), action); + return; + } const binding = command.target.kind === "action" ? command.target.action : command.target; void control.applyLogitechButtonAssignment(layer, selected, binding); setPending((previous) => ({ ...previous, [`${layer}-${selected}`]: command.label })); @@ -131,6 +265,10 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re const defaultFor = (button: number): LogitechButtonAction => (layer === "primary" ? DEFAULT_ACTION[button] : "Disabled") as LogitechButtonAction; const reset = async (buttons: number[]): Promise => { + if (host) { + updateHostButtons(buttons.map((button): [number, null] => [hostBit(button), null])); + return; + } for (const button of buttons) { await control.applyLogitechButtonAssignment(layer, button, defaultFor(button)); setPending((previous) => ({ ...previous, [`${layer}-${button}`]: defaultFor(button) })); @@ -143,14 +281,17 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re const sections = COMMAND_SECTIONS .map((section) => ({ ...section, - entries: section.entries.filter((command) => matchesCommandQuery(__uiLocale, query, command.label, command.hint)), + entries: section.entries.filter((command) => (!host || hostSupported(command)) + && matchesCommandQuery(__uiLocale, query, command.label, command.hint)), })) .filter((section) => section.entries.length > 0); const help = selectedSpot === null ? "Pick a button on the mouse, then a command." : isLocked(selectedSpot) - ? "Primary and secondary click are locked so you cannot lose control of the mouse." + ? host + ? `Only the two side buttons can be changed ${onHostPage ? "on the Host profile" : "in a game profile"}.` + : "Primary and secondary click are locked so you cannot lose control of the mouse." : `Pick a command for ${selectedSpot.label}.`; return ( @@ -158,19 +299,9 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re

{displayText(__uiLocale, "Button assignments")}

-
- {status.name} - {status.connectionType ? {displayText(__uiLocale, connectionText(snapshot.preferences.locale, status.connectionType))} : null} - {status.batteryPercent !== null ? ( - - {status.batteryPercent}% - {displayText(__uiLocale, estimate ? ` · ${estimate.time}` : "")} - - ) : null} -
- + {host ? null : }
- setSharing(false)} /> + {host ? null : setSharing(false)} />}
{displayText(__uiLocale, help)} + {onHostPage ? ( + + {displayText(__uiLocale, "Saved in Bridge and used whenever no game's profile is active, while the mouse is on the Host profile. A game's profile overrides it while that game runs.")} + + ) : null}
{sections.map((section) => (
@@ -204,7 +340,7 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re key={command.id} type="button" className="button-map-command" - aria-pressed={current ? isAssigned(command, current.action, current.raw) : false} + aria-pressed={host ? isHostAssigned(command, hostCurrent) : current ? isAssigned(command, current.action, current.raw) : false} disabled={!canAssign} onClick={() => assign(command)} > @@ -241,10 +377,12 @@ export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): Re ))}
{hints.length ?
    {hints.map((hint) =>
  • {displayText(__uiLocale, hint)}
  • )}
: null} -
- - -
+ {host ? null : ( +
+ + +
+ )}
diff --git a/src/features/buttons/logitech-commands.test.ts b/src/features/buttons/logitech-commands.test.ts index 2f139e1d..1308ed11 100644 --- a/src/features/buttons/logitech-commands.test.ts +++ b/src/features/buttons/logitech-commands.test.ts @@ -6,6 +6,15 @@ import { ALT, COMMAND_SECTIONS, CTRL, META, SHIFT, bindingFromKeyEvent, conflict const entries = COMMAND_SECTIONS.flatMap((section) => section.entries); +test("F13 to F24 record and read back, so a rarely used key can be assigned", () => { + assert.equal(usageForCode("F13"), 0x68); + assert.equal(usageForCode("F24"), 0x73); + assert.equal(usageForCode("F25"), null); + assert.equal(keyName(0x68), "F13"); + assert.equal(keyName(0x73), "F24"); + assert.equal(shortcutText(0x68, CTRL), "Ctrl + F13"); +}); + test("every command has a unique id", () => { assert.equal(new Set(entries.map((entry) => entry.id)).size, entries.length); }); diff --git a/src/features/buttons/logitech-commands.ts b/src/features/buttons/logitech-commands.ts index 3afe07a1..b04bd771 100644 --- a/src/features/buttons/logitech-commands.ts +++ b/src/features/buttons/logitech-commands.ts @@ -122,6 +122,7 @@ export function keyName(usage: number): string { if (usage >= 0x1e && usage <= 0x26) return String(usage - 0x1d); if (usage === 0x27) return "0"; if (usage >= 0x3a && usage <= 0x45) return `F${usage - 0x39}`; + if (usage >= 0x68 && usage <= 0x73) return `F${usage - 0x68 + 13}`; return KEY_NAMES[usage] ?? `Key 0x${usage.toString(16)}`; } @@ -204,6 +205,8 @@ export function usageForCode(code: string): number | null { if (digit) return digit[1] === "0" ? 0x27 : 0x1d + Number(digit[1]); const fn = /^F([1-9]|1[0-2])$/.exec(code); if (fn) return 0x39 + Number(fn[1]); + const extra = /^F(1[3-9]|2[0-4])$/.exec(code); + if (extra) return 0x68 + Number(extra[1]) - 13; return CODE_USAGE[code] ?? null; } diff --git a/src/features/devices/cards/DpiCard.tsx b/src/features/devices/cards/DpiCard.tsx index efd26b1e..0ed5f4ad 100644 --- a/src/features/devices/cards/DpiCard.tsx +++ b/src/features/devices/cards/DpiCard.tsx @@ -14,6 +14,8 @@ import { LiftOffDistance, hasLiftOff } from "./PerformanceCards"; import { IconLinked, IconUnlinked } from "../../../shared/icons"; const MAX_EDITOR_ROWS = 4; +/** G HUB's Host profile has five DPI slots. */ +const MAX_HOST_ROWS = 5; const DEFAULT_LOD = 2; type StageRow = { enabled: boolean; value: string; valueY: string; lod: number; linked?: boolean }; @@ -63,7 +65,10 @@ function DpiStageEditor({ && snapshot.capabilities?.dpiStageAxesWritable === true && Array.isArray(status?.dpiStagesY); const slotAxesEditable = mode === "logitech" && status?.supportsSeparateDpiAxes === true; - const showAxes = stageAxesEditable || slotAxesEditable; + const hostAxesEditable = mode === "generic" && snapshot.traits.logitech && status?.supportsSeparateDpiAxes === true; + const axesLinkable = slotAxesEditable || hostAxesEditable; + const showAxes = stageAxesEditable || slotAxesEditable || hostAxesEditable; + const genericRows = hostAxesEditable ? MAX_HOST_ROWS : MAX_EDITOR_ROWS; const stageColors = mode === "stage" ? status?.dpiStageColors ?? (isNoirKsnakeStatus(status) ? NOIR_DPI_STAGE_COLORS : null) : null; @@ -75,13 +80,14 @@ function DpiStageEditor({ const locked = mode === "logitech" ? snapshot.profile.slotsLocked : snapshot.settingsPending; const countCap = isLogitech ? (limits?.maxStages ?? 1) : stageEditor?.maxStages ?? 1; - const rowCap = mode === "generic" ? MAX_EDITOR_ROWS : countCap; + const rowCap = mode === "generic" ? genericRows : countCap; - const initRows = (prev?: StageRow[]): StageRow[] => { + const initRows = (): StageRow[] => { if (mode === "logitech" && snapshot.dpiSlotPlan) { const stages = snapshot.dpiSlotPlan.stages.slice(0, rowCap); + const unlinked = control.unlinkedDpiSlots(); const last = stages[stages.length - 1]?.x ?? status?.dpi ?? 800; - const rows = stages.map((stage, index) => ({ enabled: true, value: String(stage.x), valueY: String(stage.y ?? stage.x), lod: stage.lod, linked: (stage.y ?? stage.x) === stage.x && prev?.[index]?.linked !== false })); + const rows = stages.map((stage, index) => ({ enabled: true, value: String(stage.x), valueY: String(stage.y ?? stage.x), lod: stage.lod, linked: (stage.y ?? stage.x) === stage.x && !unlinked.includes(index) })); while (rows.length < rowCap) rows.push({ enabled: false, value: String(last), valueY: String(last), lod: DEFAULT_LOD, linked: true }); return rows; } @@ -98,12 +104,37 @@ function DpiStageEditor({ while (rows.length < rowCap) rows.push({ enabled: false, value: String(last), valueY: String(last), lod: DEFAULT_LOD }); return rows; } + const savedSlots = hostAxesEditable ? control.loadHostDpiSlots() : null; + if (savedSlots) { + // Keep the slots where the user put them; only the highlighted one follows the live DPI. + const slots = savedSlots.slice(0, genericRows); + const current = { x: status?.dpi ?? 800, y: status?.dpiY ?? status?.dpi ?? 800 }; + let activeAt = slots.findIndex((slot) => slot.x === current.x && slot.y === current.y); + if (activeAt < 0) { + // The mouse was changed elsewhere since: its DPI takes the first slot, so a row is always the live one. + slots.unshift(current); + slots.length = Math.min(slots.length, genericRows); + activeAt = 0; + } + while (slots.length < genericRows) slots.push({ ...(slots[slots.length - 1] ?? current) }); + return slots.map((slot, index) => ({ + enabled: index === activeAt, + value: String(slot.x), + valueY: String(slot.y), + lod: DEFAULT_LOD, + linked: slot.unlinked !== true && slot.x === slot.y, + })); + } const presets = dpiPresetValues(options); const ordered = [status?.dpi, ...presets.filter((value) => value !== status?.dpi)] .filter((value): value is number => typeof value === "number") - .slice(0, MAX_EDITOR_ROWS); - while (ordered.length < MAX_EDITOR_ROWS) ordered.push(ordered[ordered.length - 1] ?? 800); - return ordered.map((value) => ({ enabled: value === status?.dpi, value: String(value), valueY: String(value), lod: DEFAULT_LOD })); + .slice(0, genericRows); + while (ordered.length < genericRows) ordered.push(ordered[ordered.length - 1] ?? 800); + return ordered.map((value) => { + const active = value === status?.dpi; + const valueY = active && hostAxesEditable ? status?.dpiY ?? value : value; + return { enabled: active, value: String(value), valueY: String(valueY), lod: DEFAULT_LOD, linked: valueY === value }; + }); }; const [rows, setRows] = useState(initRows); @@ -155,12 +186,12 @@ function DpiStageEditor({ ? (snapshot.dpiSlotPlan?.stages.map((stage) => `${stage.x}/${stage.y}`).join(",") ?? "-") : mode === "stage" ? `${status?.dpiStages?.join(",") ?? "-"}|${status?.dpiStagesY?.join(",") ?? "-"}` - : String(status?.dpi ?? 0); + : `${status?.dpi ?? 0}/${status?.dpiY ?? 0}`; const lastSigRef = useRef(sourceSig); useEffect(() => { if (sourceSig === lastSigRef.current) return; lastSigRef.current = sourceSig; - if (Date.now() - touchedRef.current > 1500 || (mode === "logitech" && !timerRef.current)) setRows(initRows(rowsRef.current)); + if (Date.now() - touchedRef.current > 1500 || (mode === "logitech" && !timerRef.current)) setRows(initRows()); // initRows is recreated each render on purpose so it always reads fresh snapshot data. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sourceSig]); @@ -184,12 +215,28 @@ function DpiStageEditor({ const parseRow = (raw: string): number | null => /^\d+$/.test(raw.trim()) && Number(raw) > 0 ? Number(raw) : null; + const persistHostSlots = (list: StageRow[]): void => { + const snapped = (raw: string, fallback: number): number => { + const parsed = parseRow(raw) ?? fallback; + return closestDpiOption(options, parsed) ?? parsed; + }; + control.saveHostDpiSlots(list.map((row) => { + const x = snapped(row.value, status?.dpi ?? 800); + // Equal X and Y cannot say whether the pair was unlinked on purpose, so the choice is stored with it. + return { x, y: snapped(row.valueY, x), ...(row.linked === false ? { unlinked: true } : {}) }; + })); + }; + const commit = (next: StageRow[]): void => { if (mode === "logitech") { // Lift-off lives in the slot plan itself now (edited from the dedicated // side panel), so a DPI-value commit must not clobber it with the local // row copy. Enabled rows map 1:1 to plan stages in order. const planLods = snapshot.dpiSlotPlan?.stages.map((stage) => stage.lod) ?? []; + // Disabled rows drop out of the plan, so saved unlink rows move to their compacted index. + control.replaceDpiUnlinkedSlots( + next.filter((row) => row.enabled).flatMap((row, position) => (row.linked === false ? [position] : [])), + ); control.applyDpiSlotEditor( next.map((row, index) => { const position = next.slice(0, index).filter((entry) => entry.enabled).length; @@ -228,9 +275,19 @@ function DpiStageEditor({ }); return; } + if (hostAxesEditable) persistHostSlots(next); const active = next.find((row) => row.enabled); if (!active) return; const snap = closestDpiOption(options, parseRow(active.value) ?? status?.dpi ?? 0); + if (hostAxesEditable) { + const snapY = snap === null ? null : closestDpiOption(options, parseRow(active.valueY) ?? snap); + if (snap === null || snapY === null) return; + if (snap !== status?.dpi || snapY !== (status?.dpiY ?? status?.dpi)) control.applyLogitechAxisDpi(snap, snapY); + const synced = next.map((row) => (row.enabled ? { ...row, value: String(snap), valueY: String(snapY) } : row)); + setRows(synced); + rowsRef.current = synced; + return; + } if (snap !== null && (singleFillsStages ? stagesDifferFrom(snap) : snap !== status?.dpi)) { applySingleDpi(snap); const next2 = next.map((row) => (row.enabled ? { ...row, value: String(snap) } : row)); @@ -265,7 +322,9 @@ function DpiStageEditor({ const selected = next.map((row, i) => (i === index ? { ...row, enabled: true } : { ...row, enabled: false })); setRows(selected); rowsRef.current = selected; - applySingleDpi(snap); + if (hostAxesEditable) persistHostSlots(selected); + if (hostAxesEditable) control.applyLogitechAxisDpi(snap, closestDpiOption(options, parseRow(next[index].valueY) ?? snap) ?? snap); + else applySingleDpi(snap); touchedRef.current = Date.now(); return; } @@ -292,7 +351,7 @@ function DpiStageEditor({ const setValue = (index: number, raw: string, delay?: number): void => { const value = raw.replace(/[^\d]/g, ""); const next = rows.map((row, i) => (i === index - ? { ...row, value, valueY: slotAxesEditable && row.linked !== false ? value : row.valueY } + ? { ...row, value, valueY: axesLinkable && row.linked !== false ? value : row.valueY } : row)); setRows(next); rowsRef.current = next; @@ -314,11 +373,10 @@ function DpiStageEditor({ }; const toggleLink = (index: number): void => { - const next = rows.map((row, i) => { - if (i !== index) return row; - const linked = row.linked === false; - return { ...row, linked, valueY: linked ? row.value : row.valueY }; - }); + const linked = rows[index]?.linked === false; + // Host presets keep their link state with the preset; only Logitech slots live in the profile's own record. + if (mode === "logitech") control.setDpiSlotUnlinked(index, !linked); + const next = rows.map((row, i) => (i === index ? { ...row, linked, valueY: linked ? row.value : row.valueY } : row)); setRows(next); rowsRef.current = next; markEdited(next); @@ -394,7 +452,7 @@ function DpiStageEditor({ const enabledCount = rows.filter((row) => row.enabled).length; return ( -
+
{t(locale, mode === "logitech" ? "dpi.slotsInUse" : mode === "stage" ? "dpi.stagesInUse" : "dpi.presetsInUse")} @@ -462,7 +520,7 @@ function DpiStageEditor({ onBlur={flushEdit} onKeyDown={(event) => { if (event.key === "Enter") flushEdit(); }} /> - {slotAxesEditable ? ( + {axesLinkable ? (
diff --git a/src/features/devices/cards/PerformanceCards.tsx b/src/features/devices/cards/PerformanceCards.tsx index 2d65ebc2..ec496516 100644 --- a/src/features/devices/cards/PerformanceCards.tsx +++ b/src/features/devices/cards/PerformanceCards.tsx @@ -23,6 +23,10 @@ function lodLabel(locale: InterfaceLocale, level: LiftOffLevel): string { return level === "Low" ? t(locale, "perf.lodLow") : level === "Medium" ? t(locale, "perf.lodMedium") : t(locale, "perf.lodHigh"); } +function nearestRateAtOrBelow(options: number[], hz: number): number { + return options.filter((rate) => rate <= hz).at(-1) ?? options[0] ?? hz; +} + export function PollingCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const status = snapshot.status; if (!status) return null; @@ -36,6 +40,14 @@ export function PollingCard({ snapshot }: { snapshot: ControlSnapshot }): ReactN const locked = snapshot.profileFormat?.writable !== true; const shared = (snapshot.profileFormat?.id ?? 6) < 6; const nativeX11 = isNativeAttackSharkX11(status); + // A game profile keeps a rate per connection, like the onboard profiles do. + const splitInGame = snapshot.gameProfileDraft && !shared && status.connectionType !== undefined + && snapshot.profile.rateOptions.wireless.length > 0 && snapshot.profile.rateOptions.wired.length > 0; + const gameRates = (["wireless", "wired"] as const).map((link) => { + const options = snapshot.profile.rateOptions[link]; + const set = link === "wireless" ? status.pollingRateWirelessHz : status.pollingRateWiredHz; + return { link, options, hz: snapshot.gameProfileRates[link] ?? set ?? nearestRateAtOrBelow(options, status.pollingRateHz) }; + }); const advertisedRates = (status.supportedPollingRates ?? RATE_STEPS_HZ) .filter((rate) => !(snapshot.traits.eggControls && rate < 1000)) @@ -87,6 +99,25 @@ export function PollingCard({ snapshot }: { snapshot: ControlSnapshot }): ReactN /> ))}
+ ) : splitInGame ? ( +
+ {gameRates.map(({ link, hz: shown, options }) => ( + fullChargeHoursAt(status.name, hz)} + onChange={(hz) => { + control.applyGameProfilePollingRate(link, hz); + // The other slider follows the mouse's live rate until it is set, so it is set here to stay put. + for (const other of gameRates) if (other.link !== link) control.applyGameProfilePollingRate(other.link, other.hz); + }} + /> + ))} +
) : (
-

{t(locale, "perf.bunnyHop")}{t(locale, "dpi.perProfile")}

+

{t(locale, "perf.bunnyHop")}{host ? displayText(locale, "Host") : t(locale, "dpi.perProfile")}

control.applyBunnyHopMs(next ? BUNNY_HOP_LIMITS.minMs : 0)} + onChange={(next) => apply(next ? BUNNY_HOP_LIMITS.minMs : 0)} /> control.applyBunnyHopMs(clampBunnyHopMs(Number(event.currentTarget.value)))} + onChange={(event) => apply(clampBunnyHopMs(Number(event.currentTarget.value)))} /> ms
- {displayText(locale, locked + {displayText(locale, host + ? t(locale, "perf.bunnyHostNote") + : locked ? t(locale, "perf.bunnyReadonly") : tp(locale, "perf.bunnyNote", { min: BUNNY_HOP_LIMITS.minMs, max: BUNNY_HOP_LIMITS.maxMs, step: BUNNY_HOP_LIMITS.stepMs }))} diff --git a/src/features/games/CustomGameDialog.tsx b/src/features/games/CustomGameDialog.tsx new file mode 100644 index 00000000..782a11e8 --- /dev/null +++ b/src/features/games/CustomGameDialog.tsx @@ -0,0 +1,307 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + bridgeImageFromUrl, + bridgeRunningApplications, + saveBridgeCustomGameImage, + saveBridgeCustomGames, + type BridgeApplication, + type BridgeGame, +} from "../bridge/bridge"; +import { prepareGameImage } from "./game-image"; +import { t } from "../../i18n/index"; +import type { InterfaceLocale } from "../settings/interface-preferences"; +import { AppIcon } from "./GameRequestDialog"; + +const MAX_NAME_LENGTH = 80; +type Kind = "game" | "app"; + +function suggestedName(fileName: string): string { + return fileName.replace(/\.(exe|app)$/i, ""); +} + +// A browser file picker only reveals the file's name, never its folder, and the +// name is exactly what Bridge matches running games on, so that is all we keep. +export function CustomGameDialog({ open, onClose, locale = "en", knownGames, customGames, onSaved }: { + open: boolean; + onClose: () => void; + locale?: InterfaceLocale; + knownGames: BridgeGame[]; + customGames: BridgeGame[]; + onSaved: () => void; +}): ReactNode { + const dialog = useRef(null); + const picker = useRef(null); + const [name, setName] = useState(""); + const [exe, setExe] = useState(""); + const [kind, setKind] = useState("game"); + const [apps, setApps] = useState(null); + const imagePicker = useRef(null); + const [image, setImage] = useState(null); + const [imageUrl, setImageUrl] = useState(null); + const [link, setLink] = useState(""); + const [linkBusy, setLinkBusy] = useState(false); + const [linkFailed, setLinkFailed] = useState(false); + // A picked file is being resized: adding the game now would save it without its picture. + const [preparing, setPreparing] = useState(false); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open) { + if (typeof element.showModal === "function") element.showModal(); + else element.setAttribute("open", ""); + } else if (typeof element.close === "function") { + if (element.open) element.close(); + } else { + element.removeAttribute("open"); + } + }, [open]); + + // Bumped whenever the dialog opens or closes: a download or a picture still + // being prepared from the last time must not land in the next game. + const session = useRef(0); + useEffect(() => { + session.current += 1; + if (!open) return; + setName(""); + setExe(""); + setKind("game"); + setApps(null); + setImage(null); + setLink(""); + setLinkBusy(false); + setPreparing(false); + setLinkFailed(false); + setFailed(false); + }, [open]); + + useEffect(() => { + if (!image) { + setImageUrl(null); + return; + } + const url = URL.createObjectURL(image); + setImageUrl(url); + return () => URL.revokeObjectURL(url); + }, [image]); + + async function loadRunningApps(): Promise { + const mine = session.current; + try { + const list = await bridgeRunningApplications(); + if (mine !== session.current) return; + setApps(list + .filter((app) => app.executable) + .sort((a, b) => Number(b.foreground) - Number(a.foreground) || a.name.localeCompare(b.name))); + } catch { + if (mine === session.current) setApps([]); + } + } + + function pickApp(app: BridgeApplication): void { + setExe(app.executable); + if (!name.trim()) setName(app.name.slice(0, MAX_NAME_LENGTH)); + setApps(null); + } + + const trimmedName = name.trim(); + const duplicate = knownGames.some((game) => + game.name.toLowerCase() === trimmedName.toLowerCase() + || (exe !== "" && game.executables.some((known) => known.toLowerCase() === exe.toLowerCase())), + ); + const canAdd = trimmedName !== "" && exe !== "" && !duplicate && !busy && !linkBusy && !preparing; + + async function fetchLink(): Promise { + if (link.trim() === "" || linkBusy) return; + const mine = session.current; + setLinkBusy(true); + setLinkFailed(false); + try { + const prepared = await prepareGameImage(await bridgeImageFromUrl(link.trim())); + if (mine !== session.current) return; + if (!prepared) throw new Error("unreadable image"); + setImage(prepared); + setLink(""); + } catch { + if (mine === session.current) setLinkFailed(true); + } finally { + if (mine === session.current) setLinkBusy(false); + } + } + + async function add(): Promise { + if (!canAdd) return; + setBusy(true); + setFailed(false); + try { + await saveBridgeCustomGames([...customGames, { name: trimmedName, executables: [exe] }]); + // The game is saved even when its image is not; the tile then shows the default. + if (image) await saveBridgeCustomGameImage(trimmedName, image).catch(() => undefined); + onSaved(); + onClose(); + } catch { + setFailed(true); + } finally { + setBusy(false); + } + } + + return ( + { if (event.target === dialog.current) onClose(); }} + > +
{ + event.preventDefault(); + void add(); + }} + > +
+
+

{t(locale, "customgame.tile")}

+
+ +
+ +

{t(locale, "customgame.description")}

+ +
+ {(["game", "app"] as const).map((key) => ( + + ))} +
+ +
+ { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + if (!file) return; + setExe(file.name); + if (!name.trim()) setName(suggestedName(file.name).slice(0, MAX_NAME_LENGTH)); + }} + /> + + + {exe ? {exe} : null} + {apps !== null ? ( + apps.length === 0 ? ( +

{t(locale, "gamereq.noRunning")}

+ ) : ( +
    + {apps.map((app) => ( +
  • + +
  • + ))} +
+ ) + ) : null} +
+ + + +
+ { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + if (!file) return; + const mine = session.current; + setPreparing(true); + void prepareGameImage(file) + .then((prepared) => { if (mine === session.current) setImage(prepared); }) + .finally(() => { if (mine === session.current) setPreparing(false); }); + }} + /> + + {imageUrl ? ( + <> + + + + ) : null} +
+ +
+ + +
+ {linkFailed ?

{t(locale, "customgame.linkFailed")}

: null} + + {duplicate ?

{t(locale, "customgame.duplicate")}

: null} + {failed ?

{t(locale, "customgame.failed")}

: null} + +
+ + +
+
+
+ ); +} diff --git a/src/features/games/CustomGameImage.tsx b/src/features/games/CustomGameImage.tsx new file mode 100644 index 00000000..14bef4d7 --- /dev/null +++ b/src/features/games/CustomGameImage.tsx @@ -0,0 +1,11 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Gamepad2 } from "lucide-react"; +import { customGameImageUrl } from "../bridge/bridge"; + +/** The picture a custom game was given, or the default icon when it has none. */ +export function CustomGameImage({ name, version = 0, size = 28 }: { name: string; version?: number; size?: number }): ReactNode { + const [failed, setFailed] = useState(false); + useEffect(() => setFailed(false), [name, version]); + if (failed) return
-

{game.name}

+ {titleDraft !== null ? ( + { setTitleDraft(event.currentTarget.value); setTitleError(null); }} + onKeyDown={(event) => { + if (event.key === "Enter") void renameGame(); + if (event.key === "Escape") { cancelled.current = true; setTitleDraft(null); setTitleError(null); } + }} + onBlur={() => { + if (cancelled.current) { cancelled.current = false; return; } + void renameGame(); + }} + /> + ) : ( +
+

{game.name}

+ {custom && onRename ? ( + + ) : null} +
+ )} + {titleError ? {t(locale, titleError === "duplicate" ? "customgame.duplicate" : "customgame.failed")} : null} {displayText(locale, changedCount === 0 ? t(locale, "games.noChanges") : tp(locale, "games.changedCount", { n: changedCount }))}
- +
+ + +
diff --git a/src/features/games/GameRequestDialog.tsx b/src/features/games/GameRequestDialog.tsx index ee8cc61b..1d337aa0 100644 --- a/src/features/games/GameRequestDialog.tsx +++ b/src/features/games/GameRequestDialog.tsx @@ -102,7 +102,7 @@ function useCopied(): [string | null, (key: string, text: string) => void] { } /** Bridge only extracts icons on Windows; everywhere else the URL 404s. */ -function AppIcon({ app }: { app: BridgeApplication }): ReactNode { +export function AppIcon({ app }: { app: BridgeApplication }): ReactNode { const __uiLocale = useUiLocale(); const [failed, setFailed] = useState(false); if (failed) return