# 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) library.js apps registry (apps.json) + settings + playtime files, plus the "which prefix does this app use" / in-use guards 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: identity + pairing, hashing, HTTP receiver (with its receive-progress hook), UDP discovery lansyncpush.js the pushing side (createPusher factory, keeps helper state out) 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/.test.js`; electron test/.test.js for ones that need the real IPC/Electron tools/ install.sh / createshortcut.sh / build helpers ``` Main process vs renderer: the 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". - Comments explain the WHY (quirks, workarounds, races), not the WHAT. If the code says it, don't repeat it in words. No comments at all when nothing needs explaining. - No duplicate logic: the second time you copy something, move it into a helper (a shared module in src/ or a function next to its users) 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. - 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 somewhere else (prefix handling, Proton env and playtime live there). - A prefix or wine build that a running app uses cannot be renamed/deleted (wine holds its registry and user files open in that folder): the guards sit next to the handlers in src/ipc/, use the games service's running list plus the helpers in library.js (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 } and the renderer drives a bar from it (runWithProgress for deletes/imports). Emit the 'count' phase first (a treeSize walk) and copy/remove through the fsutil helpers; never ship a progress bar no data can move. - CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing rhythm comes from --button-margin and the flex gap of the container, not per-element magic numbers. - LAN sync has two sides: the receiver lives in lansync.js, the sender in lansyncpush.js and is built by createPusher(helpers) - several launcher states share the module, so nothing may be kept in its module scope. Both sides report their own progress (the receiver through the onReceive hook), so each machine can show the transfer. - An incoming sync needs CONSENT: a correct code opens the gate, but the receiver's onIncoming hook (registered in main.js) shows an Accept/Decline popup and only a click - answered through lansync:respondIncoming - starts the session. Unanswered requests expire after INCOMING_TIMEOUT_MS; a decline returns 'rejected' to the sender and KEEPS the pairing (the code was proven). Without an onIncoming hook (tests, headless) the gate stays open. - Sync progress is bytes plus per-category counters (items = announced units, itemsDone = finished ones, totals = everything the sender picked), never raw file counts: the popup says "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes, not the overall bytes/totalBytes) so a phase starts from an empty bar instead of inheriting a full one - otherwise hashing looks finished the moment it starts. Hashing and copying walk tens of thousands of files, so every per-file update goes through progressGate (~10/s, phase changes forced) - never send one IPC message per file. - The launcher's own defaults are opt-in (the "defaults" sync category): payload.defaults carries the default wine and prefix, and the receiver only applies values that exist there (an unknown wine id or prefix is skipped). - LAN sync remembers file hashes in files/data/lansync-hashes.json, keyed by path with size + mtime (machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stat's the tree. Hashing and the receiving side's comparison both run HASH_CONCURRENCY files at a time. - A modal showing a running operation (a sync) opens locked: showModal(html, { locked: true }) ignores the backdrop and Escape, because the user cannot cancel the operation by dismissing it. - Symlinks inside everything PLWL owns (a wine/proton build, a prefix, an imported app) are stored RELATIVE whenever the target can be expressed locally: a Proton prefix is full of links into its build, and an absolute one points at the machine that wrote it. symlinks.js owns this (rewireSymlinks, plus restoreExecutableBits for the executables a content copy drops) and every writer calls it: importWineFolder/healBuild, importer after copy/move, healPrefix, and the sync both on the wire (portableLinkTarget sends the relative target) and after a unit lands (healUnitRoots). dosdevices drive mappings and targets that leave files/ (a mount, a system path, the user's home) stay absolute - they are machine-specific by design. - A prefix that has NO `pfx` yet gets ONE C: drive: `pfx` is a relative symlink to the prefix itself, so Proton's `/pfx` (runner.linkPrefix) resolves to `` and shares `drive_c`, the registry and `dosdevices` with plain wine. `prefixes.ensureUnifiedPrefix` only ever CREATES that link; an existing layout (a real `pfx/` folder from an older PLWL, a both-drives prefix) is left exactly as it is. Hoisting a prefix's files one level up was tried and removed: it breaks every relative link written against the old depth (the `umu.exe` link into the build is the one that kills a Proton launch), and moving a live Windows install is not the launcher's call. `healPrefix`/`rewireSymlinks` repairs such a prefix by re-anchoring broken relative links through files/. Never assume a single drive_c path: use `prefixDriveRoots()` (deduplicated) and compare realpaths when deciding whether a folder is inside a prefix's C: drive. - App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an ordered semicolon list: inherited value first, the app's overrides last, because Wine lets a later entry replace an earlier one for the same module. An empty setting (`dll=`) is Wine's "disabled" and must survive the filter. Proton appends its own dxvk/nvapi/steam.exe defaults AFTER everything, so an app override only wins where Proton has no default. `disableUmu` (per app) drops UMU_ID and switches Proton to its `runinprefix` verb, so no umu.exe/steam.exe launcher and no Proton prefix setup take part - the build's own wine runs as close to plain wine as it gets. STEAM_COMPAT_CLIENT_INSTALL_PATH must be set in BOTH modes: Proton's session init reads it on every verb (sync_appcache runs even for runinprefix, and with SteamGameId set a missing variable aborts the launch with a KeyError before wine starts). On the default path it prefers a real Steam client found on the machine; a disableUmu run always gets the empty files/data/fake-steam, whose contents are never read. - Wine and Proton create prefix links with ABSOLUTE local targets whenever they update a prefix (font/dll stubs, Steam appcache), so on-disk links are not always relative after a run: the sync payload relativizes them anyway, and refreshWines()/healPrefix() repair what arrived from an older PLWL. A build whose binaries lost their executable bit is detected by a cheap audit in refreshWines() and healed once, because spawn() on a non-executable `proton` fails with EACCES and the game would look like it started while nothing ran. - File permissions travel with a sync (manifest entry `mode`): the receiving side applies them after writing the bytes and repairs a file that has the same hash but the wrong mode without re-uploading it. - 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 # syntax node test/import.test.js # ...and the other test files 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-settings.test.js # playtime, app settings, wine/prefix + defaults node test/lansync-links.test.js # wire: exec bits + relative links in a sync node test/lansync-pending-links.test.js # links to files created later, startup heal node test/lansync-consent.test.js # incoming sync: accept / decline / timeout gate node test/bench-sync.js # timing: phases of a cold and a cached sync 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 # really start a game ```