34 KiB
34 KiB
Development
Short guide for anyone working on the PLWL codebase.
Layout
src/ main process (Node/Electron backend)
main.js entry point: tray, single-instance handoff, --launch / --open
argv.js command-line parsing shared by boot and second-instance handoff
window.js main window creation
ipc/ all ipcMain handlers
index.js handler registration, icon protocol, dialogs
games.js games service: run/stop state, wine resolution, open-with handoff
lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress)
runner.js spawns wine/proton (env, prefix, playtime, logs) and owns
spawnTracked() - the log/playtime/exit tail every run shares
linuxrunner.js native Linux apps: redirected HOME/XDG, the isolation level
(none/relaxed/isolated/auto), the handed-document path and
the relaxed home redirects
openrelay.js the launcher side of the hand-off: the request folder an app
leaves what it wants opened in, watched while it runs
linuxapp.js native app detection (ELF/AppImage/.desktop) + icon lookup
sandbox.js bubblewrap probe (functional, cached) + buildBwrapArgs
(+ FS_ROOT, the one mount the relaxed level adds)
library.js apps registry (apps.json) + settings + playtime files, plus
the "which prefix does this app use" / in-use guards and
the isolation-mode gate on the way in
wines.js wine/proton build scan, registry, portable symlink healing
prefixes.js prefix registry (files/wineprefixes)
symlinks.js relative symlink rewiring + executable-bit restoration
(shared by imports, prefixes and LAN sync)
fsutil.js tree copy/remove with byte progress + rate-gated progress
events (imports, prefix/wine/app deletion, LAN sync)
importer.js app import (copy/move/leave/prefix)
names.js user-typed name sanitizing (prefix folder names, wine
display names)
openwith.js "Open with PLWL": ad-hoc runs, no library entry
shortcuts.js .desktop shortcut generation
paths.js portable layout, FILES-relative path helpers, translation sync
i18n.js translation loading + {0} placeholder filling
preload.js contextBridge API exposed to the renderer
lansync.js LAN sync receiver: identity + pairing, hashing, HTTP server
(with its receive-progress hook); uses hashcache.js,
lansync-identity.js, lansync-manifest.js, lansync-discovery.js
lansyncpush.js the pushing side (createPusher factory, keeps helper state out)
hashcache.js file hash cache (path -> size/mtime/hash) for LAN sync
lansync-identity.js identity + sync code (createIdentity factory)
lansync-manifest.js sender-side manifest building (createManifestBuilder)
lansync-discovery.js UDP beacon discovery (createDiscovery factory)
appicon.js window/taskbar icon
renderer/ UI (plain HTML/CSS/JS, no framework)
app.js views, modals, custom-select, all renderer logic
index.html static markup for every view
style.css the whole look; CSS variables at the top
files/ portable data the launcher ships/creates at runtime
translations/ en-us.json, pl.json (copied to the data dir on start)
test/ plain node scripts, run them with `node test/<name>.test.js`;
electron test/<name>.test.js for ones that need the real IPC/Electron
tools/ install.sh / createshortcut.sh / build helpers
Renderer never touches Node/Electron directly — everything goes through the window.plwl bridge (preload.js) and ipcMain handlers in src/ipc/. Keep new APIs in that pattern.
Rules
- Keep it simple. Plain functions over classes, no abstractions "for later".
- Structure: one topic per file. Extract a module once a file grows or logic repeats (see lansync-identity/manifest/discovery, hashcache). Factories (createX(...)) carry per-state helpers so module scope stays clean when several states share a module.
- Comments are RARE and SHORT, always in English. One line, two at most. Explain only the WHY (a quirk, a workaround, a race) — never the WHAT. No comment at all when nothing needs explaining; no banners, no restating the next line, no changelog notes.
- No duplicate logic: the second time you copy something, move it into a helper and call it from both places.
- Everything user-visible goes through the translation files (en-us.json, pl.json) with
{0},{1}placeholders. No hardcoded strings in app.js. - Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via writeJson(); playtime lives in its own tiny file so the registry is not rewritten while a game runs. A settings sync batches its patches instead of one write per app/build (
library.updateGames,wines.applySettings). - Tree walks (
copyTree/treeSize/removeTree/chmodTree) keep TREE_CONCURRENCY operations in flight instead of one syscall per await.copyTreereturns afailedcount; an import that could not reproduce every entry is refused and its partial destination removed, never registered with holes. - Deleting user data needs a confirm modal; best-effort cleanup is try/catch-and-ignore with a comment saying why it is safe to ignore.
- Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there).
- Native Linux apps run through linuxrunner.runLinuxGame()/buildLinuxEnv() the same way: HOME and the XDG folders are redirected into
{prefix}/linux_pfx/, and bwrap (when the probe says it works) forces the same layout with mounts. Never spawn a native app elsewhere.game.kindis absent on old entries — that means Windows;game.isolationis one of four levels, resolved by linuxrunner.resolveLevel:none(plain run),relaxed(bwrap + the whole host filesystem mounted writable at sandbox.FS_ROOT) andisolated(bwrap with the stock read-only profile), withauto(default) meaning relaxed where bwrap works and none where it does not. Onlyautomay weaken itself — an explicit relaxed/isolated answerssandbox-requiredinstead ("no silent downgrade"). library.js keeps exactly these four values, both when reading the registry and when a patch is saved (anything else falls back toauto), and the level always lands in the log and the UI. resolveLevel awaits the probe when nothing has measured yet: a shortcut boots HEADLESS, so no renderer ransandbox:status— without that waitautowould read an empty cache and quietly picknone(and an explicit level would refuse a machine that can sandbox perfectly well). - Under bwrap ONLY two things stay writable: the redirected home and the app's own folder (
writableAppDir: inside files/apps, or the folder the app was imported from — never/usr,/etc,/bin… and never files/wines). Portable apps write logs and data next to themselves and a read-only app folder makes them refuse to start (Prism Launcher: "couldn't create a log file - Read-only file system"). That bind is written LAST in buildBwrapArgs on purpose: an app lives under the host's home and the redirected home is mounted over that very directory, so the bind used to be swallowed by it and bwrap answeredexecvp <app>: No such file or directory— a click on Play and the window closed again (measured on 0.12.0, and again as a real exec in test/sandbox-appdir.test.js). Everything else isEROFSby design — at the isolated level. Relaxed is the deliberate exception and says so in the UI:FS_ROOTis the same filesystem, mounted writable, so "everything else" is only unreachable through the real paths. - Two pipes feed one log in spawnTracked: neither may end the stream (pipe(ws, { end: false }), closed when both sources drained, at the latest LOG_DRAIN_MS after the exit) and the write stream carries an error listener. Ending it at
exitwhile a pipe still had data killed the whole launcher with ERR_STREAM_WRITE_AFTER_END. - A prefix or wine build a running app uses cannot be renamed/deleted (wine holds files open there): the guards sit next to the handlers in src/ipc/, use the games service's running list plus library.js helpers (prefixNameForGame/gamesUsingPrefix/is*InUse) and answer
{ ok:false, error:'inUse'|'hasApps', apps:[names] }for the toast. - Long operations stream progress through the single import:progress slot as
{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes }(runWithProgress for deletes/imports). Emit 'count' (a treeSize walk) first, copy/remove through the fsutil helpers. An isolated hand-off has its own slot, handoff:progress ({ phase: 'copy'|'done'|'error', name, bytes, totalBytes, notice }), because it can start before the window exists: main.js gets the event first and, whennoticeis set (a copy above linuxrunner.STAGE_NOTICE_BYTES), puts the launcher window up so the renderer's popup can be seen; below that the copy is over before a window could paint. Copies of one file go through fsutil.copyFileProgress (streamed, throttled by progressGate,.part+ rename). - CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing comes from --button-margin and flex gaps, not per-element magic numbers.
- The blanket
transform: translateZ(0)(crisper small glyphs) makes every element its own stacking context, so a select's own z-index cannot escape the wrapper it sits in: a list opened inside.linux-only,.win-onlyor an override row was painted UNDER the field label and textarea that follow it (measuring withelementsFromPointover the open list returned the label). CustomSelect lifts the chain instead of the host —lift()adds.select-liftto every STATIC ancestor while the list is open, andclose()/closeAll()take it off again; positioned ancestors are skipped, so the fixed modal overlay keeps stacking over every view on its own.
Native Linux apps
- Entry point detection (linuxapp.js): ELF,
.AppImage, or a.desktopwhoseExec=points at one;lib/,share/… are skipped. The icon comes from the.desktopIcon=, an image next to the program, or the system theme —extractIconFromExeis PE-only. - The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured:
#eff0f1against the host's#202326). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in{prefix}/linux_pfx/.seeded.json, OUTSIDE the app's home, and a copy with no marker entry (or one the app has since changed) is the app's own and is never overwritten. The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in$HOME/.config/pulse/cookieand the app otherwise comes up silent;~/.Xauthorityis refreshed instead, since nothing exports XAUTHORITY to a sandboxed app. - Host fonts/icons/themes/colour schemes are not copied but
--ro-bindunder/tmp/.plwl-host/…and prepended to XDG_DATA_DIRS: live, and unable to write back into the user's~. Symlinks were measured and rejected — the prefix is mounted ON~, so a link to~/.config/kdeglobalspoints at itself (Too many levels of symbolic links); without bwrap the same link writes through into the real home. forceX11(per app) sets XDG_SESSION_TYPE=x11 + QT_QPA_PLATFORM=xcb + GDK_BACKEND=x11 so the app and everything it launches falls back to XWayland, where window icons still exist at all (Wayland forbids them; a compiled GLFW probe showed XDG_SESSION_TYPE picks X11 vs Wayland,GLFW_PLATFORMdoes not exist).game.envstill wins.- SESSION_MANAGER is dropped in the sandbox: the session socket sits in the private
/tmp, so the pointer led nowhere (Qt: "Could not open network socket"). - Imports normalise permissions (fsutil.chmodTree): owner
rwx,xmirrored fromr(0644 → 0755). Sources are archives, FAT sticks and read-only backups. - Reset Linux config on every prefix row removes exactly what PLWL seeded (DESKTOP_SEEDS, the pulse cookie,
home/.Xauthority,.seeded.json) plus the folders that existed only for the seed, so the next start seeds clean. syncDesktopConfig can only follow a copy it can still prove is its own; the reset does not argue. App data (data/,cache/,state/,config/<app>/, the rest ofhome/) is never touched, and a running app from that prefix blocks it (the usualinUseguard). - A path the desktop hands the app (a drag-and-drop's
text/uri-list, a file picked in a dialog -QT_QPA_PLATFORMTHEME=xdgdesktopportalon this desktop, so the chooser is the host portal's and answers with a real path) is a real/home/<user>/…path, and under bwrap that name is the app's own redirected home. A mount cannot fix it (a mount cannot be added to a namespace that is already running) and binding the host home in would hand the app the user's real folders at every level:syncHomeRedirectslinks the host home's own entries (never a name the profile mounts itself -.plwland the XDG folders, which a link would turn into bwrap'sCan't mount on symlink destinationon the first relaxed run of a fresh prefix, measured; never a name the app already has, never a link resolving nowhere) into the one view the level has - at relaxed toFS_ROOT/<entry>, which is the real folder, live and writable (measured: the real file reads through the link, and a write through it lands on it), and at the other levels there is no view to point at, so the links are taken off again before the app starts. The name the app's own folder sits under is never linked either: a portable bundle resolves itself withreadlink -f "$0", so the link answered for the app's whole tree - instances included - withFS_ROOT, and Prism Launcher read that as "Your instance folder is in a temporary folder: '/tmp'!" on every start (measured on 11.0.2 on the device whose launcher lives in the home; the same app on a launcher outside~kept its real path and stayed quiet - the folder is bound at its real path anyway). Only the launcher's own links are removed (matched by theirFS_ROOTtarget), and an isolated app gets no home folders at all: what reaches it there is the hand-off copy. The links outlive a run (a hand-off into an app that is already running is still using them); the next start at a level that cannot serve them clears them. - With HOME bound straight to
{prefix}/linux_pfx/home, the user's real~is invisible to the app — one reason absolute paths an app wrote into its config before the import (Prism's instanceJavaPath) do not resolve.relaxeddoes not change that shadowing, but it does make the real filesystem reachable throughFS_ROOT/home/… — andnonegives the app the real paths outright, which is what those two levels are for. - A clicked link belongs to the host desktop, so it leaves through
org.freedesktop.portal.OpenURI: thexdg-openBINARY is replaced inside the sandbox (a written shim--ro-bindover/usr/bin/xdg-open, and over the app bundle's ownbin/xdg-opentoo — that one sits first in the app's PATH, and its sharun wrapper dies on a closed stderr (SIGPIPE) before it ever delegates, which is exactly what Minecraft does afterRuntime.exec; the name the bundle links to that same file,gio-launch-desktop, is answered by the shim, since bwrap refuses to mount over a symlink; a--prefixed argument exits 2, because Java probes forxdg-openby running it with one) and Qt execs it (measured —QGenericUnixServicespicksxdg-openfirst, and reaches the portal itself only under/.flatpak-info/SNAP, which a bwrap profile does not have); Electron does the same. GIO resolves an URI IN-PROCESS, which no shim can reach: for that the prefix seedshome/.plwl/open-url(launcher-written, like the shim) plus a desktop entry and amimeapps.listnaming it the default forx-scheme-handler/http|https,text/html,x-scheme-handler/fileandinode/directory. Without the seed GIO falls back to the host's/usr/sharelist — measured: it offeredbrave-origin.desktop, andgio openanswered "The specified location is not supported". Measured end to end (both backends): the portal launches the host'shelium.desktopwith the exact URI.GIO_USE_PORTALS=1was measured and REJECTED: it opened NOTHING and still reported success (GIO's portal path wants a sandbox marker this launcher does not fake). A KDE-native app resolving the link in-process through KIO (kde-open) stays uncovered — KDE ignores the seeded list (measured). - The games service owns a run through the entry object it created: a start that failed, and the exit of a PREVIOUS run of the same app (stopped, then relaunched — SIGKILL is asynchronous), may not clear the new run's entry or close its request watcher (
forget/handleExitcompare that object). A start still in flight is never pruned, however long its isolated hand-off copy takes, because pruning it would drop the watcher mid-start; Stop during that window only records a kill request (killRequests), which the start fulfils the moment it has a pid — clearing the state there instead would let a second launch race past the first. Measured in test/games-service.test.js. - The portal is not told about a FOLDER or a document, which is what "Open Pack Folder" or a drop asks for: it cannot see a path from in here and answers
Response2 forfile://(measured, while https answered 0). The shim leaves those inhome/.plwl/open-request(written as.partand renamed, so nothing ever reads half a request) — a live bind of the app's home, so it reaches an app that is ALREADY running — and the launcher opens it out here with the host's ownxdg-open(openrelay.js:createOpenRelaywatches that folder for exactly as long as the app runs, consumes each request once, and quits watching on exit). The target is rewritten on the way out — the private$HOMEback to{prefix}/linux_pfx/home,FS_ROOTback to/at relaxed — so the host opens the REAL folder and never a path that only exists inside. Nothing in the sandbox can start a host program itself: measured, the host's ownxdg-openrun in there ends inkde-open, which exits 0 and opens no window at all. A scheme nothing on the host claims ends in the desktop's own "choose an application" dialog, which is the point of handing it out instead of guessing. - The portal's
Responseis NOT read back: it is a signal addressed to the caller's own bus connection, so a separategdbus monitornever receives it (measured: 15 calls, 15 timeouts, while the portal did open what it was given) and the readback only cost the app 3 s per click before giving up. Only whether the call went out is trusted, so a machine without a portal needs no special case any more: the call fails, and the request file is what still lands. (0.5.x had the host's own script parked inside the sandbox as the shim's fallback; measured, that route opens nothing.) - A file handed over (a shortcut's
--file %f, a drop on it) is the one path the sandbox cannot see by itself: a file out of the user's home is buried under the prefix home, one out of/tmpsits behind the private tmpfs, and the rest of the machine is read-only. Per-file--bind(what 0.5.0 did) is the wrong tool: a mount cannot be added to a namespace that is ALREADY running, so a hand-off into a running app could never be served that way.handedPathForinstead names the document in the one shape the chosen level can reach, and the app is told nothing else:none→ the real path;relaxed→FS_ROOT+ the real path;isolated→ a copy in{prefix}/linux_pfx/home/plwl-handoff/<name>, which is a live bind of the app's home, so it reaches a running app too (measured end to end in test/linux-e2e.js: both levels hand a document to a sandbox that is already up). Copies are one per name and refreshed on every open (the folder cannot grow), written through a.partname and renamed so nothing ever reads a half-written document, and the app's edits stay in the copy — that IS the level's promise. A handed path that is gone fails the hand-off instead of launching an app with no document. FS_ROOT(/tmp/plwl-fs-root) is where relaxed mounts the host filesystem, and it has to live under the private/tmp: bubblewrap creates the mount point itself, and the root of a read-only root cannot be written to (measured on 0.12.0:Can't create file /PLWL_FS_ROOT: Read-only file system, for a--dirbefore the--ro-bind / /too). Measured also: through it the real~reads, a write lands on the real file, and a file created AFTER a sandboxed run started is visible inside it — that last one is what makes a hand-off into a running app possible at all.- Not built: a private or overlay
/etc(buildBwrapArgsacceptsetcRoot, nothing sets it;--ro-bind / /leaves/etcread-only, which is all we promise today),--tmpfs /homeagainst OTHER users' homes (hideHomeis plumbed, no UI); app data always lands in{prefix}/linux_pfx. - Measured, not assumed (Debian 13, kernel 6.12,
bwrap0.12.0): the functional probe passes here, no firejail/proot/fuse-overlayfs on the box,/etcis 16 MB and itsmachine-idis read-only even on the host.
LAN sync
- Two sides: the receiver lives in lansync.js, the sender in lansyncpush.js, built by createPusher(helpers) — several launcher states share the module, so nothing may sit in its module scope. Both sides report their own progress (receiver through the onReceive hook).
- An incoming sync needs CONSENT: a correct code opens the gate, but the receiver's onIncoming hook (registered in main.js) shows Accept/Decline, answered through lansync:respondIncoming. Unanswered requests expire (INCOMING_TIMEOUT_MS); a decline returns 'rejected' and KEEPS the pairing (the code was proven). Without an onIncoming hook (tests, headless) the gate stays open.
- Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file. The gate holds a snapshot FUNCTION on the sender side: the snapshot is built when it flushes, so a throttled update never shows a byte count one gate window old.
- A finished sync ends with a result POPUP, not a toast: the sender swaps its locked progress modal for the summary, the receiver swaps the finished bar after ~1 s. It lists units per category, files moved/unchanged/removed, bytes transferred and the total size of the synced trees (
summary.sizeBytes; the receiver adds each unit's announcedunitBytes, replacing a retried unit's entry instead of double-counting). The device name is set with textContent, never interpolated into the modal HTML. - App selection is a seam, not a UI yet:
buildUnits(cats, selection)andbuildRegistryPayload(cats, selection)takeselection = { apps: [ids] }(a missing selection means every app). The payload then carriesappScope: 'selected';applyAppskeeps the apps of that category that were not sent, and skips theapps/orphan sweep, instead of mirroring the category. Without the marker the sender's list is complete and extras are deleted as before. - The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the
{ unitBytes }control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it). - While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged).
- Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers — batched, so a large library is one registry write:
library.updateGames(patches),wines.applySettings(entries),reorderGames. - File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time.
- A file's unit and path travel in the PUT's query, not in headers: Node rejects any header value holding a code above U+00FF (
ERR_INVALID_CHAR), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. Categories are matched by exact name; anything this build does not know is ignored. - File permissions travel with the manifest (
mode) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload. - A manifest the sender could not finish is not a licence to delete: a read that fails (a directory or file the user cannot open) sends a
{ unitIncomplete: true }control record, and an aborted plan body is detected withreq.complete. Either way the receiver still writes what it got but skipspruneTreefor that unit — a missing entry may only mean "could not read it". For the same reasonapplyApps/applyWines/applyPrefixesmirror nothing when the payload has no list at all (malformed/truncated apply), while an explicit empty list still means "the sender has none" (a partial app selection excepted: it keeps the apps it did not send). - A PUT settles on every failure path (
aborted/closewithout the body having ended, stream or writer error): the.plwl-sync-tmpis removed and the request cannot stay half-open forever. The 60 sPUT_IDLE_MSonly covers a body that stops sending. - Verification is GLOBAL one-at-a-time (
state.verifyingis a boolean, set before the first await — parallel requests would pay the 2 s cost in parallel, which is exactly what makes a scheme pointless). One outgoing sync per process (syncToanswers 'busy'), so two syncs can never write the same receiver files at once. A failed bind resolves{ ok: false, error }instead of leavinglisteningpending forever, and the beacon only starts when the server is up. - Names arriving on the wire that become paths (app ids in
data/icons/<id>, prefix folder names) are validated (safeName/safePrefixName): a corrupted or older peer must not be able to store../../xand turn a later Rename/Delete click into a write outside files/. - A sender hashing a big unit is minutes of silence: it pings
/plwl/pingevery HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' shows "Waiting for the other device…" but never while a file is being written). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events). - Native Linux app data (
{prefix}/linux_pfx/: the redirected home, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (apps-portable/apps-prefix), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload. - A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first.
listDirs()counts symlinked folders as units, so a build on another disk still travels — as the link it is (wineUnitsmaps nested builds to their TOP folder under wines/). - Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair absolute links that reached disk anyway. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable
protonfails with EACCES while the game looks started).
Paths and prefixes
- Symlinks inside everything PLWL owns are stored RELATIVE whenever the target can be expressed locally (symlinks.js owns this: rewireSymlinks + restoreExecutableBits; every writer calls it — imports, healPrefix, sync on the wire and after a unit lands). dosdevices drive mappings and targets leaving files/ stay absolute — machine-specific by design.
- A prefix with NO
pfxyet gets ONE C: drive:pfxis a relative symlink to the prefix itself (prefixes.ensureUnifiedPrefixonly CREATES it), so Proton's<compatdata>/pfxshares drive_c, registry and dosdevices with plain wine. Existing layouts are left as they are — hoisting files one level up was tried and removed (it breaks relative links, the umu.exe link kills a Proton launch). Never assume a single drive_c: useprefixDriveRoots()and compare realpaths. - App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an ordered semicolon list (inherited first, app's last; an empty
dll=is "disabled" and must survive the filter). Proton appends its dxvk/nvapi defaults AFTER everything.disableUmudrops UMU_ID and runs Proton'sruninprefixverb. STEAM_COMPAT_CLIENT_INSTALL_PATH is required in BOTH modes (Proton's session init reads it on every verb; missing it aborts the launch before wine starts): a real Steam client when found, else the empty files/data/fake-steam.
Launch and shortcuts
- Generated shortcuts are file handlers too:
Exec=<launcher> --launch <id> --file %f(a literal % is%%). argv.js takes the file from--fileor a bare trailing path, and runner.windowsPathFor passes it to the app as Windows would (C:\...inside the prefix,Z:\...otherwise). A file given to an already running app is a handoff (untracked process, no log/playtime writes). - The second-instance handoff must never read the token next to its option: Electron RE-SERIALIZES argv (
--launch <id> --file <path>arrives as--launch --file --allow-file-access-from-files <id> <path>), so argv.js pairs options with positionals IN ORDER (inline--name=valuewins, the dev app path is skipped). Reading the next token used to take the launch id as the opened file.electron test/argv-handoff.test.jsproves the real serialization. - A modal showing a running sync opens locked: showModal(html, { locked: true }) ignores backdrop and Escape.
- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to a temp dir, check(), exit 1 on failure. Add one when fixing a bug.
Verify before you commit
node --check <changed js files> # syntax
node test/smoke.js # quick sanity
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
node test/lansync-receiving.test.js # receive-side progress events (the popup)
node test/lansync-progress.test.js # live sender bytes, comparing bar widths
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
node test/lansync-links.test.js # wire: exec bits + relative links
node test/lansync-pending-links.test.js # links to files created later, startup heal
node test/lansync-consent.test.js # accept / decline / timeout gate
node test/lansync-token.test.js # long-sync token expiry (sliding TTL)
node test/bench-sync.js # timing: cold vs cached sync
node test/linux-sandbox.test.js # bwrap argv profile + the four levels, handed paths, mode gate
node test/sandbox-appdir.test.js # the app folder stays reachable inside its own redirected home
node test/linux-env.test.js # redirected HOME/XDG_* for both backends
node test/linux-import.test.js # ELF/AppImage/.desktop detection + icons
node test/linux-links.test.js # link opening: shim binds + mime seed
node test/linux-open.test.js # what an app opens: the script + the launcher relay
node test/games-service.test.js # run bookkeeping: the request watcher, Stop while starting
node test/lansync-linux-apps.test.js # Linux apps + linux_pfx travel with their categories
node test/linux-e2e.js # REAL runs: plain, relaxed (+ its mount), isolated, hand-off into a running app
npm start # click through the flow you touched
# Repairing / testing a real instance (test/ scripts are gitignored helpers):
PLWL_BUILD_DIR=/path/to/instance node test/heal-instance.js # links + exec bits
PLWL_BUILD_DIR=/path/to/instance node test/launch-instance.js <id> # really start a game