[Feature] Native play route for the RomM desktop shell (window.rommNative)
Is your feature request related to a problem? Please describe.
romm-desktop is an Electron shell for RomM. Unlike Argosy, Grout, the Playnite plugin, romm-client or RomMix, it does not build a UI of its own: it loads the server's own frontend at runtime in a native window and injects a single global, window.rommNative. Its purpose is launching a ROM in a locally installed emulator (RetroArch, PCSX2, and so on) instead of an in-browser core. When the server runs on the same machine it can also read the ROM straight off disk and skip the download.
The shell works today, but RomM's frontend has no idea it is there: there is no window.rommNative reference anywhere in frontend/src. A user running the shell sees exactly the play options a browser tab offers, and the native launch path is unreachable from the UI.
This proposes adding a native play route to the v2 frontend alongside the existing in-browser and streaming routes, gated on feature-detecting the bridge. It is a proposal, not a finished design, and one question below (whether native belongs on the platform badge) should be settled before any code is written.
Describe the solution you'd like
The bridge the shell exposes (canonical definition currently lives in the shell's src/shared/types.ts):
interface RommNativeBridge {
readonly shellVersion: string;
readonly os: "darwin" | "win32" | "linux";
launch(request: LaunchRequest): Promise<LaunchResult>;
cancel(romId: number): Promise<void>;
getPlatformSupport(query: { platformSlug: string; cores: string[] }): Promise<PlatformSupport>;
onLaunchState(listener: (state: LaunchState) => void): () => void;
openSettings(): Promise<void>;
}
interface PlatformSupport {
supported: boolean;
emulator?: string; // display name when supported
reason?: "unsupported-platform" | "no-emulator-configured" | "emulator-not-found";
detail?: string; // human-readable explanation
}launch() receives a ROM id, a server-relative download path, a platform slug and a list of candidate libretro core names. The renderer never supplies an executable or arguments; the shell resolves those against the user's own local config. That boundary is deliberate and should stay, so RomM never learns what is installed on a user's machine beyond a display name.
The precedent: how streaming is wired
Streaming is the closest existing analogue and a native route should follow its shape. The layers it touches today:
| Layer | File | What it does |
|---|---|---|
| Service | frontend/src/services/api/streaming.ts |
Typed wrapper over /api/streaming/*, aliasing generated OpenAPI schemas |
| Store | frontend/src/stores/streaming.ts |
Holds config fetched once; exposes synchronous getters containerForPlatform() and containerLabelForPlatform() |
| Bootstrap | frontend/src/v2/layouts/AppLayout.vue:173 |
void streamingStore.fetchConfig() on app load |
| Per-ROM check | frontend/src/v2/composables/useCanPlay/index.ts |
canPlayStream, folded into the aggregate canPlay |
| Per-platform check | frontend/src/v2/composables/usePlatformPlayable/index.ts |
streamable, streamLabel, mode, plus the batch usePlatformPlayableChecker |
| Action routing | frontend/src/v2/composables/useGameActions/index.ts |
PlayTarget = "auto" | "local" | "stream", the play() router, streamActionLabel |
| Buttons | GameActions.vue, GameActionsList.vue, GameActionBtn.vue (the GameAction union), GameCard.vue |
A separate affordance per route, not a precedence rule |
| Badge | frontend/src/v2/components/Platforms/PlayModeBadge.vue |
Renders PlatformPlayMode on PlatformTile and PlatformListRow |
| Index | frontend/src/v2/views/PlatformsIndex.vue:110 |
playableById map for sort and grouping, isPlayable(slug) || isStreamable(slug) |
| Route | frontend/src/v2/router/routes.ts:48 |
stream: () => import("@/v2/views/Player/Stream.vue") |
| i18n | frontend/src/locales/*/platform.json |
platform.playable-*, 8 keys across 20 locale directories |
Checklist for a comparable native route
- Adapter module, for example
frontend/src/v2/services/native/index.ts: the only file in RomM that toucheswindow.rommNative. Every call feature-detected and wrapped in try/catch, so a shell older than the server degrades instead of throwing. - Store, for example
frontend/src/stores/native.ts: probes the bridge once, caches per-platform support, exposes synchronous getters mirroringcontainerForPlatformandcontainerLabelForPlatform. - Bootstrap the probe from
AppLayout.vue, next tofetchConfig(). -
canPlayNativeinuseCanPlay, folded intocanPlay. -
nativeSupportandnativeLabelinusePlatformPlayable, plus anisNativeSupportedinusePlatformPlayableChecker. -
PlayTargetgains"native".play()calls the bridge instead of navigating, which is a real departure: every existing branch ofplay()ends in a route push or awindow.location.assign, and this one ends in an IPC call. -
GameActionunion gains"native"; a button inGameActions.vueand a menu item inGameActionsList.vue. - Launch progress and errors via
onLaunchState, surfaced through the existing snackbar patterns, andcancel(romId)wired to a cancel affordance. -
PlatformsIndex.playableByIdincludes the native answer. - i18n keys for the action label, the tooltip, and each
PlatformSupport.reason, added to all 20 locale directories. - Tests alongside:
useCanPlay,usePlatformPlayable,useGameActions,GameActionsList, plus a Storybook story if the badge changes. - Optional: an entry point to
openSettings()from RomM's own settings UI.
No backend change is required. launch() takes a server-relative download path, which the frontend already builds via getDownloadPath / getDownloadLink in frontend/src/utils/index.ts. This is frontend-only.
One shape mismatch worth flagging
The streaming store's getters are synchronous because the whole config arrives in one fetch. getPlatformSupport() is async and per-platform, which does not fit usePlatformPlayableChecker: PlatformsIndex builds playableById by looping over every platform inside a computed, and an await per platform there would be a call storm against the IPC bridge and would make the grid flicker.
Two ways out, and the first looks clearly better:
- Add a bulk query to the bridge. This is a shell-side change at no cost to RomM, and lets the store populate its whole cache in one call at boot and expose sync getters exactly as streaming does.
- Keep the per-platform call and have the store lazily populate a cache, with getters returning a tri-state (
true/false/unknown) until it resolves. This pushes loading states into surfaces that currently have none.
Note that getPlatformSupport also wants cores. The frontend can supply those (getSupportedEJSCores(resolvePlatformSlug(slug, config)), already used in usePlatformPlayable), but a bulk query would need either a slug-to-cores map or for the shell to resolve cores itself.
Describe alternatives you've considered
1. Does PlatformPlayMode need reshaping? (decision for the team)
Today, at frontend/src/v2/composables/usePlatformPlayable/index.ts:39:
export type PlatformPlayMode = "browser" | "stream" | "both" | null;"both" is a two-dimensional answer flattened into an enum, and it does not extend to a third route.
The blast radius is small. Every consumer:
usePlatformPlayable/index.ts(the type,resolveMode, themodecomputed, theplayTooltipsignature)components/Platforms/PlayModeBadge.vue(propExclude<PlatformPlayMode, null>, plusmode === 'stream'andmode === 'both'in the template)components/Platforms/PlatformTile.vue:72andPlatformListRow.vue:73(destructuremode, pass it down)components/Platforms/PlayModeBadge.stories.ts(2 story args)composables/usePlatformPlayable/index.test.ts(about 8 assertions)
PlatformsIndex.vue reads isPlayable / isStreamable directly and never touches the type, so the reshape misses it entirely (though the feature still needs it).
That is four source files, and the type change itself is mechanical. The cost is not the type, it is the two things behind it:
- The badge visual does not extend.
PlayModeBadgeencodes two dimensions as a diagonalclip-pathsplit of a single 16px play glyph. Three dimensions means seven non-empty combinations, and a three-way split of that glyph at that size will not be legible. This is a design problem, not an engineering one. - The tooltip keys are enumerated, not composed.
playTooltiphas one key per combination (playable-both,playable-stream,playable-browser-*). Enumerating a third dimension explodes that combinatorially across 20 locale directories, and restructuring the strings to compose per-route means re-translating the existing eight keys everywhere.
If the team does want a reshape, a flags object looks like the right replacement:
export interface PlatformPlayModes {
browser: boolean;
stream: boolean;
native: boolean;
}over the alternatives: it is template-friendly (modes.native), reads well in Storybook args and test assertions, and needs no special handling for Vue reactivity. A Set<PlayRoute> needs .has() at every call site and is awkward in stories and tests; a bitmask is unreadable in templates and devtools for no gain at this scale. resolveMode becomes resolveModes, and playTooltip takes the object and builds its string from one fragment per active route so key count grows linearly. A clean break is probably right over a deprecation shim, since the type has no consumers outside those four v2 files.
But the prior question is whether native belongs on the platform badge at all. Browser and stream availability are properties of the server: every user of a given RomM server gets the same answer. Native availability is a property of the user's own machine and changes when they install an emulator, so two people on the same server would see different badges on the same tile.
There is some precedent for mixing in client capability and it should be weighed honestly: isEJSEmulationSupported (frontend/src/utils/index.ts:637) already ANDs in a WebGL check evaluated once at module load. But WebGL is near-universal and effectively static, while installed emulators vary widely per machine and change over time. The precedent is real but weak in degree.
If native does not belong on the badge, the PlatformPlayMode reshape is not needed at all and the feature reduces to the per-ROM play action, which is by far the cheaper path.
2. How the frontend asks what a shell can do
Both directions of version skew are permanent: the shell loads whatever frontend the server serves, so there will always be servers predating this feature and shells older than a given server. New shell on an old server is free, since the old frontend never calls the bridge. New server on an old shell is the real case: the frontend may call methods that do not exist.
In order of preference:
- Method-presence detection as the baseline.
typeof window.rommNative?.someMethod === "function"costs nothing, needs no coordination between the two repos, and already works for every method on the bridge today. This should carry most of the weight. - An explicit capability list (
readonly capabilities: readonly string[]) for what method presence cannot express: a behavioral change to an existing method, a newreasonvalue, a new field inLaunchRequestthe shell will actually honor. Worth adding now rather than at the first such change, because a list added later cannot be detected on shells predating it (its absence is ambiguous between "old shell" and "no capabilities"). - Not
shellVersionparsing. Gating on a parsed version couples RomM's frontend to the shell's release history, breaks on forks and nightly builds, and forces a RomM release whenever the shell's version scheme shifts. KeepshellVersionfor display, support and telemetry only.
3. Who owns the bridge type
| Option | Assessment |
|---|---|
Vendored .d.ts in RomM, canonical stays in the shell |
Preferred. Zero build and release coupling, no publish pipeline, and the frontend needs an ambient global declaration for window.rommNative anyway rather than an import. The surface is roughly 40 lines. |
Published package, e.g. @rommapp/native-bridge |
Adds a registry dependency and release coordination to RomM's frontend for a type-only artifact with no runtime output. A version bump means a RomM PR regardless, so it buys little over vendoring. Reconsider if the surface grows or a third consumer appears. |
| Canonical definition moves into RomM | Inverts ownership. The shell implements the contract, and moving it here gates shell iteration on RomM's release cadence, which has much the larger blast radius. |
The usual objection to vendoring is drift, and it matters less than normal here: because of version skew the frontend must feature-detect at runtime regardless of what the type claims, so a stale vendored type is a DX problem rather than a correctness one. Mitigate by having the shell own a CI check that diffs its canonical definition against RomM's copy, since the shell releases faster and is the side that breaks, and have RomM's copy carry a comment pointing at the canonical file.
Additional context
What the shell needs from RomM: a play route that calls the bridge, and availability plumbed far enough that the affordance appears. That is all.
What the shell does not need: any backend change, any new API endpoint, any knowledge in RomM of what emulators are installed, or any executable or argument handling in the renderer.
Non-goals for a first pass: the platform badge (see the open question above), console mode under /console (v1-only, and v2 folds console into the main UI via the universal input system), and v1 surfaces generally, which are frozen.
Open questions
- Does native belong on the platform play badge? If not,
PlatformPlayModestays as it is and the scope shrinks considerably. Worth settling first. - If it does, what does a three-route badge look like at 16px?
- Bulk capability query on the bridge, or per-platform with a tri-state cache?
- Should
"auto"inPlayTargetever resolve to native? It currently prefers stream. Preferring a local emulator when one is configured seems defensible, but it silently changes behavior for existing single-button surfaces. - Add the capability list to the bridge now, or wait for the first breaking change, noting it cannot be retrofitted detectably?
Context on the shell: romm-desktop currently lives at github.com/sdornan/romm-desktop (private) and is moving to the rommapp org.
AI assistance disclosure: this issue was researched and drafted with AI assistance. All file paths, line numbers and type definitions cited above were verified against the repository at the time of writing.
Source: rommapp/romm