PLWL/development.md

21 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, bwrap or plain run
  linuxapp.js      native app detection (ELF/AppImage/.desktop) + icon lookup
  sandbox.js       bubblewrap probe (functional, cached) + buildBwrapArgs
  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 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.
  • 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.kind is absent on old entries — that means Windows; game.isolation is auto (bwrap when usable, plain run otherwise), off or require (refuse to start instead of quietly weakening). The chosen backend always lands in the log and the UI (a silent downgrade is the failure mode to avoid).
  • 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"). Everything else is EROFS by design.
  • 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 exit while 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.
  • 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.

Native Linux apps

  • Entry point detection (linuxapp.js): ELF, .AppImage, or a .desktop whose Exec= points at one; lib/, share/ … are skipped. The icon comes from the .desktop Icon=, an image next to the program, or the system theme — extractIconFromExe is 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: #eff0f1 against 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 prefix from an older launcher is adopted on evidence (byte-identical with the host, or the copy is older than the host's file). The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in $HOME/.config/pulse/cookie and the app otherwise comes up silent; ~/.Xauthority is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app.
  • Host fonts/icons/themes/colour schemes are not copied but --ro-bind under /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/kdeglobals points 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_PLATFORM does not exist). game.env still 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, x mirrored from r (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 of home/) is never touched, and a running app from that prefix blocks it (the usual inUse guard).
  • The user's real ~ is deliberately invisible in the sandbox (HOME is bound straight to {prefix}/linux_pfx/home), so absolute paths an app wrote into its config before the import (Prism's instance JavaPath) do not resolve — that is what isolation: 'off' is for.
  • A clicked link belongs to the host desktop, so it leaves through org.freedesktop.portal.OpenURI — both routes, because two different things open a link. The xdg-open BINARY is replaced inside the sandbox (a written shim --ro-bind over /usr/bin/xdg-open, the host's own script parked at /tmp/.plwl-host/xdg-open as what the shim falls back to for anything that is not a URI): Qt execs it (measured — QGenericUnixServices picks xdg-open first, and reaches the portal itself only under /.flatpak-info/SNAP, which a bwrap profile does not have) and so does Electron. GIO resolves an URI IN-PROCESS, which no shim can reach: for that the prefix seeds home/.plwl/open-url (launcher-written, like the shim) plus a desktop entry and a mimeapps.list naming it the default for x-scheme-handler/http|https and text/html. Without the seed GIO falls back to the host's /usr/share list — measured: it offered brave-origin.desktop, and gio open answered "The specified location is not supported". Measured end to end (both backends): the portal launches the host's helium.desktop with the exact URI. GIO_USE_PORTALS=1 was measured and REJECTED: it opened NOTHING and still reported success (GIO's portal path wants a sandbox marker this launcher does not fake), while on a machine WITHOUT a portal it fails hard (“Failed to create OpenURI proxy”). The whole wiring is therefore written only when a session bus and a portal are present (portalReady), and a KDE-native app resolving through KIO in-process (kde-open) stays uncovered — KDE ignores the seeded list (measured).
  • Not built: a private or overlay /etc (buildBwrapArgs accepts etcRoot, nothing sets it; --ro-bind / / leaves /etc read-only, which is all we promise today), --tmpfs /home against OTHER users' homes (hideHome is plumbed, no UI) and dataMode — app data always lands in {prefix}/linux_pfx.
  • Measured, not assumed (Debian 13, kernel 6.12, bwrap 0.12.0): the functional probe passes here, no firejail/proot/fuse-overlayfs on the box, /etc is 16 MB and its machine-id is 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.
  • 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, old name "defaults" still accepted as an alias on both sides). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu 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 (setWineDisplayName, library.updateGame, 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.
  • 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 sender hashing a big unit is minutes of silence: it pings /plwl/ping every 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, caches, PulseAudio cookie, machine-local state) is MACHINE-LOCAL: the sender's manifest skips it and the receiver's pruneTree refuses to delete it (prefixes.isLocalOnlyRel, shared by lansync-manifest.js and lansync.js). Otherwise a mirrored prefix sync would either push a huge cache or delete the other machine's app data.
  • 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 (wineUnits maps 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 what arrived from an older PLWL. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable proton fails 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 pfx yet gets ONE C: drive: pfx is a relative symlink to the prefix itself (prefixes.ensureUnifiedPrefix only CREATES it), so Proton's <compatdata>/pfx shares 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: use prefixDriveRoots() 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. disableUmu drops UMU_ID and runs Proton's runinprefix verb. 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 --file or 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=value wins, 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.js proves 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 + auto/off/require choice
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-sync.test.js             # linux_pfx stays out of a sync
node test/linux-e2e.js                   # REAL run: plain + bwrap (/etc read-only, home in prefix)
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