Skip to content

feat(games): custom games, game profile parity and custom game images - #1177

Draft
onyxhq-dev wants to merge 1 commit into
OpenMouse-Project:mainfrom
onyxhq-dev:feat/custom-game-icons
Draft

onyxhq-dev wants to merge 1 commit into
OpenMouse-Project:mainfrom
onyxhq-dev:feat/custom-game-icons

Conversation

@onyxhq-dev

@onyxhq-dev onyxhq-dev commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Lets you add your own games to the Games page, and brings Logitech game profiles to the same level as the Host profile in G HUB. Needs OpenMouse Bridge for all of it.

Custom games

  • Add a game by picking its exe, or pick from the apps that are running. Bridge tracks it like a catalog game. It is stored in the Bridge config, not in the browser or on the site.
  • A custom game can have a picture: upload one or paste a link. The picture shows on the games grid and on the game's profile page. "Change image" appears when you hover the picture (always visible on touch screens).
  • A custom game can be renamed from a pencil next to its title. Enter or clicking away saves, Escape cancels, and a name already in the list is refused. The picture and the saved profile move with the name.

Game profiles

  • DPI X and Y, lift-off, report rate, bunny hop (HITS) and the side button assignments are part of a game profile, with an "Apply now" button.
  • A report rate per connection: a mouse such as the PRO X 3 Superstrike shows a wireless and a wired slider in a game profile, and a launch applies the one for the connection in use.
  • Back and Forward can be assigned to a key or a media key per game, and in the default profile. Bridge presses them while the profile is active.
  • Includes the change from Keep an unlinked Logitech X/Y slot unlinked after a refresh #1154 (an unlinked Logitech X/Y slot stays unlinked after a refresh), so that PR can be closed.

Checks

  • npx tsc --noEmit, npm test (408 tests) and npm run build pass. The JS budget is raised from 2,800,000 to 2,825,000 bytes (measured 2,823,553), with a note in build/check-bundle-size.ts, as AGENTS.md asks.
  • All new strings are in every locale.
  • Tried by hand on a PRO X 3 Superstrike with Bridge on Windows: custom games, game profiles, bunny hop, the default Back and Forward mapping and the side buttons all work.

Draft because it uses MouseStatus.pollingRateWirelessHz, pollingRateWiredHz and hostButtons from OpenMouse-Project/mouse-protocol#246. Once that is released, @openmouse/protocol in package.json needs bumping to it, and the type check will pass in CI. Bridge side: OpenMouse-Project/OpenMouse-Bridge#32.

Note

Add custom games with images, game profile parity, and Host-button editing

  • Adds a custom-game dialog on the Games page: pick an executable or a running app, name the game, and optionally set an image from a file or URL. Games can be renamed, removed, and have their image replaced later; images are downscaled by prepareGameImage to at most 800px/450 KiB.
  • Extends game profiles with Host-side settings: per-connection (wired/wireless) polling rates, Host bunny-hop, Host side-button assignments, and analog tuning, plus an Apply Now action that temporarily writes the draft and restores prior values when leaving the page.
  • Adds Bridge APIs for custom games, custom-game images, image-from-URL fetch, and the default profile; updates all 12 locales and raises the JS bundle budget to 2,825,000 bytes.
  • Behavioral changes: while a game-profile draft is active, buildProfileView hides the normal profile entry, DPI slots, and bunny-hop support; analog-button writes during a profile operation are no longer persisted to the mouse; ordinary pending-change flashing stays blocked during draft editing while Apply Now uses the internal staged flasher.
📊 Macroscope summarized 14b5232. 30 files reviewed, 24 issues evaluated, 8 issues filtered, 15 comments posted

🗂️ Filtered Issues

src/features/devices/cards/PerformanceCards.tsx — 0 comments posted, 1 evaluated, 1 filtered
  • line 113: The new game-rate sliders remain enabled during snapshot.settingInProgress. Clicking a different rate while “Apply now” is flashing calls applyGameProfilePollingRate, which updates gameProfileDraft.rates before stageChange rejects the change because a write is in progress. Consequently the slider and gameProfileDraftSnapshot() show the new rate, but the mouse receives the previous rate and no pending change remains to apply the new one. Disable these sliders during writes (as the onboard-profile sliders do), or guard before invoking the controller. [ Cross-file consolidated ]
src/features/games/CustomGameDialog.tsx — 1 comment posted, 2 evaluated, 1 filtered
  • line 104: canAdd ignores pending image preparation/downloads. After clicking “Use link”, the user can immediately click Add; add() captures the current image (often null), saves the game and closes without waiting for fetchLink(). Its later setImage(prepared) never uploads the chosen picture. File preparation has the same race. Track pending image work and await it or disable submission until it finishes. [ Exceeded comment limit ]
src/features/games/GameProfilePanel.tsx — 0 comments posted, 3 evaluated, 3 filtered
  • line 189: Dropping the Apply-now restore snapshot solely because this game is active loses the original settings when the game starts while the editor is open. useBridgeProfileApplier cannot apply/capture its baseline during a draft (flashGameProfileSnapshot returns busy). For example, start at 800 DPI, apply 1600 here, launch the game, then leave this panel: forgetGameProfileApply() discards 800, and the applier subsequently captures 1600 as its baseline. Stopping the game then restores 1600 instead of 800. Transfer the saved baseline to the applier or restore it before the applier takes ownership. [ Cross-file consolidated ]
  • line 190: The Apply-now restoration is tied only to panel unmount, not to the mouse it modified. Apply a draft on mouse A, select mouse B using the device list, then go Back: selectTarget switches the controller without restoring A, and this cleanup calls restoreGameProfileApply() on B. The controller keeps one unkeyed gameProfileRestore and flashes it through the currently active client, so A retains the temporary settings and B is overwritten with A's previous settings. Restore before switching targets and associate the restore state with the originating device. [ Cross-file consolidated ]
  • line 257: An edit made while a rename request is pending can be silently lost if the rename fails. The workspace remains editable, but the debounce callback clears pendingSave.current and returns here while renaming.current is true. If onRename later returns failed (its network-error path), resetting the ref does not rerun the save effect, whose dependencies remain [currentKey, loaded]; unmount also has no pending save left to flush. Reschedule suppressed saves after renaming finishes, or prevent edits during the rename. [ Exceeded comment limit ]
src/features/games/GamesPage.tsx — 0 comments posted, 3 evaluated, 3 filtered
  • line 28: A failure of /v1/custom-games now hides even a successfully fetched games catalog. bridgeRequest throws on non-OK responses, and Promise.all rejects when either input rejects (MDN, Promise.all), so the swallowed rejection leaves both lists empty on initial load. GamesPage uses that empty bridgeList whenever Bridge is active, with no catalog fallback or retry until active or version changes. Handle the requests independently or expose a recoverable loading error so a custom-games request failure does not disable all existing game-profile selection. [ Exceeded comment limit ]
  • line 82: renameCustomGame commits the custom-game rename before fetching/saving its profiles, but updates local state only after all those requests succeed. If bridgeProfiles() or saveBridgeProfiles() fails, the catch returns "failed" while the custom-game list has already been saved with the new name. The panel and customGames still use the old name, so retrying operates on stale entries and the saved profile can remain stranded under the old name. Reconcile or roll back the first write on failure, rather than treating the multi-write operation as atomic. [ Exceeded comment limit ]
  • line 99: Removing games is neither serialized nor reflected optimistically in customGames. With games A and B, clicking both remove buttons before the refresh completes sends [B] followed by [A] through saveBridgeCustomGames; the second request includes the game just removed. Both promises can report success while one deletion is undone. Disable removals until the write/refetch completes, or serialize updates against the latest list. [ Exceeded comment limit ]

@coderabbitai

coderabbitai Bot commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added area: ui Interface and styling area: devices Device integration and HID support area: translations Localized interface strings area: ci Automation and build configuration labels Oct 20, 2026
@onyxhq-dev
onyxhq-dev force-pushed the feat/custom-game-icons branch from 85d6a15 to 14b5232 Compare October 23, 2026 02:29
Comment thread src/device/controller.ts
command: "Set the side button assignments",
progress: "Saving the side button assignments…",
preview: (status) => { status.hostButtons = structuredClone(buttons); },
apply: async () => {},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High device/controller.ts:5585

Clicking Apply now reports success without applying the selected side-button assignments, and the subsequent status refresh resets hostButtons to {}, losing the displayed draft. stageHostButtons supplies an empty apply, so flashStagedChanges has nothing to write; implement the Bridge-side write or keep this change pending instead of treating it as applied.

🤖 Copy this AI Prompt to have your agent fix this:
In file @src/device/controller.ts around line 5585:

Clicking Apply now reports success without applying the selected side-button assignments, and the subsequent status refresh resets `hostButtons` to `{}`, losing the displayed draft. `stageHostButtons` supplies an empty `apply`, so `flashStagedChanges` has nothing to write; implement the Bridge-side write or keep this change pending instead of treating it as applied.

Comment thread src/device/controller.ts Outdated
if (!restore) return;
recordDiagnosticCommand(`Game profile: restore ${JSON.stringify(restore)}`);
for (let attempt = 0; attempt < 5; attempt += 1) {
if (await flashGameProfileSnapshot(restore) !== "busy") return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High device/controller.ts:1606

After switching from mouse A to mouse B, restoreGameProfileApply() writes A's saved settings to B, overwriting B while A remains on its temporary game settings. gameProfileRestore has no device identity, and flashGameProfileSnapshot() writes to the currently active device; associate the restore snapshot with its source device and restore it before switching targets.

Also found in 1 other location(s)

src/features/games/GameProfilePanel.tsx:190

The Apply-now restoration is tied only to panel unmount, not to the mouse it modified. Apply a draft on mouse A, select mouse B using the device list, then go Back: selectTarget switches the controller without restoring A, and this cleanup calls restoreGameProfileApply() on B. The controller keeps one unkeyed gameProfileRestore and flashes it through the currently active client, so A retains the temporary settings and B is overwritten with A's previous settings. Restore before switching targets and associate the restore state with the originating device.

🤖 Copy this AI Prompt to have your agent fix this:
In file @src/device/controller.ts around line 1606:

After switching from mouse A to mouse B, `restoreGameProfileApply()` writes A's saved settings to B, overwriting B while A remains on its temporary game settings. `gameProfileRestore` has no device identity, and `flashGameProfileSnapshot()` writes to the currently active device; associate the restore snapshot with its source device and restore it before switching targets.

Also found in 1 other location(s):
- src/features/games/GameProfilePanel.tsx:190 -- The Apply-now restoration is tied only to panel unmount, not to the mouse it modified. Apply a draft on mouse A, select mouse B using the device list, then go Back: `selectTarget` switches the controller without restoring A, and this cleanup calls `restoreGameProfileApply()` on B. The controller keeps one unkeyed `gameProfileRestore` and flashes it through the currently active client, so A retains the temporary settings and B is overwritten with A's previous settings. Restore before switching targets and associate the restore state with the originating device.

Comment thread src/device/controller.ts Outdated

/** Drops the saved values without writing them (the game is running and its profile now owns the mouse). */
export function forgetGameProfileApply(): void {
gameProfileRestore = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High device/controller.ts:1613

When the game starts while this draft is open, forgetGameProfileApply() discards the original settings, so the game-profile applier later captures the previewed values as its baseline and restores those values when the game exits. Transfer the saved snapshot to the applier or restore it before the applier takes ownership.

Also found in 1 other location(s)

src/features/games/GameProfilePanel.tsx:189

Dropping the Apply-now restore snapshot solely because this game is active loses the original settings when the game starts while the editor is open. useBridgeProfileApplier cannot apply/capture its baseline during a draft (flashGameProfileSnapshot returns busy). For example, start at 800 DPI, apply 1600 here, launch the game, then leave this panel: forgetGameProfileApply() discards 800, and the applier subsequently captures 1600 as its baseline. Stopping the game then restores 1600 instead of 800. Transfer the saved baseline to the applier or restore it before the applier takes ownership.

🤖 Copy this AI Prompt to have your agent fix this:
In file @src/device/controller.ts around line 1613:

When the game starts while this draft is open, `forgetGameProfileApply()` discards the original settings, so the game-profile applier later captures the previewed values as its baseline and restores those values when the game exits. Transfer the saved snapshot to the applier or restore it before the applier takes ownership.

Also found in 1 other location(s):
- src/features/games/GameProfilePanel.tsx:189 -- Dropping the Apply-now restore snapshot solely because this game is active loses the original settings when the game starts while the editor is open. `useBridgeProfileApplier` cannot apply/capture its baseline during a draft (`flashGameProfileSnapshot` returns `busy`). For example, start at 800 DPI, apply 1600 here, launch the game, then leave this panel: `forgetGameProfileApply()` discards 800, and the applier subsequently captures 1600 as its baseline. Stopping the game then restores 1600 instead of 800. Transfer the saved baseline to the applier or restore it before the applier takes ownership.

Comment thread src/device/controller.ts
apply: async () => {
const client = logitechClient();
if (!client) throw new Error(st("ctl.gone"));
if (lastDeviceMode !== "Host") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High device/controller.ts:5615

When Apply now runs with an Onboard-mode game draft, this branch returns normally without calling setHostBunnyHopMs, so the staging flow treats the change as applied and drops it; the subsequent status refresh also restores the old value in gameProfileDraftSnapshot. Reject or retain the pending change, or switch to Host mode before applying it.

🤖 Copy this AI Prompt to have your agent fix this:
In file @src/device/controller.ts around line 5615:

When `Apply now` runs with an Onboard-mode game draft, this branch returns normally without calling `setHostBunnyHopMs`, so the staging flow treats the change as applied and drops it; the subsequent status refresh also restores the old value in `gameProfileDraftSnapshot`. Reject or retain the pending change, or switch to Host mode before applying it.

Comment thread src/device/controller.ts
Comment thread src/features/buttons/ButtonMap.tsx
Comment thread src/features/devices/cards/DpiCard.tsx Outdated
Comment thread src/features/devices/cards/DpiCard.tsx Outdated
Comment thread src/features/devices/cards/DpiCard.tsx Outdated
Comment thread src/features/games/CustomGameDialog.tsx
@onyxhq-dev
onyxhq-dev force-pushed the feat/custom-game-icons branch from 14b5232 to fec697c Compare October 23, 2026 02:51
Comment thread src/features/games/GameProfilePanel.tsx Outdated
Comment thread src/device/controller.ts
Comment on lines 1609 to 1620
const staged = hasPendingChanges();
if (staged) await flashPendingChanges();
restorePendingChanges(stash);
recordDiagnosticCommand(`Game profile: write ${Object.keys(snapshot).join(", ") || "nothing"} (staged ${staged})`);
if (staged) {
gameProfileWriting = true;
try {
await flashPendingChanges();
} finally {
gameProfileWriting = false;
}
}
restoreEverything(stash);
return staged ? "written" : "unchanged";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High device/controller.ts:1609

A failed restore write is reported as successful, so restoreGameProfileApply() deletes gameProfileRestores and never retries; the temporary Apply-now settings can remain on the mouse. flashGameProfileSnapshot() returns "written" whenever changes were staged, even if flashPendingChanges() leaves them pending. Check whether the staged changes cleared before returning success so the restore state is retained on failure.

   const staged = hasPendingChanges();
+  let writeSucceeded = !staged;
   recordDiagnosticCommand(`Game profile: write ${Object.keys(snapshot).join(", ") || "nothing"} (staged ${staged})`);
   if (staged) {
     gameProfileWriting = true;
     try {
       await flashPendingChanges();
+      writeSucceeded = !hasPendingChanges();
     } finally {
       gameProfileWriting = false;
     }
   }
   restoreEverything(stash);
-  return staged ? "written" : "unchanged";
+  return writeSucceeded ? (staged ? "written" : "unchanged") : "busy";
🤖 Copy this AI Prompt to have your agent fix this:
In file @src/device/controller.ts around lines 1609-1620:

A failed restore write is reported as successful, so `restoreGameProfileApply()` deletes `gameProfileRestores` and never retries; the temporary Apply-now settings can remain on the mouse. `flashGameProfileSnapshot()` returns `"written"` whenever changes were staged, even if `flashPendingChanges()` leaves them pending. Check whether the staged changes cleared before returning success so the restore state is retained on failure.

Comment thread src/features/games/CustomGameDialog.tsx Outdated
Comment thread src/features/buttons/ButtonMap.tsx Outdated
Comment thread src/device/controller.ts
const held = gameProfileDraft?.held;
if (!held) return;
if (held.hostButtons) status.hostButtons = structuredClone(held.hostButtons);
if (held.hostBunnyHopMs !== undefined) status.hostBunnyHopMs = held.hostBunnyHopMs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High device/controller.ts:1518

Host-mode bunny-hop edits are omitted from gameProfileDraftSnapshot(), so autosave removes the changed value from the saved game profile. overlayHeldValues puts hostBunnyHopMs into the base used by gameProfileFieldsOf, making the preview appear unchanged and preventing the field from being added to touched; track it before applying the overlay or otherwise preserve it as touched.

🤖 Copy this AI Prompt to have your agent fix this:
In file @src/device/controller.ts around line 1518:

Host-mode bunny-hop edits are omitted from `gameProfileDraftSnapshot()`, so autosave removes the changed value from the saved game profile. `overlayHeldValues` puts `hostBunnyHopMs` into the base used by `gameProfileFieldsOf`, making the preview appear unchanged and preventing the field from being added to `touched`; track it before applying the overlay or otherwise preserve it as touched.

@onyxhq-dev
onyxhq-dev force-pushed the feat/custom-game-icons branch from fec697c to 993a64d Compare October 23, 2026 03:00
Comment thread src/features/games/GameProfilePanel.tsx Outdated
@onyxhq-dev
onyxhq-dev force-pushed the feat/custom-game-icons branch from 993a64d to 158539f Compare October 23, 2026 03:56
Comment thread src/features/games/GameProfilePanel.tsx Outdated
- 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.
@onyxhq-dev
onyxhq-dev force-pushed the feat/custom-game-icons branch from 158539f to c81bd7c Compare October 23, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ci Automation and build configuration area: devices Device integration and HID support area: translations Localized interface strings area: ui Interface and styling type: enhancement PR title category: enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant