skip to content

My FiveM Client Sandbox

The technical side of a FiveM profile launcher: NTFS junctions, Windows file locks, a hash that had to match a JavaScript bug, and rewriting the whole thing from Electron to Tauri to go from a 170 MB binary down to 5.

18 min read
Try it live (opens in a new tab)

Overview

FiveLaunch is a desktop launcher for FiveM that keeps a stack of fully separate client setups on one game install and swaps them in at launch. Each client owns its own mods, plugins, and settings, and at launch the app points FiveM’s fixed paths at whichever client you picked. Simple idea. The trouble is that “point a game’s real folders at some other folder, safely, on Windows, without losing anyone’s data” hides a genuinely surprising number of sharp edges.

So this one is about the sharp edges. The Windows-specific pain, the filesystem bugs, a hash I had to break on purpose, and the ground-up rewrite from Electron to Tauri that took the whole thing from roughly 170 MB down to about 5. If you want the feature-tour version first, that lives at fivelaunch.help.

Why I Rewrote It At All

The first version of FiveLaunch was Electron. React renderer, all the filesystem and process logic in the Node main process, ShadCN for the UI. It shipped, it worked, people used it. It was also a 168.9 MB executable that took the better part of half a second to show a window, for an app whose entire job is “move some folders and start a game.”

That bugged me more than it probably should have.

Electron ships a whole Chromium and a whole Node runtime inside every app. For something with a rich web frontend and a big team, fine, that’s the deal you sign up for. But FiveLaunch is a small tool that pokes at the filesystem. I was paying a 170 MB tax and a cold-start tax for a browser engine I was barely using, plus I was doing all my dangerous filesystem work in a language where “this folder is actually a junction” is something you find out at runtime, if you’re lucky.

Tauri flips both of those. The webview is the one already sitting in Windows (WebView2), so it isn’t in my bundle, and all the risky logic moves into Rust where the type system and the tests can actually hold me to account. So I rewrote it. Same app, same on-disk data, completely different engine underneath.

Installed binary size (MB, lower is better)
Loading chart…

That is a real measurement, not an illustrative one. The Electron build was 168.9 MB. The Tauri build is 5.51 MB for the portable exe, and the signed installer is smaller still at 2.64 MB. Startup told the same story:

Time to input-idle (ms, lower is better)
Loading chart…

v1 needed a splash window because the perceived start was slow enough to look broken without one. v2 paints fast enough that there is no splash at all, because there is nothing to cover up.

I will do the full honest scorecard near the end, including the number that did not move much. But the size and startup wins were the whole reason I started, so they get to go first.

Now the actual point of this post. Everything that made the rewrite harder than “port the files and call it a day.”

Junctions, Not Symlinks

The core trick of the whole app is a link. FiveM wants to read mods from one fixed location, and I want that location to actually be a client folder somewhere else. On Unix you reach for a symlink and move on.

On Windows, symlinks need elevated rights or Developer Mode turned on. I am not going to ship a launcher that demands admin every time you switch clients, and I am definitely not going to make people toggle a Windows setting to play a game. So FiveLaunch uses NTFS junctions instead. A junction is a directory reparse point, it works for folders, and critically it works without admin. FiveM does not care which one it is, it just resolves the path and reads.

That solves the permission problem and creates a subtler one.

The moment you have junctions in your folder tree, any code that recursively walks a directory has to be very careful, because a naive walk will happily follow a junction straight out of the client folder and into the real game install, or into a loop. My “how big is this client” stats walker did exactly that in an early build and reported wildly wrong sizes.

The fix is to stat with the equivalent of lstat, look at the link type before you decide to descend, and never step through a reparse point. There is a golden test guarding it, literally named does_not_descend_into_junctions, because it is the kind of thing that quietly breaks and you do not notice until someone’s disk-usage number is off by fifty gigabytes.

Windows Will Not Let Go Of A File

Here is a thing that is true on Windows and mostly not true elsewhere: a file you have every right to move can just refuse to move, because something else has it open.

FiveLaunch moves real folders and settings files into a backup store before it takes over their location. And FiveM, ReShade, an overlay, or even Explorer previewing a thumbnail can be holding one of those files at the exact instant you try. Windows answers with a sharing violation and the whole operation fails, even though nothing is actually wrong. Wait 80 milliseconds and it works fine.

So every rename that matters goes through a small retry wrapper:

pub fn rename_with_retry(from: &Path, to: &Path) -> io::Result<()> {
const RETRIES: u32 = 20;
const DELAY: Duration = Duration::from_millis(75);
let mut attempt = 0;
loop {
match fs::rename(from, to) {
Ok(()) => return Ok(()),
Err(err) => {
let retryable = err.kind() == io::ErrorKind::PermissionDenied
|| matches!(err.raw_os_error(), Some(5) | Some(32) | Some(33));
if !retryable || attempt == RETRIES {
return Err(err);
}
attempt += 1;
thread::sleep(DELAY);
}
}
}
}

Those three magic numbers are the whole story. 5 is ACCESS_DENIED, 32 is SHARING_VIOLATION, 33 is LOCK_VIOLATION, the raw Windows codes hiding behind Node’s friendlier EPERM and EBUSY. Twenty tries at 75 ms gives you a decent 1.5 second window for a lock to clear, which covers basically every transient hold in practice.

The part that is easy to get wrong is the !retryable branch. If the file genuinely is not there, you do not want to sit and retry twenty times over a second and a half to eventually report “not found.” A real missing-file error has to fail immediately. There is a test that does nothing but assert a NotFound returns in well under the retry budget, because a retry loop that retries the wrong things is somehow worse than no retry loop at all.

The Hash I Had To Break On Purpose

This one is my favorite, because the correct answer was to reproduce a bug.

ReShade stores config and presets in awkward spots, so FiveLaunch keeps client-owned copies in folders named after a hash of the original source path. Something like settings/reshade/sources/<hash>/. v1 computed that hash in JavaScript with FNV-1a, walking the string with charCodeAt.

Here is the trap. charCodeAt gives you UTF-16 code units. The obvious Rust port walks the bytes of the string, which is UTF-8. For plain ASCII paths, those are identical, every test passes, you ship it. But the day a user has an accent in their Windows username, so their path runs through C:\Users\José\..., the two encodings diverge. v2 would hash to a different folder than the one v1 created, quietly fail to find the existing mapping, and look like it lost the user’s ReShade setup.

So the Rust version does the deliberately weird thing and iterates UTF-16 to match the JavaScript exactly:

pub fn fnv1a32_hex(input: &str) -> String {
let mut hash: u32 = 0x811c_9dc5;
for unit in input.encode_utf16() {
hash ^= u32::from(unit);
hash = hash.wrapping_mul(0x0100_0193);
}
format!("{hash:08x}")
}

There is a test whose entire job is to prove that hashing "é" does not match the UTF-8-bytes version, so nobody “cleans this up” later and silently breaks every existing install with a non-ASCII path.

Two Folders, Two Clocks

Some things need to sync both ways. CitizenFX.ini and a handful of ReShade files can change while you play, so at launch and again on exit FiveLaunch syncs them between the game location and the client folder, preferring whichever copy is newer.

“Whichever is newer” sounds like one comparison. It is not, because modification times lie to you in small ways. Two folders can sit on different volumes with different timestamp granularity, a copy can nudge an mtime by a few hundred milliseconds, and FAT-style timestamps round in ways NTFS does not. Compare mtimes exactly and you get flip-flopping, where a file endlessly “wins” against its own identical twin.

The fix is a skew window. If two mtimes are within 900 ms of each other, treat it as a tie rather than a difference, and only then fall back to a real content comparison plus a fixed tie preference for which side wins:

pub const MTIME_SKEW_MS: f64 = 900.0;
// within the window? not "newer", just "same enough", compare contents
if (a_time - b_time).abs() <= MTIME_SKEW_MS {
// content-compare tiebreak, deterministic winner
}

The f64 there is not an accident either. v1 stored its mtime cache as JavaScript mtimeMs floats, so the Rust cache keeps them as f64 milliseconds too, and the persisted cache file stays readable across both versions. Same theme as the hash: the data format on disk is the boss, and the new code bends to it.

The Settings File The Game Keeps Overwriting

GTA’s gta5_settings.xml is the pettiest file in the whole app.

FiveLaunch lets each client own its own graphics settings. The catch is that the game feels entitled to rewrite that file whenever it likes, so simply seeding your settings once and hoping is not enough. If the game decides your settings look like they were made on a different machine, it throws them out and re-runs auto-detection, and your carefully tuned config is gone.

The specific landmine is a field called VideoCardDescription. If the GPU name in the file does not match your actual card, or is blank, GTA assumes the settings belong to someone else’s hardware and resets them. So FiveLaunch has to preserve the real GPU string when it writes the file, and when it is seeding a fresh one with nothing to copy from, it auto-detects your hardware and picks the discrete GPU over the integrated one, because writing “Intel integrated” onto a machine with a real graphics card triggers the exact reset you were trying to avoid.

On top of that there is a background enforcement thread that watches the file during a session and rewrites the client’s version if the game clobbers it.

Which led to my favorite flavor of bug, the one where the code is right and the test is wrong. The enforcement thread worked perfectly. But a test that checked it did a plain read_to_string().unwrap() on the file in a tight poll loop, and on the Windows CI runner that read would occasionally land in the exact microsecond the enforcement thread was mid-write. Sharing violation, failed read, panicked test. Nothing was broken except my assumption that reading a file is an atomic thing that always succeeds.

// was: unwrap() panics on a transient mid-write sharing violation
// now: a failed read just means "not restored yet, keep polling"
if fs::read_to_string(&target).ok().as_deref() == Some(SETTINGS_TEMPLATE_XML) {
break;
}

Keeping Two Clients Out Of Each Other’s Folder

Junctions handle most linking for free, but plugins get a second mode. Some plugins misbehave when their folder is a junction and want a real directory at the real path, so FiveLaunch offers a sync mode that mirrors a client’s plugins into the game folder before launch and syncs safe changes back afterward.

Sync mode has one genuinely scary failure. Client A writes into the real plugins folder, then you launch Client B, and B quietly inherits A’s files. That is exactly the cross-contamination the whole app exists to prevent, showing up through the back door.

So the folder carries an ownership marker. Before FiveLaunch reuses that real plugins folder, it checks who last owned it. If the contents look unmanaged, or look like they belong to a different client, the folder gets rotated aside into the backup store instead of reused. It makes sync mode a little more ceremonious on every launch, and a lot harder to accidentally blend two setups together, which is the trade I want.

The UI Was Freezing And Rust Was Not The Problem

Here is a Tauri gotcha that is not in any “Electron vs Tauri” comparison chart.

A Tauri command that is not marked async runs on the main thread. That is fine for a command that reads a little JSON. It is very much not fine for “duplicate this client,” which copies a multi-gigabyte folder, or “delete this client,” which removes one. Early on, those ran on the main thread, and the entire window locked up solid for the whole copy. Rust was doing the work at full speed. It was just doing it in the one place that also has to keep the UI alive.

The fix was to push every heavy command onto a blocking worker with spawn_blocking, the same pattern the launch pipeline already used, and leave only the tiny latency-sensitive reads on the main thread. Same lesson Electron people learn about not blocking the event loop, just with a different name on the trap.

While I was in there I found two more:

  • Three separate background watchers (tray status, restore-on-exit, in-game sync) each scanned the entire process table on their own timer, adding up to roughly 2.3 full process scans a second while you played. They now share one cached process check behind a 250 ms window, so a burst of callers costs a single scan.
  • The UI fetched settings, then version, then clients, then the current selection, one after another, four round trips before it could paint with real data. That is now one Promise.all. Four sequential waits became one.

None of that is glamorous. All of it is the difference between an app that feels instant and one that feels like it is thinking.

The Honest Scorecard

Here is the full head-to-head, same machine, same method, real numbers.

v1 (Electron 28) vs v2 (Tauri 2), measured on the same machine
Installed binary168.9 MB5.51 MB~31x smaller
Time to input-idle467 ms68 ms~6.9x faster
Frontend JS shippedmulti-MB66 KB (22.7 KB gzip)~30x smaller
Private memory, idle244.5 MB200.9 MB18% less
Working set, idle341.6 MB325.7 MB5% less
Process polling while playingspawns tasklist.exe ~1/snative, zero subprocessesgone

The binary and startup wins are enormous, and the subprocess win is real quality-of-life: v1 spawned a tasklist.exe process about once a second while you played, just to check whether the game was still running. v2 reads the process table natively and spawns nothing.

Now the number that did not move much, because leaving it out would be dishonest. Idle memory dropped 18% on private memory and only about 5% on working set. That is nice, but it is not the 10x you might expect from the binary shrinking 30x, and the reason is simple: WebView2 is still Chromium. The webview came out of my bundle, which is why the binary is tiny, but it did not come out of memory, because it is still a browser engine rendering my UI. Anyone selling a Tauri rewrite as a giant RAM win is quietly not measuring the webview.

The Rewrite Was Not Free

I would be lying if I called this all upside.

It is more languages and a wider skill surface. v1 was TypeScript top to bottom. v2 is Rust for the core, TypeScript and Svelte for the UI, and a typed bridge between them that I have to keep honest by hand. When something breaks near a launch, I am now debugging across a language boundary instead of within one.

The compatibility rule that made the rewrite safe also made it slower to write. Every format on disk had to stay byte-for-byte identical to v1, so a pile of the work was not “build the feature,” it was “prove the new code produces the exact same bytes the old code did,” hash quirks and float caches and all. That is a lot of golden-file tests for zero visible features. It is also the only reason you can flip between the Electron and Tauri builds against the same profiles without migrating anything, so I would do it again, but it was not free.

And I traded a language I could move fast in for one that makes me slow down and be correct. Most days that is the trade I want in code that rewrites people’s game folders. Some days it is just slower.

Old vs New

Areav1v2
ShellElectron 28 (bundled Chromium + Node)Tauri 2 (system WebView2 + Rust)
UIReact + Tailwind + ShadCNSvelte 5 runes + Tailwind v4
Risky logicTypeScript in the main processRust core, unit-tested in isolation
Process checksspawned tasklist.exe ~1/snative enumeration, zero subprocesses
Packaging~170 MB portable exe, no installer5.5 MB exe, signed installer, in-app updates
ToolchainpnpmBun
On-disk data%APPDATA%\FiveLaunch, v1 JSON formatsidentical, byte-for-byte compatible

Closing

None of the hard parts of FiveLaunch were the parts I expected going in. I thought the launcher pipeline would be the tricky bit. It was fine. The tricky bits were a hash that had to stay wrong to stay compatible, a file the game keeps stealing back, two folders that cannot agree what time it is, and a webview that shrinks your binary but not your memory.

The rewrite was worth it. Not because Rust is a magic speed word, it turned an app that felt heavy into one that opens before you finish letting go of the mouse, and it moved every dangerous filesystem decision into a place where the compiler and a wall of tests get a say before your data does. The rest was just the long, unglamorous work of not breaking anyone who was already using it.

If you want to try it, that is fivelaunch.help, and the source is here: