A desktop shell for RomM that runs your server's own web interface in a native window and launches games in a locally installed emulator instead of an in-browser core.
It has no interface of its own. Other desktop clients talk to the RomM API and rebuild the browsing experience; this loads the frontend your server already serves and adds one thing to it: a bridge that hands a game to a real emulator.
Early work in progress. The launch path has not been tested against a real RetroArch install on macOS or Windows, and the surfaces listed under Untested have not been exercised at all.
| Option | Emulator runs | Interface |
|---|---|---|
| RomM emulator streaming | On the server | RomM's own, in a browser |
| Argosy, Grout, Playnite plugin (first party) | Your device | Their own, per platform |
| romm-client, RomMix | Your machine | Their own |
| RomM Desktop | Your machine | RomM's own, at runtime |
The API clients own their interface, which makes them independent of RomM's frontend but leaves them reimplementing it. This takes the opposite trade. For save syncing, offline mode, or a non-desktop device, check those projects first -- they are further along.
This repository contains no RomM code. It loads your server's frontend at
runtime and injects one global, window.rommNative, whose shape is defined in
src/shared/types.ts; RomM's own UI feature-detects it. So the "Play natively"
button ships with the server, a server without the integration renders in a
normal window, and there is no version lock between the two. The shell owns
only the launch bridge, emulator resolution, the ROM cache, and the window's
security policy.
- A reachable RomM server running a version whose frontend ships the native play route. A server without it still loads, just with no way to reach the bridge.
- An emulator. RetroArch is autodetected, the shell offers to fetch its installer if you have none, and missing cores are downloaded on demand; anything else is configured by hand.
npm install
npm run devOn first launch it asks for your server address, then loads it. Log in exactly as you would in a browser; the shell holds no credentials of its own.
| Flag | Effect |
|---|---|
--setup |
Reopen the server-address window to correct a mistyped address |
--spike |
Inject a test panel, for a server without the RomM-side integration |
The --spike panel is throwaway scaffolding. It carries a hardcoded slice of
RomM's platform/core map, so a platform outside that list reports "no core in
the spike map"; a standalone emulator configured under emulators still
launches, since user mappings need no core. Once the launch path has been
judged on real hardware, delete src/main/spike.ts, src/main/spike.test.ts,
and the installSpike wiring in src/main/window.ts.
A username and password work as they do in a browser. OIDC and SSO get a window of their own, since the identity provider is off-origin while the main window is confined to your server; it shares the session and closes as soon as the flow lands back on your server.
Logging out is handed to your browser. RomM clears its own session before returning your provider's end-session URL, so you are signed out of RomM either way; whether your provider's session ends depends on the browser that URL opens in.
RomM's own interface handles controller navigation, so the shell adds none.
/settings/controller-debug shows whether the input system can see your pad.
The Gamepad API is restricted to secure contexts, a browser rule rather than a
shell one: https:// or http://localhost works, a plain http://192.168.x.x
fails silently. Quitting the emulator brings the window back to the front.
The in-browser emulators (EmulatorJS, Ruffle, js-dos, PICO-8), file downloads and clipboard actions are untested in this shell and worth exercising.
Multi-disc games have unit tests over disc selection, the playlist, and which emulators are handed one, but no real disc set has been launched through an emulator yet.
Config lives in desktop-config.json in Electron's userData directory:
| Platform | Path |
|---|---|
| Linux | ~/.config/romm-desktop/ |
| macOS | ~/Library/Application Support/romm-desktop/ |
| Windows | %APPDATA%\romm-desktop\ |
The file is re-read whenever it changes on disk, so an edit takes effect on the next launch attempt without a restart and is not overwritten by the next save.
| Key | Default | What it does |
|---|---|---|
retroarchPath |
autodetected | RetroArch executable, for an install somewhere unusual |
retroarchCoresPath |
derived from above | Cores directory |
preferredCores |
none | Your core order per platform |
autoInstallCores |
true |
Fetch a missing core from the libretro buildbot |
offerRetroArchInstall |
true |
Offer RetroArch's installer when nothing is installed |
useDetectedEmulators |
true |
Use detected PCSX2, Dolphin, RPCS3, Cemu |
offerStandaloneInstall |
true |
Offer to fetch those when they are missing |
emulators |
none | Your own platform-to-command rows |
emulatorsBasePath |
none | Prefix for relative command values |
libraryPath |
none | Library root, to launch in place instead of downloading |
cachePath |
rom-cache |
ROM cache directory |
cacheLimitBytes |
20 GB | Cache size before LRU eviction |
saveDataPath |
save-data |
Save and state directories |
useRommFirmware |
true |
Mirror RomM's firmware library |
biosPath |
bios |
Where that mirror lives |
fullscreen |
false |
Open the main window with no title bar, for a TV or cabinet |
The three unset paths default to directories beside the config file.
cachePath, saveDataPath and biosPath must not contain one another, and
the shell refuses a launch when any two overlap: cache eviction deletes a ROM
directory whole and the firmware mirror deletes what the server no longer
lists, so an overlap means one of them deleting files another owns.
With fullscreen on, the setup window stays windowed regardless, since it is
the one screen that needs a keyboard. F11 toggles fullscreen at runtime on
Windows and Linux, Control Command F on macOS.
RetroArch and its cores directory are detected from the usual install
locations: standard packages on Linux and macOS, and on Windows the portable
C:\RetroArch-Win64 layout, both Program Files directories, the per-user
Programs directory, scoop, Steam, and RetroBat's bundled copy. Anywhere else --
notably a drive other than C: -- needs retroarchPath. Point it at the
executable and the cores directory is derived from its parent.
When a game is launched, RomM's platform/core map decides which libretro cores are candidates, and the first one actually installed wins.
With nothing installed and nothing configured, the shell offers once on startup
to fetch RetroArch's official installer and open it. It never installs anything
itself -- the file is handed to the operating system, so you get the UAC,
SmartScreen or disk-image flow you already recognise. Linux is pointed at the
download page instead, since the only build published there is a 179 MB
portable .7z while your distribution's package is the copy that will actually
receive updates. The installer is kept in installers beside the config and
deleted once an emulator has been found.
Nothing needs restarting afterwards -- the usual locations are re-probed on
every launch attempt. A RetroArch that has never been run has no cores
directory, so the shell falls back to where that directory belongs on your
platform and creates it when it writes the first core. That fallback applies
only to a detected install; a hand-configured emulator, Flatpak included, still
needs retroarchCoresPath.
RomM's map names the cores that will play a game, in its own order, and the
first one installed wins. It has no opinion about which of them
RetroAchievements recognises.
preferredCores puts your choice at the front, for resolving and downloading
alike:
{
"preferredCores": {
"psx": ["mednafen_psx_hw", "swanstation"],
"saturn": ["mednafen_saturn"],
"3ds": ["azahar"]
}
}A core named here is honoured even when the frontend never offered it, which is
how you reach a core RomM's map does not list. Nothing is narrowed away --
whatever the frontend offered still follows, in its original order. A
preference you do not have is downloaded even when something else that plays
the game is installed, which is the case the setting exists for: pcsx_rearmed
plays PlayStation games well but is not on RetroAchievements' supported list.
A preference that cannot be resolved never costs you a launch. One not
published for your system, one that fails to download, and a typo
(mednafen_psx_h is a legal name for a core that does not exist) all fall
through to RomM's suggestion.
Achievements themselves are RetroArch's business -- log in under its own Settings and RomM shows the progression once it syncs.
A core that is not installed is fetched from the libretro buildbot rather than failing the launch -- the same build RetroArch's own core updater installs. Candidates are tried in the frontend's order and the first one published for this machine wins, so the usual case is a few seconds' wait before the game starts.
Nothing is downloaded unless RetroArch is already installed, the platform's
emulator actually loads a libretro core, the cores directory is known, none of
the candidates are present, and the buildbot publishes for this architecture. A
standalone emulator never triggers it. Set autoInstallCores to false for a
launch that fails with the missing cores named instead.
The core has to match the emulator's architecture rather than this shell's, so an x86_64 RetroArch under Rosetta on an Apple Silicon Mac is handed arm64 cores it cannot load; install those through RetroArch's own updater. Only the nightly channel exists per core, so this tracks upstream rather than pinning a version.
Some platforms need an emulator that is not a libretro core: RetroAchievements
recognises the standalone PCSX2 and Dolphin but not their cores, and libretro
has no core for PS3 or Wii U at all. All four are configurable under
emulators, but the executable name and argument template are not guessable,
so the shell looks for them where they land:
| Emulator | Platforms | Looked for in |
|---|---|---|
| PCSX2 | ps2 |
/Applications, Program Files, the per-user Programs directory, scoop, RetroBat |
| Dolphin | ngc, wii |
the same, plus /usr/bin and /usr/games and its Flatpak on Linux |
| RPCS3 | ps3 |
the same, plus /usr/bin and /usr/local/bin and its Flatpak on Linux |
| Cemu | wiiu |
the same, plus %LOCALAPPDATA%\Cemu, which is where its own installer puts it |
Nothing is written to your config, and an emulator installed by any means is
found the same way, a frontend's own tree included -- so RetroBat in
C:\RetroBat needs no configuring. Only that path, though: a portable RetroBat
on another drive needs emulatorsBasePath and an emulators row.
A row you wrote yourself always wins. A detected emulator does beat a *
wildcard row: a catch-all should not claim a platform that has a real emulator
installed for it. useDetectedEmulators: false switches the whole thing off,
and takes the download offer with it.
When there is nothing to find, the platform is still reported as launchable --
naming the emulator it would set up -- and pressing Play offers to fetch it
from the project's own release index. The file is handed to the operating
system exactly as RetroArch's installer is, and the launch waits: install it
the way its project intends and the game starts on its own. Cancelling the
download or closing the window stops the wait. Asked at most once per emulator
per run; offerStandaloneInstall: false never asks.
| Emulator | macOS | Windows | Linux |
|---|---|---|---|
| PCSX2 | .tar.xz, opens Archive Utility |
installer | Flatpak |
| Dolphin | disk image | .7z, opens in Explorer on 11 |
Flatpak |
| RPCS3 | .7z, one per architecture |
.7z |
AppImage |
| Cemu | disk image | installer | AppImage |
A macOS archive holds a .app, and a .app goes to Applications, the first
place detection looks -- so those wait like anything else. On Windows and Linux
an archive leaves a portable build wherever you extract it, which detection
cannot guess, so the launch does not wait and you point at the executable under
emulators afterwards. An AppImage is made executable and shown in your file
manager, since running a binary it just downloaded is not something the shell
does. A machine a project does not build for -- 32-bit Windows in every case,
ARM Linux for all but Dolphin -- is sent to the download page.
RPCS3 on Apple silicon is a special case: the endpoint RPCS3's own updater
calls names a single macOS build and that build is x86-64, so the native build
is taken from rpcs3-binaries-mac-arm64 under the same name with _aarch64
before the suffix. If a release stops following that naming the download 404s
and the offer falls back to the download page.
Two things the shell cannot do for you. RetroAchievements wants Dolphin 2407-68
or newer for GameCube (2603a for Wii) with "Enable Dual Core (speedup)" off,
both in Dolphin's own settings. And RPCS3 boots a single file it is handed --
an EBOOT.BIN, a .self -- while a PS3 title kept in RomM as a folder of many
files downloads as an archive it cannot boot, so keep such titles somewhere
RPCS3 already sees them. Cemu is unaffected: .wua, .wud and .wux are each
one file.
emulators maps a platform to any executable. platformSlug uses RomM's own
slugs (snes, n64, ps2); * is the fallback for any platform without an
entry of its own.
{
"emulators": [
{
"platformSlug": "ps2",
"label": "PCSX2",
"command": "/usr/bin/pcsx2",
"args": ["-batch", "{rom}"]
},
{
"platformSlug": "*",
"label": "RetroArch (Flatpak)",
"command": "/usr/bin/flatpak",
"args": ["run", "org.libretro.RetroArch", "-L", "{core}", "{rom}"]
}
]
}Tokens are substituted per argv entry, so no shell is involved and paths containing spaces need no quoting:
| Token | Expands to |
|---|---|
{rom} |
The cached, or in-place, ROM path |
{core} |
The resolved libretro core path |
{saves} |
This game's save directory |
{states} |
This game's save-state directory |
{savefile} |
The save file inside {saves} |
{statefile} |
The state file inside {states} |
{bios} |
This platform's firmware directory |
{biosconfig} |
A generated RetroArch config naming system_directory |
A token that cannot be resolved fails the launch with an explanation rather
than passing an empty argument to the emulator, so a wildcard RetroArch row
still needs retroarchCoresPath to be findable. An optional playlist key
says whether the emulator boots an .m3u, which only affects
multi-disc games.
Set emulatorsBasePath and a command can be relative to it, for a frontend
like RetroBat that keeps every emulator under one tree. An absolute command
is always used as given.
{
"emulatorsBasePath": "E:/RetroBat/emulators",
"emulators": [
{
"platformSlug": "ps2",
"label": "PCSX2",
"command": "pcsx2/pcsx2-qt.exe",
"args": ["-batch", "-fullscreen", "{rom}"]
}
]
}The executable name is not guessable from the directory name -- pcsx2 holds
pcsx2-qt.exe next to an updater.exe -- so list a directory to see what is
there: Get-ChildItem E:\RetroBat\emulators\<name> -Filter *.exe.
When the server runs on the same machine, downloading a ROM copies a file that
is already on local disk. Point libraryPath at the library root as this
machine sees it and the ROM is launched in place instead:
{
"libraryPath": "E:/library"
}RomM reports each ROM's path relative to its own library root, so only the root
needs configuring. The lookup is skipped, and the download happens as usual,
whenever libraryPath is unset, the file is not there, or its size does not
match what the server reports. The server supplies only the path below the
root, and anything resolving outside it is rejected rather than normalised.
Save data goes to its own directory either way, so launching in
place leaves nothing in your library for RomM to scan.
A game split across discs is one ROM with several files on the server, and asking for that ROM as a single download returns an archive that a multi-disc game cannot boot out of. So a ROM the server reports as two or more disc images is fetched as those individual files instead, one request each.
Discs are ordered by the number in their name (Disc 2, disk 2, CD2).
Where a sheet (.cue, .gdi, .ccd, .mds) is present, the track formats it
describes are not discs -- the rule RomM itself applies in utils/m3u.py --
though the tracks are still fetched beside the sheet, which cannot boot without
them. A whole-disc image is never a sheet's track whatever it is named, so a
.chd beside a .gdi is a disc of its own; the ambiguous case of a bare
.bin beside a .cue reads as a track. A set that ships its own .m3u is
handed that instead of a generated one.
What the emulator is handed depends on whether it reads an .m3u:
| Handed | Changing disc | |
|---|---|---|
| RetroArch, Dolphin, DuckStation | discs.m3u |
the emulator's disc-control menu |
| PCSX2, RPCS3, Cemu | the first disc | the emulator's own "change disc", with the set in one directory |
PCSX2 is the reason for the second row: its M3U request was closed as not
planned and
automatic swapping is still open,
so handing it a playlist would fail the launch outright. There, disc 2 is
System > Change Disc from the menu bar, or Change Disc in the on-screen quick
menu on a controller; the shell passes -batch and never -nogui, which would
hide that menu bar. The playlist is written as UTF-8 with LF endings because
that is all Dolphin accepts.
A detected emulator carries its own answer. One configured by hand is assumed
not to read a playlist, unless its arguments name {core} or RetroArch,
Dolphin or DuckStation is named in the command or its arguments -- which covers
flatpak run org.duckstation.DuckStation as well as an executable path.
"playlist" outranks both inferences:
{
"emulators": [
{
"platformSlug": "psx",
"command": "/usr/bin/mednafen",
"args": ["{rom}"],
"playlist": true
}
]
}A set already under libraryPath is launched in place, all of it or none of
it, and only when it sits in one directory: a sheet's tracks sit beside it by
relative name, and an emulator with no playlist looks for the next disc beside
the one it booted. Otherwise the whole set lands in the ROM cache. The playlist
is always written to the cache, never into the library.
Nothing here can fail a launch that would otherwise have worked -- a server that will not answer, files that cannot be read, a set that turns out to hold one disc, and an interrupted transfer all fall back to the ordinary single-payload download. Only cancelling stays fatal.
Left to itself an emulator writes save data next to the ROM, where cache eviction eventually deletes it or RomM scans it out of your library. So each game gets a directory of its own, keyed on the ROM id. Syncing it back to RomM is out of scope.
<saveDataPath>/<romId>/saves/<name>.srm
<saveDataPath>/<romId>/states/<name>.state
The filename comes from the server, so a cached launch and an in-place launch
land on one file. A detected RetroArch is passed -s and -S, which override
whatever savefile_directory your retroarch.cfg sets. Set saveDataPath to
move the whole tree, a synced folder say.
A configured emulator has to be told, with {saves}, {states}, {savefile}
and {statefile}:
{
"emulators": [
{
"platformSlug": "*",
"label": "RetroArch (Flatpak)",
"command": "/usr/bin/flatpak",
"args": [
"run",
"org.libretro.RetroArch",
"-L",
"{core}",
"-s",
"{savefile}",
"-S",
"{statefile}",
"{rom}"
]
}
]
}Which of the four an emulator wants varies, and several only read a save
directory from their own config -- there the entry is better left without the
tokens. Prefer {savefile} and {statefile} where they are accepted: given
only a directory an emulator names the save after the ROM, and since the cached
copy carries a name the shell has made portable for Windows, a ROM whose name
needed rewriting derives two save names, one per launch path.
RomM has a firmware library of its own: BIOS files uploaded per platform,
served from /api/firmware. These are fetched the way a ROM is -- same server,
same session cookies, skipped when what is on disk already matches the size the
server reports -- into one directory per platform:
<biosPath>/<platformSlug>/<file name>
Per platform rather than per game, because the emulator is what has to find these under the name it expects. Never evicted, unlike the ROM cache; the directory is kept as a mirror instead, so firmware deleted in RomM goes from here on the next launch, and anything inside a platform's directory that RomM does not list is treated as firmware it no longer has.
Nothing about it can fail a launch: a platform needing no firmware, a missing
firmware read scope, and a server too old for the endpoint all end as "no
firmware". The mirror only ever deletes in response to a list it actually
received, so anything short of that -- being offline, an unrecognised reply --
leaves it as it was. Set useRommFirmware to false to switch it off, or
biosPath to put the mirror elsewhere.
A detected RetroArch is pointed at the mirror for you, with a generated config
naming system_directory passed as --appendconfig -- layered over your own
settings for that one run rather than editing your retroarch.cfg. Those files
live in <biosPath>/.retroarch/, are rewritten on every launch that syncs, and
are not worth editing.
A RetroArch you configured yourself does not get that automatically, since the
shell cannot tell that flatpak run org.libretro.RetroArch is RetroArch, nor
where in your arguments a flag of its own would be safe to insert. Say where
with {biosconfig}; any other emulator takes {bios}, that platform's
directory:
{
"emulators": [
{
"platformSlug": "*",
"label": "RetroArch (Flatpak)",
"command": "/usr/bin/flatpak",
"args": [
"run",
"org.libretro.RetroArch",
"--appendconfig={biosconfig}",
"-L",
"{core}",
"{rom}"
]
},
{
"platformSlug": "psx",
"label": "DuckStation",
"command": "/usr/bin/duckstation-qt",
"args": ["-bios-path", "{bios}", "-batch", "{rom}"]
}
]
}{biosconfig} is safe on every platform, including the many with no firmware:
the file always exists while the mirror is on, and where there is nothing to
find it contains only comments. Remove the token if you set useRommFirmware
to false.
Two things this cannot do for you. PCSX2, Dolphin, RPCS3 and Cemu take no BIOS
directory on the command line at all, each reading its own, so for those the
mirror is a staging directory you point the emulator at once in its own
settings -- PCSX2's Settings, BIOS, or RPCS3's Install Firmware for a
PS3UPDAT.PUP sitting there. And the mirror is flat, because a RomM firmware
row carries a filename and nothing else, while a few libretro cores want a
subdirectory of the system directory (Flycast looks for dc/dc_boot.bin). Put
those where the core wants them, outside <biosPath>.
Downloaded ROMs are cached under cachePath, one directory per ROM:
<cachePath>/<romId>/<name>
The directory carries the ROM id, so the file keeps the name the server gave it
and an emulator deriving anything from the content name agrees with a launch
straight out of the library. Once the cache exceeds cacheLimitBytes (20 GB by
default), least-recently-used ROMs are evicted a whole directory at a time.
The window loads a remote origin and renders artwork and descriptions pulled from third-party metadata providers, so the renderer is treated as untrusted:
contextIsolation,sandboxandnodeIntegration: falseare all enforced.- In-window navigation is restricted to your server's origin, redirects
included, and every other link is handed to your real browser. The one
address that leaves is the OIDC endpoint, which opens the separate auth
window described under Signing in; that window carries no
preload, so
window.rommNativeis reachable only from your server's page. - The camera is granted only to your server's origin, only to the top-level frame and only for video, so RomM's barcode scanner works while embedded third-party metadata cannot reach it. Every other permission is refused.
- The renderer never supplies an executable or arguments. It names a game and the libretro cores its platform supports; the command comes from your config.
- Core names are matched against
[a-z0-9_]+before becoming a path, so they cannot point the loader outside the cores directory. The same check gates the buildbot URL, so a name that cannot be a filename cannot be a request either. - A downloaded core is written only to the cores directory, under the filename the shell derived; no path inside the archive is read, and the contents are checked against its own checksum before the emulator loads them.
- An installer or emulator build is downloaded only after you say yes, only to a fixed directory, and only from the origins pinned for that project's artifacts -- pinned separately from the origins its release index may answer from, and narrowed to the project's own repository path for a GitHub release. The shell never runs it, and a transfer that stops short of the declared length is deleted rather than opened.
- ROM and firmware download URLs must resolve to the configured server origin
and an
/api/route, and processes are spawned with an argument array, never a shell string. - A firmware filename from the server is used verbatim, because that is the name an emulator looks for, so one that is not already a plain filename is refused rather than rewritten into a safe one. Nothing the server says can name a path outside the platform's own directory.
- Self-signed certificates, common on a LAN, prompt once and are then remembered by fingerprint.
npm run package # for this machine
npm run package -- --linux # or --win, --macOutput lands in release/. Tagging v* runs the same build on all three
platforms and opens a draft GitHub release; a manual workflow run builds the
artifacts without releasing them.
| Platform | Format | Unsigned experience |
|---|---|---|
| Linux | AppImage | Normal, nothing is signed on Linux anyway |
| Windows | zip | SmartScreen warns until the binary earns reputation |
| macOS | zip | Gatekeeper blocks; approve under Privacy and Security |
Nothing is signed yet. electron-builder.yml carries the signing and
notarization options as commented configuration, so enabling them is a
credentials change rather than a code change. Auto-update is not wired up
either, which matters more here than for most apps: the shell renders remote
content in Chromium and so carries a standing obligation to track Electron
releases. macOS auto-update needs a Developer ID, so signing and updates land
together.
Linux ships an AppImage rather than a Flatpak deliberately: a Flatpak cannot casually launch the emulators installed on the host, which is the one thing this shell exists to do.
src/
main/ Main process
config.ts Persisted settings and RetroArch autodetection
emulator/ Which emulator and core a platform gets, and fetching
either one: RetroArch's installer, libretro cores from
the buildbot, and the standalone PCSX2, Dolphin, RPCS3
and Cemu -- detected, or offered from each project's
own release index
launcher.ts Download, resolve, spawn, track
rom-cache.ts Download with the window's session cookies
cache/ LRU eviction over the ROM cache
saves/ Per-game save and state directories
discs/ Multi-disc sets: disc selection and the .m3u that boots
them
firmware/ Mirroring RomM's own BIOS library, per platform
safety.ts Validation of everything the renderer sends
window.ts Window creation and navigation policy
index.ts App lifecycle, single-instance lock, initial window
ipc.ts IPC handlers behind window.rommNative
argv.ts Command-line flag parsing
download.ts Fetching a file the OS is then asked to open
zip.ts Minimal reader for the buildbot's core archives
spike.ts TEMPORARY: the --spike harness (see above)
preload/ contextBridge surface (window.rommNative)
shared/ Types shared with the RomM frontend
AGPL-3.0-only, matching RomM.