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.
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 then swap between them at launch. Each client has its own mods, plugins, and settings, and when you launch it, FiveLaunch uses FiveM’s fixed paths at whichever one you picked. It’s a pretty simple idea, if you compare to Lunar Client. However, the problem is that redirecting a game’s working folders somewhere else, especially with no issues like losing any data has way more into it than I expected.
So that’s what this post is about: the Windows issues, all fs “discoveries”, and the rewrite from Electron to Tauri that took the app from roughly 170 MB down to about 5. If you’d rather see the features first that’s over at fivelaunch.help.
Why I rewrote it at all
v1 was an Electron app (ew) with the React renderer and all the filesystem and process logic was in the Node main process. It definitely worked but it was soooooo slow and it was a 168.9 MB executable that took almost half a second to show a window and for an app whose entire job is “move some folders and start a game.” that was terrible.
Electron ships a whole Chromium and Node runtime in every build. So, you get all of the bloat even if you don’t use all of the features, but FiveLaunch is a pretty small tool that barely touches the filesystem. So, I was paying a 170 MB tax for a browser engine I was barely using. On top of that all my dangerous filesystem code lived in a javascript, which I wasn’t very happy about.
Tauri fixed both! The webview is already built into Windows (WebView2) so it’s not in my bundle, and all the risky logic moves into Rust where the types actually do something during runtime. So I rewrote the same app with a completely different engine underneath.
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:
Just to show how slow v1 was, there was a splash screen because it took so long to start. v2’s first paint is fast enough that it doesn’t need one.
Junctions, not symlinks
The main thing that the app does is file linking. FiveM wants to read mods from a fixed location (the mods folder), and I want that location to be somewhere else. On Linux or Mac you can make a symlink which does exactly what I need it to.
But on Windows, symlinks need admin rights or Developer Mode turned on but I didn’t want a launcher that asks for admin every time you switch clients, and I also didn’t want people to have to enable developer mode. So, FiveLaunch uses NTFS junctions instead which is a directory reparse point. It works on Folders with no admin perms and FiveM doesn’t care which kind of link it is.
Once you have junctions in your folder tree, any code that recursively walks a directory has to be careful, because a walk could follow a junction out of the client folder and into folders that users probably don’t want to be touched.
The fix was to stat with the equivalent of lstat and then check the link type before doing just about anything, and never step through a reparse point. There’s a test guarding this literally named does_not_descend_into_junctions because this is like the one thing that should be ensured even more than the program actually working.
Windows will not let go of a file
Windows is odd because it is the only OS that I’ve seen where a file where just refuse to move even on privileged and raised permissions
FiveLaunch moves folders and settings files into a backup before taking over their location. So FiveM, ReShade, an overlay, or even generated thumbnails can be holding one of those files at the exact moment you try. Windows throws a sharing violation and whatever you tried just doesn’t work, even though nothing is actually wrong. Wait 80 milliseconds and it works fine.
So everything that does something similar goes through a 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 random numbers: 5 is ACCESS_DENIED, 32 is SHARING_VIOLATION, 33 is LOCK_VIOLATION. They are raw Windows codes behind Node’s EPERM and EBUSY. Twenty tries at 75 ms gives a lock about 1.5 seconds to clear, which covers every short hold I’ve run into.
The part that’s easy to get wrong is the !retryable branch. If the file 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 first. So, there’s a test whose only job is asserting that a NotFound comes back way under the retry ;limit, because a retry loop that retries the wrong things is worse than no retry loop.
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.
charCodeAt gives you UTF-16 code units. The obvious Rust port goes through the string’s bytes, which are UTF-8. For plain ASCII paths they are pretty much teh same, so every test passes and “it works”. But when a user has some accent in their Windows username, their path runs through C:\Users\José\..., the two stop matching. v2 would hash to a different folder than the one v1 created, which missed the other mapping, and look like it just lost the user’s ReShade setup.
So the Rust version does something weird 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 making sure that hashing "é" does not match the UTF-8-bytes version, so nobody “cleans this up” later and 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 contentsif (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.
| Installed binary | 168.9 MB | 5.51 MB | ~31x smaller |
| Time to input-idle | 467 ms | 68 ms | ~6.9x faster |
| Frontend JS shipped | multi-MB | 66 KB (22.7 KB gzip) | ~30x smaller |
| Private memory, idle | 244.5 MB | 200.9 MB | 18% less |
| Working set, idle | 341.6 MB | 325.7 MB | 5% less |
| Process polling while playing | spawns tasklist.exe ~1/s | native, zero subprocesses | gone |
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
| Area | v1 | v2 |
|---|---|---|
| Shell | Electron 28 (bundled Chromium + Node) | Tauri 2 (system WebView2 + Rust) |
| UI | React + Tailwind + ShadCN | Svelte 5 runes + Tailwind v4 |
| Risky logic | TypeScript in the main process | Rust core, unit-tested in isolation |
| Process checks | spawned tasklist.exe ~1/s | native enumeration, zero subprocesses |
| Packaging | ~170 MB portable exe, no installer | 5.5 MB exe, signed installer, in-app updates |
| Toolchain | pnpm | Bun |
| On-disk data | %APPDATA%\FiveLaunch, v1 JSON formats | identical, 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: