Why Tauri had to go
The first wrapper was Tauri: tiny binaries (~10 MB) and a Rust core that could one day link Steam's SDK directly. On paper, perfect. In practice it nearly sank the port — and not for any one dramatic reason. It was death by a thousand hoops.
The single biggest problem, by far, was just getting it to launch through Steam at all. Tauri renders with the operating system's own web view — WebKitGTK on SteamOS — which dynamically links a sprawl of system libraries. But Steam launches games inside the Steam Linux Runtime, a locked-down container that doesn't ship that library set. Double-click it from the desktop and it'd come up; launch it the way players actually do — through Steam — and it died before a window ever appeared. Every workaround we tried just surfaced the next missing piece. We sank far more time into "why won't it start?" than into anything in the game itself.
And the reward for finally coaxing it open was a gut-punch: it wasn't performant. WebKitGTK gave us no usable WebGPU, so the renderer silently fell back to the older WebGL2, and the modern water pipeline (the node-material shaders from Chapter 01) crawled. So the best case after all that effort was a build that launched unreliably and, when it did, ran badly. That's not a thing you can ship.
The hypothesis: Electron bundles its own Chromium. So it doesn't depend on the container's libraries, and it ships a modern browser engine with WebGPU. The price is size — about 210 MB versus 10 — which is fine if it actually runs. So before committing, we built a throwaway spike and tested it on a real Deck.
The spike, bug by bug
Four problems surfaced, in order. Tellingly, the first three were the exact same fight that doomed Tauri — just getting the thing to start through Steam — except this time each had a clean, one-time fix instead of a fundamental dead end. Every lesson is now baked into the build.
1. Every asset 404s under file://
Loading the build straight off disk, nothing loaded. Vite builds with absolute asset paths
(/assets/…), and under file:// those resolve against the
filesystem root. The fix was to serve the bundle over a custom
app://bundle/ scheme instead — which, conveniently, also
gives the page a secure context, the thing WebGPU refuses to run without.
2. The launcher did nothing — and logged nothing
We zipped the build onto the Deck and the launcher silently did nothing — not even an
error log, which was the clue: the process never ran. A plain .zip doesn't
preserve Unix permissions, so the executable landed without its +x bit and
Steam couldn't exec it. Lesson, now permanent in CI: ship Linux trees as a
tar, never a zip.
3. "Steam says running" — but no window
Launched through Steam, the process showed as running with nothing on screen; from the file
manager it worked fine. The culprit: Electron's chrome-sandbox helper must be
setuid-root, copied depot files never are, and the runtime container blocks the fallback
the bare desktop allowed — so sandbox init failed and Electron exited silently. Because we
only ever load local, trusted content, the fix is the standard one for Steam:
--no-sandbox.
4. The real one: 200 lines of shader errors (and the best news of the spike)
Once it launched, the log filled with ~200 repeats of unresolved interpolation
sampling 'either'. But buried in the same log was a line that
only ever prints when WebGPU is genuinely active — so the core bet had paid off:
real WebGPU on the Deck. 🎉 The errors turned out to be Three.js's internal
mipmap shaders using a newer WGSL syntax (@interpolate(flat, either)) that the
older Dawn engine inside Electron 31 couldn't parse — a version mismatch, not a wrapper bug.
The fix was a one-liner: bump to Electron 42, whose Dawn understands it.
If your browser shows WebGPU: available and Secure context: yes, you're passing exactly the checks WebKitGTK-in-the-container failed. That gap — modern Chromium versus the system web view — is the entire reason for the migration.
Building out the real pipeline
The spike was green, so the throwaway became the shell. A few decisions that aren't obvious until a container bites you:
-
The wrapper is ~40 lines. Register
app://bundle/(with a path-traversal guard), append--no-sandboxand the Vulkan/WebGPU flags, open a window, loadapp://bundle/index.html. The Deck even gets detected for free: Steam sets aSteamDeck=1env var, so the wrapper appends aSteamDecktoken to the user agent — the same signal the game already looks for. There's no IPC at all, because the web side never called into the old Rust shell. - Linux ships the unpacked folder, not an AppImage. AppImages self-mount via FUSE, which the Steam runtime often lacks. The plain unpacked tree is what most Electron-on-Steam games ship.
-
asar: false. Ourapp://handler reads files with Electron'snet.fetch, which can't see inside an asar archive. Loose files sidestep it. - A whole install dance, deleted. Tauri's WebView needed Microsoft's Edge WebView2 runtime bootstrapped on Windows via a Valve-approved install script. Electron bundles Chromium, so the download, the cache, and the approval dance are simply gone. CI also dropped the entire Rust + WebKitGTK toolchain — about five minutes off every run.
What this cost, and what's still open
The honest ledger: binaries went from ~10 MB to ~210 MB. Two things are
deliberately not done yet — the Steamworks SDK (achievements, cloud saves, rich
presence) isn't wired in (the old Rust commands were never-called stubs, so nothing
functional was lost), and the online leaderboard server still rejects the
app://bundle origin, so leaderboards are offline in the desktop build until
that's allowed. Both are tracked.
The quiet lesson
"Web-first" doesn't mean "ships anywhere for free" — the native shell is a real platform
with sandboxes, secure-context rules, container libraries, and file-permission gotchas of
its own. But notice what didn't change: not a single line of game code. The same
boundary that lets this very site embed the real engine (Chapter
05) is what made ripping out one native shell and bolting on another a contained,
one-pull-request job. The game is just a dist/ folder; everything else is
packaging.
Read the code
The whole wrapper is
electron/main.cjs; packaging in
electron-builder.yml; the Steam depot upload in
tools/steam-upload.mjs. The full chronological account is
PR #204, and the operator docs
in docs/steam-deck.md + docs/desktop-builds.md.