Compare commits

..

No commits in common. "7d8ab87eee4b7194ee0ea6877a532d9e9407e593" and "2bcb47076a9fc61f2e64993dac9cfd68f84cb648" have entirely different histories.

31 changed files with 1808 additions and 1114 deletions

View file

@ -24,47 +24,63 @@ npm start
## Building & installing ## Building & installing
`./build.sh` produces `build/PLWL-<version>.tar.gz`: the Electron runtime renamed to `olcxja.plwl/` (binary `olcxja.plwl/olcxja.plwl`, whole app in `olcxja.plwl/resources/app.asar`), plus the icon, `install.sh` and `createshortcut.sh`. `./build.sh` produces `build/PLWL-<version>.tar.gz`: the Electron runtime renamed to `olcxja.plwl/` (binary `olcxja.plwl/olcxja.plwl`) with the whole app (code, renderer and bundled `files/`) packed into `olcxja.plwl/resources/app.asar`, plus the `olcxja.plwl.png` icon, `install.sh` and `createshortcut.sh`:
```bash ```bash
./install.sh # install to ~/.local/bin/olcxja.plwl/ (icon + shortcut) tar -xzf PLWL-<version>.tar.gz
./createshortcut.sh # portable mode: icon + shortcut only cd PLWL
./olcxja.plwl/olcxja.plwl # or run without installing ./install.sh # copies everything to ~/.local/bin/olcxja.plwl/,
# installs the icon (olcxja.plwl.png) + shortcut
# removes the extraction folder, launches
./createshortcut.sh # portable mode: only installs the icon + shortcut
# or run without any shortcuts:
./olcxja.plwl/olcxja.plwl
``` ```
## Importing from a wine prefix ## Importing from a wine prefix
Besides copy/move/leave, an app can be imported **from a prefix**: nothing is copied — paths are stored relative to the prefix, so wine sees the app as installed on `C:\` (registry entries keep working). Plain prefixes (`drive_c` on top) and Proton ones (`pfx/`) both work. The app stays tied to that prefix; removing the entry never deletes prefix files. A prefix can be renamed later — the folder moves and everything stored relative follows. Edits wait for running apps: renaming/deleting a prefix in use (or moving the default away from an app without its own) is refused with the app to close first; deleting a prefix that still has apps is refused too. Imports recreate symlinks (relative when the target belongs to the launcher) and keep permissions, so an imported game runs straight away. Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix. A prefix can be renamed later (Wine prefixes): the folder moves on disk and everything running in it follows the new name, because those paths are stored relative to the prefix. Edits wait for running apps: while anything from a prefix is playing, renaming or deleting it (and switching the default prefix away from an app that has none of its own) is refused with the name of the app to close first — wine keeps its registry and user files open in that folder. Deleting is also refused while a prefix still has apps registered in it, so an app is never silently orphaned into the default prefix. Imports keep the folder as it was: symlinks are recreated (rewritten to relative whenever the target is part of the launcher) and file permissions are carried over, so an imported game or build runs straight away.
## "Open with PLWL" ## "Open with PLWL" in the file manager
The desktop entry registers `.exe/.msi/.bat/.cmd/.lnk` in file managers. Opening one shows a popup to pick the **Wine/Proton build** and **prefix** for that run (defaults preselected, nothing imported). A second instance hands the path to the running one. App shortcuts work as **Open With** targets too: the app receives the file as the Windows path it would get on Windows — `C:\...` inside its prefix, `Z:\...` otherwise. A file handed to an already running app goes to that instance instead of a second tracked run. The desktop entry (`MimeType` + `Exec %f`) makes PLWL show up in Dolphin/Nautilus under **Open With** for Windows executables (`.exe`, `.msi`, `.bat`, `.cmd`, `.lnk`). Opening a file that way shows a small popup in the launcher where you pick the **Wine/Proton version** and the **prefix** for this run — both default to the launcher's defaults, nothing is imported into the library. A second launcher instance hands the path to the running one and exits (same single-instance mechanism the shortcuts use).
The app shortcuts created in an app's configuration work as **Open With** targets too: pick one in the file manager (right-click a photo → Open With → your Photoshop shortcut) and that app starts with the file, receiving it as the Windows path it would get on Windows — `C:\...` when the file sits inside its prefix, `Z:\...` for the rest of the machine. Giving a file to an app that is already running hands it to that instance instead of starting a second tracked run.
## LAN syncing ## LAN syncing
Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**). No cloud, no accounts. Two PLWL installs on the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings):
- **Device ID** (24 chars) and **Sync code** (8 chars, letters+digits, case-insensitive), both re-rollable. A caller must prove it knows its own code; each verification costs 2 s and one may run at a time, so guessing is pointless. A correct code still opens an **Accept/Decline popup** on the receiver (60 s timeout); declining or ignoring never touches this machine and keeps the pairing. - **Device ID** - 24 characters, generated once per install (current timestamp + random characters) and re-rollable at any time.
- **Devices on your network** are found over UDP and visible only while PLWL runs there (a few seconds after it closes they are gone). - **Sync code** - 8 characters, letters and digits, case-insensitive. A device only accepts a connection when the caller proves it knows *its own* code; each verification costs two seconds and a device may only have one in flight, so guessing over the LAN is pointless. Both the ID and the code can be re-rolled. A correct code is still not enough: the receiving device shows an **Accept / Decline popup** before anything moves, and without a click within a minute the sender is turned down. Declining (or ignoring) the popup never touches this machine and does not unpair the two devices.
- Pick what to push: **portable apps**, **prefix-imported apps**, **Wine/Proton builds**, **prefixes**, **Sync settings**. Only what is ticked travels — without **Sync settings** the other machine's defaults and app configs are untouched. An accepted code is stored with the device ID, so later syncs need no typing; a re-rolled code is forgotten and asked for again. - **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed.
**Both machines show a progress popup** (phase, unit, files, per-category counters like `Apps: 2/5`, transferred vs. planned bytes; the bar follows the current phase). The popups cannot be dismissed, and a sender hashing a big unit shows *Waiting for the other device…* instead of a dead popup — the sender pings while it works. They close on completion, or after 15 minutes of silence (and reopen if the sync turns out to be alive). Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Sync settings**. Only what is ticked travels - a sync without **Sync settings** never touches the other machine's defaults, names or app configurations. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently.
The **sender is the source of truth**: differing files are overwritten, missing ones written, extras inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) deleted. Files are compared with BLAKE2b-512 (SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison. Hashes are cached per file (size + mtime), so an unchanged library costs nothing to re-sync. Permissions travel with the files, and symlinks are sent as **relative** paths whenever the target belongs to the launcher (a Proton prefix's links into its build keep working on the other machine); `dosdevices` and targets outside `files/` stay absolute, and links wine itself rewrote to absolute are relativized again on the wire, on import and before every run. **Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing, and the sending side keeps signalling while it works, so even a long hashing phase never looks like a dead peer. The popup closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has really vanished — and comes back if the sync turns out to be alive after all.
A sync also carries **how apps are set up**: wine build and prefix, args, env, DLL overrides, playtime and last played. **Sync settings** carries the registry-level part even when no files move: default wine/prefix, custom build names, prefix registry, every app's configuration and the library order. It only patches what already exists (unknown ids are ignored, unknown apps/prefixes stay), deletes nothing, and playtime only grows — the higher counter wins. Language, window settings, logs and external (leave) apps stay local. The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Hashes are remembered per file (size + modification time), so a repeat sync of a library that did not change reads nothing at all.
**Wine builds and prefixes survive the trip.** Permissions travel with a sync, so the `proton`, `wine` and `wineserver` binaries are still executable on the other machine (a build whose executable bit was lost is additionally repaired at startup, without re-importing it), and symlinks are sent as RELATIVE paths whenever the file they point at belongs to the launcher: a Proton prefix is full of links into its build, and the receiving side gets links that resolve to its own copy instead of the sender's `/home/...` folders. Links that leave the launcher on purpose (`dosdevices` drive mappings, a mounted drive, the user's home folder) stay absolute, and links into a folder that does not exist yet on the other machine are still written relative, so they start working the moment that folder appears. Wine and Proton rewrite a prefix's links with local absolute targets while they update it; PLWL relativizes them again on the wire, on import and before every run.
A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The **Sync settings** category carries the registry-level part of that even when no files move: the launcher's **default Wine build and default prefix**, the custom **names** you gave Wine/Proton builds (and their disabled flags), the **prefix registry**, every **app's configuration**, and the **order of the library**. It only patches what already exists on the other machine - an unknown Wine id or prefix is ignored instead of leaving a dangling default or a broken app behind, apps and prefixes the sender does not know stay where they are, and a settings-only sync deletes nothing (that is what the file categories are for). Playtime only ever grows - the higher counter wins - so pushing from a freshly installed machine cannot wipe a library's play history. Everything else stays local: language and window settings, logs, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix.
## Wine & Proton support ## Wine & Proton support
- **System Wine** — `wine` from PATH. - **System Wine** — uses the `wine` binary from PATH.
- **Imported builds** — drop any wine/Proton build into `files/wines/` (one nesting level auto-detected). Symlinks are rewired to relative and executable bits restored, so builds from a zip or another machine run as they are. A build can get a **display name** of its own; the folder keeps its name. - **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically). After the copy, PLWL rewires the build's absolute symlinks to relative ones and restores the executable bit on the binaries it needs to run (`proton`, `wine`, `wineserver`, `winetricks`), so a build that arrived from a zip or another machine works without further fiddling. A build can also be given a **displayed name** of its own (Wine versions): it is used everywhere the build is offered — library config, settings and the *Open with PLWL* popup — while the folder in `files/wines/` keeps its name and the symlinks inside prefixes stay valid.
- **Proton without Steam** — invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` linked to the launcher's prefix, `STEAM_COMPAT_CLIENT_INSTALL_PATH` pointing at a real Steam client when there is one (read-only) and at the empty `files/data/fake-steam` otherwise. GE builds run through Proton's UMU path (`UMU_ID`) with no Steam client at all. A per-app **Disable UMU** switch runs the build's own wine directly (`runinprefix`), as close to plain wine as it gets. - **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` is a per-build compat folder linked to the launcher's own prefix, and `STEAM_COMPAT_CLIENT_INSTALL_PATH` points at an installed Steam client when there is one (Proton reads its legacy DLLs and `steamclient.so` from there, read-only) and at the empty `files/data/fake-steam` otherwise. GE builds work out of the box: PLWL launches through Proton's built-in UMU path (`UMU_ID`), which needs no Steam client at all. An app that breaks under umu.exe has a per-app **Disable UMU** switch in its config — PLWL then runs the build's own wine directly (Proton's `runinprefix` verb): no umu.exe/steam.exe launcher and no Proton prefix setup take part, so the run stays as close to plain wine as possible. (Proton's session init still reads `STEAM_COMPAT_CLIENT_INSTALL_PATH` on every verb; a disableUmu run gets the empty `fake-steam` folder and nothing is copied out of it.)
- **One C: drive per prefix** — when a prefix has no `pfx` yet, PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, registry and `dosdevices`. Existing layouts (a real `pfx/` folder, a prefix with both trees) are left exactly as they are. - **One C: drive per prefix** — Proton normally keeps its prefix one level deeper (`<compat>/pfx/drive_c`) than wine (`<prefix>/drive_c`), which left a prefix used with both kinds with two separate C: drives (two registries, two sets of saves). Whenever a prefix has no `pfx` yet (a fresh prefix, or a plain-wine prefix first used through Proton), PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, one registry and one `dosdevices`. Existing layouts are left exactly as they are — prefixes from older versions keep their real `pfx/` folder, and a prefix with both `drive_c` trees keeps both, because two live Windows installs cannot be merged safely (both stay visible to the prefix import picker).
## Translations ## Translations
All UI text comes from JSON files in `files/translations/`: `data` holds the language names, `texts` maps keys to strings with `{0}`, `{1}` ... placeholders. The bundled files are refreshed into that folder on every start when their content changed (so updates deliver new keys); user-added languages are never touched. All UI text comes from JSON files in `files/translations/`. The `data` object carries the language names; `texts` maps keys to strings, with `{0}`, `{1}` ... placeholders filled at runtime.
The translations bundled with a release are launcher-owned: on every start they are refreshed into `files/translations/` whenever their content changed, so launcher updates deliver new/changed keys to existing installs. Extra language files the bundle does not ship (user-added) are never touched.
## Development ## Development
Codebase overview and contributor rules: [development.md](development.md). Codebase overview, conventions and rules for contributors: [development.md](development.md).

View file

@ -30,14 +30,9 @@ src/ main process (Node/Electron backend)
paths.js portable layout, FILES-relative path helpers, translation sync paths.js portable layout, FILES-relative path helpers, translation sync
i18n.js translation loading + {0} placeholder filling i18n.js translation loading + {0} placeholder filling
preload.js contextBridge API exposed to the renderer preload.js contextBridge API exposed to the renderer
lansync.js LAN sync receiver: identity + pairing, hashing, HTTP server lansync.js LAN sync: identity + pairing, hashing, HTTP receiver (with its
(with its receive-progress hook); uses hashcache.js, receive-progress hook), UDP discovery
lansync-identity.js, lansync-manifest.js, lansync-discovery.js
lansyncpush.js the pushing side (createPusher factory, keeps helper state out) 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 appicon.js window/taskbar icon
renderer/ UI (plain HTML/CSS/JS, no framework) renderer/ UI (plain HTML/CSS/JS, no framework)
app.js views, modals, custom-select, all renderer logic app.js views, modals, custom-select, all renderer logic
@ -50,61 +45,182 @@ test/ plain node scripts, run them with `node test/<name>.test.js`;
tools/ install.sh / createshortcut.sh / build helpers 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. 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 ## Rules
- Keep it simple. Plain functions over classes, no abstractions "for later". - 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 explain the WHY (quirks, workarounds, races), not the WHAT. If the
- 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. code says it, don't repeat it in words. No comments at all when nothing
- No duplicate logic: the second time you copy something, move it into a helper and call it from both places. needs explaining.
- Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js. - No duplicate logic: the second time you copy something, move it into a
- Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/. helper (a shared module in src/ or a function next to its users) and call
- 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. it from both places.
- 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. - Everything user-visible goes through the translation files (en-us.json,
- Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there). pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
- 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. - Paths stored on disk are FILES-relative (portable layout): write with
- 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. toPortablePath(), read with resolvePortablePath() - never store absolute
- 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. paths for anything inside files/.
- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via
### LAN sync writeJson(); playtime lives in its own tiny file so the registry is not
rewritten while a game runs.
- 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). - Deleting user data needs a confirm modal; best-effort cleanup is
- 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. try/catch-and-ignore with a comment saying why it is safe to ignore.
- 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. - Windows apps run through runGame()/buildEnv() - never spawn wine directly
- 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). somewhere else (prefix handling, Proton env and playtime live there).
- 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 prefix or wine build that a running app uses cannot be renamed/deleted
- 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. (wine holds its registry and user files open in that folder): the guards sit
- 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' changes nothing on screen). 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). next to the handlers in src/ipc/, use the games service's running list plus
- 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/). the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and
- 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). answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast.
- Long operations stream progress through the single import:progress slot as
### Paths and prefixes { phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes } and
the renderer drives a bar from it (runWithProgress for deletes/imports).
- 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. Emit the 'count' phase first (a treeSize walk) and copy/remove through the
- 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. fsutil helpers; never ship a progress bar no data can move.
- 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. - CSS: reuse the existing variables/classes (.button, .field-label,
.custom-select, form-card); spacing rhythm comes from --button-margin and
### Launch and shortcuts 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
- 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). lansyncpush.js and is built by createPusher(helpers) - several launcher
- 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. states share the module, so nothing may be kept in its module scope. Both
- A modal showing a running sync opens locked: showModal(html, { locked: true }) ignores backdrop and Escape. sides report their own progress (the receiver through the onReceive hook),
- 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. 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 ## Verify before you commit
``` ```
node --check <changed js files> # syntax node --check <changed js files> # syntax
node test/smoke.js # quick sanity node test/import.test.js # ...and the other test files
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror) 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-receiving.test.js # receive-side progress events (the popup)
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults 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-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-pending-links.test.js # links to files created later, startup heal
node test/lansync-consent.test.js # accept / decline / timeout gate node test/lansync-consent.test.js # incoming sync: accept / decline / timeout gate
node test/lansync-token.test.js # long-sync token expiry (sliding TTL) node test/bench-sync.js # timing: phases of a cold and a cached sync
node test/bench-sync.js # timing: cold vs cached sync
npm start # click through the flow you touched npm start # click through the flow you touched
# Repairing / testing a real instance (test/ scripts are gitignored helpers): # Repairing / testing a real instance (test/ scripts are gitignored helpers):

View file

@ -81,13 +81,13 @@
"config.prefixLocked": "This app was imported from a prefix, its prefix cannot be changed", "config.prefixLocked": "This app was imported from a prefix, its prefix cannot be changed",
"config.prefixLockedHint": "Pinned to the prefix it was imported from (its paths are stored relative to it)", "config.prefixLockedHint": "Pinned to the prefix it was imported from (its paths are stored relative to it)",
"config.disableUmu": "Disable UMU (launch with wine directly)", "config.disableUmu": "Disable UMU (launch with wine directly)",
"config.disableUmuHint": "Runs the game with the build's own wine binary instead of its umu.exe/steam.exe launcher. This is the closest to a plain wine run.", "config.disableUmuHint": "Runs the game with the build's own wine binary instead of its umu.exe/steam.exe launcher - no UMU and no Steam client is started, and Proton's prefix setup is skipped too. This is the closest to a plain wine run.",
"config.dllOverrides": "DLL overrides", "config.dllOverrides": "DLL overrides",
"config.overrides.add": "Add override", "config.overrides.add": "Add override",
"config.overrides.dll": "DLL name", "config.overrides.dll": "DLL name",
"config.overrides.setting": "Setting", "config.overrides.setting": "Setting",
"config.overrides.remove": "Remove", "config.overrides.remove": "Remove",
"config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module.", "config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module. 'name=setting' pasted into the name box is split automatically.",
"config.save": "Save changes", "config.save": "Save changes",
"config.shortcut": "Create shortcut", "config.shortcut": "Create shortcut",
"config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)",
@ -175,11 +175,6 @@
"settings.saved": "Settings saved", "settings.saved": "Settings saved",
"settings.openFiles": "Open files folder", "settings.openFiles": "Open files folder",
"settings.closeToTray": "Close window to tray (keep tracking playtime in background)", "settings.closeToTray": "Close window to tray (keep tracking playtime in background)",
"settings.hashCache": "Sync file cache",
"settings.hashCacheHint": "LAN syncing remembers the hash of every file (with its size and modification time), so an unchanged library is not read again on the next sync. Clear it if a transfer compares wrong - everything gets hashed fresh on the next sync.",
"settings.clearHashCache": "Clear cache",
"settings.hashCacheClearConfirm": "Forget all remembered file hashes? The next sync will hash the whole library again.",
"settings.hashCacheCleared": "Hash cache cleared",
"lansync.title": "LAN syncing", "lansync.title": "LAN syncing",
"lansync.subtitle": "Push your apps, prefixes and Wine builds to another PLWL on the same network.", "lansync.subtitle": "Push your apps, prefixes and Wine builds to another PLWL on the same network.",
"lansync.thisDevice": "This device", "lansync.thisDevice": "This device",

View file

@ -81,13 +81,13 @@
"config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu, nie można zmienić jej prefiksu", "config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu, nie można zmienić jej prefiksu",
"config.prefixLockedHint": "Przypięta do prefiksu, z którego została zaimportowana (ścieżki trzymane są względem niego)", "config.prefixLockedHint": "Przypięta do prefiksu, z którego została zaimportowana (ścieżki trzymane są względem niego)",
"config.disableUmu": "Wyłącz UMU (uruchamiaj bezpośrednio wine)", "config.disableUmu": "Wyłącz UMU (uruchamiaj bezpośrednio wine)",
"config.disableUmuHint": "Uruchamia grę bezpośrednio binarką wine z builda, z pominięciem launchera umu.exe/steam.exe. To najbliżej zwykłego uruchomienia wine.", "config.disableUmuHint": "Uruchamia grę bezpośrednio binarką wine z builda, z pominięciem launchera umu.exe/steam.exe — bez UMU i bez uruchamiania klienta Steam, z pominięciem przygotowania prefiksu przez Protona. To najbliżej zwykłego uruchomienia wine.",
"config.dllOverrides": "Nadpisania DLL", "config.dllOverrides": "Nadpisania DLL",
"config.overrides.add": "Dodaj nadpisanie", "config.overrides.add": "Dodaj nadpisanie",
"config.overrides.dll": "Nazwa DLL", "config.overrides.dll": "Nazwa DLL",
"config.overrides.setting": "Ustawienie", "config.overrides.setting": "Ustawienie",
"config.overrides.remove": "Usuń", "config.overrides.remove": "Usuń",
"config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł.", "config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł. Wklejone 'nazwa=ustawienie' zostanie rozdzielone automatycznie.",
"config.save": "Zapisz zmiany", "config.save": "Zapisz zmiany",
"config.shortcut": "Utwórz skrót", "config.shortcut": "Utwórz skrót",
"config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)",
@ -175,11 +175,6 @@
"settings.saved": "Ustawienia zapisane", "settings.saved": "Ustawienia zapisane",
"settings.openFiles": "Otwórz folder files", "settings.openFiles": "Otwórz folder files",
"settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)", "settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)",
"settings.hashCache": "Pamięć podręczna haszy",
"settings.hashCacheHint": "Synchronizacja LAN zapamiętuje hash każdego pliku (razem z rozmiarem i datą modyfikacji), dzięki czemu niezmieniona biblioteka nie jest czytana przy każdym syncu. Wyczyść, jeśli transfer porównuje pliki błędnie — przy następnej synchronizacji wszystko zostanie zahaszowane od nowa.",
"settings.clearHashCache": "Wyczyść",
"settings.hashCacheClearConfirm": "Zapomnieć wszystkie zapamiętane hasze plików? Następna synchronizacja zahashuje całą bibliotekę od nowa.",
"settings.hashCacheCleared": "Pamięć podręczna wyczyszczona",
"lansync.title": "Synchronizacja LAN", "lansync.title": "Synchronizacja LAN",
"lansync.subtitle": "Wypchnij swoje aplikacje, prefiksy i buildy Wine na inny PLWL w tej samej sieci.", "lansync.subtitle": "Wypchnij swoje aplikacje, prefiksy i buildy Wine na inny PLWL w tej samej sieci.",
"lansync.thisDevice": "To urządzenie", "lansync.thisDevice": "To urządzenie",

View file

@ -1,7 +1,7 @@
{ {
"name": "PLWL", "name": "PLWL",
"productName": "PLWL", "productName": "PLWL",
"version": "0.4.1", "version": "0.4.0",
"description": "Portable Linux Wine Launcher", "description": "Portable Linux Wine Launcher",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {

View file

@ -15,7 +15,9 @@ const state = {
games: [], games: [],
currentGame: null, currentGame: null,
lansync: null, lansync: null,
// true while this device pushes a sync: it owns the single popup // true while this device pushes a sync (and while it shows the progress
// popup): only one such popup exists, the pushing side wins over an
// incoming sync
lanPushing: false, lanPushing: false,
currentView: 'library' currentView: 'library'
} }
@ -76,7 +78,8 @@ function setButtonIcons() {
} }
} }
// FLIP: animate elements between their previous and new layout positions. // Smoothly animate elements of `container` between their previous and new
// positions after `mutate()` changes the layout (FLIP technique).
function flipShift(container, mutate) { function flipShift(container, mutate) {
const entries = [...container.children].map(el => ({ el, top: el.getBoundingClientRect().top })) const entries = [...container.children].map(el => ({ el, top: el.getBoundingClientRect().top }))
mutate() mutate()
@ -108,13 +111,20 @@ function toast(message, kind) {
}) })
} }
// A delayed teardown must never kill a modal opened in the meantime. // The teardown of a closing modal is delayed (for the fade-out) - an
// eagerly armed timer must never kill a modal opened in the meantime (e.g.
// import mode -> prefix picker happens within milliseconds), so showModal
// cancels any pending teardown and clears the closing state.
let modalTeardownTimer = null let modalTeardownTimer = null
let modalSwapTimer = null let modalSwapTimer = null
// A locked modal ignores backdrop/Escape; only its own buttons close it. // A running sync must not be thrown away by a stray click: a locked modal
// ignores the backdrop and Escape, only its own buttons (or the end of the
// operation) close it.
let modalLocked = false let modalLocked = false
// Settles the promise-based modal (confirm/prompt) so callers never hang. // Resolver of the current promise-based modal (confirm/prompt). Escape, the
// backdrop or another modal opening must settle it, or the awaiting caller
// would never continue.
let modalSettle = null let modalSettle = null
function settleModal(value) { function settleModal(value) {
@ -133,7 +143,9 @@ function showModal(html, opts) {
const overlay = qs('#modal-overlay') const overlay = qs('#modal-overlay')
const modal = qs('#modal') const modal = qs('#modal')
modal.classList.remove('closing') modal.classList.remove('closing')
// morph instead of close-then-open when a modal opens over a visible one // Opening over an already-visible modal (import mode -> prefix picker):
// morph instead of close-then-open - the box glides to the new height
// while old content dissolves into the new one.
const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim() const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim()
if (swapping) swapModalContent(modal, html) if (swapping) swapModalContent(modal, html)
else modal.innerHTML = html else modal.innerHTML = html
@ -142,8 +154,10 @@ function showModal(html, opts) {
return qs('#modal') return qs('#modal')
} }
// Animated content swap: new HTML goes in synchronously, the old one fades // Animated content swap while the modal stays open. The new HTML is put in
// out on top while the box glides to the new height. // SYNCHRONOUSLY (callers wire their buttons right after showModal returns);
// an opaque snapshot of the old content is laid on top and fades out while
// the box animates to the new content's height. Same feel as switching tabs.
function swapModalContent(modal, html) { function swapModalContent(modal, html) {
if (modalSwapTimer) { if (modalSwapTimer) {
clearTimeout(modalSwapTimer) clearTimeout(modalSwapTimer)
@ -220,7 +234,8 @@ function confirmModal(message, okLabel) {
}) })
} }
// Shared "type a name" modal; Cancel/Escape/backdrop resolve null. // Shared "type a name" modal (prefix rename, wine display name): Enter
// submits, Cancel/Escape/backdrop resolve null.
function promptModal({ title, label, value, confirm, hint }) { function promptModal({ title, label, value, confirm, hint }) {
return new Promise(resolve => { return new Promise(resolve => {
const modal = showModal(` const modal = showModal(`
@ -404,7 +419,10 @@ function iconUrl(id) {
return `plwl-icon://${encodeURIComponent(id)}/icon` return `plwl-icon://${encodeURIComponent(id)}/icon`
} }
// Portable apps show files/-relative paths; external apps keep absolute ones. // Paths of imported (copy/move) apps live inside FILES/ and are stored
// relative to it in the registry (portable mode) - show them prefixed with
// files/ instead of the absolute install location. External (leave) apps
// keep their absolute path.
function displayPath(p) { function displayPath(p) {
if (!p) return '' if (!p) return ''
const files = window.plwl.FILES_DIR const files = window.plwl.FILES_DIR
@ -412,7 +430,8 @@ function displayPath(p) {
return p return p
} }
// Inverse of displayPath: files/-prefixed or bare path -> absolute. // Inverse of displayPath for input fields: accept a files/-prefixed (or bare,
// FILES-relative) path and resolve it to an absolute one.
function absPathInput(v) { function absPathInput(v) {
if (!v) return v if (!v) return v
const files = window.plwl.FILES_DIR const files = window.plwl.FILES_DIR
@ -422,7 +441,9 @@ function absPathInput(v) {
return v return v
} }
// Prefix-imported apps: show paths as wine sees them (prefix-relative). // Prefix-imported apps: show paths the way wine sees them - relative to the
// prefix ("pfx/drive_c/..." or "drive_c/...") instead of the long
// files/wineprefixes/<prefix>/... form.
function displayGamePath(p, game) { function displayGamePath(p, game) {
if (game && game.importSource === 'prefix' && game.prefix) { if (game && game.importSource === 'prefix' && game.prefix) {
const files = window.plwl.FILES_DIR const files = window.plwl.FILES_DIR
@ -432,7 +453,9 @@ function displayGamePath(p, game) {
return displayPath(p) return displayPath(p)
} }
// Inverse of displayGamePath: prefix-relative path -> absolute under the prefix. // Inverse of displayGamePath: accept a prefix-relative (drive_c/... or
// pfx/drive_c/...) path and resolve it under the game's prefix; anything
// else falls through to the normal FILES/absolute handling.
function absGamePathInput(v, game) { function absGamePathInput(v, game) {
if (game && game.importSource === 'prefix' && game.prefix && /^(pfx\/)?drive_c([\/]|$)/i.test(v || '')) { if (game && game.importSource === 'prefix' && game.prefix && /^(pfx\/)?drive_c([\/]|$)/i.test(v || '')) {
const files = window.plwl.FILES_DIR const files = window.plwl.FILES_DIR
@ -451,8 +474,11 @@ function fmtPlaytime(seconds) {
} }
// --- reordering the library (drag & drop) ---------------------------------- // --- reordering the library (drag & drop) ----------------------------------
// The registry array is the display order; a drop reorders state.games and // The registry array IS the display order, so a drop reorders state.games and
// persists the whole id list. // persists the whole id list; the main process appends any id the renderer did
// not know about, so a stale card list can never drop an entry. Cards are
// plain flex items, so the insertion point is the half of the hovered card the
// pointer is over.
let dragId = null let dragId = null
function clearDropMarks(except) { function clearDropMarks(except) {
@ -524,7 +550,8 @@ function wireCardDrag(card, game) {
} }
async function attachCardIcon(card, game) { async function attachCardIcon(card, game) {
// placeholder first, swapped for the real icon once it loads // placeholder first so every card has a stable icon slot; swapped for the
// real icon once it loads - games without one just keep the placeholder
const ph = document.createElement('div') const ph = document.createElement('div')
ph.className = 'game-icon placeholder' ph.className = 'game-icon placeholder'
card.prepend(ph) card.prepend(ph)
@ -681,7 +708,9 @@ async function startImportFlow() {
}) })
} }
// Import step 1: pick the prefix the app folder lives in. // Import from a wine prefix, step 1: pick WHICH prefix - the app folder must
// live inside one so its paths can stay prefix-relative (wine then sees it as
// a regular C:\ install).
async function startPrefixImportFlow() { async function startPrefixImportFlow() {
try { try {
state.prefixes = await window.plwl.prefixesList() state.prefixes = await window.plwl.prefixesList()
@ -734,7 +763,8 @@ async function doImport(srcDir, mode) {
const bar = modal.querySelector('#import-progress') const bar = modal.querySelector('#import-progress')
const pct = modal.querySelector('.progress-pct') const pct = modal.querySelector('.progress-pct')
const status = modal.querySelector('#import-status') const status = modal.querySelector('#import-status')
// byte totals drive the bar when the copy knows them // byte totals drive the bar when the copy knows them; a rename or a
// symlink-only run sends plain path events and only bumps the count
if (bar && data && data.totalBytes) { if (bar && data && data.totalBytes) {
renderProgressBar(bar, data.bytes, data.totalBytes) renderProgressBar(bar, data.bytes, data.totalBytes)
if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%'
@ -785,9 +815,12 @@ function isWindowsExecutable(p) {
return /\.(exe|msi|bat|cmd|lnk)$/i.test(p || '') return /\.(exe|msi|bat|cmd|lnk)$/i.test(p || '')
} }
// "Open with" popup: wine + prefix selects defaulting to the launcher defaults. // The popup: wine version + prefix selects defaulting to the LAUNCHER's
// defaults (settings.defaultWine / prefixes.default), like the per-app
// config's "Default (...)" options - just without importing anything.
async function showOpenWithModal(openPath) { async function showOpenWithModal(openPath) {
// always fresh: defaults can change while the launcher sits in the tray // always fresh: wines/prefixes/default can change while the launcher sits
// in the tray
try { try {
const [wines, prefixes, settings] = await Promise.all([ const [wines, prefixes, settings] = await Promise.all([
window.plwl.winesList(), window.plwl.winesList(),
@ -859,16 +892,21 @@ async function handleOpenWith(openPath) {
} }
function wireOpenWith() { function wireOpenWith() {
// both delivery paths converge on openwithTake(); newest file wins // Both delivery paths (live handoff and the boot queue) converge on
// openwithTake(): the main-process slot is drained exactly when a popup
// shows, so a reloaded window can never resurrect an old popup and two
// quick handoffs collapse into one (the newest file wins).
window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith)) window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith))
// file queued before the window existed (boot handoff): pick it up once // file queued before the window existed (boot handoff): pick it up once
window.plwl.openwithTake().then(handleOpenWith) window.plwl.openwithTake().then(handleOpenWith)
} }
// Import step 2: register the app in place, nothing is copied. // Import from a prefix, step 2: register the app in place - nothing is
// copied, paths stay prefix-relative.
async function doPrefixImport(prefix, srcDir) { async function doPrefixImport(prefix, srcDir) {
const name = srcDir.split('/').filter(Boolean).pop() const name = srcDir.split('/').filter(Boolean).pop()
// nothing copied: no progress to show // nothing is copied here - the app is just registered in place, so there is
// no progress to show (no bar that would sit at zero)
showModal(` showModal(`
<h2>${t('import.adding', name)}</h2> <h2>${t('import.adding', name)}</h2>
<p class="subtitle">${t('import.prefix.registering')}</p> <p class="subtitle">${t('import.prefix.registering')}</p>
@ -896,7 +934,9 @@ async function doPrefixImport(prefix, srcDir) {
} }
} }
// Apps blocking a prefix rename/delete, fetched fresh for the warning. // Apps the launcher would name when it refuses a prefix rename/delete: a
// running app (blocking immediately) or a registered one (orphaned by a
// delete). Fetched fresh - the Prefixes view does not keep the library hot.
async function prefixUsers(name) { async function prefixUsers(name) {
const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()]) const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()])
const runningSet = new Set(running || []) const runningSet = new Set(running || [])
@ -975,7 +1015,8 @@ function renderPrefixes() {
const delBtn = row.querySelector('[data-act=delete]') const delBtn = row.querySelector('[data-act=delete]')
delBtn.innerHTML = `${ICONS.trash()}<span>${t('prefixes.delete')}</span>` delBtn.innerHTML = `${ICONS.trash()}<span>${t('prefixes.delete')}</span>`
delBtn.onclick = async () => { delBtn.onclick = async () => {
// refused up front: a running app holds the folder, or apps reference it // refused before the confirm prompt, with the reason: the folder is
// either held by a running app or still referenced by registered ones
const users = await prefixUsers(p.name) const users = await prefixUsers(p.name)
if (users.all.length) return toast(t('prefixes.hasApps', p.name, users.all.map(g => g.name).join(', ')), 'error') if (users.all.length) return toast(t('prefixes.hasApps', p.name, users.all.map(g => g.name).join(', ')), 'error')
const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete')) const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete'))
@ -1134,7 +1175,8 @@ function renderPrefixOptions(selectId, selected) {
{ value: '', label: t('config.prefixDefault', defs.default) }, { value: '', label: t('config.prefixDefault', defs.default) },
...defs.prefixes.filter(p => p.name !== defs.default).map(p => ({ value: p.name, label: p.name })) ...defs.prefixes.filter(p => p.name !== defs.default).map(p => ({ value: p.name, label: p.name }))
] ]
// prefix imports pin their prefix even when it is the default // prefix imports pin their prefix even when it IS the default (which the
// list above omits) - keep it visible instead of a blank select
if (selected && !opts.some(o => o.value === selected)) opts.push({ value: selected, label: selected }) if (selected && !opts.some(o => o.value === selected)) opts.push({ value: selected, label: selected })
if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, selected || '') if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, selected || '')
else selects[selectId].update(opts, selected || '') else selects[selectId].update(opts, selected || '')
@ -1179,7 +1221,8 @@ async function openGameConfig(id) {
qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n') qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')
renderWineOptions('gc-wine', game.wine) renderWineOptions('gc-wine', game.wine)
renderPrefixOptions('gc-prefix', game.prefix) renderPrefixOptions('gc-prefix', game.prefix)
// prefix-imported apps are pinned: the select is locked, not corrected on save // apps imported from a prefix are pinned to it (their paths are prefix-
// relative) - the select is locked, not just corrected on save
const prefixLocked = game.importSource === 'prefix' const prefixLocked = game.importSource === 'prefix'
selects['gc-prefix'].setDisabled(prefixLocked) selects['gc-prefix'].setDisabled(prefixLocked)
const lockHint = qs('#gc-prefix-lock-hint') const lockHint = qs('#gc-prefix-lock-hint')
@ -1200,7 +1243,8 @@ async function createAppShortcut() {
} }
} }
// Shows the newest run log in the file manager. // Shows the newest run log in the file manager - it lives next to the app
// configuration, where the user is when something needs debugging.
async function showAppLogs() { async function showAppLogs() {
const game = state.currentGame const game = state.currentGame
if (!game) return if (!game) return
@ -1217,7 +1261,8 @@ function renderOverrides(overrides) {
} }
} }
// native,builtin is the default a new override row starts on // native,builtin first: that is the default a new override row starts on
// (load a native DLL when the game has one, fall back to Wine's builtin)
const OVERRIDE_OPTIONS = [ const OVERRIDE_OPTIONS = [
{ value: 'native,builtin', label: 'native,builtin' }, { value: 'native,builtin', label: 'native,builtin' },
{ value: 'native', label: 'native' }, { value: 'native', label: 'native' },
@ -1235,7 +1280,8 @@ function buildOverrideRow(o) {
` `
row.querySelector('.dll').value = o.dll || '' row.querySelector('.dll').value = o.dll || ''
const selectHost = document.createElement('span') const selectHost = document.createElement('span')
// an empty setting IS the "disabled" choice - no || fallback here // a stored empty setting IS the "disabled" choice - a `||` fallback would
// turn every disabled override back into the default on reopen
new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting == null ? 'native,builtin' : o.setting) new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting == null ? 'native,builtin' : o.setting)
row.insertBefore(selectHost, row.querySelector('[data-act=del]')) row.insertBefore(selectHost, row.querySelector('[data-act=del]'))
row.querySelector('[data-act=del]').onclick = () => row.remove() row.querySelector('[data-act=del]').onclick = () => row.remove()
@ -1248,7 +1294,9 @@ function addOverrideRow() {
rows[rows.length - 1].querySelector('.dll').focus() rows[rows.length - 1].querySelector('.dll').focus()
} }
// Guide-style settings ("n", "native,builtin", "d"...) mapped to select values. // The setting a guide or a launch option uses ("n", "native,builtin", "d"...)
// mapped onto the values this select offers. Unknown text keeps the select's
// choice instead of inventing one.
function normalizeOverrideSetting(raw) { function normalizeOverrideSetting(raw) {
const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '') const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '')
if (!v || v === 'd' || v === 'disabled') return '' if (!v || v === 'd' || v === 'disabled') return ''
@ -1263,7 +1311,8 @@ function readOverrides() {
return qsa('#gc-overrides .override-row').map(row => { return qsa('#gc-overrides .override-row').map(row => {
let dll = row.querySelector('.dll').value.trim() let dll = row.querySelector('.dll').value.trim()
let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin' let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin'
// split pasted "dinput8=native" into name + setting // "dinput8=native" pasted into the name box is the form every guide uses -
// split it here instead of storing a name with '=' that Wine cannot parse
const eq = dll.indexOf('=') const eq = dll.indexOf('=')
if (eq > 0) { if (eq > 0) {
const pasted = normalizeOverrideSetting(dll.slice(eq + 1)) const pasted = normalizeOverrideSetting(dll.slice(eq + 1))
@ -1283,7 +1332,10 @@ async function saveGameConfig() {
const idx = line.indexOf('=') const idx = line.indexOf('=')
if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim() if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
}) })
// prefix imports keep their prefix; only the prefix snaps back (with a hint) // prefix imports keep their paths relative to the prefix they were
// imported from - pointing them at another prefix would silently break
// exePath/workdir, so the original prefix stays locked. Other edits in
// the same save are kept; only the prefix snaps back (with a hint).
let prefixLockedHint = false let prefixLockedHint = false
let prefix = selects['gc-prefix'].value let prefix = selects['gc-prefix'].value
if (game.importSource === 'prefix') { if (game.importSource === 'prefix') {
@ -1306,7 +1358,8 @@ async function saveGameConfig() {
} }
await window.plwl.gamesUpdate(game.id, patch) await window.plwl.gamesUpdate(game.id, patch)
state.games = await window.plwl.gamesList() state.games = await window.plwl.gamesList()
// show what was actually stored (split/stripped) // show what was actually stored: a pasted "name=setting" was split and a
// .dll suffix stripped, so the rows would otherwise disagree with the file
renderOverrides(patch.dllOverrides) renderOverrides(patch.dllOverrides)
toast(t('config.saved'), 'success') toast(t('config.saved'), 'success')
if (prefixLockedHint) toast(t('config.prefixLocked'), 'error') if (prefixLockedHint) toast(t('config.prefixLocked'), 'error')
@ -1351,11 +1404,15 @@ const LAN_CATEGORIES = [
{ id: 'apps-prefix', label: 'lansync.appsPrefix' }, { id: 'apps-prefix', label: 'lansync.appsPrefix' },
{ id: 'wines', label: 'lansync.wines' }, { id: 'wines', label: 'lansync.wines' },
{ id: 'prefixes', label: 'lansync.prefixes' }, { id: 'prefixes', label: 'lansync.prefixes' },
// registry-level settings only; no files move with this category // registry-level settings: default wine/prefix, custom names of wine builds
// and prefixes, and each app's configuration plus its library order. No
// files move with this category - it only patches what already exists.
{ id: 'settings', label: 'lansync.settings' } { id: 'settings', label: 'lansync.settings' }
] ]
// Byte sizes in decimal units, as a file manager shows them. // Byte sizes for the progress popups. Decimal units, the way a file manager
// or a download shows them, because that is what a "1.2 GB" on screen means
// to the user.
function formatBytes(bytes) { function formatBytes(bytes) {
const value = Math.max(0, Number(bytes) || 0) const value = Math.max(0, Number(bytes) || 0)
if (value < 1000) return `${Math.round(value)} B` if (value < 1000) return `${Math.round(value)} B`
@ -1364,7 +1421,10 @@ function formatBytes(bytes) {
return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB` return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB`
} }
// "Apps: 2/5 · ..." per-category progress; no totals -> plain count. // "Apps: 2/5 · Prefixes: 1/2 · Wine versions: 0/1" - how much of each category
// is done out of how much the sync carries, so the line says what is still
// coming instead of only listing what has already been walked over. A peer
// that does not announce its totals falls back to the plain count.
function lanStatsLine(items, itemsDone, totals) { function lanStatsLine(items, itemsDone, totals) {
const rows = [ const rows = [
['apps', 'lansync.statsApps'], ['apps', 'lansync.statsApps'],
@ -1383,7 +1443,11 @@ function lanStatsLine(items, itemsDone, totals) {
return out.join(' · ') return out.join(' · ')
} }
// The bar follows bytes and the current phase (a phase starts empty). // The bar follows BYTES, not file counts: a sync spends its time on the big
// files, and a file-count bar sits at 99% while the last gigabyte crawls in.
// It also follows the PHASE it is in (barBytes/barTotalBytes): a phase starts
// from an empty bar instead of inheriting a full one from the phase before, so
// hashing never looks finished while it has just begun.
function renderProgressBar(bar, bytes, totalBytes) { function renderProgressBar(bar, bytes, totalBytes) {
const sent = Math.max(0, Number(bytes) || 0) const sent = Math.max(0, Number(bytes) || 0)
const total = Math.max(0, Number(totalBytes) || 0) const total = Math.max(0, Number(totalBytes) || 0)
@ -1398,8 +1462,12 @@ function barValues(info) {
return [bytes, total] return [bytes, total]
} }
// Progress modal for byte-measured work; `action` is the ipc call, the modal // One modal for operations with a measurable size (import, delete, long
// closes itself when it settles. // copies): the bar follows BYTES for the same reason the sync bar does, and a
// percent label sits next to it. Main-process work streams through the single
// import:progress slot (preload keeps only the newest listener), so the modal
// is the only thing showing progress at a time. `action` is the ipc call;
// the modal closes itself when it settles.
async function runWithProgress({ title, action }) { async function runWithProgress({ title, action }) {
const modal = showModal(` const modal = showModal(`
<h2>${title}</h2> <h2>${title}</h2>
@ -1411,7 +1479,8 @@ async function runWithProgress({ title, action }) {
const status = modal.querySelector('#op-status') const status = modal.querySelector('#op-status')
window.plwl.onImportProgress(data => { window.plwl.onImportProgress(data => {
if (!data || !data.phase) return if (!data || !data.phase) return
// the counting pass reports its own phase with the totals // the counting pass reports its own phase with the totals; the bar starts
// moving once the real units arrive
if (data.phase === 'count') return if (data.phase === 'count') return
renderProgressBar(bar, data.bytes, data.totalBytes) renderProgressBar(bar, data.bytes, data.totalBytes)
pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : '' pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : ''
@ -1424,7 +1493,9 @@ async function runWithProgress({ title, action }) {
} }
} }
// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes. // "340 MB / 1.2 GB" once the total is known (the sending side learns it as it
// plans the units, the receiving side as the manifests arrive), otherwise
// just what has moved so far - so the line is never a lying zero.
function lanBytesLine(bytes, totalBytes) { function lanBytesLine(bytes, totalBytes) {
const sent = formatBytes(bytes) const sent = formatBytes(bytes)
if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : '' if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : ''
@ -1473,7 +1544,8 @@ function renderLanDevices(devices) {
} }
} }
// Collects the other device's sync code plus what to push. // Modal that collects the other device's sync code plus what to push. Shown
// again with a cleared code after a rejected one, so the user can retype it.
async function openLanSyncModal(device, errorKey) { async function openLanSyncModal(device, errorKey) {
const modal = showModal(` const modal = showModal(`
<h2 id="ls-modal-title"></h2> <h2 id="ls-modal-title"></h2>
@ -1557,7 +1629,10 @@ async function runLanSync(device, code, categories, force) {
state.lanPushing = true state.lanPushing = true
window.plwl.onLanSyncProgress(data => { window.plwl.onLanSyncProgress(data => {
if (!active) return if (!active) return
// the file counter is cumulative across units; only the bar follows phases // Every line keeps its value while the sync moves between phases: the
// file counter is CUMULATIVE across units (it never resets to 0/N with
// the next app) and phases that move no files simply do not touch it -
// only the byte bar follows the phase being worked on
phase.textContent = t('lansync.phase.' + data.phase, data.label || '').trim() phase.textContent = t('lansync.phase.' + data.phase, data.label || '').trim()
renderProgressBar(bar, ...barValues(data)) renderProgressBar(bar, ...barValues(data))
if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total) if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total)
@ -1598,21 +1673,29 @@ async function runLanSync(device, code, categories, force) {
// --- receiving a sync (the other device is the source of truth) ------------- // --- receiving a sync (the other device is the source of truth) -------------
// A sync pushed onto this device shows the same popup, driven by the // A sync pushed onto THIS device must also be visible here - otherwise the
// receiving server's events. Hashing can be minutes of silence: the popup // library changes under the user's hands with no explanation. Same popup, but
// switches to "waiting" and events reopen it if the watchdog closed it. // driven by the events the receiving server emits, and equally not closable
// by a click.
// The sending machine does its hashing before anything is written here, which
// can be minutes of complete silence - so the popup switches to "waiting"
// instead of closing, and only a really long silence drops it. Events after
// that reopen it (the sync is still running, the user just cannot see it).
const RECEIVE_WATCHDOG_MS = 15 * 60 * 1000 const RECEIVE_WATCHDOG_MS = 15 * 60 * 1000
const RECEIVE_STALL_MS = 6 * 1000 const RECEIVE_STALL_MS = 6 * 1000
// phase wordings that differ on this side; the rest are neutral // phases whose wording differs on this side (everything else is neutral:
// "Comparing X", "Updating the library", "Finishing up")
const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' } const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' }
// Incoming push: consent popup before anything moves. // A device wants to push to us: the popup asks for consent before anything
// moves. One pending request at a time (the server enforces that too).
let incomingRequest = null let incomingRequest = null
let incomingTimer = null let incomingTimer = null
function onLanIncoming(info) { function onLanIncoming(info) {
if (!info || !info.requestId) return if (!info || !info.requestId) return
if (state.lanPushing || receiving) { if (state.lanPushing || receiving) {
// our own transfer owns the modal slot: decline so the sender fails fast // our own transfer owns the modal slot - answer so the sender fails
// fast instead of waiting out its timeout
window.plwl.lansyncRespondIncoming(info.requestId, false) window.plwl.lansyncRespondIncoming(info.requestId, false)
return return
} }
@ -1636,7 +1719,8 @@ function onLanIncoming(info) {
modal.querySelector('#ls-incoming-title').textContent = t('lansync.incomingTitle', name) modal.querySelector('#ls-incoming-title').textContent = t('lansync.incomingTitle', name)
modal.querySelector('#ls-incoming-text').textContent = t('lansync.incomingText', name) modal.querySelector('#ls-incoming-text').textContent = t('lansync.incomingText', name)
modal.querySelector('#ls-incoming-what').textContent = parts.length ? parts.join(' · ') : t('lansync.incomingNothing') modal.querySelector('#ls-incoming-what').textContent = parts.length ? parts.join(' · ') : t('lansync.incomingNothing')
// phrased from the receiving side's point of view // the SENDER is the source of truth: the warning is phrased from this
// (receiving) side's point of view, not the send modal's mirrorWarning
modal.querySelector('#ls-incoming-warning').textContent = t('lansync.incomingMirror') modal.querySelector('#ls-incoming-warning').textContent = t('lansync.incomingMirror')
modal.querySelector('#ls-incoming-decline').textContent = t('lansync.decline') modal.querySelector('#ls-incoming-decline').textContent = t('lansync.decline')
modal.querySelector('#ls-incoming-accept').textContent = t('lansync.accept') modal.querySelector('#ls-incoming-accept').textContent = t('lansync.accept')
@ -1663,7 +1747,10 @@ function onLanIncoming(info) {
} }
let receiving = false let receiving = false
// Only the sender's 'done' ends this; a watchdog-closed popup may reopen. // true only once the sender's 'done' event ended the transfer. A popup the
// safety watchdog closed while the sender was still hashing must be allowed to
// reopen when events resume - permanently closing it made a running sync
// invisible even though the transfer continued.
let receiveEnded = false let receiveEnded = false
let receiveWatchdog = null let receiveWatchdog = null
let receiveStall = null let receiveStall = null
@ -1704,7 +1791,10 @@ function endReceiving(message) {
closeModal() closeModal()
} }
// A crashed sender must not leave the window locked: close after a long silence. // A sender that crashed halfway through must not leave this window locked: the
// popup closes itself if nothing at all arrives for a long time (the server
// drops the session after the same idle time). While it waits, the phase line
// says so rather than the popup disappearing mid-transfer.
function armReceiveWatchdog() { function armReceiveWatchdog() {
if (receiveWatchdog) clearTimeout(receiveWatchdog) if (receiveWatchdog) clearTimeout(receiveWatchdog)
receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS) receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS)
@ -1722,7 +1812,10 @@ function onLanReceiving(info) {
if (info.stage === 'start') receiveEnded = false if (info.stage === 'start') receiveEnded = false
if (info.stage === 'done') receiveEnded = true if (info.stage === 'done') receiveEnded = true
if (!receiving) { if (!receiving) {
// reopen a watchdog-closed popup unless the sync already ended // The popup also opens mid-sync: the sender may have been hashing for
// minutes when this window starts listening, and a popup that the safety
// timeout closed comes back for the rest of the transfer. Only a sync
// that already ended stays closed.
if (receiveEnded) return if (receiveEnded) return
if (qs('#modal-overlay').style.display !== 'none') return if (qs('#modal-overlay').style.display !== 'none') return
receiving = true receiving = true
@ -1747,12 +1840,17 @@ function onLanReceiving(info) {
function renderReceiving(info) { function renderReceiving(info) {
const parts = receiveModalParts() const parts = receiveModalParts()
if (!parts) return if (!parts) return
// a ping keeps the screen as it is; only a reopened popup needs a phase line // A liveness ping from the sender (it is busy hashing a unit): the watchdog
// was re-armed by the caller, but what is on screen stays as it is - the
// sync is not stalled, just quiet. The exception is the popup this very ping
// just reopened (the safety timeout had closed it): every line would sit
// blank, so the phase says what is actually happening.
if (info.stage === 'wait') { if (info.stage === 'wait') {
if (!parts.phase.textContent) parts.phase.textContent = t('lansync.recvWaiting') if (!parts.phase.textContent) parts.phase.textContent = t('lansync.recvWaiting')
return return
} }
// the same phases, worded for receiving // The same phases read from this side's point of view: a file arriving here
// is being received, not sent.
const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage
parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim() parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim()
renderProgressBar(parts.bar, ...barValues(info)) renderProgressBar(parts.bar, ...barValues(info))
@ -1761,7 +1859,8 @@ function renderReceiving(info) {
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals) parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes) parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
if (info.stage === 'done') { if (info.stage === 'done') {
// let the totals be read, then close (the library reloads itself) // let the totals be read, then get out of the way (the library reloads
// itself through lansync:applied)
if (receiveCloseTimer) clearTimeout(receiveCloseTimer) if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
receiveCloseTimer = setTimeout(() => { receiveCloseTimer = setTimeout(() => {
receiveCloseTimer = null receiveCloseTimer = null
@ -1770,7 +1869,10 @@ function renderReceiving(info) {
} }
} }
// A received sync changed local files: reload every list (settings included). // Data received from another device changed the local files: reload every
// list so the views show the merged library instead of stale state. Settings
// too - a sync with "Default settings" rewrote the default wine/prefix on
// disk, and the settings view would keep showing the values from before.
async function reloadLocalData() { async function reloadLocalData() {
const [wines, prefixes, games, settings] = await Promise.all([ const [wines, prefixes, games, settings] = await Promise.all([
window.plwl.winesList(), window.plwl.winesList(),
@ -1819,7 +1921,8 @@ function wireEvents() {
}) })
qs('#btn-import-top').addEventListener('click', startImportFlow) qs('#btn-import-top').addEventListener('click', startImportFlow)
qs('#btn-import-empty').addEventListener('click', startImportFlow) qs('#btn-import-empty').addEventListener('click', startImportFlow)
// a drop on the empty grid area moves the card to the end // a drop on the empty area of the grid moves the card to the end; drops on a
// card are handled by the card itself (and stop there)
const gameList = qs('#game-list') const gameList = qs('#game-list')
gameList.addEventListener('dragover', e => { gameList.addEventListener('dragover', e => {
if (dragId) e.preventDefault() if (dragId) e.preventDefault()
@ -1867,19 +1970,16 @@ function wireEvents() {
}) })
qs('#btn-settings-save').addEventListener('click', saveSettings) qs('#btn-settings-save').addEventListener('click', saveSettings)
qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR)) qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR))
qs('#btn-clear-hash-cache').addEventListener('click', async () => {
const ok = await confirmModal(t('settings.hashCacheClearConfirm'), t('settings.clearHashCache'))
if (!ok) return
await window.plwl.lansyncClearHashCache()
toast(t('settings.hashCacheCleared'), 'success')
})
qs('#btn-gc-back').addEventListener('click', () => showView('library')) qs('#btn-gc-back').addEventListener('click', () => showView('library'))
qs('#btn-gc-save').addEventListener('click', saveGameConfig) qs('#btn-gc-save').addEventListener('click', saveGameConfig)
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut) qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
qs('#btn-gc-logs').addEventListener('click', showAppLogs) qs('#btn-gc-logs').addEventListener('click', showAppLogs)
qs('#gc-override-add').addEventListener('click', addOverrideRow) qs('#gc-override-add').addEventListener('click', addOverrideRow)
qs('#gc-exe-browse').addEventListener('click', async () => { qs('#gc-exe-browse').addEventListener('click', async () => {
// resolve the SHOWN path to an absolute one, or the picker opens at cwd // start from the field being edited (exe picker from the exe field) and
// resolve the SHOWN path (prefix apps display pfx/drive_c/...) to an
// absolute one - absPathInput alone would pass the prefix-relative form
// through and the picker would open at the process cwd ('/')
const cur = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame) const cur = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR }) const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR })
if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame) if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame)
@ -1895,12 +1995,14 @@ function wireEvents() {
if (res.ok) refreshConfigIcon() if (res.ok) refreshConfigIcon()
} }
qs('#gc-icon-auto').addEventListener('click', async () => { qs('#gc-icon-auto').addEventListener('click', async () => {
// use the exe the field shows, not the saved one // extract from the exe the field shows, not the saved one - the user may
// have just pointed the app at another exe and expects its icon
const exe = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame) const exe = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame)
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id, exe)) applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id, exe))
}) })
qs('#gc-icon-browse').addEventListener('click', async () => { qs('#gc-icon-browse').addEventListener('click', async () => {
// start in the app's own folder, where the icon exe most likely lives // start in the app's own folder (prefix apps included) - that is where
// the exe with the embedded icon most likely lives
const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame) const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({ const p = await window.plwl.dialogPickFile({
properties: ['openFile'], properties: ['openFile'],

View file

@ -103,13 +103,6 @@
<button class="button" id="btn-open-files" data-t="settings.openFiles"></button> <button class="button" id="btn-open-files" data-t="settings.openFiles"></button>
</div> </div>
</div> </div>
<div class="form-card">
<h2 data-t="settings.hashCache"></h2>
<p class="subtitle" data-t="settings.hashCacheHint"></p>
<div class="form-actions">
<button class="button danger" id="btn-clear-hash-cache" data-t="settings.clearHashCache"></button>
</div>
</div>
</section> </section>
<section id="view-lansync" class="view"> <section id="view-lansync" class="view">

View file

@ -3,15 +3,35 @@
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
// Command-line parsing shared by boot and the single-instance handoff. // Shared command-line parsing for the launcher process itself. Both boot
// Supported: --launch <id>, --file <path>, --open <path>, or a bare file path. // (process.argv) and the single-instance handoff (second-instance argv) use
// Electron re-serializes handoff argv, so options/positionals match in order. // it, so a shortcut, a "Open with PLWL" file-open and Chromium's own injected
// flags are handled the same way everywhere.
//
// Supported forms:
// --launch <id> | --launch=<id> auto-start that library app
// --file <path> | --file=<path> hand this file to the --launch app
// --open <path> | --open=<path> open an .exe with the wine picker
// a bare existing-file path same as --open (plumbing for Exec %f)
//
// Electron hands the second instance's argv over RE-SERIALIZED: every switch
// comes first and its value follows as a positional argument, e.g. a shortcut
// `--launch <id> --file <path>` arrives as
// `--launch --file --allow-file-access-from-files <id> <path>`. Taking the
// token next to an option therefore picks the wrong value (it used to take
// the launch id as the opened file, so an app started without its image).
// Options and positionals are matched IN ORDER here instead.
const VALUE_OPTIONS = ['--launch', '--file', '--open'] const VALUE_OPTIONS = ['--launch', '--file', '--open']
// Value options, the positionals that follow the switches, and inline // The options that carry a value, the positional arguments that follow the
// `--name=value` pairs. Tokens before the first switch are the executable // switches, and the values written inline (`--name=value`, which keeps its
// (matched against the running app path in dev mode and skipped). // value no matter how the rest is ordered).
//
// Tokens before the first switch are the executable - never values. In dev
// mode (`electron <app> ...`) Electron re-serializes the app path ALONG WITH
// the values, so it is matched against the running app's own path and skipped:
// it would otherwise be paired with the first option and shift every value.
function splitArgv(argv) { function splitArgv(argv) {
const options = [] const options = []
const positional = [] const positional = []
@ -36,8 +56,10 @@ function splitArgv(argv) {
return { options, positional, inline } return { options, positional, inline }
} }
// An option's value: inline `--name=value`, or the positional at the same // The value of an option: inline when it was written as `--name=value`,
// in-order position among the value options. // otherwise the positional argument with the SAME position as the option
// among our value options (Chromium pushes all switches before all values, so
// any other pairing would read another option's value).
function optionValue(parsed, name) { function optionValue(parsed, name) {
if (parsed.inline.has(name)) return parsed.inline.get(name) if (parsed.inline.has(name)) return parsed.inline.get(name)
const index = parsed.options.indexOf(name) const index = parsed.options.indexOf(name)
@ -46,11 +68,16 @@ function optionValue(parsed, name) {
return value === undefined ? null : value return value === undefined ? null : value
} }
// The file an app shortcut was asked to open; only an absolute path counts. // The file an app shortcut was asked to open: `--file <path>` when the
// generated shortcut passes it explicitly, or the path a file manager appends
// on its own to an Exec line without a file field (KDE does that). Only an
// absolute path counts in either place: the launch id, Chromium's injected
// flags and the launcher's own path can never be mistaken for the document.
const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/ const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/
function launchFileOf(parsed, launchId) { function launchFileOf(parsed, launchId) {
const explicit = optionValue(parsed, '--file') const explicit = optionValue(parsed, '--file')
// a literal %f token is never a file path // a desktop environment that does not expand field codes would hand over
// the literal token - never treat that as a file path
if (explicit && !FIELD_CODE_RE.test(explicit) && path.isAbsolute(explicit)) return explicit if (explicit && !FIELD_CODE_RE.test(explicit) && path.isAbsolute(explicit)) return explicit
if (!launchId) return null if (!launchId) return null
for (let i = parsed.positional.length - 1; i >= 0; i--) { for (let i = parsed.positional.length - 1; i >= 0; i--) {
@ -70,7 +97,10 @@ function parseArgv(argv) {
const launchFile = launchFileOf(parsed, launchId) const launchFile = launchFileOf(parsed, launchId)
let openPath = optionValue(parsed, '--open') let openPath = optionValue(parsed, '--open')
if (!openPath && !launchId) { if (!openPath && !launchId) {
// Exec %f: the file path arrives as the only non-flag argument // "Open with" plumbing (Exec %f): the file path arrives as the only
// non-flag argument - never pick a bare token when a real flag carries
// the intent, and skip this process's own binary (electron dev mode
// passes it as the first argument).
const bare = args.find( const bare = args.find(
a => a =>
!a.startsWith('-') && !a.startsWith('-') &&

View file

@ -4,16 +4,6 @@ const fs = require('fs')
const fsp = require('fs').promises const fsp = require('fs').promises
const path = require('path') const path = require('path')
// accessSync instead of existsSync: same answer, one syscall fewer.
function pathExists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...). // Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
function dedupeDir(base, name) { function dedupeDir(base, name) {
const candidate = path.join(base, name) const candidate = path.join(base, name)
@ -23,7 +13,11 @@ function dedupeDir(base, name) {
return path.join(base, `${name} (${n})`) return path.join(base, `${name} (${n})`)
} }
// Events are rate limited to ~10/s; `force` pushes the final value through. // Progress is cheap to produce and expensive to render: hashing or copying a
// tree can emit tens of thousands of events, and forwarding every single one
// over IPC makes the window stutter instead of showing progress. Events are
// rate limited to ~10/s; `force` lets a caller push the value that matters
// (the end of a unit) through immediately.
const PROGRESS_MIN_MS = 100 const PROGRESS_MIN_MS = 100
function progressGate(emit) { function progressGate(emit) {
@ -56,26 +50,15 @@ function progressGate(emit) {
return push return push
} }
// Run `fn` over `items` with at most `limit` in flight, results in order. // What a tree copy will have to move. Symlinks have no size of their own
async function mapLimit(items, limit, fn) { // (they are recreated, not read), so they only count for the file total.
const out = new Array(items.length) // `skipDir` is a single directory name to leave out (a game folder's
let next = 0 // redistributables) - walking the whole tree first is fast next to the copy.
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++
out[i] = await fn(items[i], i)
}
})
await Promise.all(workers)
return out
}
// What a tree copy will move. Symlinks are recreated, not read. `skipDir`
// leaves out one directory name (redistributables).
async function treeSize(root) { async function treeSize(root) {
let files = 0 let files = 0
let bytes = 0 let bytes = 0
// a symlinked root is the whole tree; never measure through it // a symlink (or a plain file) as the root is the whole tree: walking it
// would measure whatever it points at, not what is about to be removed
let rootStat = null let rootStat = null
try { try {
rootStat = await fsp.lstat(root) rootStat = await fsp.lstat(root)
@ -108,8 +91,11 @@ async function treeSize(root) {
return { files, bytes } return { files, bytes }
} }
// Copy a tree with symlinks recreated and permissions carried (copyFile // Copy a directory tree the way an imported game/wine build has to arrive:
// drops both); reports cumulative { done, bytes } through onProgress. // symlinks recreated as they are (copyFile would silently drop them) and file
// permissions carried over (copyFile writes 0644, killing scripts and any
// binary the game executes itself). Reports cumulative { done, bytes } after
// every file through `onProgress`, so long copies can drive a progress bar.
async function copyTree(src, dst, onProgress) { async function copyTree(src, dst, onProgress) {
let done = 0 let done = 0
let bytes = 0 let bytes = 0
@ -161,8 +147,10 @@ async function copyTree(src, dst, onProgress) {
return { done, bytes } return { done, bytes }
} }
// Delete deepest-first with progress through onProgress; symlinks are // Delete a tree deepest-first so a long removal (a multi-GB prefix) can drive
// unlinked, never followed. // a progress bar instead of freezing the window on one rmSync. Symlinks are
// unlinked, never followed. Reports cumulative { files, bytes, dirs } through
// `onProgress`; a missing tree is simply done.
async function removeTree(root, onProgress) { async function removeTree(root, onProgress) {
let files = 0 let files = 0
let dirs = 0 let dirs = 0
@ -171,7 +159,9 @@ async function removeTree(root, onProgress) {
const tick = () => { const tick = () => {
if (gate) gate({ files, dirs, bytes }, false) if (gate) gate({ files, dirs, bytes }, false)
} }
// a symlinked root is removed as itself, never walked through // A symlink (or a file) as the root is removed as itself, never walked
// through: the root of an import/prefix can be a link to another disk, and
// deleting through it would take the target's files instead of the link.
let rootStat = null let rootStat = null
try { try {
rootStat = await fsp.lstat(root) rootStat = await fsp.lstat(root)
@ -228,5 +218,5 @@ async function removeTree(root, onProgress) {
return { files, dirs, bytes } return { files, dirs, bytes }
} }
module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, progressGate, mapLimit } module.exports = { dedupeDir, copyTree, treeSize, removeTree, progressGate }

View file

@ -1,145 +0,0 @@
'use strict'
// Content hashing for the LAN sync, plus the per-file digest cache
// (files/data/lansync-hashes.json, keyed by path with size + mtime). The
// cache is machine-local and never travels in a sync.
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
// BLAKE2b is faster than SHA-2; older runtimes fall back to SHA-2.
const HASH_ALGO = (() => {
for (const algo of ['blake2b512', 'sha512', 'sha256']) {
try {
crypto.createHash(algo)
return algo
} catch {}
}
return 'sha256'
})()
// Streamed hashing keeps memory flat on huge files.
function hashFile(file, onBytes) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(HASH_ALGO)
const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
stream.on('error', reject)
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
stream.on('data', chunk => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex')))
stream.resume()
})
}
// Beyond a quarter million entries, the least recently touched are dropped.
const HASH_CACHE_MAX = 250000
// Mid-sync saves are throttled; callers that must not lose entries pass force=true.
const SAVE_MIN_MS = 60000
function createHashCache(dataDir) {
const FILE = path.join(dataDir, 'lansync-hashes.json')
let cache = null
let dirty = false
let savedAt = 0
const stats = { hits: 0, misses: 0 }
function load() {
if (cache) return cache
try {
const stored = JSON.parse(fs.readFileSync(FILE, 'utf8'))
cache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
} catch {
cache = {}
}
return cache
}
// Compact JSON: a full cache writes in tens of ms.
function save(force) {
if (!dirty || !cache) return
if (!force && Date.now() - savedAt < SAVE_MIN_MS) return
const names = Object.keys(cache)
if (names.length > HASH_CACHE_MAX) {
names.sort((a, b) => (cache[b][3] || 0) - (cache[a][3] || 0))
const trimmed = {}
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = cache[name]
cache = trimmed
}
dirty = false
savedAt = Date.now()
try {
fs.mkdirSync(path.dirname(FILE), { recursive: true })
const tmp = `${FILE}.tmp`
fs.writeFileSync(tmp, JSON.stringify({ v: 1, algo: HASH_ALGO, files: cache }))
fs.renameSync(tmp, FILE)
} catch {}
}
// The remembered digest, or null when it has to be read again. A hit marks
// the cache dirty only when the timestamp is stale.
function hit(rel, size, mtimeMs) {
const known = load()[rel]
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
stats.misses++
return null
}
stats.hits++
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
dirty = true
}
return known[2]
}
function remember(rel, size, mtimeMs, hash) {
load()[rel] = [size, mtimeMs, hash, Date.now()]
dirty = true
}
// Unchanged size + mtime -> cached digest; otherwise read and store.
async function cachedHash(rel, abs, size, mtimeMs, onBytes) {
const known = hit(rel, size, mtimeMs)
if (known) return known
const hash = await hashFile(abs, onBytes)
if (hash) remember(rel, size, mtimeMs, hash)
return hash
}
// Settings view "clear cache" and stale files go through here.
function clear() {
cache = null
dirty = false
savedAt = 0
stats.hits = 0
stats.misses = 0
try {
fs.rmSync(FILE, { force: true })
fs.rmSync(`${FILE}.tmp`, { force: true })
} catch {}
}
// Dropping the memory copy makes the next lookup re-read from disk (tests).
function forget() {
cache = null
dirty = false
}
return {
HASH_ALGO,
hashFile,
hit,
remember,
cachedHash,
save,
clear,
forget,
stats: () => ({ ...stats, size: cache ? Object.keys(cache).length : 0, algo: HASH_ALGO }),
resetStats: () => {
stats.hits = 0
stats.misses = 0
}
}
}
module.exports = { createHashCache, hashFile, HASH_ALGO }

View file

@ -120,13 +120,17 @@ function pngSize(img) {
return null return null
} }
// RT_ICON payloads are BITMAPINFOHEADER bitmaps; read real dimensions from // Raw RT_ICON payloads are BITMAPINFOHEADER-based bitmaps: biSize, biWidth,
// there (ICONDIRENTRY-style first bytes yield garbage). // biHeight (2x the visible height - XOR + AND mask), biPlanes, biBitCount,
// ..., biClrUsed. Read the real dimensions from there instead of treating the
// first bytes as an ICONDIRENTRY (which yielded garbage like h=0, planes=48
// and made Chromium decode an empty image).
function bmpDims(img) { function bmpDims(img) {
if (img.length < 12) return null if (img.length < 12) return null
const biSize = img.readUInt32LE(0) const biSize = img.readUInt32LE(0)
if (biSize === 12) { if (biSize === 12) {
// BITMAPCOREHEADER (old icons): WORD width/height/planes/bitcount // BITMAPCOREHEADER (Win 2.x/3.x era icons): DWORD size, then WORD width,
// height, planes, bitcount
const w = img.readUInt16LE(4) const w = img.readUInt16LE(4)
const h = Math.floor(img.readUInt16LE(6) / 2) const h = Math.floor(img.readUInt16LE(6) / 2)
const planes = img.readUInt16LE(8) || 1 const planes = img.readUInt16LE(8) || 1

View file

@ -13,8 +13,9 @@ const { copyTree, treeSize } = require('./fsutil')
// Directories that typically hold installers/redistributables, not the game. // Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc']) const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
// The copy itself lives in fsutil.copyTree; moveFolder falls back to it // The copy itself lives in fsutil (copyTree): symlinks recreated, permissions
// across filesystems only. // carried over, byte-level progress. moveFolder only falls back to it across
// filesystems; a same-device rename reports nothing (nothing is copied).
function moveFolder(src, dst, onProgress) { function moveFolder(src, dst, onProgress) {
const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true })) const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
if (path.parse(src).root !== path.parse(dst).root) return fallback() if (path.parse(src).root !== path.parse(dst).root) return fallback()
@ -22,13 +23,16 @@ function moveFolder(src, dst, onProgress) {
} }
async function importGame(srcDir, mode, onProgress) { async function importGame(srcDir, mode, onProgress) {
// a prefix (or its drive_c/pfx) is not an app folder // a prefix (or its drive_c / pfx) is not an app folder: copy/move would take
// the prefix's C: drive with it and leave the prefix gutted
if (isPrefixRoot(srcDir)) return { ok: false, error: 'prefix-root' } if (isPrefixRoot(srcDir)) return { ok: false, error: 'prefix-root' }
const name = path.basename(srcDir) const name = path.basename(srcDir)
// validate before touching anything: no .exe -> refuse // Validate before touching the user's files: refuse an import without any
// .exe (and don't half-move a folder we'd then have to roll back).
const preExe = await findDefaultExe(srcDir) const preExe = await findDefaultExe(srcDir)
if (!preExe) return { ok: false, error: 'no-exe-found' } if (!preExe) return { ok: false, error: 'no-exe-found' }
// the id doubles as the imported folder name // The id doubles as the imported folder name, so generate it first and
// create files/apps/<id> - no dedupe suffixes, ids can never collide.
const id = newGameId() const id = newGameId()
let workdir = srcDir let workdir = srcDir
if (mode === 'copy' || mode === 'move') { if (mode === 'copy' || mode === 'move') {
@ -43,7 +47,9 @@ async function importGame(srcDir, mode, onProgress) {
await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
} else await moveFolder(srcDir, dst, onProgress) } else await moveFolder(srcDir, dst, onProgress)
workdir = dst workdir = dst
// in-data link targets become relative, so the import survives a move // anything the game linked to INSIDE the launcher data (its own folder, an
// icon, a shared prefix file) becomes a relative link, so the imported app
// survives the launcher being moved - the same healing wine builds get
try { try {
await rewireSymlinks(dst, [DIRS.FILES]) await rewireSymlinks(dst, [DIRS.FILES])
} catch {} } catch {}
@ -54,7 +60,8 @@ async function importGame(srcDir, mode, onProgress) {
} }
const game = newGame({ id, name, exePath, workdir, mode }) const game = newGame({ id, name, exePath, workdir, mode })
game.imported = mode === 'copy' || mode === 'move' game.imported = mode === 'copy' || mode === 'move'
// auto-picked exe -> grab its icon; a missing icon is not a failure // the exe was picked automatically -> grab its icon right away; a missing
// icon is not an import failure (game can still get one from config)
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico') const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
if (extractIconFromExe(resolvePortablePath(exePath), icon).ok) { if (extractIconFromExe(resolvePortablePath(exePath), icon).ok) {
game.iconSource = icon game.iconSource = icon
@ -62,14 +69,22 @@ async function importGame(srcDir, mode, onProgress) {
return addGame(game) return addGame(game)
} }
// Import from a prefix: the folder stays in place, paths stored prefix-relative. // Import from a wine prefix: the folder is picked under the prefix's
// drive_c and left in place - the game paths are stored relative to the
// prefix, so wine sees the app exactly as if it had been installed
// normally into C:\...
async function importGameFromPrefix(prefix, srcDir, onProgress) { async function importGameFromPrefix(prefix, srcDir, onProgress) {
if (!listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' } if (!listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' }
// the folder must live inside one of the prefix's C: drives // The folder must live inside one of the prefix's C: drives so the C:\
// mapping holds - drive_c sits at the prefix top for plain wine, under pfx/
// for Proton-managed prefixes, and a prefix used with BOTH kinds has both.
// Every existing drive_c counts; the stored path keeps its drive_c segment
// either way, so it resolves in the layout the app will run in.
const driveRoots = prefixDriveRoots(prefix) const driveRoots = prefixDriveRoots(prefix)
if (!driveRoots.length) return { ok: false, error: 'no-drive-c' } if (!driveRoots.length) return { ok: false, error: 'no-drive-c' }
const src = path.resolve(String(srcDir)) const src = path.resolve(String(srcDir))
// realpath both sides: a unified prefix reaches drive_c through pfx too // realpath on both sides: a unified prefix reaches drive_c through the pfx
// symlink too, and that path is the same folder (see ensureUnifiedPrefix)
let realSrc let realSrc
try { try {
realSrc = fs.realpathSync(src) realSrc = fs.realpathSync(src)
@ -93,7 +108,8 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) {
const game = newGame({ id, name: path.basename(src), exePath: preExe, workdir: src, mode: 'prefix' }) const game = newGame({ id, name: path.basename(src), exePath: preExe, workdir: src, mode: 'prefix' })
game.importSource = 'prefix' game.importSource = 'prefix'
game.prefix = prefix game.prefix = prefix
// auto-picked exe -> grab its icon; a missing icon is not a failure // exe picked automatically -> grab its icon right away; a missing icon is
// not an import failure (game can still get one from config)
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico') const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
if (extractIconFromExe(resolvePortablePath(preExe), icon).ok) { if (extractIconFromExe(resolvePortablePath(preExe), icon).ok) {
game.iconSource = icon game.iconSource = icon
@ -101,7 +117,9 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) {
return addGame(game) return addGame(game)
} }
// Largest .exe in the tree, skipping redistributable folders. // Largest .exe in the tree, skipping redistributable folders. Files are
// stat'ed in parallel and folders walked breadth-first so big games don't
// turn the picker into a sequential crawl.
async function findDefaultExe(dir) { async function findDefaultExe(dir) {
let best = '' let best = ''
let bestSize = -1 let bestSize = -1

View file

@ -17,9 +17,15 @@ function sendToWindow(win, channel, data) {
if (win && !win.isDestroyed()) win.webContents.send(channel, data) if (win && !win.isDestroyed()) win.webContents.send(channel, data)
} }
// Game-process state: id -> { pid, resolved prefix name and wine id }, frozen // Game-process state: id -> { pid (null while the process is starting), the
// at launch (the in-use guards must name what the process really locked). // prefix name and wine id the run was resolved to }. Both are frozen here:
// onGamesChanged fires in the main process when the running set changes. // the default prefix/wine can be switched and the app's own config edited
// while it plays, and the in-use guards have to name what the process really
// locked - not what the registry says now. Editing an app's own prefix while
// it runs does not move the process, so the new folder is blocked next to the
// snapshot (a pre-edit prefix does not exist in it yet).
// onGamesChanged fires in the MAIN process whenever the running-games set
// changes (the tray menu rebuilds itself from it).
const STARTUP_GRACE_MS = 60000 const STARTUP_GRACE_MS = 60000
function createGamesService({ getWindow, onGamesChanged }) { function createGamesService({ getWindow, onGamesChanged }) {
@ -41,7 +47,11 @@ function createGamesService({ getWindow, onGamesChanged }) {
} }
} }
// a stale entry (no pid yet / dead pid) must not answer "already running" // A game is "running" only while its process really is. An entry left behind
// by a launch that never got a pid (the binary could not be executed, so the
// spawn failed) or whose pid is gone (the game crashed, the launcher was
// restarted) would otherwise answer every later Play with "already
// running".
function prune() { function prune() {
for (const [id, entry] of [...running]) { for (const [id, entry] of [...running]) {
if (alive(entry.pid)) continue if (alive(entry.pid)) continue
@ -60,7 +70,11 @@ function createGamesService({ getWindow, onGamesChanged }) {
prune() prune()
return [...running.keys()] return [...running.keys()]
} }
// snapshot + live name together name every folder a run touches // What the still-running games actually use, for the "is this prefix/wine in
// use?" guards: [{ id, name, prefixes: [snapshot, ...], wine }]. The
// snapshot alone goes stale when a running app is pointed at another prefix
// (the process keeps its folder open); the live name alone misses the folder
// it currently holds. Together they name every folder the run touches.
const runningInfo = () => { const runningInfo = () => {
prune() prune()
const out = [] const out = []
@ -73,7 +87,9 @@ function createGamesService({ getWindow, onGamesChanged }) {
return out return out
} }
// "Open with" handoff: queue the file for the renderer. // "Open with PLWL" handoff: queue the file for the renderer (picker popup
// with wine version + prefix). Safe to call at any time - if the window is
// not up yet, the renderer picks the file up right after boot.
function openExternally(openPath) { function openExternally(openPath) {
require('../openwith').setPending(openPath) require('../openwith').setPending(openPath)
sendToWindow(getWindow(), 'openwith:file', openPath) sendToWindow(getWindow(), 'openwith:file', openPath)
@ -102,7 +118,10 @@ function createGamesService({ getWindow, onGamesChanged }) {
return { ok: false, error: 'wine-not-found' } return { ok: false, error: 'wine-not-found' }
} }
if (running.has(id)) { if (running.has(id)) {
// a document into a running app is a handoff, not a tracked run // Opening a document in an app that is already running is a HANDOFF,
// exactly like Windows: a second process receives the file and the app's
// own single-instance logic takes it over. It is not a tracked run -
// playtime, the tray and the kill switch belong to the first process.
if (!openFile) return { ok: false, error: 'already-running' } if (!openFile) return { ok: false, error: 'already-running' }
try { try {
await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true }) await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true })
@ -115,7 +134,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
startedAt.set(id, Date.now()) startedAt.set(id, Date.now())
try { try {
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id), openFile }) const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id), openFile })
// stop requested while still starting: kill and clear // stop was requested while the process was still starting: kill it and
// leave nothing in the running set
if (killRequests.has(id)) { if (killRequests.has(id)) {
killRequests.delete(id) killRequests.delete(id)
if (res.pid) killProcessGroup(res.pid) if (res.pid) killProcessGroup(res.pid)
@ -125,7 +145,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
notifyChanged() notifyChanged()
return { ok: true, pid: null, logFile: res.logFile || null } return { ok: true, pid: null, logFile: res.logFile || null }
} }
// no pid: never keep the entry, it would block the next launch // no pid means the process was not started after all - never keep such
// an entry, it would block the next launch with "already running"
if (!res.pid) { if (!res.pid) {
running.delete(id) running.delete(id)
startedAt.delete(id) startedAt.delete(id)
@ -143,7 +164,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
} }
} }
// Stop always clears the state. // Stop always clears the state: whether the process was running, still
// starting (kill request for as soon as it has a pid) or already gone.
async function kill(id) { async function kill(id) {
const entry = running.get(id) const entry = running.get(id)
if (entry) killProcessGroup(entry.pid) if (entry) killProcessGroup(entry.pid)
@ -172,7 +194,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
} }
} }
// subscribe to game exits (tray/headless auto-quit) // subscribe to game exits (used by the tray/headless mode to e.g. quit when
// the game that auto-started the launcher has finished)
function setOnGameExit(fn) { function setOnGameExit(fn) {
onGameExit = typeof fn === 'function' ? fn : null onGameExit = typeof fn === 'function' ? fn : null
} }
@ -181,7 +204,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data)) return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data))
} }
// prefix import: register in place, nothing copied // prefix import: nothing is copied, the game just gets registered with
// paths relative to the prefix (wine sees it as a normal C:\ install)
async function importFromPrefix(win, prefix, srcDir) { async function importFromPrefix(win, prefix, srcDir) {
return importGameFromPrefix(prefix, srcDir) return importGameFromPrefix(prefix, srcDir)
} }

View file

@ -25,7 +25,9 @@ function errorResult(err) {
const iconDir = id => path.join(DIRS.DATA, 'icons', id) const iconDir = id => path.join(DIRS.DATA, 'icons', id)
// `keep` spares the icon just written: the protocol picks the LAST icon.*. // `keep` spares one file (the icon just written): the icon protocol picks the
// LAST icon.* alphabetically, so a leftover icon.png from an earlier pick
// would otherwise keep showing the old icon.
function clearIconDir(id, keep) { function clearIconDir(id, keep) {
try { try {
for (const f of fs.readdirSync(iconDir(id))) { for (const f of fs.readdirSync(iconDir(id))) {
@ -66,16 +68,26 @@ function registerIpc({ getWindow, onGamesChanged }) {
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch)) ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
// wines ---------------------------------------------------------------------- // wines ----------------------------------------------------------------------
function systemWine() {
let version = ''
try {
const { execFileSync } = require('child_process')
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
} catch {}
return { id: 'system', type: 'system', label: 'System Wine', version }
}
ipcMain.handle('wines:list', async () => { ipcMain.handle('wines:list', async () => {
await wines.refreshWines() await wines.refreshWines()
return [wines.systemWine(), ...wines.getWines()] return [systemWine(), ...wines.getWines()]
}) })
ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled)) ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled))
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled)) ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
ipcMain.handle('wines:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name)) ipcMain.handle('wines:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name))
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id)) ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id))
ipcMain.handle('wines:delete', async (e, id) => { ipcMain.handle('wines:delete', async (e, id) => {
// the build's files back a running prefix; deleting it breaks that game // the build's files back the running prefix (dlls, stub exes) - deleting
// it under a running game breaks that game
if (library.isWineInUse(id, games.runningInfo())) { if (library.isWineInUse(id, games.runningInfo())) {
const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name) const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name)
return { ok: false, error: 'inUse', apps } return { ok: false, error: 'inUse', apps }
@ -91,7 +103,10 @@ function registerIpc({ getWindow, onGamesChanged }) {
}) })
// prefixes --------------------------------------------------------------------- // prefixes ---------------------------------------------------------------------
// A prefix must not move/disappear while a game runs in it. // A prefix folder must not move or disappear while a game runs in it: wine
// keeps the registry and user files open and would write into a folder that
// no longer exists (or, for a delete, lose them mid-run). The renderer shows
// these as a toast naming the blocking app.
const prefixInUse = name => { const prefixInUse = name => {
const apps = library.appsUsingPrefix(name, games.runningInfo()) const apps = library.appsUsingPrefix(name, games.runningInfo())
return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null
@ -101,14 +116,17 @@ function registerIpc({ getWindow, onGamesChanged }) {
ipcMain.handle('prefixes:delete', async (e, name) => { ipcMain.handle('prefixes:delete', async (e, name) => {
const busy = prefixInUse(name) const busy = prefixInUse(name)
if (busy) return busy if (busy) return busy
// deleting a prefix with registered apps would orphan them // deleting a prefix that still has apps registered in it would orphan
// them (they fall back to the default prefix and lose their files)
const users = library.gamesUsingPrefix(name).map(g => g.name) const users = library.gamesUsingPrefix(name).map(g => g.name)
if (users.length) return { ok: false, error: 'hasApps', apps: users } if (users.length) return { ok: false, error: 'hasApps', apps: users }
return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data)) return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data))
}) })
ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName)) ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName))
ipcMain.handle('prefixes:setDefault', (e, name) => { ipcMain.handle('prefixes:setDefault', (e, name) => {
// switching the default would silently move apps that follow it // apps without a prefix field of their own resolve the default at launch;
// switching it now would silently move their next run (a prefix import is
// pinned to its own name)
const defs = prefixes.listPrefixes() const defs = prefixes.listPrefixes()
if (name !== defs.default) { if (name !== defs.default) {
const affected = library.gamesFollowingDefault(games.runningInfo()) const affected = library.gamesFollowingDefault(games.runningInfo())
@ -120,7 +138,8 @@ function registerIpc({ getWindow, onGamesChanged }) {
// games: library ----------------------------------------------------------------- // games: library -----------------------------------------------------------------
ipcMain.handle('games:list', () => library.loadGames()) ipcMain.handle('games:list', () => library.loadGames())
ipcMain.handle('games:get', (e, id) => library.getGame(id)) ipcMain.handle('games:get', (e, id) => library.getGame(id))
// the prefix pin is enforced inside library.updateGame // prefix pin is enforced inside library.updateGame (prefix/importSource
// fields are ignored for prefix imports), so nothing extra needed here
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch)) ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids)) ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids))
ipcMain.handle('games:remove', (e, id) => { ipcMain.handle('games:remove', (e, id) => {
@ -177,7 +196,8 @@ function registerIpc({ getWindow, onGamesChanged }) {
if (!game || !game.exePath) return { ok: false, error: 'bad-request' } if (!game || !game.exePath) return { ok: false, error: 'bad-request' }
return extractGameIcon(id, game.exePath) return extractGameIcon(id, game.exePath)
}) })
// use the exe the field holds (maybe picked but not saved yet) // exePath comes from the config field, which may hold a path the user just
// picked but has not saved yet - the icon has to come from THAT exe
ipcMain.handle('games:autoIcon', (e, id, exePath) => { ipcMain.handle('games:autoIcon', (e, id, exePath) => {
const game = library.getGame(id) const game = library.getGame(id)
if (!game) return { ok: false, error: 'bad-request' } if (!game) return { ok: false, error: 'bad-request' }
@ -187,7 +207,9 @@ function registerIpc({ getWindow, onGamesChanged }) {
}) })
// "Open with PLWL" ------------------------------------------------------------------ // "Open with PLWL" ------------------------------------------------------------------
// `--open` (Exec %f) hands the file over; nothing is imported. // The renderer asks for the file handed in via `--open` (Exec %f), then
// launches it with the wine/prefix picked in the popup. Nothing is imported
// into the library.
ipcMain.handle('openwith:take', async () => { ipcMain.handle('openwith:take', async () => {
const p = openwith.getPending() const p = openwith.getPending()
if (!p) return null if (!p) return null
@ -233,9 +255,14 @@ function registerIpc({ getWindow, onGamesChanged }) {
return errorResult(err) return errorResult(err)
} }
}) })
// import from an existing prefix: paths are stored prefix-relative // import from an existing wine prefix: pick the prefix first, then the app
// folder inside it (drive_c) - paths are stored prefix-relative so the app
// shows up for wine as a regular C:\ install
ipcMain.handle('games:pickPrefixFolder', async (e, prefix) => { ipcMain.handle('games:pickPrefixFolder', async (e, prefix) => {
// open the picker in the prefix's C: drive (mixed layouts: prefix folder) // open the picker straight in the prefix's C: drive (plain wine: top
// level, Proton: under pfx/); a prefix used with BOTH kinds has two of
// them, so it opens at the prefix folder - the only spot that keeps
// drive_c and pfx/drive_c one click away
const driveRoots = prefixes.prefixDriveRoots(prefix) const driveRoots = prefixes.prefixDriveRoots(prefix)
const start = driveRoots.length === 1 ? driveRoots[0] : prefixes.prefixPath(prefix) const start = driveRoots.length === 1 ? driveRoots[0] : prefixes.prefixPath(prefix)
const res = await dialog.showOpenDialog(getWindow(), { const res = await dialog.showOpenDialog(getWindow(), {
@ -259,7 +286,10 @@ function registerIpc({ getWindow, onGamesChanged }) {
// dialogs & shell --------------------------------------------------------------------------- // dialogs & shell ---------------------------------------------------------------------------
ipcMain.handle('dialog:pickFile', async (e, opts) => { ipcMain.handle('dialog:pickFile', async (e, opts) => {
const o = { ...(opts || {}) } const o = { ...(opts || {}) }
// anchor FILES-relative paths, or Electron resolves them against cwd // Electron resolves a RELATIVE defaultPath against the process cwd (the
// install root in packaged mode), which lands the picker somewhere
// random like '/'. Renderer paths are FILES-relative by convention, so
// anchor them there instead.
if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) { if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) {
o.defaultPath = path.join(DIRS.FILES, o.defaultPath) o.defaultPath = path.join(DIRS.FILES, o.defaultPath)
} }
@ -285,7 +315,9 @@ function registerIpc({ getWindow, onGamesChanged }) {
if (pick) file = path.join(iconDir(id), pick) if (pick) file = path.join(iconDir(id), pick)
} catch {} } catch {}
if (!file) return new Response('', { status: 404 }) if (!file) return new Response('', { status: 404 })
// the icon url never changes: send no-cache headers // a new icon lands at the SAME url, so the browser would keep its cached
// copy and the library card would only catch up after a restart - never
// let the icon request be cached
const res = await net.fetch('file://' + file) const res = await net.fetch('file://' + file)
return new Response(await res.arrayBuffer(), { return new Response(await res.arrayBuffer(), {
headers: { headers: {

View file

@ -19,10 +19,6 @@ function registerLanSyncIpc({ getWindow }) {
lansync.forgetDevice(id) lansync.forgetDevice(id)
return { ok: true } return { ok: true }
}) })
ipcMain.handle('lansync:clearHashCache', () => {
lansync.clearHashCache()
return { ok: true }
})
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept)) ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
ipcMain.handle('lansync:sync', async (e, payload) => { ipcMain.handle('lansync:sync', async (e, payload) => {
try { try {

View file

@ -1,133 +0,0 @@
'use strict'
// UDP discovery for the LAN sync: beacons fill the devices list and double
// as liveness (a device drops off a few seconds after close).
const dgram = require('dgram')
const os = require('os')
// Broadcast to the global address AND to every interface's own subnet
// broadcast - some networks drop one or the other.
function broadcastAddresses() {
const out = ['255.255.255.255']
const interfaces = os.networkInterfaces()
for (const list of Object.values(interfaces)) {
for (const iface of list || []) {
if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue
const addr = ipToInt(iface.address)
const mask = ipToInt(iface.netmask)
if (addr == null || mask == null) continue
out.push(intToIp((addr | (~mask >>> 0)) >>> 0))
}
}
return out
}
function ipToInt(ip) {
const parts = String(ip).split('.')
if (parts.length !== 4) return null
let out = 0
for (const part of parts) {
const n = Number(part)
if (!Number.isInteger(n) || n < 0 || n > 255) return null
out = (out << 8) | n
}
return out >>> 0
}
function intToIp(int) {
return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.')
}
function createDiscovery({ identity, tag, protocol, port, beaconMs, deviceTtlMs, getPort }) {
let socket = null
let timer = null
let devicesCb = null
// id -> { id, name, address, port, at }
const seen = new Map()
function listDevices() {
const known = identity().known
const now = Date.now()
return [...seen.values()]
.filter(d => now - d.at <= deviceTtlMs)
.map(d => {
const pair = known[d.id]
return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' }
})
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
}
function emitDevices() {
if (devicesCb) devicesCb(listDevices())
}
function pruneDevices() {
const now = Date.now()
let changed = false
for (const [id, device] of seen) {
if (now - device.at > deviceTtlMs) {
seen.delete(id)
changed = true
}
}
if (changed) emitDevices()
}
function beacon() {
if (!socket || !getPort()) return
const me = identity()
const payload = Buffer.from(JSON.stringify({ t: tag, v: protocol, id: me.deviceId, name: os.hostname(), port: getPort() }))
for (const address of broadcastAddresses()) {
try {
socket.send(payload, 0, payload.length, port, address)
} catch {}
}
pruneDevices()
}
function start(onDevices) {
stop()
devicesCb = onDevices
socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
socket.on('error', () => stop())
socket.on('message', (buf, rinfo) => {
let msg = null
try {
msg = JSON.parse(buf.toString('utf8'))
} catch {}
if (!msg || msg.t !== tag || msg.v !== protocol || !msg.id) return
if (msg.id === identity().deviceId) return // our own broadcast
const previous = seen.get(msg.id)
const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() }
const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name
seen.set(msg.id, next)
if (changed) emitDevices()
})
socket.bind(port, () => {
try {
socket.setBroadcast(true)
} catch {}
beacon()
timer = setInterval(beacon, beaconMs)
})
}
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
if (socket) {
const closing = socket
socket = null
try {
closing.close()
} catch {}
}
}
return { start, stop, listDevices }
}
module.exports = { createDiscovery }

View file

@ -1,118 +0,0 @@
'use strict'
// Device identity for the LAN sync: id, pairing code, paired devices. Factory
// over the config file, one per launcher state.
const crypto = require('crypto')
const { readJson, writeJson } = require('./paths')
// The code is read out and typed; the alphabet skips 0/O, 1/I/L.
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
function randomChars(alphabet, count) {
let out = ''
while (out.length < count) {
for (const byte of crypto.randomBytes(count)) {
if (out.length >= count) break
out += alphabet[byte % alphabet.length]
}
}
return out
}
// base36 timestamp + random padding: unique across machines.
function makeDeviceId() {
const stamp = Date.now().toString(36)
return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24)
}
function makeSyncCode() {
return randomChars(CODE_ALPHABET, 8)
}
function createIdentity(configFile) {
// Read once, kept in memory: the beacon calls identity() constantly.
let cached = null
function readConfig() {
if (cached) return cached
const cfg = readJson(configFile, {})
cached = cfg && typeof cfg === 'object' ? cfg : {}
return cached
}
// Id and code are generated on first use and kept forever.
function identity() {
const cfg = readConfig()
let changed = false
if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) {
cfg.deviceId = makeDeviceId()
changed = true
}
if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) {
cfg.syncCode = makeSyncCode()
changed = true
}
if (!cfg.known || typeof cfg.known !== 'object') {
cfg.known = {}
changed = true
}
if (changed) writeJson(configFile, cfg)
return cfg
}
// Rotations return the whole identity.
function rotateDeviceId() {
const cfg = identity()
const previous = cfg.deviceId
cfg.deviceId = makeDeviceId()
writeJson(configFile, cfg)
if (cfg.deviceId === previous) return rotateDeviceId()
return cfg
}
function rotateSyncCode() {
const cfg = identity()
const previous = cfg.syncCode
cfg.syncCode = makeSyncCode()
writeJson(configFile, cfg)
if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation
return cfg
}
// A paired device remembers its code; a rejected code is forgotten.
function knownDevice(id) {
return identity().known[id] || null
}
function knownPeers() {
return identity().known || {}
}
function rememberDevice(id, code, name) {
const cfg = identity()
const stored = String(code || '').trim().toUpperCase()
cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } }
writeJson(configFile, cfg)
}
function forgetDevice(id) {
const cfg = identity()
const known = { ...cfg.known }
delete known[id]
cfg.known = known
writeJson(configFile, cfg)
}
return { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode }
}
// Constant-time code comparison.
function sameCode(a, b) {
const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase())
const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase())
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y)
}
module.exports = { createIdentity, sameCode, makeDeviceId, makeSyncCode }

View file

@ -1,132 +0,0 @@
'use strict'
// Sender-side hashing: walk sync units into a manifest of dirs, links, files.
// Factory over the path guards and hash store, one per launcher state.
const path = require('path')
const fs = require('fs')
const fsp = fs.promises
function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) {
// Targets inside the launcher data travel relative (the mirrored layout is
// the same on both machines); outside targets stay as they are.
function portableLinkTarget(linkAbs, target) {
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
const rel = path.relative(path.dirname(linkAbs), target)
if (!rel || path.isAbsolute(rel)) return target
const root = path.resolve(filesRoot)
const resolved = path.resolve(path.dirname(linkAbs), rel)
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
return rel
}
// Walk without following symlinks (dosdevices etc. are part of the sync).
// Iterative, with one directory's stat/readlink calls in flight together.
async function scanTree(abs, rel, out, mtimes) {
const queue = [[abs, rel]]
while (queue.length) {
const [dirAbs, dirRel] = queue.pop()
let entries
try {
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
} catch {
continue
}
const dirs = []
const leaves = []
for (const entry of entries) {
const relChild = `${dirRel}/${entry.name}`
if (entry.isDirectory()) {
out.push({ path: relChild, type: 'dir' })
dirs.push([path.join(dirAbs, entry.name), relChild])
} else {
leaves.push(entry)
}
}
await mapLimit(leaves, concurrency, async entry => {
const absChild = path.join(dirAbs, entry.name)
const relChild = `${dirRel}/${entry.name}`
if (entry.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(absChild)
} catch {}
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
return
}
let st
try {
st = await fsp.stat(absChild)
} catch {
return
}
if (!st.isFile()) return
// mode travels with the file
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
// mtimes are machine-local: kept out of the manifest
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
})
queue.push(...dirs)
}
}
// Manifest of one unit; `force` skips hashing (receiver replaces everything).
async function buildManifest(roots, force, onProgress) {
const entries = []
const mtimes = new Map()
for (const rel of roots) {
const abs = filesPath(rel)
if (!abs) continue
let st
try {
st = await fsp.lstat(abs)
} catch {
continue
}
if (st.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(abs)
} catch {}
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
} else if (st.isDirectory()) {
entries.push({ path: rel, type: 'dir' })
await scanTree(abs, rel, entries, mtimes)
} else if (st.isFile()) {
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
mtimes.set(rel, st.mtimeMs || 0)
}
}
const files = entries.filter(e => e.type === 'file')
// total size is known before hashing, so the bar starts empty
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
if (!force && files.length) {
let done = 0
let hashed = 0
await mapLimit(files, concurrency, async entry => {
const abs = filesPath(entry.path)
try {
// unchanged size + mtime: the cached digest still holds
const mtimeMs = mtimes.get(entry.path) || 0
entry.hash = abs
? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => {
hashed += bytes
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
: null
} catch {
entry.hash = null
}
done++
if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
}
hash.save()
return entries
}
return { buildManifest, scanTree, portableLinkTarget }
}
module.exports = { createManifestBuilder }

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,26 @@
'use strict' 'use strict'
// The pushing side of a LAN sync: per unit, send a manifest, learn what the // The pushing side of a LAN sync: this device is the source of truth and
// receiver has, upload the rest, delete the surplus. Exported as a factory; // drives the whole transfer. For every sync unit it sends a manifest, asks
// src/lansync.js hands in its own helpers and state. // the receiver what it already has, uploads the rest and has the receiver
// delete whatever the manifest does not list.
//
// Exported as a factory: src/lansync.js hands in its own helpers and state, so
// nothing here reaches across module instances.
const http = require('http') const http = require('http')
const os = require('os') const os = require('os')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
// Files sent at once: one at a time wastes LAN round trips on small files. // Files sent at once: one at a time wastes most of a LAN round trip on the
// small files a game or prefix is mostly made of.
const UPLOAD_CONCURRENCY = 4 const UPLOAD_CONCURRENCY = 4
// Keeps the receiver alive while this side hashes (minutes of silence); env // Keeps the receiving side's session and popup alive while this device hashes
// override for tests. // a unit (minutes of complete silence from the network) - well under the
// receiving popup's watchdog, and cheap enough to run through the transfer too.
// Env override: tests cannot wait 20 s for a ping.
const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000 const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
function createPusher(api) { function createPusher(api) {
@ -35,9 +42,11 @@ function createPusher(api) {
prefixes prefixes
} = api } = api
// Manifest in, upload list out; NDJSON avoids one giant string. // Manifest in, list of files to upload out. NDJSON keeps a prefix with tens
// of thousands of entries from being one giant string in memory either side.
function planUnit(device, token, unit, entries, force, meta) { function planUnit(device, token, unit, entries, force, meta) {
// announced up front, so the receiver's popup can say "Apps: 2/5" // the receiver is told how many apps/prefixes/wines the whole sync carries
// up front, which is what lets its popup say "Apps: 2/5"
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : '' const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}${totals}` const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}${totals}`
@ -79,7 +88,8 @@ function createPusher(api) {
function putFile(device, token, unitKey, rel, abs) { function putFile(device, token, unitKey, rel, abs) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// unit and path travel as headers: constant per request // unit and path travel as headers: the receiver used to parse a fresh
// query string per file for values that are constant per request
const req = http.request( const req = http.request(
{ {
host: device.address, host: device.address,
@ -129,7 +139,12 @@ function createPusher(api) {
for (const wine of wines.getWines()) { for (const wine of wines.getWines()) {
const rel = String(toPortablePath(wine.path) || '') const rel = String(toPortablePath(wine.path) || '')
const parts = rel.split('/') const parts = rel.split('/')
// the unit is the TOP folder under wines/, not the detected build dir // a build folder can sit one level down (zip-extracted layouts are
// detected at any depth): the unit is always the TOP folder under
// wines/ - that is the folder mirrored as a whole, and listDirs()
// names it as well. Using the basename here would point the unit at a
// folder that does not exist and inflate the "Wine versions" totals
// with a phantom unit.
add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label) add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label)
} }
for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`) for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`)
@ -152,7 +167,9 @@ function createPusher(api) {
// --- registry payload ----------------------------------------------------- // --- registry payload -----------------------------------------------------
// The whole app config travels (paths rewritten to relative form). // Everything the app config holds travels: paths (portableGame rewrites them
// to the FILES/prefix relative form both machines understand), the wine
// build and prefix it runs in, args/env/dllOverrides and the play counters.
function portableApp(game) { function portableApp(game) {
return library.portableGame(game) return library.portableGame(game)
} }
@ -174,8 +191,13 @@ function createPusher(api) {
payload.wines = wineUnits().map(unit => ({ name: unit.id })) payload.wines = wineUnits().map(unit => ({ name: unit.id }))
} }
if (cats.has('settings')) { if (cats.has('settings')) {
// "Sync settings": registry-level values only; opt-in - apps must not // "Sync settings": every registry-level value that describes how the
// silently rewrite the other machine's settings. // launcher is set up here - the defaults, the names/flags the user gave
// wine builds, the prefix registry, and each app's configuration plus
// its position in the library. The receiver only patches things it
// already has; this category creates and deletes no files. Opt-in on
// purpose - a sync of apps must not silently rewrite the other
// machine's settings.
const defs = prefixes.listPrefixes() const defs = prefixes.listPrefixes()
const games = library.loadGames() const games = library.loadGames()
payload.defaults = { payload.defaults = {
@ -221,7 +243,9 @@ function createPusher(api) {
skipped: 0, skipped: 0,
deleted: 0, deleted: 0,
bytes: 0, bytes: 0,
// the popup reports "3 games, 1 prefix" instead of file counts // how many apps / wine builds / prefixes this sync walked over: the
// progress popup reports "3 games, 1 prefix" instead of file counts,
// which mean nothing to the user
items: { apps: 0, wines: 0, prefixes: 0 }, items: { apps: 0, wines: 0, prefixes: 0 },
apps: { copied: 0, skipped: 0, deleted: 0 }, apps: { copied: 0, skipped: 0, deleted: 0 },
wines: { copied: 0, skipped: 0, deleted: 0 }, wines: { copied: 0, skipped: 0, deleted: 0 },
@ -229,7 +253,8 @@ function createPusher(api) {
} }
} }
// Every sync resolves to the same shape, success or failure. // Every sync resolves to the same shape, successes and failures alike, so
// callers can read counters/events without checking first.
function syncResult(ok, error, summary, events) { function syncResult(ok, error, summary, events) {
const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines } const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines }
summary.progress = events.length summary.progress = events.length
@ -255,19 +280,23 @@ function createPusher(api) {
} }
} }
// Push to the peer; a rejected code comes back as 'bad-code'. // Push the selected categories onto the peer. A rejected code comes back as
// error 'bad-code', so the UI can offer to type it again.
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) { async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || []) const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || [])
const device = target || api.listDevices().find(d => d.id === deviceId) const device = target || api.listDevices().find(d => d.id === deviceId)
if (!device || !device.address || !device.port) return fail('offline') if (!device || !device.address || !device.port) return fail('offline')
const cats = new Set(normalizeCategories(categories)) const cats = new Set(normalizeCategories(categories))
if (!cats.size) return fail('nothing-selected') if (!cats.size) return fail('nothing-selected')
// units are known before anything is read: announce them with the verify // The units are known before anything is read, so how many apps / wine
// builds / prefixes this sync carries is announced with the verification:
// the other side can show "Apps: 0/5" from its first event.
const units = buildUnits(cats) const units = buildUnits(cats)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps') const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const totals = { apps: 0, wines: 0, prefixes: 0 } const totals = { apps: 0, wines: 0, prefixes: 0 }
for (const unit of units) totals[bucketOf(unit.kind)] += 1 for (const unit of units) totals[bucketOf(unit.kind)] += 1
// every progress event is kept, so callers can report without subscribing // every progress event is kept (and handed to onProgress when given) so
// callers can report on the transfer without subscribing first
const events = [] const events = []
const progress = data => { const progress = data => {
events.push(data) events.push(data)
@ -276,7 +305,8 @@ function createPusher(api) {
let verified let verified
try { try {
// longer than the receiver's incoming-request timeout (popup wait) // longer than the receiver's incoming-request timeout: the user over
// there gets a popup and this side waits for the click
verified = await httpJson(device, 'POST', '/plwl/verify', { verified = await httpJson(device, 'POST', '/plwl/verify', {
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals }, json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
timeout: 90000 timeout: 90000
@ -286,15 +316,21 @@ function createPusher(api) {
} }
if (!verified.data || !verified.data.ok) { if (!verified.data || !verified.data.ok) {
const error = (verified.data && verified.data.error) || `http-${verified.status}` const error = (verified.data && verified.data.error) || `http-${verified.status}`
// a rotated code drops the pairing; a plain 'rejected' keeps it // the peer rotated its code: drop the stored pairing, so the next
// attempt asks for the new one instead of failing silently. A plain
// 'rejected' (the user over there declined the popup) keeps the pairing
// - the code was proven correct, the popup is the consent gate.
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId) if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
return fail(error) return fail(error)
} }
const token = verified.data.token const token = verified.data.token
// paired now: the next sync needs no typing // the code worked, so this device is paired now: the next sync needs no
// typing (a rejected code is never stored)
rememberDevice(device.id || deviceId, code, device.name) rememberDevice(device.id || deviceId, code, device.name)
// hashing precedes any byte: keep the receiver's popup alive meanwhile // Hashing a big unit happens here, before a single byte is sent: without a
// sign of life the other side would see minutes of silence and its
// "receiving" popup could close while this device is still working.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {}) httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {})
}, HEARTBEAT_MS) }, HEARTBEAT_MS)
@ -302,13 +338,17 @@ function createPusher(api) {
const summary = newSummary(units.length) const summary = newSummary(units.length)
const finished = { apps: 0, wines: 0, prefixes: 0 } const finished = { apps: 0, wines: 0, prefixes: 0 }
// planned bytes so far, so the popup can show "1.2 GB / 3.4 GB" // bytes the units planned so far will put on the wire: the popup can then
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
let plannedBytes = 0 let plannedBytes = 0
let itemsSeen = 0 let itemsSeen = 0
// files from previous units: the popup counter is cumulative // files uploaded/announced in PREVIOUS units: the popup's file counter is
// cumulative across the whole sync (a per-unit counter resets with every
// next app, which reads like progress going backwards)
let allDone = 0 let allDone = 0
let allTotal = 0 let allTotal = 0
// the bar tracks the current phase only: hashing starts empty // the bar tracks the PHASE being worked on (its own bytes), never a
// leftover from the phase before: hashing starts from an empty bar
let barBytes = 0 let barBytes = 0
let barTotal = 0 let barTotal = 0
const snapshot = (phase, unit, done, total) => { const snapshot = (phase, unit, done, total) => {
@ -331,14 +371,19 @@ function createPusher(api) {
barBytes, barBytes,
barTotalBytes: barTotal barTotalBytes: barTotal
} }
// only the copy phase carries a file count, so the popup never jumps back // only phases that actually move the file counter carry one (the copy
// phase passes absolute, cumulative values); the rest omit it, so the
// popup freezes the last count instead of jumping backwards while this
// unit is still being scanned or compared
if (Number.isFinite(done) && Number.isFinite(total)) { if (Number.isFinite(done) && Number.isFinite(total)) {
data.done = done data.done = done
data.total = total data.total = total
} }
return data return data
} }
// snapshot per file, forward a few times per second (phase changes always) // hashing and copying walk thousands of files: the state is snapshotted on
// every file but forwarded a few times per second (phase changes always
// go through, so nothing on screen ever jumps backwards)
const gate = typeof api.progressGate === 'function' ? api.progressGate(progress) : data => progress(data) const gate = typeof api.progressGate === 'function' ? api.progressGate(progress) : data => progress(data)
const emit = (phase, unit, done, total, force) => gate(snapshot(phase, unit, done, total), !!force) const emit = (phase, unit, done, total, force) => gate(snapshot(phase, unit, done, total), !!force)
const end = async () => { const end = async () => {
@ -360,7 +405,8 @@ function createPusher(api) {
const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => { const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => {
barBytes = hashedBytes barBytes = hashedBytes
barTotal = totalBytes barTotal = totalBytes
// no file count until the plan returns // the file counter is not touched here: until the plan comes back
// nobody knows how many of this unit's files will actually move
emit('hash', unit, null, null) emit('hash', unit, null, null)
}) })
emit('plan', unit, null, null, true) emit('plan', unit, null, null, true)
@ -368,14 +414,11 @@ function createPusher(api) {
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`) if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
const upload = new Set(plan.data.upload || []) const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || []) const same = new Set(plan.data.same || [])
// per category: files created/overwritten vs already identical // per category every manifest entry counts: created/overwritten vs
const fileEntries = entries.filter(e => e.type === 'file') // already identical (the plan reports both)
const filePaths = new Set(fileEntries.map(e => e.path)) bucket.copied += entries.length - same.size
let sameFiles = 0 bucket.skipped += same.size
for (const p of same) if (filePaths.has(p)) sameFiles++ summary.skipped += same.size
bucket.copied += fileEntries.length - sameFiles
bucket.skipped += sameFiles
summary.skipped += sameFiles
const pending = [] const pending = []
for (const entry of entries) { for (const entry of entries) {
if (entry.type !== 'file') continue if (entry.type !== 'file') continue
@ -383,8 +426,10 @@ function createPusher(api) {
pending.push(entry) pending.push(entry)
plannedBytes += entry.size || 0 plannedBytes += entry.size || 0
} }
// a few files in flight at once: small files cost round trips // a few files in flight at once: with thousands of small ones the
// the bar counts only bytes that move, and starts empty for the copy // round trip to the other machine costs more than the bytes do
// the files that were already identical never move, so the bar counts
// only the bytes that do - and starts empty for the transfer phase
if (pending.length) { if (pending.length) {
barBytes = 0 barBytes = 0
barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 0) barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 0)
@ -409,7 +454,9 @@ function createPusher(api) {
else for (const entry of pending) await send(entry) else for (const entry of pending) await send(entry)
if (failed) throw failed if (failed) throw failed
emit('copy', unit, allDone + done, allTotal + pending.length, true) emit('copy', unit, allDone + done, allTotal + pending.length, true)
// the totals count a unit's files only once they are on the wire // this unit's files count towards the running totals only once they
// are really on the wire - the counter holds there while the receiver
// prunes and the next unit is scanned/compared
allDone += pending.length allDone += pending.length
allTotal += pending.length allTotal += pending.length
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} }) const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })

View file

@ -3,13 +3,14 @@
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { prefixPath, isPrefixRel } = require('./prefixes') const { prefixPath } = require('./prefixes')
const REGISTRY = path.join(DIRS.DATA, 'apps.json') const REGISTRY = path.join(DIRS.DATA, 'apps.json')
const SETTINGS = path.join(DIRS.DATA, 'settings.json') const SETTINGS = path.join(DIRS.DATA, 'settings.json')
// Playtime lives in its own tiny file (files/data/appdata/<id>/playtime.txt), // Playtime lives in its own tiny file holding ONLY a number of seconds
// updated every ~30s without touching apps.json. // (files/data/appdata/<id>/playtime.txt) so the running game can update it
// every ~30s without rewriting the whole apps.json registry on each tick.
function playtimeFile(id) { function playtimeFile(id) {
return path.join(DIRS.DATA, 'appdata', id, 'playtime.txt') return path.join(DIRS.DATA, 'appdata', id, 'playtime.txt')
} }
@ -28,10 +29,18 @@ function writePlaySeconds(id, seconds) {
fs.writeFileSync(file, String(Math.max(0, Math.round(seconds)))) fs.writeFileSync(file, String(Math.max(0, Math.round(seconds))))
} }
// Prefix imports keep exe/workdir paths relative to the PREFIX ROOT
// ("drive_c/..." for plain wine, "pfx/drive_c/..." for Proton-managed
// prefixes) so wine sees them under C:\... - everything else is
// FILES-relative/absolute.
const isPrefixRel = v => typeof v === 'string' && /^(pfx[/\\])?drive_c([/\\]|$)/i.test(v)
function loadGames() { function loadGames() {
const games = readJson(REGISTRY, []) const games = readJson(REGISTRY, [])
for (const g of games) { for (const g of games) {
// portable paths resolve against FILES, prefix imports against their prefix // stored relative to FILES (portable) -> resolve to absolute for use.
// Prefix imports resolve against their own prefix instead (never the
// launcher-wide default).
const usePrefix = g.importSource === 'prefix' && g.prefix ? prefixPath(g.prefix) : null const usePrefix = g.importSource === 'prefix' && g.prefix ? prefixPath(g.prefix) : null
for (const k of ['path', 'exePath', 'workdir']) { for (const k of ['path', 'exePath', 'workdir']) {
if (!g[k]) continue if (!g[k]) continue
@ -44,7 +53,9 @@ function loadGames() {
return games return games
} }
// Paths inside FILES/ are stored FILES-relative (portable); others as-is. // Paths of anything living inside FILES/ are stored RELATIVE to FILES so the
// whole launcher folder can be moved (portable mode). Absolute paths outside
// FILES (apps left in place) are kept as-is.
function portableGame(g) { function portableGame(g) {
const out = { ...g } const out = { ...g }
const pfx = out.importSource === 'prefix' && out.prefix ? prefixPath(out.prefix) : null const pfx = out.importSource === 'prefix' && out.prefix ? prefixPath(out.prefix) : null
@ -52,7 +63,8 @@ function portableGame(g) {
const v = out[k] const v = out[k]
if (!v) continue if (!v) continue
if (pfx && k !== 'iconSource' && !isPrefixRel(v)) { if (pfx && k !== 'iconSource' && !isPrefixRel(v)) {
// prefix game: store relative to the prefix root ("drive_c/...") // prefix game: store relative to the PREFIX ROOT (keeps the drive_c
// segment, e.g. "drive_c/Games/..." or "pfx/drive_c/Games/...")
const rel = path.relative(pfx, resolvePortablePath(v)) const rel = path.relative(pfx, resolvePortablePath(v))
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) out[k] = rel if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) out[k] = rel
} else if (!(pfx && isPrefixRel(v))) { } else if (!(pfx && isPrefixRel(v))) {
@ -70,21 +82,28 @@ function getGame(id) {
return loadGames().find(g => g.id === id) || null return loadGames().find(g => g.id === id) || null
} }
// Which prefix a game's runs land in (launcher default when unset). // Which prefix a game's runs land in: an app without one follows the launcher
// default. The single place for that fallback (runner.buildEnv and the in-use
// guards both use it, so a rename/delete can never blame the wrong folder).
function prefixNameForGame(game) { function prefixNameForGame(game) {
if (game && game.prefix) return game.prefix if (game && game.prefix) return game.prefix
const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] }) const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] })
return defs.default || 'Main' return defs.default || 'Main'
} }
// Apps whose runs land in this prefix (in-use guard). // Apps whose runs land in this prefix - used to keep a prefix that any app
// still references from being deleted out from under it.
function gamesUsingPrefix(name) { function gamesUsingPrefix(name) {
if (!name) return [] if (!name) return []
return loadGames().filter(g => prefixNameForGame(g) === name) return loadGames().filter(g => prefixNameForGame(g) === name)
} }
// In-use guards: rename/delete of a prefix or build waits until no live run // In-use guards for registry edits: a prefix folder being renamed or deleted
// uses it. `running` is the games service's list of live runs. // (or a wine build deleted) while a game runs in it would leave that process
// without its registry/files - the folder move/removal has to wait until the
// game stops. `running` is the games service's list of live runs
// ([{ name, prefixes, wine }]); prefixes lists every folder a run holds (its
// snapshot plus a new one it was pointed at mid-run).
function appsUsingPrefix(name, running) { function appsUsingPrefix(name, running) {
if (!name) return [] if (!name) return []
return (running || []).filter(r => (r.prefixes || []).includes(name)) return (running || []).filter(r => (r.prefixes || []).includes(name))
@ -94,13 +113,15 @@ function isPrefixInUse(name, running) {
return appsUsingPrefix(name, running).length > 0 return appsUsingPrefix(name, running).length > 0
} }
// Only an exact match on a registered build counts. // A run with no explicit wine falls back to System Wine, which cannot be
// deleted anyway - only an exact match on a registered build counts.
function isWineInUse(id, running) { function isWineInUse(id, running) {
if (!id || id === 'system') return false if (!id || id === 'system') return false
return (running || []).some(r => r.wine === id) return (running || []).some(r => r.wine === id)
} }
// Switching the default prefix waits while any default-running app is live. // Switching the default prefix would silently move the next launch of every
// app that has no prefix of its own - so it waits while any of them runs.
function gamesFollowingDefault(running) { function gamesFollowingDefault(running) {
return (running || []).filter(r => { return (running || []).filter(r => {
const game = getGame(r.id) const game = getGame(r.id)
@ -119,13 +140,16 @@ function updateGame(id, patch) {
const games = loadGames() const games = loadGames()
const g = games.find(x => x.id === id) const g = games.find(x => x.id === id)
if (!g) return null if (!g) return null
// prefix imports are pinned; their patch fields are ignored // prefix imports are pinned: their paths are stored relative to their
// prefix, so NOTHING may move them to another prefix or flip their
// import source (the patch fields are simply ignored)
if (g.importSource === 'prefix' && patch) { if (g.importSource === 'prefix' && patch) {
patch = { ...patch } patch = { ...patch }
delete patch.prefix delete patch.prefix
delete patch.importSource delete patch.importSource
} }
// playSeconds lives in its own file; the registry mirrors it // playSeconds goes to its own tiny file; the registry just mirrors it for
// anything reading apps.json directly (loadGames prefers the file anyway).
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds) if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)
Object.assign(g, patch) Object.assign(g, patch)
saveGames(games) saveGames(games)
@ -136,7 +160,9 @@ function clearPlaytime(id) {
return updateGame(id, { playSeconds: 0, lastPlayed: 0 }) return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
} }
// Delete a portable app's folder with measured progress. // A portable app's folder is removed along with its registry entry - measured
// first (staging) so the renderer can show a bar while the tree is walked,
// instead of freezing on one rmSync of a multi-GB game.
async function removeGame(id, onProgress) { async function removeGame(id, onProgress) {
const games = loadGames() const games = loadGames()
const idx = games.findIndex(g => g.id === id) const idx = games.findIndex(g => g.id === id)
@ -151,7 +177,9 @@ async function removeGame(id, onProgress) {
const total = await treeSize(g.path) const total = await treeSize(g.path)
await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
} }
// prefix imports: drop only the registry entry, never prefix files // prefix imports live inside the prefix: deleting launcher files would
// nuke the user's prefix (installed DLLs, saves of every other app), so
// only the registry entry is dropped
games.splice(idx, 1) games.splice(idx, 1)
saveGames(games) saveGames(games)
return true return true
@ -167,7 +195,9 @@ function saveSettings(patch) {
return s return s
} }
// Timestamp id, incremented while it collides. // ID is the current timestamp (ms); while a game with that id or a folder
// with that name exists, keep incrementing - so ids and import folder names
// can never collide.
function newGameId() { function newGameId() {
let id = Date.now().toString() let id = Date.now().toString()
const taken = () => getGame(id) || fs.existsSync(path.join(DIRS.APPS, id)) const taken = () => getGame(id) || fs.existsSync(path.join(DIRS.APPS, id))
@ -187,7 +217,8 @@ function newGame({ id, name, exePath, workdir, mode }) {
prefix: '', prefix: '',
args: '', args: '',
dllOverrides: [], dllOverrides: [],
// Proton-only: UMU by default, or the build's steam.exe launcher // Proton-only switch: keep UMU (the default path) or fall back to the
// build's steam.exe launcher for this app (see runner.buildEnv)
disableUmu: false, disableUmu: false,
env: {}, env: {},
iconSource: '', iconSource: '',
@ -197,7 +228,9 @@ function newGame({ id, name, exePath, workdir, mode }) {
} }
} }
// Reorder the registry array; unsent ids keep their order at the end. // The registry array IS the library order, so a drag & drop reorder rewrites
// it whole. Ids the caller did not send keep their relative order at the end:
// a renderer that raced an import must never drop an entry.
function reorderGames(ids) { function reorderGames(ids) {
const games = loadGames() const games = loadGames()
const rest = new Map(games.map(g => [g.id, g])) const rest = new Map(games.map(g => [g.id, g]))
@ -213,8 +246,10 @@ function reorderGames(ids) {
return out return out
} }
// Follows a prefix rename through the registry. Raw JSON: the normal // Follows a prefix rename through the app registry. Raw JSON on purpose:
// load/save would re-relativize paths against the renamed folder. // loadGames()/saveGames() would resolve and re-relativize every path against
// a prefix folder that was just renamed. The stored paths of prefix imports
// are relative to the prefix root, so only the name reference changes.
function renamePrefixRefs(oldName, newName) { function renamePrefixRefs(oldName, newName) {
const games = readJson(REGISTRY, []) const games = readJson(REGISTRY, [])
let updated = 0 let updated = 0

View file

@ -32,7 +32,9 @@ let autoQuitWhenDone = false // headless boot: quit after that game exits
const getWindow = () => win const getWindow = () => win
// --- single instance --------------------------------------------------------- // --- single instance ---------------------------------------------------------
// Diagnostics trace for the otherwise invisible shortcut handoff. // Diagnostics: the shortcut handoff path is invisible otherwise (a failing
// handoff just "does nothing" for the user). Everything lands in a small
// trace file next to the launcher data so problems are reproducible.
function trace(msg) { function trace(msg) {
try { try {
const file = path.join(DIRS.DATA, 'instance-trace.log') const file = path.join(DIRS.DATA, 'instance-trace.log')
@ -61,11 +63,15 @@ if (!gotLock) {
}) })
} }
// auto-quit skips itself briefly after a handoff (start/exit race guard) // set on every second-instance handoff: the auto-quit check skips itself for
// a moment afterwards, so "one game exits" racing "a shortcut just started
// another game" can never kill the launcher mid-handoff
let lastHandoffAt = 0 let lastHandoffAt = 0
// --- "Open with PLWL" (Exec %f from the file manager) -------------------------- // --- "Open with PLWL" (Exec %f from the file manager) --------------------------
// Hands the file to the window (picker popup); headless boots open the window. // Hands the file to the window (renderer shows the wine/prefix picker popup);
// headless boots open the window for it - a file cannot be launched without
// the user choosing how.
async function openWith(filePath) { async function openWith(filePath) {
trace(`openWith ${filePath} serviceReady=${!!gamesService}`) trace(`openWith ${filePath} serviceReady=${!!gamesService}`)
showMainWindow() showMainWindow()
@ -93,7 +99,9 @@ function createTray() {
try { try {
tray = new Tray(icon) tray = new Tray(icon)
} catch (err) { } catch (err) {
// say why the tray icon is missing instead of failing silently // never fatal (the window keeps working), but say why the icon is missing
// instead of failing silently - a missing tray looks like a hung app when
// the launcher runs in the background
console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`) console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`)
tray = null tray = null
return return
@ -169,7 +177,8 @@ function showMainWindow() {
} }
function wireWindow(w) { function wireWindow(w) {
// closing hides to the tray (unless disabled in settings, then it quits) // closing the window hides it to the tray (playtime keeps counting in the
// background) unless the user disabled that in settings - then it quits
w.on('close', e => { w.on('close', e => {
if (quitting) return if (quitting) return
let closeToTray = true let closeToTray = true
@ -199,10 +208,13 @@ async function launchGame(id, openFile) {
trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`) trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`)
if (res.ok) { if (res.ok) {
rebuildTrayMenu() rebuildTrayMenu()
// headless boot: quit when the auto-started game finishes // headless boot: when the auto-started game finishes and nothing else is
// running, quit the launcher
if (autoQuitWhenDone) { if (autoQuitWhenDone) {
gamesService.setOnGameExit(() => { gamesService.setOnGameExit(() => {
// quit only when the last game ended and nothing new came in // fires for EVERY game exit; quit only when the last one just ended,
// the user never opened the window and no shortcut handed a new game
// over in the last few seconds (start/exit race guard)
if (Date.now() - lastHandoffAt < 5000) return if (Date.now() - lastHandoffAt < 5000) return
if (!gamesService.listRunning().length) { if (!gamesService.listRunning().length) {
quitting = true quitting = true
@ -229,10 +241,12 @@ app.whenReady().then(async () => {
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu }) const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
gamesService = games gamesService = games
// the tray comes first: nothing may keep it from coming up // the tray comes first: nothing (least of all the network) may keep it from
// coming up - a launcher without a tray icon looks simply broken
createTray() createTray()
// LAN sync runs whenever the launcher runs, so this device is discoverable // LAN sync runs whenever the launcher runs - that is what makes this
// device show up on the other machines' client lists
const lanHooks = { const lanHooks = {
onDevices: devices => sendToWindow(win, 'lansync:devices', devices), onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', info), onApplied: info => sendToWindow(win, 'lansync:applied', info),
@ -254,7 +268,8 @@ app.whenReady().then(async () => {
} else { } else {
win = createMainWindow() win = createMainWindow()
wireWindow(win) wireWindow(win)
// file open queued while the service was still booting // file open handed over while the service was still booting (or queued
// by a second instance racing the first boot)
if (autoOpenPath || openwith.hasPending()) { if (autoOpenPath || openwith.hasPending()) {
openWith(autoOpenPath || openwith.getPending()) openWith(autoOpenPath || openwith.getPending())
} }

View file

@ -9,8 +9,10 @@ function cleanName(name) {
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
} }
// A folder-safe name: letters/digits of any language plus safe punctuation; // A name that becomes a folder inside the launcher (a prefix): letters and
// "." and ".." are not names. // digits of any language (Polish names keep their diacritics) plus the
// punctuation that is safe in a path segment. "." and ".." are path segments,
// not names.
function normalizePathName(name) { function normalizePathName(name) {
const out = cleanName(name).replace(/[^\p{L}\p{N} ._-]/gu, '') const out = cleanName(name).replace(/[^\p{L}\p{N} ._-]/gu, '')
return /^\.+$/.test(out) ? '' : out return /^\.+$/.test(out) ? '' : out

View file

@ -1,7 +1,9 @@
'use strict' 'use strict'
// "Open with PLWL": a file handed in via --open is parked here until the // "Open with PLWL" (file manager -> Open With -> PLWL): a file handed in via
// renderer picks it up, then launched ad hoc. Nothing is imported. // `--open <path>` (or the Exec %f plumbing) is parked here until the renderer
// picks it up, then launched ad hoc with the wine version + prefix chosen in
// the picker popup. Nothing is imported into the library.
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
@ -24,7 +26,8 @@ function clearPending() {
pending = null pending = null
} }
// Fall back to English keys on older installs. // The launcher's own translations may not cover openwith texts in older
// installs - fall back to English keys so the UI never shows raw key names.
function textsFor(code) { function textsFor(code) {
let texts = {} let texts = {}
try { try {
@ -55,7 +58,8 @@ function resolvePrefix(name) {
return known || defs.default || 'Main' return known || defs.default || 'Main'
} }
// Ad-hoc run target shaped like a library game; nothing persisted. // Ad-hoc run target: shaped exactly like a library game (runner.buildEnv and
// the playtime/log paths expect those fields) but nothing is persisted.
function makeTarget(openPath, wineId, prefixName) { function makeTarget(openPath, wineId, prefixName) {
const dir = path.dirname(openPath) const dir = path.dirname(openPath)
return { return {
@ -78,7 +82,9 @@ function makeTarget(openPath, wineId, prefixName) {
} }
} }
// Launch without importing, exactly like a library app. // Launch without importing: resolves the chosen wine/prefix, builds the ad
// hoc target and runs it exactly like a library app (same env, same logging,
// same detached process group).
async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarted }) { async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarted }) {
if (!openPath || !fs.existsSync(openPath)) return { ok: false, error: 'file-missing' } if (!openPath || !fs.existsSync(openPath)) return { ok: false, error: 'file-missing' }
if (!/\.(exe|msi|bat|cmd|lnk)$/i.test(openPath)) return { ok: false, error: 'not-windows' } if (!/\.(exe|msi|bat|cmd|lnk)$/i.test(openPath)) return { ok: false, error: 'not-windows' }
@ -86,7 +92,9 @@ async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarte
const prefixName = resolvePrefix(prefixName0) const prefixName = resolvePrefix(prefixName0)
const runner = require('./runner') const runner = require('./runner')
try { try {
// shared `openwith` id keeps logs/playtime away from library ids // runAdHoc builds the synthetic target internally (shared `openwith` id,
// so logs/playtime never collide with a library app id); the resolved
// prefix is passed through so buildEnv uses the chosen one
const res = await runner.runAdHoc({ openPath, wine, systemWine: wine.type === 'system', prefix: prefixName }) const res = await runner.runAdHoc({ openPath, wine, systemWine: wine.type === 'system', prefix: prefixName })
if (onStarted) onStarted({ name: path.basename(openPath), pid: res.pid }) if (onStarted) onStarted({ name: path.basename(openPath), pid: res.pid })
return { ok: true, pid: res.pid || null, logFile: res.logFile || null } return { ok: true, pid: res.pid || null, logFile: res.logFile || null }

View file

@ -3,7 +3,9 @@
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
// Packaged: resources/app.asar two levels below the portable root. // Packaged builds run from <portable root>/olcxja.plwl/resources/app.asar:
// the portable layout (<root>/files) is two levels above the resources dir
// and files bundled with the release are unpacked next to the asar.
function computeRoots() { function computeRoots() {
const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/) const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/)
if (m) { if (m) {
@ -14,7 +16,8 @@ function computeRoots() {
APP_ROOT, APP_ROOT,
// FILES = BUILD/files, so BUILD is the portable root itself. // FILES = BUILD/files, so BUILD is the portable root itself.
BUILD: process.env.PLWL_BUILD_DIR || APP_ROOT, BUILD: process.env.PLWL_BUILD_DIR || APP_ROOT,
// bundled release files live inside the asar // Bundled release files live inside the asar (Electron's patched fs
// can read them directly from the archive).
BUNDLED: fs.existsSync(path.join(RESOURCES, 'app.asar', 'files', 'translations')) BUNDLED: fs.existsSync(path.join(RESOURCES, 'app.asar', 'files', 'translations'))
? path.join(RESOURCES, 'app.asar', 'files') ? path.join(RESOURCES, 'app.asar', 'files')
: path.join(APP_ROOT, 'files') : path.join(APP_ROOT, 'files')
@ -47,11 +50,16 @@ const DIRS = {
FAKE_STEAM: path.join(DATA, 'fake-steam') FAKE_STEAM: path.join(DATA, 'fake-steam')
} }
// Bundled translations are refreshed on start whenever their content changed, // Bundled translation files are launcher-owned: on every start they are
// so updates reach existing installs; user-added languages are untouched. // refreshed in the portable files/translations folder WHENEVER THEIR CONTENT
// CHANGED, so an update shipping new or changed keys reaches existing
// installs too (the old copy-only-if-missing logic froze translations at the
// version that happened to run first). Extra files in the target folder that
// the bundle does not ship (user-added languages) are left untouched.
function filesDiffer(a, b) { function filesDiffer(a, b) {
try { try {
// compare as text: Buffer.equals() throws on a string // compare as text on purpose: Buffer.equals() throws on a string, and that
// throw was read as "differs", so every boot recopied every translation
return fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8') return fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8')
} catch { } catch {
return true // target missing/unreadable -> needs a (re)copy return true // target missing/unreadable -> needs a (re)copy
@ -92,8 +100,10 @@ function ensureDirs() {
} }
} }
// Anything under FILES/ is stored FILES-relative; absolute paths outside are // Portable paths: anything stored under FILES/ goes into the registry as a
// kept as-is. Resolve on read, normalize on write. // path RELATIVE to FILES (e.g. apps/<id>/game.exe), so moving the whole
// launcher folder keeps the library working. Absolute paths outside FILES
// (games left in place) are stored as-is. Resolve on read, normalize on write.
function toPortablePath(p) { function toPortablePath(p) {
if (typeof p !== 'string' || !p || !path.isAbsolute(p)) return p if (typeof p !== 'string' || !p || !path.isAbsolute(p)) return p
const rel = path.relative(FILES, p) const rel = path.relative(FILES, p)
@ -106,8 +116,15 @@ function resolvePortablePath(p) {
return path.join(FILES, p) return path.join(FILES, p)
} }
// The launcher's own icon (renderer/plwl.png) for tray, app icon, shortcut // The launcher's own icon (renderer/plwl.png), used by the tray, the app
// fallback. The module-relative copy is the only candidate in every layout. // icon and as the .desktop shortcut fallback.
//
// The module-relative copy comes FIRST: it is the only candidate that exists
// in every layout, because a packaged build keeps renderer/ inside
// app.asar and Electron's patched fs reads straight out of the archive (a
// release only ships olcxja.plwl.png next to the portable root, so looking at
// APP_ROOT alone made the tray icon vanish in installed builds). Cached: it
// never moves at runtime.
function launcherIconCandidates() { function launcherIconCandidates() {
const bundled = path.join(__dirname, '..', 'renderer') const bundled = path.join(__dirname, '..', 'renderer')
return [ return [
@ -134,7 +151,8 @@ function launcherIconPath() {
return launcherIcon || null return launcherIcon || null
} }
// The icon as a real file: a .desktop Icon= cannot live inside app.asar. // The same icon, but only as a real file on disk: a .desktop entry's Icon= is
// resolved by the desktop environment, which cannot look inside app.asar.
function launcherIconDiskPath() { function launcherIconDiskPath() {
const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`)) const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`))
return firstExisting(onDisk) return firstExisting(onDisk)

View file

@ -7,11 +7,6 @@ const { rewireSymlinks } = require('./symlinks')
const { removeTree, treeSize } = require('./fsutil') const { removeTree, treeSize } = require('./fsutil')
const { normalizePathName } = require('./names') const { normalizePathName } = require('./names')
// An app path stored relative to its prefix root ("drive_c/..." or
// "pfx/drive_c/..."); everything else is FILES-relative or absolute.
const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i
const isPrefixRel = v => typeof v === 'string' && PREFIX_REL_RE.test(v)
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json') const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
const MAIN = 'Main' const MAIN = 'Main'
@ -47,8 +42,12 @@ function prefixExists(name) {
} }
} }
// Every Windows C: drive a prefix has (top-level for plain wine, under pfx/ // Every Windows C: drive a prefix has: plain wine keeps one at the top
// for Proton); a unified prefix reports its single drive once. // (WINEPREFIX=<prefix>), legacy Proton-managed prefixes nest one under pfx/
// (WINEPREFIX=<prefix>/pfx). A prefix used with BOTH wine kinds has both, and
// an import may come from either one - so callers that can accept several get
// them all, top level first. A unified prefix (pfx -> .) reaches the same
// drive twice; it is reported once.
function prefixDriveRoots(name) { function prefixDriveRoots(name) {
const root = prefixPath(name) const root = prefixPath(name)
const found = [] const found = []
@ -74,9 +73,22 @@ function lstatOrNull(p) {
} }
} }
// Without a link, a prefix used with both wine kinds grows two C: drives; // Wine keeps its C: drive at <prefix>/drive_c, Proton always runs its prefix
// `pfx` -> prefix root collapses them. Only a prefix with no pfx at all gets // from <compatdata>/pfx - and PLWL points compatdata at the launcher's prefix
// the link - an existing layout is left exactly as it is. // folder, so without a link a prefix used with both wine kinds grows TWO C:
// drives with separate registries, saves and installed DLLs. Making `pfx` a
// relative symlink back to the prefix itself collapses them: Proton's
// <compatdata>/pfx resolves to the prefix root, the exact folder wine uses.
// Every walker PLWL owns (fsutil, symlinks, LAN sync) treats symlinks as
// links and never follows them, so the self-reference is safe.
//
// Only a prefix that has no pfx AT ALL gets the link - that covers a fresh
// prefix and a plain-wine prefix first used through Proton. An existing
// layout is left exactly as it is: a prefix from an older PLWL keeps its real
// pfx/ folder (and here the two wine kinds keep two C: drives), and a prefix
// with both real drive_c trees keeps them - moving Windows installs around
// behind the user's back loses data and breaks the absolute/relative links
// inside them.
function ensureUnifiedPrefix(name) { function ensureUnifiedPrefix(name) {
if (!name) return false if (!name) return false
const pfx = path.join(prefixPath(name), 'pfx') const pfx = path.join(prefixPath(name), 'pfx')
@ -90,8 +102,12 @@ function ensureUnifiedPrefix(name) {
} }
} }
// True when a path IS one of a prefix's own roots (folder, pfx/, drive_c): // True when a path IS one of a prefix's own roots: the prefix folder, its
// importing one would gut the prefix. // pfx/ or either drive_c. A copy/move import of such a folder would move the
// prefix's C: drive (or the whole prefix) into files/apps, gutting it - the
// "import from a prefix" flow is the one that works in place. Both drive
// layouts are checked literally, not through prefixDriveRoots(): a prefix used
// with wine AND Proton has a drive_c each and BOTH are off limits.
function isPrefixRoot(p) { function isPrefixRoot(p) {
if (!p) return false if (!p) return false
const abs = path.resolve(String(p)) const abs = path.resolve(String(p))
@ -118,7 +134,9 @@ function createPrefix(name) {
return { ok: true, name: clean } return { ok: true, name: clean }
} }
// Removal is measured (staging) so the UI shows a bar that moves. // A prefix is a whole windows drive - removal is measured before it starts
// (staging) so the UI can show a bar that actually moves while the tree is
// walked file by file (removeTree), instead of one blocking rmSync.
async function deletePrefix(name, onProgress) { async function deletePrefix(name, onProgress) {
if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' } if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
const defs = listPrefixes() const defs = listPrefixes()
@ -134,13 +152,19 @@ async function deletePrefix(name, onProgress) {
return { ok: true } return { ok: true }
} }
// Rewire absolute links to relative so a moved prefix keeps working. // A prefix that arrived from another machine (or a launcher that was moved) is
// full of absolute symlinks into its wine/proton build - fonts, built-in
// program stubs, dlls. Rewire them to relative paths so the prefix keeps
// working here and stays relocatable (same healing wine builds get on import).
async function healPrefix(name) { async function healPrefix(name) {
if (!name) return 0 if (!name) return 0
return rewireSymlinks(prefixPath(name), [DIRS.FILES]) return rewireSymlinks(prefixPath(name), [DIRS.FILES])
} }
// Rename moves the folder and follows the name through the registry. // Renaming a prefix moves its folder on disk and follows the name into the
// registry (default included) and every app that runs in it. Prefix imports
// keep their stored paths (they are relative to the prefix root), so only the
// reference changes - the files move with the folder.
function renamePrefix(oldName, newName) { function renamePrefix(oldName, newName) {
const clean = normalizePathName(newName) const clean = normalizePathName(newName)
if (!clean) return { ok: false, error: 'invalidName' } if (!clean) return { ok: false, error: 'invalidName' }
@ -150,7 +174,8 @@ function renamePrefix(oldName, newName) {
if (entry.locked || String(oldName).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' } if (entry.locked || String(oldName).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
if (clean === oldName) return { ok: true, name: clean, games: 0 } if (clean === oldName) return { ok: true, name: clean, games: 0 }
if (defs.prefixes.some(p => p.name === clean) || fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' } if (defs.prefixes.some(p => p.name === clean) || fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' }
// a registered prefix without a folder yet can still be renamed // a registered prefix whose folder wine has not created yet can still be
// renamed - only the registry entry exists
if (fs.existsSync(prefixPath(oldName))) { if (fs.existsSync(prefixPath(oldName))) {
try { try {
fs.renameSync(prefixPath(oldName), prefixPath(clean)) fs.renameSync(prefixPath(oldName), prefixPath(clean))
@ -173,4 +198,4 @@ function setDefaultPrefix(name) {
return { ok: true } return { ok: true }
} }
module.exports = { isPrefixRel, PREFIX_REL_RE, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN } module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }

View file

@ -3,23 +3,33 @@
const { contextBridge, ipcRenderer } = require('electron') const { contextBridge, ipcRenderer } = require('electron')
const { DIRS } = require('./paths') const { DIRS } = require('./paths')
// Single-slot event callbacks: only the latest subscriber receives an event. // single-slot progress: every import modal used to register ANOTHER listener
function singleSlot(channel) { // and old ones never unregistered, so the file counter sped up per import
let slot = null let importProgressCb = null
ipcRenderer.on(channel, (e, data) => { ipcRenderer.on('import:progress', (e, data) => {
if (slot) slot(data) if (importProgressCb) importProgressCb(data)
}) })
return fn => {
slot = fn
}
}
const onImportProgress = singleSlot('import:progress') // "Open with PLWL": single-slot callback for files handed over via --open
// "Open with PLWL": files handed over via --open (Exec %f). // (Exec %f) - the renderer shows the wine/prefix picker popup for it.
const onOpenWith = singleSlot('openwith:file') let openWithCb = null
// one sync (and one pending consent request, enforced server-side) at a time ipcRenderer.on('openwith:file', (e, openPath) => {
const onLanSyncProgress = singleSlot('lansync:progress') if (openWithCb) openWithCb(openPath)
const onLanSyncIncoming = singleSlot('lansync:incoming') })
// LAN sync progress: same single-slot rule as the import counter, only one
// sync can run at a time anyway.
let lanSyncProgressCb = null
ipcRenderer.on('lansync:progress', (e, data) => {
if (lanSyncProgressCb) lanSyncProgressCb(data)
})
// Incoming sync consent: the receiver's Accept/Decline popup. Single slot for
// the same reason - one pending request at a time (the server enforces it).
let lanSyncIncomingCb = null
ipcRenderer.on('lansync:incoming', (e, data) => {
if (lanSyncIncomingCb) lanSyncIncomingCb(data)
})
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
@ -44,7 +54,9 @@ contextBridge.exposeInMainWorld('plwl', {
openwithTake: () => ipcRenderer.invoke('openwith:take'), openwithTake: () => ipcRenderer.invoke('openwith:take'),
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload), openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang), openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang),
onOpenWith: onOpenWith, onOpenWith: cb => {
openWithCb = cb
},
basename: p => (p ? path.basename(p) : ''), basename: p => (p ? path.basename(p) : ''),
fileExists: p => { fileExists: p => {
try { try {
@ -79,19 +91,24 @@ contextBridge.exposeInMainWorld('plwl', {
dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts), dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts),
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p), shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p), shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
onImportProgress: onImportProgress, onImportProgress: cb => {
importProgressCb = cb
},
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()), onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()),
lansyncGet: () => ipcRenderer.invoke('lansync:get'), lansyncGet: () => ipcRenderer.invoke('lansync:get'),
lansyncDevices: () => ipcRenderer.invoke('lansync:devices'), lansyncDevices: () => ipcRenderer.invoke('lansync:devices'),
lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'), lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'),
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'), lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id), lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'),
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload), lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept), lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)), onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
onLanSyncProgress: onLanSyncProgress, onLanSyncProgress: cb => {
onLanSyncIncoming: onLanSyncIncoming, lanSyncProgressCb = cb
},
onLanSyncIncoming: cb => {
lanSyncIncomingCb = cb
},
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)), onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data)) onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
}) })

View file

@ -8,8 +8,11 @@ const { DIRS } = require('./paths')
const { prefixPath, ensureUnifiedPrefix } = require('./prefixes') const { prefixPath, ensureUnifiedPrefix } = require('./prefixes')
const { prefixNameForGame } = require('./library') const { prefixNameForGame } = require('./library')
// The fake Steam install used when no real Steam client is found. Both folders // The fake Steam install PLWL falls back to when no real Steam client is
// Proton links into its prefix have to exist, or the links dangle. // found (see findSteamClientDir). Both folders Proton links into its prefix
// have to exist: it symlinks <prefix>/drive_c/Program Files (x86)/Steam/
// appcache -> <fake>/appcache while setting the prefix up, and a link into a
// missing folder is a dangling link in a tree that gets synced.
function ensureFakeSteam() { function ensureFakeSteam() {
const fake = DIRS.FAKE_STEAM const fake = DIRS.FAKE_STEAM
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true }) fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
@ -17,9 +20,14 @@ function ensureFakeSteam() {
return fake return fake
} }
// Proton needs the Steam client at STEAM_COMPAT_CLIENT_INSTALL_PATH or a Valve // Proton expects the Steam client it is normally started by at
// build dies in a "steamclient_main.c" assert; use a real client read-only, // STEAM_COMPAT_CLIENT_INSTALL_PATH: it copies the client's legacy DLLs from
// the empty fake otherwise (GE builds skip steam.exe entirely). // <client>/legacycompat into the prefix, its built-in steam.exe loads the
// client's own steamclient.so, and media libs come from <client>/ubuntu12_*.
// A client installed on this machine is worth using (read-only) - without one
// a Valve build's launch dies in a "steamclient_main.c" assert dialog - and
// only when there is none does PLWL fall back to the empty fake folder. GE
// builds don't need any of it: their UMU path never starts steam.exe.
const STEAM_CLIENT_MARKERS = [ const STEAM_CLIENT_MARKERS = [
['legacycompat', 'steamclient.dll'], ['legacycompat', 'steamclient.dll'],
['ubuntu12_32', 'steamclient.so'] ['ubuntu12_32', 'steamclient.so']
@ -49,15 +57,21 @@ function steamClientDir() {
return findSteamClientDir() || ensureFakeSteam() return findSteamClientDir() || ensureFakeSteam()
} }
// WINEDLLOVERRIDES: ';'-separated, '=' splits name/setting, ',' separates // Module names travel in a semicolon-separated WINEDLLOVERRIDES list where
// names. Guides write a trailing .dll; Wine strips it, so store the bare name. // '=' splits name from setting and ',' separates names - anything else in a
// name would corrupt the entries around it. A trailing .dll is what guides
// write; Wine strips it itself, so PLWL stores the bare name.
function sanitizeDllName(name) { function sanitizeDllName(name) {
const clean = String(name || '').trim().replace(/\.dll$/i, '') const clean = String(name || '').trim().replace(/\.dll$/i, '')
return /^[^;=\s]+$/.test(clean) ? clean : '' return /^[^;=\s]+$/.test(clean) ? clean : ''
} }
// Wine keeps the LAST entry per module: inherited overrides go first, the // Wine replaces an earlier entry for the same module with a later one, so an
// app's own after. An empty setting is Wine's "disabled" and stays as `name=`. // inherited WINEDLLOVERRIDES goes FIRST and the app's own overrides are
// appended after it. An empty setting is Wine's "disabled" and has to survive
// as `name=`, not be dropped. Proton appends its own dxvk/nvapi/steam.exe
// defaults after everything - an app override for one of those modules only
// takes effect where Proton itself has no default.
function dllOverridesFor(game, env) { function dllOverridesFor(game, env) {
const parts = [] const parts = []
const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '') const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '')
@ -73,8 +87,11 @@ function dllOverridesFor(game, env) {
return parts.join(';') return parts.join(';')
} }
// Repair only broken links (a resolvable absolute link would be synced out // Wine only ever repairs links that are BROKEN: a prefix that came from
// again). Once per prefix per launch. // another machine (or from an older PLWL) can still hold absolute links into
// its build that happen to resolve here, and those would be synced out again.
// Healing once per prefix per launch keeps the whole tree relocatable and
// costs a single walk in the common (already relative) case.
const healedPrefixes = new Set() const healedPrefixes = new Set()
async function healPrefixOnce(game) { async function healPrefixOnce(game) {
const name = prefixNameForGame(game) const name = prefixNameForGame(game)
@ -88,9 +105,13 @@ async function healPrefixOnce(game) {
} }
} }
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix. // Proton gets its prefix through STEAM_COMPAT_DATA_PATH, which has to be a
// A leftover EMPTY compat dir (an older PLWL bug) is replaced by the link; a // symlink to the launcher's own prefix folder. The parent directory is created
// populated one is left alone. // FIRST: symlink() fails with ENOENT when compat/ does not exist yet, and the
// old fallback then created a plain empty directory instead - Proton silently
// started with an empty prefix (the game never saw its own data). A leftover
// EMPTY directory from exactly that situation is safe to replace with the
// link; a populated one is left alone.
function linkPrefix(compatData, prefixDir) { function linkPrefix(compatData, prefixDir) {
try { try {
fs.mkdirSync(path.dirname(compatData), { recursive: true }) fs.mkdirSync(path.dirname(compatData), { recursive: true })
@ -119,9 +140,12 @@ function linkPrefix(compatData, prefixDir) {
function buildEnv({ game, wine }) { function buildEnv({ game, wine }) {
const env = { ...process.env } const env = { ...process.env }
const isProton = wine && wine.type === 'proton' const isProton = wine && wine.type === 'proton'
// same fallback the games service uses, so in-use checks name this folder // same fallback the games service snapshots into its running entries
// (library.prefixNameForGame), so in-use checks always name this folder
const prefixName = prefixNameForGame(game) const prefixName = prefixNameForGame(game)
// no pfx yet -> shared-link layout; an existing nested layout is left as is // a prefix with no pfx yet gets the shared-link layout, so every launch -
// wine, Proton, tools - lands on that prefix's ONE C: drive; an existing
// nested layout is left as it is (see ensureUnifiedPrefix)
try { try {
ensureUnifiedPrefix(prefixName) ensureUnifiedPrefix(prefixName)
} catch {} } catch {}
@ -132,23 +156,36 @@ function buildEnv({ game, wine }) {
env.STEAM_COMPAT_APP_ID = '0' env.STEAM_COMPAT_APP_ID = '0'
env.SteamAppId = '0' env.SteamAppId = '0'
env.SteamGameId = '0' env.SteamGameId = '0'
// every Proton verb reads STEAM_COMPAT_CLIENT_INSTALL_PATH (a missing one // Proton's session init reads STEAM_COMPAT_CLIENT_INSTALL_PATH on EVERY
// aborts with a KeyError). UMU run: real client; disableUmu: the fake. // verb: its appcache sync (g_compatdata.sync_appcache) runs even for
// runinprefix and does os.environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] as
// soon as SteamGameId is set, so the variable must never be dropped - a
// missing one aborts the launch with a KeyError before wine even starts.
// Only what it POINTS AT depends on the mode: a UMU run may use a real
// client found on this machine, a disableUmu run always gets the empty
// fake folder (runinprefix copies nothing out of it; no steam.exe and no
// umu.exe is started there).
if (game.disableUmu) { if (game.disableUmu) {
delete env.UMU_ID delete env.UMU_ID
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam() env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam()
} else { } else {
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir() env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir()
// UMU_ID makes newer Protons run through umu.exe instead of steam.exe, // Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID
// which needs the Steam client's DLLs (see steamClientDir). // set, newer Protons (incl. GE-Proton 9+) run the game through their
// built-in umu.exe instead of the steam.exe helper, which needs the
// Steam client's DLLs and dies in a "steamclient_main.c" assert dialog
// without them (see steamClientDir).
if (!env.UMU_ID) env.UMU_ID = 'umu-0' if (!env.UMU_ID) env.UMU_ID = 'umu-0'
} }
env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx') env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx')
env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs') env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs')
fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true }) fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true })
linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir) linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir)
// no silent fallback: a real dir would orphan the prefix outside where // No silent fallback to a real directory: Proton would then set its prefix
// PLWL tracks it; only a missing path is refused // up under files/data/compat/<id>/pfx, outside files/wineprefixes/ where
// PLWL tracks it - rename, delete, the in-use guards and the LAN sync all
// look at the launcher prefix, so its C: drive would be orphaned. Only a
// missing path is refused; a populated one is linkPrefix's deliberate case.
if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) { if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) {
throw new Error('prefix-link-failed') throw new Error('prefix-link-failed')
} }
@ -161,13 +198,17 @@ function buildEnv({ game, wine }) {
if (overrides) env.WINEDLLOVERRIDES = overrides if (overrides) env.WINEDLLOVERRIDES = overrides
else delete env.WINEDLLOVERRIDES else delete env.WINEDLLOVERRIDES
// the app's own environment comes last: it overrides everything above // the app's own environment comes last: it overrides everything above,
// including a WINEDLLOVERRIDES configured here
for (const [k, v] of Object.entries(game.env || {})) env[k] = v for (const [k, v] of Object.entries(game.env || {})) env[k] = v
return { env, prefixDir } return { env, prefixDir }
} }
// Proton verbs: 'run' goes through umu.exe/steam.exe, 'runinprefix' runs the // Proton verbs: 'run' goes through the build's umu.exe/steam.exe launcher,
// build's wine directly and skips Proton's prefix setup (see game.disableUmu). // while 'runinprefix' runs the build's wine directly (same session env, no
// umu/steam launcher ever starts - see game.disableUmu). runinprefix also skips
// Proton's prefix setup, so a prefix that never saw a normal Proton run stays
// a plain wine prefix instead of gaining Proton's default DLLs.
function protonVerb(game) { function protonVerb(game) {
return game.disableUmu ? 'runinprefix' : 'run' return game.disableUmu ? 'runinprefix' : 'run'
} }
@ -180,8 +221,10 @@ function parseArgs(args) {
return out return out
} }
// "Open with" handoff: the file reaches the app as a WINDOWS path (C:\ inside // A file handed over from the Linux file manager ("Open with" on an app
// the prefix's drive, Z:\ for the rest). // shortcut) has to reach the Windows app as a WINDOWS path, the way it would
// on Windows: C:\ for anything inside the prefix's C: drive, and wine's Z:\
// mapping (the filesystem root) for everything else.
function windowsPathFor(file, prefixDir) { function windowsPathFor(file, prefixDir) {
let abs = String(file || '') let abs = String(file || '')
try { try {
@ -199,9 +242,13 @@ function windowsPathFor(file, prefixDir) {
const GRACE_MS = 60000 const GRACE_MS = 60000
// Live playtime: tiny file updated every 30s, totals mirrored to the registry // Live playtime: while the game runs, update ONLY the tiny playtime file
// on exit. `base` comes from the caller (live file, not the registry) so // (a number of seconds) every 30s - apps.json is left untouched to spare the
// shared ids never mix playtime. // disk. lastPlayed is stamped once at start, final totals are mirrored into
// the registry once on exit.
// `base` is the seconds total this run adds up to. It is resolved by the
// CALLER from the live playtime file (not the registry) so ad-hoc runs that
// share one id (see runAdHoc) never mix two apps' playtime.
function startPlaytimeTicker({ game, startedAt, base }) { function startPlaytimeTicker({ game, startedAt, base }) {
const library = require('./library') const library = require('./library')
const timer = setInterval(() => { const timer = setInterval(() => {
@ -249,29 +296,38 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal
child = spawn(cmd, base, { child = spawn(cmd, base, {
cwd: workdir, cwd: workdir,
env, env,
// a handoff is untracked: its pipes drain so the app never blocks // a handoff (a file given to an already running app) is not tracked:
// its pipes are left unread, so the output goes nowhere instead of
// filling up and blocking the app
stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'], stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'],
detached: true detached: true
}) })
} catch (err) { } catch (err) {
return reject(err) return reject(err)
} }
// spawn() resolves before the process exists; a missing binary only // spawn() returns before the OS actually starts the program: when the
// surfaces as an 'error' event, so wait for the real 'spawn'. // binary is missing or has no executable bit (a wine build copied without
// its permissions, a wrong path) the failure only arrives as an 'error'
// event, AFTER this function would have resolved. Resolving there marked
// games that never started as running forever, so the promise only settles
// once the process really exists ('spawn').
let started = false let started = false
child.once('error', err => { child.once('error', err => {
if (!started) reject(err) if (!started) reject(err)
}) })
child.once('spawn', () => { child.once('spawn', () => {
started = true started = true
// a handoff writes no log or playtime: the tracked run owns both // a handoff never writes the app's log or playtime: the tracked run owns
// both, and truncating the live log would destroy what it is writing
if (handoff) { if (handoff) {
child.unref() child.unref()
return resolve({ pid: child.pid, logFile: null }) return resolve({ pid: child.pid, logFile: null })
} }
const logFile = latestLogPath(game.id) const logFile = latestLogPath(game.id)
const ws = openLatestLog(game.id) const ws = openLatestLog(game.id)
// a launch line first, so the log answers "which prefix/overrides/run" // a launch line of our own before the raw wine output: when a game
// refuses to start, "which prefix, which overrides, umu or steam.exe"
// is exactly what has to be answerable from the log
ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`) ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`)
ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`) ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`)
if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\n`) if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\n`)
@ -280,10 +336,11 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal
child.stderr.pipe(ws) child.stderr.pipe(ws)
child.unref() child.unref()
const startedAt = Date.now() const startedAt = Date.now()
// read the LIVE playtime file, not the registry (shared ids) // read the LIVE playtime file, not the (possibly shared/stale) registry
// value - ad-hoc "open with" runs share one id and must not mix playtime
const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0) const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0)
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds }) const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds })
// stamp "last played" once at start, even if we crash later // stamp "last played" once at start so it is correct even if we crash later
require('./library').updateGame(game.id, { lastPlayed: startedAt }) require('./library').updateGame(game.id, { lastPlayed: startedAt })
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {
stopTicker() stopTicker()
@ -328,9 +385,11 @@ async function runTool({ game, wine, tool, args, systemWine }) {
}) })
} }
// Ad-hoc launch ("Open with PLWL"): the runGame pipeline under the shared // Ad-hoc launch ("Open with PLWL"): the exact runGame pipeline, but with a
// `openwith` id; no library entry is created. `prefix` is the picker's // synthetic target under the shared `openwith` id - log and playtime files
// choice ('' = launcher default). // live in files/data/appdata/openwith/ and no library entry is created.
// `prefix` carries the picker's choice ('' = launcher default, resolved by
// buildEnv); wine/systemWine are the already-resolved build objects.
function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) { function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) {
const game = require('./openwith').makeTarget(openPath, 'default', prefix || '') const game = require('./openwith').makeTarget(openPath, 'default', prefix || '')
return runGame({ game, wine, systemWine, onExit }) return runGame({ game, wine, systemWine, onExit })

View file

@ -1,7 +1,9 @@
'use strict' 'use strict'
// .desktop shortcut generation: a shortcut runs the launcher with // .desktop shortcut generation. A shortcut launches the launcher itself with
// `--launch <id>` (headless boot when nothing is running yet). // `--launch <id>`: the main process either starts the game in the running
// instance or boots headless (no window, tray only), starts the game and lets
// playtime tracking continue normally.
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
@ -9,7 +11,8 @@ const { DIRS, resolvePortablePath, launcherIconDiskPath } = require('./paths')
const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA
// XDG shortcuts dir; PLWL_SHORTCUTS_DIR overrides it (tests). // Where user-visible shortcuts live (XDG). Override with PLWL_SHORTCUTS_DIR
// so tests (and portable exotic setups) can redirect it.
function desktopDirs() { function desktopDirs() {
const home = process.env.HOME || '/' const home = process.env.HOME || '/'
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share') const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share')
@ -19,7 +22,8 @@ function desktopDirs() {
} }
} }
// The launcher executable as an argv list (packaged binary or electron dev). // Launcher executable as the shortcut will see it, as an argv list: the
// packaged olcxja.plwl/olcxja.plwl binary, or `electron <project>` in dev.
function launcherArgv() { function launcherArgv() {
if (process.env.PLWL_LAUNCHER_CMD) return process.env.PLWL_LAUNCHER_CMD.split(' ').filter(Boolean) if (process.env.PLWL_LAUNCHER_CMD) return process.env.PLWL_LAUNCHER_CMD.split(' ').filter(Boolean)
const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/) const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/)
@ -32,13 +36,19 @@ function launcherArgv() {
return ['electron', path.resolve(__dirname, '..')] return ['electron', path.resolve(__dirname, '..')]
} }
// Desktop Entry Exec escaping per the spec (% doubled, quotes, spaces). // Desktop Entry Exec field escaping (per the spec): % must be doubled,
// quotes and backslashes escaped, and tokens containing spaces wrapped in
// double quotes.
function execToken(token) { function execToken(token) {
const escaped = token.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"') const escaped = token.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped
} }
// %f appended after escaping makes the shortcut a file handler. // The `%f` file field is appended AFTER escaping (a literal % would have been
// doubled into a literal). It makes the shortcut a file handler: the file
// manager substitutes the opened file there - and removes the field when the
// shortcut is launched from the menu - so "Open with" on the shortcut runs the
// app with that file, like it does on Windows.
function execLine(extraArgs) { function execLine(extraArgs) {
const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)] const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)]
return [...tokens.map(execToken), '%f'].join(' ') return [...tokens.map(execToken), '%f'].join(' ')
@ -58,7 +68,8 @@ function buildDesktopEntry(game) {
if (found) icon = path.join(iconDir, found) if (found) icon = path.join(iconDir, found)
} catch {} } catch {}
if (!icon) { if (!icon) {
// on-disk launcher icon fallback (Icon= cannot read the asar) // fall back to the launcher icon so the shortcut is never naked (the
// on-disk copy: the desktop environment cannot read inside app.asar)
icon = launcherIconDiskPath() || '' icon = launcherIconDiskPath() || ''
} }
const lines = [ const lines = [

View file

@ -1,15 +1,44 @@
'use strict' 'use strict'
// Symlink and permission healing, shared by wine builds, prefixes, game // Symlink and permission healing, shared by wine builds, prefixes, game
// imports and LAN sync. Rewires links to relative paths (absolute always; // imports and LAN sync.
// relative only when broken) and restores exec bits on build binaries. //
// Two things break when a tree is copied between machines (or the launcher is
// just moved):
// - symlinks stored ABSOLUTE keep pointing at the path they had on the
// machine that wrote them. A Proton prefix is full of them (fonts, built-in
// program stubs, dlls, all pointing into its build), and a wine build's
// bundled default prefix has the same problem.
// - the executable bit is dropped by every content-piping copy
// (createWriteStream, fs.copyFile), which makes an imported `proton` or
// `wine` binary unrunnable - the launcher then cannot start that build at
// all.
//
// Everything here rewires a link to a RELATIVE path when the file it points at
// can be found locally, so the tree stays relocatable forever, and restores
// the executable bit on the binaries a wine/proton build needs. An absolute
// link is always rewritten; a relative one is only touched when it no longer
// resolves, because a tree that changed layout (a prefix whose contents moved
// one level up, see prefixes.ensureUnifiedPrefix) keeps relative links written
// against the old depth and every one of them breaks at once.
const fsp = require('fs').promises const fsp = require('fs').promises
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const { pathExists: exists } = require('./fsutil')
// dosdevices/ mappings are supposed to point outside the prefix; never touched. function exists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
// dosdevices/ holds the prefix's drive mappings (c:, z:, d:, com1 ...). They
// are *supposed* to point outside the prefix - at the filesystem root, a
// mounted CD or a serial port - and wine rewrites them itself, so they are
// never touched.
const SKIP_DIRS = new Set(['dosdevices']) const SKIP_DIRS = new Set(['dosdevices'])
async function collectSymlinks(dir, out) { async function collectSymlinks(dir, out) {
@ -26,12 +55,18 @@ async function collectSymlinks(dir, out) {
} }
} }
// A missing target can only be something PLWL creates later (fake-steam, // A target that does not exist here YET can only be something PLWL itself
// files/wines/...); the restriction keeps /usr/lib/... links alone. // creates later: Proton links files/data/fake-steam/appcache into its prefix,
// a prefix links into files/wines/<build>/... (see the fallback pass below).
// Restricting the fallback to these trees keeps an unrelated /usr/lib/... link
// from being mistaken for a wine build's own files/lib/ directory.
const ANCHOR_DIRS = new Set(['wines', 'wineprefixes', 'apps', 'data', 'translations']) const ANCHOR_DIRS = new Set(['wines', 'wineprefixes', 'apps', 'data', 'translations'])
// True when `candidate` sits under `root` and its deepest existing parent is // True when `candidate` sits under `root` and its deepest EXISTING parent is at
// at least two components inside it. // least two components inside it. `files/data/fake-steam/appcache` passes even
// before appcache is created, because files/data/fake-steam is already there;
// `files/bin/<x>` does not, because a single-component parent is no evidence
// that the link really belongs to the launcher.
function anchoredInside(root, candidate) { function anchoredInside(root, candidate) {
let p = candidate let p = candidate
while (p.length > root.length + 1) { while (p.length > root.length + 1) {
@ -42,8 +77,12 @@ function anchoredInside(root, candidate) {
return false return false
} }
// The new target for an absolute link, relative to the link's own directory; // The new target for an absolute link: the same file, reached from the link's
// falls back to the longest suffix that exists locally. // own directory. `roots` are the trees the target may live in - the tree being
// healed first, then (for a prefix linking into its build) the launcher's
// files/ root. When the target is not directly inside one of them, the longest
// suffix that exists here is used instead, which is what makes a link written
// on another machine work on this one.
function relativeTarget(link, target, roots) { function relativeTarget(link, target, roots) {
const dir = path.dirname(link) const dir = path.dirname(link)
for (const root of roots) { for (const root of roots) {
@ -53,7 +92,9 @@ function relativeTarget(link, target, roots) {
if (rel && !path.isAbsolute(rel)) return rel if (rel && !path.isAbsolute(rel)) return rel
} }
} }
// second chance for links whose file is not here yet // Second chance for links whose file is not here yet (or whose parent chain
// only partially exists): the relative path is still the right one, and
// writing it now beats leaving a link that can never resolve.
for (const requireLeaf of [true, false]) { for (const requireLeaf of [true, false]) {
for (const root of roots) { for (const root of roots) {
if (!root) continue if (!root) continue
@ -79,8 +120,11 @@ function relativeTarget(link, target, roots) {
return null return null
} }
// Rewire rewritable symlinks under `root`; returns how many changed. Repeats // Rewire the symlinks under `root` that can be expressed relative to `roots`
// until nothing changes (links can chain). // (usually [root, DIRS.FILES]): absolute ones always, relative ones only when
// they no longer resolve. Returns how many links changed. A link may only
// become rewirable once another link it chains through has been fixed, so the
// pass repeats until nothing changes.
async function rewireSymlinks(root, roots = []) { async function rewireSymlinks(root, roots = []) {
if (!root) return 0 if (!root) return 0
const candidates = [root, ...roots.filter(r => r && r !== root)] const candidates = [root, ...roots.filter(r => r && r !== root)]
@ -97,7 +141,9 @@ async function rewireSymlinks(root, roots = []) {
continue continue
} }
const absolute = path.isAbsolute(target) const absolute = path.isAbsolute(target)
// a relative link that still resolves is already relocatable // a relative link that still resolves is already relocatable - only a
// broken one is worth touching, and the suffix search in relativeTarget
// finds the file it was pointing at before the layout changed
if (!absolute && exists(path.resolve(path.dirname(link), target))) continue if (!absolute && exists(path.resolve(path.dirname(link), target))) continue
const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target) const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target)
const next = relativeTarget(link, from, candidates) const next = relativeTarget(link, from, candidates)
@ -114,11 +160,15 @@ async function rewireSymlinks(root, roots = []) {
return fixed return fixed
} }
// Binaries a wine/proton build must be able to execute; content copies drop // Binaries a wine/proton build has to be able to execute. A copy that pipes
// the bit and spawn fails with EACCES. Windows .exe files need no bit. // file contents (the LAN sync, fs.copyFile) drops the bit, and without it
// `spawn` fails with EACCES and the build can never run. Windows .exe files
// are NOT in here on purpose: wine starts those itself, they need no bit.
const EXEC_NAMES = new Set(['proton', 'wine', 'wine64', 'wine-preloader', 'wine64-preloader', 'wineserver', 'winetricks', 'wineboot']) const EXEC_NAMES = new Set(['proton', 'wine', 'wine64', 'wine-preloader', 'wine64-preloader', 'wineserver', 'winetricks', 'wineboot'])
// Give the exec bit back to a build's binaries and bin/ dirs. // Give the exec bit back to a build's binaries (and anything in a bin/ dir).
// Used after a sync or an import - and to repair a build that was synced
// before the sync knew about permissions, without re-copying it.
async function restoreExecutableBits(root) { async function restoreExecutableBits(root) {
if (!root) return 0 if (!root) return 0
let fixed = 0 let fixed = 0

View file

@ -4,7 +4,7 @@ const fs = require('fs')
const fsp = fs.promises const fsp = fs.promises
const path = require('path') const path = require('path')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths') const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { dedupeDir, copyTree, treeSize, removeTree, pathExists } = require('./fsutil') const { dedupeDir, copyTree, treeSize, removeTree } = require('./fsutil')
const { normalizeLabel } = require('./names') const { normalizeLabel } = require('./names')
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks') const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
@ -12,7 +12,14 @@ const WINES_DIR = DIRS.WINES
const REGISTRY = path.join(DIRS.DATA, 'wines.json') const REGISTRY = path.join(DIRS.DATA, 'wines.json')
const DEFAULT_KEY = 'defaultWine' const DEFAULT_KEY = 'defaultWine'
const exists = pathExists function exists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
function detectType(dir) { function detectType(dir) {
if (exists(path.join(dir, 'proton'))) return 'proton' if (exists(path.join(dir, 'proton'))) return 'proton'
@ -103,8 +110,12 @@ async function refreshWines() {
} }
const prev = readJson(REGISTRY, []).map(wineAbs) const prev = readJson(REGISTRY, []).map(wineAbs)
const prevById = new Map(prev.map(w => [w.id, w])) const prevById = new Map(prev.map(w => [w.id, w]))
// heal moved builds and builds missing their exec bit; only a broken build // A build whose path changed since last scan was moved (or the launcher
// pays for the walk // itself was) - rewire its absolute symlinks to the current location. A
// build that cannot be executed (no exec bit on proton/wine - exactly what
// a sync from a PLWL that predates permission transfer leaves behind) is
// healed here instead: two access() calls per build, and only a broken build
// pays for the walk. Without it such a build stays dead weight forever.
for (const w of found) { for (const w of found) {
const old = prevById.get(w.id) const old = prevById.get(w.id)
const moved = !old || old.path !== w.path const moved = !old || old.path !== w.path
@ -115,7 +126,8 @@ async function refreshWines() {
} }
} }
const disabled = new Set(prev.filter(w => w.disabled).map(w => w.id)) const disabled = new Set(prev.filter(w => w.disabled).map(w => w.id))
// display name and disabled flag live in the registry and survive rescans // a display name the user gave the build has to survive every rescan, just
// like the disabled flag (both live in the registry, not in the folder)
const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName])) const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName]))
for (const w of found) { for (const w of found) {
if (disabled.has(w.id)) w.disabled = true if (disabled.has(w.id)) w.disabled = true
@ -126,7 +138,8 @@ async function refreshWines() {
return found return found
} }
// Stored FILES-relative (portable); `id`/`name`/`label`/`version`/`type` pass through. // Stored relative to FILES (portable), resolved to absolute for use - same
// scheme as the game registry. `id`, `name`, `label`, `version`, `type` pass through.
function wineAbs(w) { function wineAbs(w) {
return applyDisplayName({ ...w, path: resolvePortablePath(w.path), wineBin: resolvePortablePath(w.wineBin) }) return applyDisplayName({ ...w, path: resolvePortablePath(w.path), wineBin: resolvePortablePath(w.wineBin) })
} }
@ -143,20 +156,25 @@ function getWine(id) {
return getWines().find(w => w.id === id) || null return getWines().find(w => w.id === id) || null
} }
// The user's label wins over the folder-derived one, everywhere. // What the user typed wins over the folder-derived label everywhere the
// build is shown (library config, settings, Open with popup). Every registry
// read goes through here, so a stale stored label can never leak out.
function applyDisplayName(w) { function applyDisplayName(w) {
if (w && w.displayName) w.label = w.displayName if (w && w.displayName) w.label = w.displayName
return w return w
} }
// "Renaming" only changes the shown label; folder and id stay. // A build keeps its folder and id (apps point at the id, and prefix symlinks
// point inside the folder), so "renaming" only changes the shown label.
function setWineDisplayName(id, name) { function setWineDisplayName(id, name) {
const clean = normalizeLabel(name) const clean = normalizeLabel(name)
if (!clean) return { ok: false, error: 'invalidName' } if (!clean) return { ok: false, error: 'invalidName' }
return writeWineLabel(id, clean) return writeWineLabel(id, clean)
} }
// Clears a custom label back to the folder-derived one (used by LAN sync). // Clears a custom label back to the folder-derived one: what the LAN sync
// calls when the sender never renamed the build. The rename dialog never
// clears - an empty name there is an error (setWineDisplayName above).
function clearWineDisplayName(id) { function clearWineDisplayName(id) {
return writeWineLabel(id, '') return writeWineLabel(id, '')
} }
@ -192,7 +210,9 @@ function getDefaultWine() {
return require('./library').loadSettings().defaultWine || 'system' return require('./library').loadSettings().defaultWine || 'system'
} }
// 'default'/'system'/unknown all fall back to System Wine. // Resolve the wine build a run should use: 'default'/'system'/unknown all
// fall back to System Wine. Shared by the games service and the ad-hoc
// "open with" runner.
function resolveWine(id) { function resolveWine(id) {
const system = { id: 'system', type: 'system', label: 'System Wine' } const system = { id: 'system', type: 'system', label: 'System Wine' }
const wanted = id === 'default' ? getDefaultWine() : id const wanted = id === 'default' ? getDefaultWine() : id
@ -225,15 +245,22 @@ async function deleteWine(id, onProgress) {
} }
// --- symlink healing ------------------------------------------------------- // --- symlink healing -------------------------------------------------------
// Builds contain absolute symlinks that dangle after a move and crash Proton // Wine/Proton builds contain symlinks (e.g. inside the bundled default prefix)
// during prefix setup; the rewiring lives in symlinks.js. // that point to absolute paths from wherever the build lived before (zip
// layout, previous install dir). After the build or the whole launcher is
// moved those links dangle and Proton crashes during prefix setup
// (try_copy -> FileNotFoundError). The rewiring itself lives in symlinks.js
// because prefixes, synced apps and game imports need exactly the same thing.
async function fixBuildSymlinks(root) { async function fixBuildSymlinks(root) {
return rewireSymlinks(root, [DIRS.FILES]) return rewireSymlinks(root, [DIRS.FILES])
} }
// Make a freshly arrived build runnable: relative links, exec bits. // Everything a freshly arrived build needs before it can run: relative links
// and executable binaries. Returns what was changed, for the callers' reports.
async function healBuild(root) { async function healBuild(root) {
// a symlinked build folder is left alone: its target may be outside // a build folder that IS a symlink (kept on another disk) is left alone:
// the walk would follow the link and edit its target, which is not
// necessarily part of this launcher's trees
let st = null let st = null
try { try {
st = fs.lstatSync(root) st = fs.lstatSync(root)
@ -255,11 +282,16 @@ function isExec(p) {
} }
} }
// Where a build keeps the programs wine spawns (wine, wineserver, ...). // Where a wine/proton build keeps the programs wine itself spawns (wine,
// wineserver, wineboot, ...) and the loaders next to the shared libraries.
const AUDIT_DIRS = ['files/bin', 'bin', 'dist/bin', 'files/lib/wine/x86_64-unix', 'files/lib/wine/i386-unix'] const AUDIT_DIRS = ['files/bin', 'bin', 'dist/bin', 'files/lib/wine/x86_64-unix', 'files/lib/wine/i386-unix']
// Cheap audit for a build missing its exec bits (invisible until Play fails // Is this build missing a permission it needs to run? A build synced from a
// with EACCES); only a failing build pays for the full walk. // PLWL that predates permission transfer has no executable bit anywhere, and
// that stays invisible until the user presses Play (spawn fails with EACCES).
// Auditing is a handful of directory listings - only a build that FAILS it
// pays for the full walk, and the audit says nothing about a healthy build on
// every later start.
function buildAuditFailed(root, wineBin) { function buildAuditFailed(root, wineBin) {
if (!root) return false if (!root) return false
if (wineBin && !isExec(wineBin)) return true if (wineBin && !isExec(wineBin)) return true
@ -294,7 +326,8 @@ async function importWineFolder(srcDir, onProgress) {
if (build) copySrc = path.join(srcDir, build.name) if (build) copySrc = path.join(srcDir, build.name)
} }
const dst = dedupeDir(WINES_DIR, path.basename(copySrc)) const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
// count first (staging), then report bytes as they land // a wine/proton build is big enough that the copy needs a real bar: count it
// first (staging), then report bytes as they land while it copies
const emit = (phase, detail) => { const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: path.basename(dst), ...detail }) if (onProgress) onProgress({ phase, label: path.basename(dst), ...detail })
} }
@ -310,14 +343,4 @@ async function importWineFolder(srcDir, onProgress) {
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables } return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
} }
// Pseudo entry for the distro's wine; version probe is best-effort. module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
function systemWine() {
let version = ''
try {
const { execFileSync } = require('child_process')
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
} catch {}
return { id: 'system', type: 'system', label: 'System Wine', version }
}
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }