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)

What it does

FiveLaunch is a desktop launcher for FiveM that lets you keep a bunch of fully separate client setups on one game install and swap between them at launch. Each client has its own mods, plugins, and settings, and when you hit launch the app points FiveM’s fixed paths at whichever one you picked. Simple idea on paper. The problem is that “redirect a game’s real folders somewhere else, safely, on Windows, without losing anyone’s data” has way more sharp edges than I expected going in.

So that’s what this post is: 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 app from roughly 170 MB down to about 5. If you’d rather see the feature tour first that’s over at fivelaunch.help.

Why I rewrote it at all

v1 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 almost 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 an entire Chromium and an entire Node runtime inside every app. If you’ve got a rich web frontend and a big team, fine, that’s the deal you signed up for. But FiveLaunch is a small tool that pokes at the filesystem. I was paying a 170 MB tax and a slow cold start for a browser engine I was barely using, and on top of that all my dangerous filesystem code lived in a language where “this folder is actually a junction” is something you find out at runtime, if you’re lucky.

Tauri fixes both. The webview is the one already built into Windows (WebView2) so it’s not in my bundle, and all the risky logic moves into Rust where the type system and the tests actually have my back. So I rewrote it. Same app, same on-disk data, completely different engine underneath.

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

Real measurements by the way, not marketing numbers. The Electron build was 168.9 MB. The Tauri build is 5.51 MB for the portable exe, and the signed installer is even smaller at 2.64 MB. Startup went the same way:

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

v1 needed a splash screen because startup was slow enough to look broken without one. v2 paints fast enough that there’s no splash at all, since there’s nothing to cover up.

There’s a full scorecard near the end, including the one number that didn’t really move. But size and startup were the whole reason I started, so they get to go first.

Okay, 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 Linux or Mac you’d make a symlink and get on with your life.

On Windows, symlinks need admin rights or Developer Mode turned on. I’m not shipping a launcher that asks for admin every time you switch clients, and I’m definitely not making people flip a Windows setting just to play a game. So FiveLaunch uses NTFS junctions instead. A junction is a directory reparse point. It works on folders, and more importantly it works without admin. FiveM doesn’t care which kind of link it is, it resolves the path and reads.

That solves the permission problem and creates a sneakier one.

Once you have junctions in your folder tree, any code that recursively walks a directory has to be careful, because a naive walk will happily follow a junction right 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 some wildly wrong sizes.

Fix is to stat with the equivalent of lstat, check the link type before descending, and never step through a reparse point. There’s a test guarding this literally named does_not_descend_into_junctions, because it’s the kind of thing that quietly breaks and nobody notices until someone’s disk-usage number is off by fifty gigabytes.

Windows will not let go of a file

Something that’s true on Windows and mostly not true anywhere else: 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 taking over their location. And FiveM, ReShade, an overlay, or even Explorer generating a thumbnail can be holding one of those files at the exact moment you try. Windows throws 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);
}
}
}
}

About those three magic numbers: 5 is ACCESS_DENIED, 32 is SHARING_VIOLATION, 33 is LOCK_VIOLATION. Raw Windows codes hiding behind Node’s friendlier EPERM and EBUSY. Twenty tries at 75 ms gives a lock about 1.5 seconds to clear, which covers basically every short hold I’ve run into.

The part that’s easy to get wrong is the !retryable branch. If the file genuinely isn’t there you don’t want to sit and retry for a second and a half just to eventually report “not found.” A real missing-file error has to fail immediately. There’s a test whose only job is asserting that a NotFound comes back way under the retry budget, because a retry loop that retries the wrong things is worse than no retry loop at all.

The hash I had to break on purpose

This one’s my favorite, because the correct fix 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’s the trap. charCodeAt gives you UTF-16 code units. The obvious Rust port walks the string’s bytes, which are UTF-8. For plain ASCII paths those are identical, so every test passes and 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 stop matching. v2 would hash to a different folder than the one v1 created, quietly miss the existing mapping, and look like it just lost the user’s ReShade setup.

So the Rust version does the weird thing on purpose and walks 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’s a test whose entire job is proving 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, keeping whichever copy is newer.

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

Fix is a skew window. If two mtimes are within 900 ms of each other, treat that as a tie instead of a difference, and only then fall back to an actual content comparison plus a fixed 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
}

That f64 isn’t 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 format on disk wins and the new code has to bend to it.

The settings file the game keeps stealing back

GTA’s gta5_settings.xml is the most annoying file in this entire project.

FiveLaunch lets each client have its own graphics settings. The catch is the game will happily rewrite that file whenever it feels like it, so seeding your settings once and hoping isn’t enough. If the game decides your settings look like they came from a different machine it throws them out, 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 doesn’t match your actual card, or is blank, GTA assumes the settings belong to someone else’s hardware and resets everything. So FiveLaunch has to preserve the real GPU string when it writes the file, and when it’s seeding a fresh one with nothing to copy from, it 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 exactly the reset you were trying to avoid.

On top of that, a background thread watches the file during a session and puts the client’s version back if the game clobbers it.

Which brought me to my favorite kind of bug: the code was right and the test was wrong. The enforcement thread worked perfectly. But one test checked it with a plain read_to_string().unwrap() 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 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 of the linking for free, but plugins get a second mode. Some plugins misbehave when their folder is a junction and insist on a real directory at the real path, so FiveLaunch has 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 case. Client A writes into the real plugins folder, then you launch Client B, and B quietly inherits A’s files. That’s exactly the cross-contamination the whole app exists to prevent, sneaking in through the back door.

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

The UI was freezing and Rust was not the problem

Here’s a Tauri gotcha you won’t find on any “Electron vs Tauri” comparison chart.

A Tauri command that isn’t marked async runs on the main thread. Totally fine for a command that reads a little JSON. 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 whole window locked up for the entire 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.

Fix was pushing every heavy command onto a blocking worker with spawn_blocking, same pattern the launch pipeline already used, and leaving only the tiny latency-sensitive reads on the main thread. Same lesson Electron devs learn about not blocking the event loop, 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) were each scanning 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’s now one Promise.all. Four sequential waits became one.

None of that is exciting but it’s the difference between an app that feels instant and one that feels like it’s thinking.

The honest scorecard

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

Binary and startup are the huge wins, and the subprocess one is real quality-of-life. v1 spawned a tasklist.exe process about once a second while you played just to check if the game was still running. v2 reads the process table natively and spawns nothing.

Now the number that didn’t really move, because leaving it out would be cheating. Idle memory dropped 18% on private memory and only about 5% on working set. Nice, but nowhere near the 10x you might expect from a binary that shrank 30x. Reason is simple: WebView2 is still Chromium. The webview left my bundle, which is why the binary is tiny, but it didn’t leave memory, because it’s still a full browser engine rendering my UI. Anyone selling a Tauri rewrite as a big RAM win is quietly not measuring the webview.

The rewrite was not free

I won’t pretend this was all upside.

It’s more languages and a wider surface to maintain. 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’m now debugging across a language boundary instead of inside one.

The compatibility rule that made the rewrite safe also made it slow to write. Every on-disk format had to stay byte-for-byte identical to v1, so a big chunk of the work wasn’t “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’s a lot of golden-file tests for zero visible features. It’s also the only reason you can flip between the Electron and Tauri builds against the same profiles without migrating anything, so I’d do it again. But it wasn’t free.

And I traded a language I move fast in for one that makes me slow down and get things right. Most days that’s the trade I want in code that rewrites people’s game folders. Some days it’s 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

None of the hard parts here were the ones I expected going in. I figured the launch pipeline would be the tricky bit and it was fine. The actual tricky bits were a hash that had to stay wrong to stay compatible, a settings file the game keeps stealing back, two folders that can’t agree on what time it is, and finding out a smaller binary doesn’t mean less memory.

Still worth it. Not because Rust is magically fast, but because it turned an app that felt heavy into one that opens before you’ve let go of the mouse, and it moved every dangerous filesystem decision somewhere the compiler and a wall of tests get a say before your data does. The rest was just the long boring work of not breaking anyone who was already using it.

Want to try it, it’s at fivelaunch.help, and the source is here: