229 lines
15 KiB
Markdown
229 lines
15 KiB
Markdown
# 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/<name>.test.js`;
|
|
electron test/<name>.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.
|
|
- Registry-level settings are opt-in (the "settings" sync category, shown as
|
|
"Sync settings"; the old name "defaults" is still accepted as an alias on
|
|
BOTH sides - the receiver normalizes payload.categories, so a sender from an
|
|
older version keeps working). payload carries defaults (default wine and
|
|
prefix), wineSettings (displayName + disabled per build id), prefixSettings
|
|
(the registry names; a settings sync never renames or creates folders -
|
|
that is the prefixes category), appSettings (name, wine, prefix, args, env,
|
|
dllOverrides, disableUmu per app id) and appOrder. The receiver only patches
|
|
things that already exist there: an unknown wine id / prefix / app is
|
|
skipped, nothing is created or deleted, and the order merge keeps local
|
|
entries the sender does not know at the end. Values are written through the
|
|
normal helpers (setWineDisplayName clears a custom label when the sender has
|
|
none, library.updateGame pins the prefix of prefix imports, reorderGames
|
|
merges by id).
|
|
- 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 `<compatdata>/pfx`
|
|
(runner.linkPrefix) resolves to `<prefix>` 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.
|
|
- A sync must never walk through a symlinked ROOT. pruneTree/healUnitRoots
|
|
(sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat their root
|
|
first and treat anything that is not a real directory as the link (or file)
|
|
it is: a build, prefix or app folder kept on another disk must not have its
|
|
target deleted, pruned or rewritten through the link. `listDirs()` in the
|
|
sync counts symlinked folders as units, so such a folder still travels - as
|
|
the link it is (`wineUnits` maps nested builds to their TOP folder under
|
|
wines/, otherwise a zip-extracted build becomes a phantom unit whose root
|
|
does not exist and the "Wine versions" totals grow).
|
|
- Generated app shortcuts are file handlers too: `Exec=<launcher>
|
|
--launch <id> --file %f` (the field code is appended after escaping - a
|
|
literal % is `%%`). argv.js takes the file from `--file` or from a bare
|
|
trailing path (KDE appends it to Exec lines without a file field), and
|
|
runner.windowsPathFor passes it to the app the way Windows would: `C:\...`
|
|
when it sits inside the prefix's drive_c, `Z:\...` otherwise. Giving a file
|
|
to an app that is ALREADY running is a handoff (runGame `handoff: true`):
|
|
an extra untracked process, no log/playtime writes, because the app's own
|
|
single-instance logic is what actually opens the file. The second-instance
|
|
handoff must never read a value from the token next to its option: Electron
|
|
RE-SERIALIZES that argv (all switches first, their values after, the dev app
|
|
path among them), so `--launch <id> --file <path>` arrives as
|
|
`--launch --file --allow-file-access-from-files <id> <path>`. argv.js pairs
|
|
options with positionals IN ORDER (inline `--name=value` wins, the dev app
|
|
path is skipped); reading the next token used to take the launch id as the
|
|
opened file, so an app started without its image.
|
|
`electron test/argv-handoff.test.js` proves the real serialization.
|
|
- A sender hashing a big unit is minutes of network silence: it pings
|
|
`/plwl/ping` (+token) every HEARTBEAT_MS for the whole sync, the receiver's
|
|
handler re-arms its popup watchdog (stage 'wait' changes nothing on screen),
|
|
and a receiving popup its watchdog closed reopens on the next real event -
|
|
only the 'done' stage ends a sync for good, and reopening NEVER asks for
|
|
consent again: the Accept/Decline gate is the onIncoming popup at verify
|
|
time (a fresh session), while the progress popup is driven by the same
|
|
token's events. A 'wait' event that reopens a closed popup fills the phase
|
|
line (`recvWaiting`) instead of leaving every line blank.
|
|
- 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/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 <id> # really start a game
|
|
```
|