Compare commits

..

No commits in common. "2bcb47076a9fc61f2e64993dac9cfd68f84cb648" and "5d5ca345f1c46c1b629d52803014609245095f68" have entirely different histories.

21 changed files with 181 additions and 877 deletions

View file

@ -47,8 +47,6 @@ Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the
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 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 the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings): 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):
@ -57,22 +55,21 @@ Two PLWL installs on the same LAN can mirror each other's content - no cloud, no
- **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. - **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.
- **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. - **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.
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. Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Default settings**. Only what is ticked travels - a sync without **Default settings** never touches the other machine's defaults. 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.
**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. **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; it closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has vanished.
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. 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. **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. 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 launcher's **default Wine build and its default prefix** travel with the **Default settings** category, and only if that build or prefix exists on the other machine - an id it does not have is ignored instead of leaving a dangling default behind. 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** — uses the `wine` binary from PATH. - **System Wine** — uses the `wine` binary from PATH.
- **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. - **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** — 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.) - **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_CLIENT_INSTALL_PATH` points to a fake steam client folder, `STEAM_COMPAT_DATA_PATH` is a per-build compat folder, and the prefix lives at `<compat>/pfx` which PLWL links to its own prefix folder. No Steam installation required (GE builds work out of the box; for a few Valve builds that refuse to run without a client, PLWL fakes a minimal steam folder in `files/data/fake-steam`).
- **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

View file

@ -102,20 +102,9 @@ handlers in src/ipc/. Keep new APIs in that pattern.
looks finished the moment it starts. Hashing and copying walk tens of looks finished the moment it starts. Hashing and copying walk tens of
thousands of files, so every per-file update goes through progressGate thousands of files, so every per-file update goes through progressGate
(~10/s, phase changes forced) - never send one IPC message per file. (~10/s, phase changes forced) - never send one IPC message per file.
- Registry-level settings are opt-in (the "settings" sync category, shown as - The launcher's own defaults are opt-in (the "defaults" sync category):
"Sync settings"; the old name "defaults" is still accepted as an alias on payload.defaults carries the default wine and prefix, and the receiver only
BOTH sides - the receiver normalizes payload.categories, so a sender from an applies values that exist there (an unknown wine id or prefix is skipped).
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 - 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 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 means equal bytes, so a repeat sync only stat's the tree. Hashing and the
@ -133,34 +122,6 @@ handlers in src/ipc/. Keep new APIs in that pattern.
relative target) and after a unit lands (healUnitRoots). dosdevices drive relative target) and after a unit lands (healUnitRoots). dosdevices drive
mappings and targets that leave files/ (a mount, a system path, the user's mappings and targets that leave files/ (a mount, a system path, the user's
home) stay absolute - they are machine-specific by design. 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 - 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 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 always relative after a run: the sync payload relativizes them anyway, and
@ -171,41 +132,6 @@ handlers in src/ipc/. Keep new APIs in that pattern.
- File permissions travel with a sync (manifest entry `mode`): the receiving - 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 side applies them after writing the bytes and repairs a file that has the
same hash but the wrong mode without re-uploading it. 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 - 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. a temp dir, check(), exit 1 on failure. Add one when fixing a bug.

View file

@ -35,6 +35,7 @@
"library.removeFilesNote": "\nIts imported files in files/apps will also be deleted.", "library.removeFilesNote": "\nIts imported files in files/apps will also be deleted.",
"library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.", "library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.",
"library.tools": "Wine tools", "library.tools": "Wine tools",
"library.viewLogs": "View logs",
"library.reorderHint": "Drag to change the order", "library.reorderHint": "Drag to change the order",
"import.question": "Should the app files be brought into the launcher so it stays portable, or left where they are?", "import.question": "Should the app files be brought into the launcher so it stays portable, or left where they are?",
"import.mode.copy": "Copy into launcher (portable)", "import.mode.copy": "Copy into launcher (portable)",
@ -80,20 +81,15 @@
"config.prefixDefault": "Default prefix ({0})", "config.prefixDefault": "Default prefix ({0})",
"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.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. '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)",
"config.shortcutFailed": "Failed to create the shortcut", "config.shortcutFailed": "Failed to create the shortcut",
"config.viewLogs": "View logs",
"config.noLogs": "No logs yet - run the app first",
"config.saved": "Configuration saved", "config.saved": "Configuration saved",
"config.back": "Back to library", "config.back": "Back to library",
"config.browse": "Browse", "config.browse": "Browse",
@ -194,7 +190,7 @@
"lansync.appsPrefix": "Apps imported from a prefix", "lansync.appsPrefix": "Apps imported from a prefix",
"lansync.wines": "Wine / Proton versions", "lansync.wines": "Wine / Proton versions",
"lansync.prefixes": "Wine prefixes", "lansync.prefixes": "Wine prefixes",
"lansync.settings": "Sync settings (names, app configs and order)", "lansync.defaults": "Default settings (default Wine and prefix)",
"lansync.force": "Force sync all files (skip the hash check and overwrite everything)", "lansync.force": "Force sync all files (skip the hash check and overwrite everything)",
"lansync.mirrorWarning": "This device is the source of truth: on the other device, files and items that do not exist here will be deleted.", "lansync.mirrorWarning": "This device is the source of truth: on the other device, files and items that do not exist here will be deleted.",
"lansync.incomingMirror": "The other device is the source of truth: files and items on this device that do not exist there will be deleted.", "lansync.incomingMirror": "The other device is the source of truth: files and items on this device that do not exist there will be deleted.",

View file

@ -35,6 +35,7 @@
"library.removeFilesNote": "\nJej zaimportowane pliki w files/apps również zostaną usunięte.", "library.removeFilesNote": "\nJej zaimportowane pliki w files/apps również zostaną usunięte.",
"library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.", "library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.",
"library.tools": "Narzędzia Wine", "library.tools": "Narzędzia Wine",
"library.viewLogs": "Podejrzyj logi",
"library.reorderHint": "Przeciągnij, aby zmienić kolejność", "library.reorderHint": "Przeciągnij, aby zmienić kolejność",
"import.question": "Czy pliki aplikacji mają trafić do launchera, aby był przenośny, czy mają zostać tam, gdzie są?", "import.question": "Czy pliki aplikacji mają trafić do launchera, aby był przenośny, czy mają zostać tam, gdzie są?",
"import.mode.copy": "Skopiuj do launchera (przenośne)", "import.mode.copy": "Skopiuj do launchera (przenośne)",
@ -80,20 +81,15 @@
"config.prefixDefault": "Prefiks domyślny ({0})", "config.prefixDefault": "Prefiks domyślny ({0})",
"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.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ł. 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)",
"config.shortcutFailed": "Nie udało się utworzyć skrótu", "config.shortcutFailed": "Nie udało się utworzyć skrótu",
"config.viewLogs": "Podejrzyj logi",
"config.noLogs": "Brak logów — uruchom najpierw aplikację",
"config.saved": "Konfiguracja zapisana", "config.saved": "Konfiguracja zapisana",
"config.back": "Wróć do biblioteki", "config.back": "Wróć do biblioteki",
"config.browse": "Przeglądaj", "config.browse": "Przeglądaj",
@ -194,7 +190,7 @@
"lansync.appsPrefix": "Aplikacje importowane z prefiksu", "lansync.appsPrefix": "Aplikacje importowane z prefiksu",
"lansync.wines": "Wersje Wine / Proton", "lansync.wines": "Wersje Wine / Proton",
"lansync.prefixes": "Prefiksy Wine", "lansync.prefixes": "Prefiksy Wine",
"lansync.settings": "Ustawienia (nazwy, konfiguracje i kolejność aplikacji)", "lansync.defaults": "Ustawienia domyślne (domyślne Wine i prefiks)",
"lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)", "lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)",
"lansync.mirrorWarning": "To urządzenie jest źródłem prawdy: na drugim urządzeniu pliki i elementy, których tu nie ma, zostaną usunięte.", "lansync.mirrorWarning": "To urządzenie jest źródłem prawdy: na drugim urządzeniu pliki i elementy, których tu nie ma, zostaną usunięte.",
"lansync.incomingMirror": "Drugie urządzenie jest źródłem prawdy: pliki i elementy na tym urządzeniu, których tam nie ma, zostaną usunięte.", "lansync.incomingMirror": "Drugie urządzenie jest źródłem prawdy: pliki i elementy na tym urządzeniu, których tam nie ma, zostaną usunięte.",

View file

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

View file

@ -45,6 +45,7 @@ const ICONS = {
folder: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>', folder: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
trash: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></svg>', trash: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></svg>',
terminal: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5M12 19h8"/></svg>', terminal: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5M12 19h8"/></svg>',
logs: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3h9l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM9 12h6M9 16h6"/></svg>',
star: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l2.7 5.8 6.3.8-4.6 4.3 1.2 6.1-5.6-3.1-5.6 3.1 1.2-6.1L3 9.6l6.3-.8z"/></svg>', star: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l2.7 5.8 6.3.8-4.6 4.3 1.2 6.1-5.6-3.1-5.6 3.1 1.2-6.1L3 9.6l6.3-.8z"/></svg>',
download: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 9l6 6 6-6"/><path d="M4 21h16"/></svg>', download: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 9l6 6 6-6"/><path d="M4 21h16"/></svg>',
chevronDown: () => '<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' chevronDown: () => '<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
@ -597,6 +598,7 @@ function renderLibrary() {
<button class="button small danger" data-act="kill" style="display:none">${t('library.stop')}</button> <button class="button small danger" data-act="kill" style="display:none">${t('library.stop')}</button>
<button class="button small" data-act="config" title="${t('library.config')}">${ICONS.config()}</button> <button class="button small" data-act="config" title="${t('library.config')}">${ICONS.config()}</button>
<button class="button small" data-act="folder" title="${t('library.openFolder')}">${ICONS.folder()}</button> <button class="button small" data-act="folder" title="${t('library.openFolder')}">${ICONS.folder()}</button>
<button class="button small" data-act="logs" title="${t('library.viewLogs')}">${ICONS.logs()}</button>
<button class="button small danger" data-act="remove" title="${t('library.remove')}">${ICONS.trash()}</button> <button class="button small danger" data-act="remove" title="${t('library.remove')}">${ICONS.trash()}</button>
</div> </div>
` `
@ -617,6 +619,11 @@ function renderLibrary() {
} }
card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id) card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id)
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.workdir) card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.workdir)
card.querySelector('[data-act=logs]').onclick = async () => {
const logs = await window.plwl.gamesGetLogs(game.id)
if (logs.length) window.plwl.shellShowItemInFolder(logs[logs.length - 1])
else toast(t('library.viewLogs'))
}
card.querySelector('[data-act=remove]').onclick = async () => { card.querySelector('[data-act=remove]').onclick = async () => {
const note = game.mode === 'copy' || game.mode === 'move' const note = game.mode === 'copy' || game.mode === 'move'
? t('library.removeFilesNote') ? t('library.removeFilesNote')
@ -1228,7 +1235,6 @@ async function openGameConfig(id) {
const lockHint = qs('#gc-prefix-lock-hint') const lockHint = qs('#gc-prefix-lock-hint')
if (lockHint) lockHint.style.display = prefixLocked ? '' : 'none' if (lockHint) lockHint.style.display = prefixLocked ? '' : 'none'
renderOverrides(game.dllOverrides || []) renderOverrides(game.dllOverrides || [])
qs('#gc-disable-umu').checked = !!game.disableUmu
renderTools() renderTools()
} }
@ -1243,16 +1249,6 @@ async function createAppShortcut() {
} }
} }
// 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() {
const game = state.currentGame
if (!game) return
const logs = await window.plwl.gamesGetLogs(game.id)
if (logs.length) window.plwl.shellShowItemInFolder(logs[logs.length - 1])
else toast(t('config.noLogs'), 'error')
}
function renderOverrides(overrides) { function renderOverrides(overrides) {
const box = qs('#gc-overrides') const box = qs('#gc-overrides')
box.innerHTML = '' box.innerHTML = ''
@ -1261,12 +1257,10 @@ function renderOverrides(overrides) {
} }
} }
// 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', label: 'native' }, { value: 'native', label: 'native' },
{ value: 'builtin', label: 'builtin' }, { value: 'builtin', label: 'builtin' },
{ value: 'native,builtin', label: 'native,builtin' },
{ value: 'builtin,native', label: 'builtin,native' }, { value: 'builtin,native', label: 'builtin,native' },
{ value: '', label: 'disabled' } { value: '', label: 'disabled' }
] ]
@ -1280,48 +1274,23 @@ 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')
// a stored empty setting IS the "disabled" choice - a `||` fallback would new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting || 'native')
// turn every disabled override back into the default on reopen
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()
return row return row
} }
function addOverrideRow() { function addOverrideRow() {
qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native,builtin' })) qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native' }))
const rows = qsa('#gc-overrides .override-row') const rows = qsa('#gc-overrides .override-row')
rows[rows.length - 1].querySelector('.dll').focus() rows[rows.length - 1].querySelector('.dll').focus()
} }
// 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) {
const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '')
if (!v || v === 'd' || v === 'disabled') return ''
if (v === 'n' || v === 'native') return 'native'
if (v === 'b' || v === 'builtin') return 'builtin'
if (v === 'n,b' || v === 'native,builtin') return 'native,builtin'
if (v === 'b,n' || v === 'builtin,native') return 'builtin,native'
return null
}
function readOverrides() { 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() dll: row.querySelector('.dll').value.trim(),
let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin' setting: row.querySelector('.custom-select').dataset.value ?? 'native'
// "dinput8=native" pasted into the name box is the form every guide uses - })).filter(o => o.dll)
// split it here instead of storing a name with '=' that Wine cannot parse
const eq = dll.indexOf('=')
if (eq > 0) {
const pasted = normalizeOverrideSetting(dll.slice(eq + 1))
if (pasted !== null) setting = pasted
dll = dll.slice(0, eq).trim()
}
dll = dll.replace(/\.dll$/i, '')
return { dll, setting }
}).filter(o => o.dll)
} }
async function saveGameConfig() { async function saveGameConfig() {
@ -1353,14 +1322,10 @@ async function saveGameConfig() {
wine: selects['gc-wine'].value, wine: selects['gc-wine'].value,
prefix, prefix,
env, env,
dllOverrides: readOverrides(), dllOverrides: readOverrides()
disableUmu: qs('#gc-disable-umu').checked
} }
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: a pasted "name=setting" was split and a
// .dll suffix stripped, so the rows would otherwise disagree with the file
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')
if (state.currentView === 'gameconfig') { if (state.currentView === 'gameconfig') {
@ -1404,10 +1369,8 @@ 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: default wine/prefix, custom names of wine builds // opt-in: without it a sync never touches the other machine's defaults
// and prefixes, and each app's configuration plus its library order. No { id: 'defaults', label: 'lansync.defaults' }
// files move with this category - it only patches what already exists.
{ id: 'settings', label: 'lansync.settings' }
] ]
// Byte sizes for the progress popups. Decimal units, the way a file manager // Byte sizes for the progress popups. Decimal units, the way a file manager
@ -1747,11 +1710,7 @@ function onLanIncoming(info) {
} }
let receiving = false let receiving = false
// true only once the sender's 'done' event ended the transfer. A popup the let receiveClosed = false
// 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 receiveWatchdog = null let receiveWatchdog = null
let receiveStall = null let receiveStall = null
let receiveCloseTimer = null let receiveCloseTimer = null
@ -1786,6 +1745,7 @@ function endReceiving(message) {
} }
const wasReceiving = receiving const wasReceiving = receiving
receiving = false receiving = false
receiveClosed = true
if (!wasReceiving) return if (!wasReceiving) return
if (message) toast(message, 'error') if (message) toast(message, 'error')
closeModal() closeModal()
@ -1809,14 +1769,13 @@ function armReceiveWatchdog() {
function onLanReceiving(info) { function onLanReceiving(info) {
if (!info || !info.stage) return if (!info || !info.stage) return
if (state.lanPushing) return // our own push owns the (single) popup if (state.lanPushing) return // our own push owns the (single) popup
if (info.stage === 'start') receiveEnded = false if (info.stage === 'start') receiveClosed = false
if (info.stage === 'done') receiveEnded = true
if (!receiving) { if (!receiving) {
// The popup also opens mid-sync: the sender may have been hashing for // 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 // 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 // timeout closed has to come back for the rest of the transfer. A sync
// that already ended stays closed. // that already ended stays closed.
if (receiveEnded) return if (receiveClosed && info.stage !== 'start') return
if (qs('#modal-overlay').style.display !== 'none') return if (qs('#modal-overlay').style.display !== 'none') return
receiving = true receiving = true
const modal = showModal(` const modal = showModal(`
@ -1840,15 +1799,6 @@ function onLanReceiving(info) {
function renderReceiving(info) { function renderReceiving(info) {
const parts = receiveModalParts() const parts = receiveModalParts()
if (!parts) return if (!parts) return
// 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 (!parts.phase.textContent) parts.phase.textContent = t('lansync.recvWaiting')
return
}
// The same phases read from this side's point of view: a file arriving here // The same phases read from this side's point of view: a file arriving here
// is being received, not sent. // 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
@ -1973,7 +1923,6 @@ function wireEvents() {
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('#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 () => {
// start from the field being edited (exe picker from the exe field) and // start from the field being edited (exe picker from the exe field) and

View file

@ -180,23 +180,15 @@
<span id="gc-prefix"></span> <span id="gc-prefix"></span>
<p class="subtitle" id="gc-prefix-lock-hint" data-t="config.prefixLockedHint" style="display:none"></p> <p class="subtitle" id="gc-prefix-lock-hint" data-t="config.prefixLockedHint" style="display:none"></p>
<label class="check-label">
<input type="checkbox" id="gc-disable-umu">
<span data-t="config.disableUmu"></span>
</label>
<p class="subtitle" data-t="config.disableUmuHint"></p>
<label class="field-label" data-t="config.env"></label> <label class="field-label" data-t="config.env"></label>
<textarea id="gc-env" rows="3" data-ph="config.envHint"></textarea> <textarea id="gc-env" rows="3" data-ph="config.envHint"></textarea>
<label class="field-label" data-t="config.dllOverrides"></label> <label class="field-label" data-t="config.dllOverrides"></label>
<div id="gc-overrides" class="overrides"></div> <div id="gc-overrides" class="overrides"></div>
<button class="button" id="gc-override-add" data-t="config.overrides.add"></button> <button class="button" id="gc-override-add" data-t="config.overrides.add"></button>
<p class="subtitle" data-t="config.overrides.hint"></p>
<div class="form-actions"> <div class="form-actions">
<button class="button danger" id="btn-gc-clear-playtime" data-t="config.clearPlaytime"></button> <button class="button danger" id="btn-gc-clear-playtime" data-t="config.clearPlaytime"></button>
<button class="button" id="btn-gc-logs" data-t="config.viewLogs"></button>
<button class="button" id="btn-gc-shortcut" data-t="config.shortcut"></button> <button class="button" id="btn-gc-shortcut" data-t="config.shortcut"></button>
<button class="button primary" id="btn-gc-save" data-t="config.save"></button> <button class="button primary" id="btn-gc-save" data-t="config.save"></button>
</div> </div>

View file

@ -1,101 +1,43 @@
'use strict' 'use strict'
const path = require('path')
const fs = require('fs')
// Shared command-line parsing for the launcher process itself. Both boot // Shared command-line parsing for the launcher process itself. Both boot
// (process.argv) and the single-instance handoff (second-instance argv) use // (process.argv) and the single-instance handoff (second-instance argv) use
// it, so a shortcut, a "Open with PLWL" file-open and Chromium's own injected // it, so a shortcut, a "Open with PLWL" file-open and Chromium's own injected
// flags are handled the same way everywhere. // flags are handled the same way everywhere.
// //
// Supported forms: // Supported forms:
// --launch <id> | --launch=<id> auto-start that library app // --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
// --open <path> | --open=<path> open an .exe with the wine picker // a bare existing-file path same as --open (plumbing for Exec %f)
// 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'] function firstNonFlagAfter(args, i) {
// Chromium injects its own flags into the second instance's argv and they
// The options that carry a value, the positional arguments that follow the // can land BETWEEN the option and its value (e.g. `--launch
// switches, and the values written inline (`--name=value`, which keeps its // --allow-file-access-from-files 1790414564627`), so skip anything that
// value no matter how the rest is ordered). // looks like a flag and take the first non-flag token.
// for (let j = i + 1; j < args.length; j++) {
// Tokens before the first switch are the executable - never values. In dev if (!args[j].startsWith('--')) return args[j]
// 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) {
const options = []
const positional = []
const inline = new Map()
const appPath = process.defaultApp && process.argv[1] ? path.resolve(process.argv[1]) : null
let switched = false
for (const arg of Array.isArray(argv) ? argv : []) {
if (typeof arg !== 'string' || !arg) continue
if (arg.startsWith('-')) {
switched = true
const eq = arg.indexOf('=')
const name = eq === -1 ? arg : arg.slice(0, eq)
if (!VALUE_OPTIONS.includes(name)) continue
if (eq === -1) options.push(name)
else inline.set(name, arg.slice(eq + 1))
continue
}
// absolute in the handed-over argv, possibly relative here
if (appPath && path.resolve(arg) === appPath) continue
if (switched) positional.push(arg)
}
return { options, positional, inline }
}
// The value of an option: inline when it was written as `--name=value`,
// 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) {
if (parsed.inline.has(name)) return parsed.inline.get(name)
const index = parsed.options.indexOf(name)
if (index === -1) return null
const value = parsed.positional[index]
return value === undefined ? null : value
}
// 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]?$/
function launchFileOf(parsed, launchId) {
const explicit = optionValue(parsed, '--file')
// 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 (!launchId) return null
for (let i = parsed.positional.length - 1; i >= 0; i--) {
const arg = parsed.positional[i]
if (!path.isAbsolute(arg)) continue
try {
if (fs.statSync(arg).isFile()) return arg
} catch {}
} }
return null return null
} }
function findValue(args, names) {
for (const name of names) {
const i = args.indexOf(name)
if (i !== -1) return firstNonFlagAfter(args, i)
const eq = args.find(a => a.startsWith(name + '='))
if (eq) return eq.slice(name.length + 1)
}
return null
}
// PLWL's own options must not swallow Chromium's flags (--flag=... style),
// so anything after a flag that itself carries a value is skipped here by
// looking only at the KNOWN option names above.
function parseArgv(argv) { function parseArgv(argv) {
const args = Array.isArray(argv) ? argv : process.argv const args = Array.isArray(argv) ? argv : process.argv
const parsed = splitArgv(args) const launchId = findValue(args, ['--launch'])
const launchId = optionValue(parsed, '--launch') let openPath = findValue(args, ['--open'])
const launchFile = launchFileOf(parsed, launchId)
let openPath = optionValue(parsed, '--open')
if (!openPath && !launchId) { if (!openPath && !launchId) {
// "Open with" plumbing (Exec %f): the file path arrives as the only // "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 // non-flag argument - never pick a bare token when a real flag carries
@ -105,11 +47,11 @@ function parseArgv(argv) {
a => a =>
!a.startsWith('-') && !a.startsWith('-') &&
/\.(exe|msi|bat|cmd|lnk)$/i.test(a) && /\.(exe|msi|bat|cmd|lnk)$/i.test(a) &&
!/\b(electron|olcxja\.plwl)(\.exe)?$/i.test(a) !/(electron|olcxja\.plwl)(\.exe)?$/i.test(a)
) )
if (bare) openPath = bare if (bare) openPath = bare
} }
return { launchId, openPath, launchFile } return { launchId, openPath }
} }
module.exports = { parseArgv } module.exports = { parseArgv, findValue }

View file

@ -57,15 +57,6 @@ function progressGate(emit) {
async function treeSize(root) { async function treeSize(root) {
let files = 0 let files = 0
let bytes = 0 let bytes = 0
// 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
try {
rootStat = await fsp.lstat(root)
} catch {
return { files, bytes }
}
if (!rootStat.isDirectory()) return { files: 1, bytes: rootStat.isSymbolicLink() ? 0 : rootStat.size }
const walk = async dir => { const walk = async dir => {
let entries let entries
try { try {
@ -159,24 +150,6 @@ async function removeTree(root, onProgress) {
const tick = () => { const tick = () => {
if (gate) gate({ files, dirs, bytes }, false) if (gate) gate({ files, dirs, bytes }, false)
} }
// 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
try {
rootStat = await fsp.lstat(root)
} catch {
return { files, dirs, bytes }
}
if (!rootStat.isDirectory()) {
files = 1
bytes = rootStat.isSymbolicLink() ? 0 : rootStat.size
try {
await fsp.unlink(root)
} catch {}
if (gate) gate({ files, dirs, bytes }, true)
return { files, dirs, bytes }
}
const walk = async dir => { const walk = async dir => {
let entries let entries
try { try {

View file

@ -1,7 +1,6 @@
'use strict' 'use strict'
const fs = require('fs') const fsp = require('fs').promises
const fsp = fs.promises
const path = require('path') const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths') const { DIRS, resolvePortablePath } = require('./paths')
const { newGame, newGameId, addGame } = require('./library') const { newGame, newGameId, addGame } = require('./library')
@ -83,22 +82,8 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) {
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 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
try {
realSrc = fs.realpathSync(src)
} catch {
return { ok: false, error: 'outside-drive-c' }
}
const insideDrive = driveRoots.some(root => { const insideDrive = driveRoots.some(root => {
let realRoot const rel = path.relative(root, src)
try {
realRoot = fs.realpathSync(root)
} catch {
return false
}
const rel = path.relative(realRoot, realSrc)
return rel && !rel.startsWith('..') && !path.isAbsolute(rel) return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
}) })
if (!insideDrive) return { ok: false, error: 'outside-drive-c' } if (!insideDrive) return { ok: false, error: 'outside-drive-c' }

View file

@ -107,33 +107,21 @@ function createGamesService({ getWindow, onGamesChanged }) {
if (onGameExit) onGameExit(id) if (onGameExit) onGameExit(id)
} }
async function start(win, id, openFile) { async function start(win, id) {
const game = library.getGame(id) const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' } if (!game) return { ok: false, error: 'game-not-found' }
prune() prune()
if (running.has(id)) return { ok: false, error: 'already-running' }
let wine let wine
try { try {
wine = resolveWine(game) wine = resolveWine(game)
} catch { } catch {
return { ok: false, error: 'wine-not-found' } return { ok: false, error: 'wine-not-found' }
} }
if (running.has(id)) {
// 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' }
try {
await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true })
return { ok: true, pid: null, logFile: null }
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
}
}
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id }) running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id })
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) })
// stop was requested while the process was still starting: kill it and // stop was requested while the process was still starting: kill it and
// leave nothing in the running set // leave nothing in the running set
if (killRequests.has(id)) { if (killRequests.has(id)) {

View file

@ -53,7 +53,7 @@ const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
// What a sync can carry, plus lenient aliases so callers can name the // What a sync can carry, plus lenient aliases so callers can name the
// categories the way the UI/README does. // categories the way the UI/README does.
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings'] const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'defaults']
const CATEGORY_ALIASES = { const CATEGORY_ALIASES = {
// plain "apps" means the whole app library: portable ones AND the ones // plain "apps" means the whole app library: portable ones AND the ones
@ -74,15 +74,12 @@ const CATEGORY_ALIASES = {
wineprefixes: ['prefixes'], wineprefixes: ['prefixes'],
prefixes: ['prefixes'], prefixes: ['prefixes'],
prefix: ['prefixes'], prefix: ['prefixes'],
// the launcher's own settings ("Sync settings"): the defaults, the names and // the launcher's own defaults ("Default settings"): which wine everything
// disabled flags of wine builds, the prefix registry, and every app's // falls back to and where a new import lands
// configuration plus its place in the library. "defaults" is what this defaults: ['defaults'],
// category used to be called - an older sender still names it that way. default: ['defaults'],
settings: ['settings'], defaultsettings: ['defaults'],
syncsettings: ['settings'], settings: ['defaults']
defaults: ['settings'],
default: ['settings'],
defaultsettings: ['settings']
} }
// Which sync category an app entry belongs to. Portable copy/move imports own // Which sync category an app entry belongs to. Portable copy/move imports own
@ -319,33 +316,26 @@ function saveHashCache() {
} catch {} } catch {}
} }
// The remembered digest of a file, or null when it has to be read again.
// A hit only marks the cache dirty when its timestamp is actually stale:
// marking it on every lookup would force a full rewrite of the file per sync
// even though nothing a second sync could reuse has changed.
function hashCacheHit(rel, size, mtimeMs) {
const cache = loadHashCache()
const known = cache[rel]
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
hashStats.misses++
return null
}
hashStats.hits++
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
hashCacheDirty = true
}
return known[2]
}
// Hash of a file we already stat'ed: size and mtime unchanged means the bytes // Hash of a file we already stat'ed: size and mtime unchanged means the bytes
// are the same, and the digest from an earlier sync still describes them. // are the same, and the digest from an earlier sync still describes them.
function cachedHash(rel, abs, size, mtimeMs) { function cachedHash(rel, abs, size, mtimeMs) {
const known = hashCacheHit(rel, size, mtimeMs) const cache = loadHashCache()
if (known) return Promise.resolve(known) const known = cache[rel]
if (known && known[0] === size && known[1] === mtimeMs && typeof known[2] === 'string') {
hashStats.hits++
// a hit only marks the cache dirty when its timestamp is actually stale:
// marking it every time would force a full rewrite of the file per sync
// even though nothing a second sync could reuse has changed
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
hashCacheDirty = true
}
return Promise.resolve(known[2])
}
hashStats.misses++
return hashFile(abs).then(hash => { return hashFile(abs).then(hash => {
if (hash) { if (hash) {
loadHashCache()[rel] = [size, mtimeMs, hash, Date.now()] cache[rel] = [size, mtimeMs, hash, Date.now()]
hashCacheDirty = true hashCacheDirty = true
} }
return hash return hash
@ -509,18 +499,26 @@ async function buildManifest(roots, force, onProgress) {
// start empty and fill with the bytes actually read // start empty and fill with the bytes actually read
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
if (!force && files.length) { if (!force && files.length) {
const cache = loadHashCache()
let done = 0 let done = 0
let hashed = 0 let hashed = 0
await mapLimit(files, HASH_CONCURRENCY, async entry => { await mapLimit(files, HASH_CONCURRENCY, async entry => {
const abs = filesPath(entry.path) const abs = filesPath(entry.path)
try { try {
// identical size and mtime means identical bytes, so the digest from // inline cache hit: identical size and mtime means identical bytes, so
// an earlier sync still describes this file // the digest from an earlier sync still describes this file - the bar
// still advances by the file's size, because the check WAS the work
const mtimeMs = mtimes.get(entry.path) || 0 const mtimeMs = mtimes.get(entry.path) || 0
const known = hashCacheHit(entry.path, entry.size, mtimeMs) const known = cache[entry.path]
if (known && abs) { if (abs && known && known[0] === entry.size && known[1] === mtimeMs && typeof known[2] === 'string') {
entry.hash = known hashStats.hits++
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
hashCacheDirty = true
}
entry.hash = known[2]
} else { } else {
hashStats.misses++
entry.hash = abs entry.hash = abs
? await hashFile(abs, bytes => { ? await hashFile(abs, bytes => {
hashed += bytes hashed += bytes
@ -528,7 +526,7 @@ async function buildManifest(roots, force, onProgress) {
}) })
: null : null
if (entry.hash) { if (entry.hash) {
loadHashCache()[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()] cache[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()]
hashCacheDirty = true hashCacheDirty = true
} }
} }
@ -734,10 +732,6 @@ async function healUnitRoots(roots) {
const abs = filesPath(ownedRoot(rel)) const abs = filesPath(ownedRoot(rel))
if (!abs) continue if (!abs) continue
try { try {
// a unit root that is a symlink is the whole unit: its target is not
// ours to walk (it may live outside the launcher), and the real folder
// behind it is healed through its own unit
if (!fs.lstatSync(abs).isDirectory()) continue
healed.links += await rewireSymlinks(abs, [DIRS.FILES]) healed.links += await rewireSymlinks(abs, [DIRS.FILES])
if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs) if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs)
} catch {} } catch {}
@ -801,43 +795,19 @@ function removeOrphans(dir, keep) {
return removed return removed
} }
// Folder names in a registry directory. A symlinked folder counts too: a
// wine build or prefix kept on another disk is still part of the tree, and the
// sync carries it as the link it is (following it would copy the target and,
// for the pfx self link, loop).
function listDirs(dir) { function listDirs(dir) {
const out = []
let entries
try { try {
entries = fs.readdirSync(dir, { withFileTypes: true }) return fs
.readdirSync(dir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
} catch { } catch {
return out return []
} }
for (const entry of entries) {
if (entry.isDirectory()) {
out.push(entry.name)
continue
}
if (!entry.isSymbolicLink()) continue
try {
if (fs.statSync(path.join(dir, entry.name)).isDirectory()) out.push(entry.name)
} catch {}
}
return out
} }
// Delete everything under a unit root that is not in the sender's manifest. // Delete everything under a unit root that is not in the sender's manifest.
async function pruneTree(abs, rel, keep) { async function pruneTree(abs, rel, keep) {
// A unit root can BE a symlink (a build or prefix kept on another disk).
// readdir would follow it and delete the real files behind it, which belong
// to the target, not to this unit.
let rootStat = null
try {
rootStat = await fsp.lstat(abs)
} catch {
return 0
}
if (!rootStat.isDirectory()) return 0
let entries let entries
try { try {
entries = await fsp.readdir(abs, { withFileTypes: true }) entries = await fsp.readdir(abs, { withFileTypes: true })
@ -980,129 +950,12 @@ function applyPrefixes(payload, useSenderDefault) {
return removed return removed
} }
// Wine display names and disabled flags ("Sync settings"): registry-only // The launcher-wide defaults, asked for with the "Default settings" category:
// values, matched by build id. Only builds registered HERE are touched, and an // the wine every app falls back to and the prefix new imports land in. Only
// empty name CLEARS a custom label - syncing from a machine where the build // values that exist HERE are taken - a default pointing at a build or prefix
// was never renamed restores the folder-derived label there, exactly like the // this device does not have would be worse than keeping the current one.
// sender shows. System Wine has no registry entry and is never renamed. function applyDefaults(payload, cats) {
function applyWineSettings(payload) { if (!cats.has('defaults')) return null
const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : []
let applied = 0
for (const entry of incoming) {
if (!entry || !entry.name) continue
const id = String(entry.name)
const wine = wines.getWine(id)
if (!wine || wine.type === 'system') continue
let changed = false
const wanted = typeof entry.displayName === 'string' ? entry.displayName.trim() : ''
if (wanted !== (wine.displayName || '')) {
// a sender that never renamed the build clears the custom label here
if (wanted) wines.setWineDisplayName(id, wanted)
else wines.clearWineDisplayName(id)
changed = true
}
if (entry.disabled !== undefined && !!entry.disabled !== !!wine.disabled) {
wines.setWineDisabled(id, !!entry.disabled)
changed = true
}
if (changed) applied++
}
return applied
}
// Prefix REGISTRY names ("Sync settings"): a prefix's name IS its folder, so
// this never renames or creates folders - folders and their removal belong to
// the "Wine prefixes" category. It registers the sender's entries so a prefix
// whose folder already arrived (or is about to be used as a default) is known
// here, and it leaves every local prefix alone.
function applyPrefixSettings(payload) {
const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null
if (!sender) return 0
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
if (!incoming.length) return 0
const defs = prefixes.listPrefixes()
const known = new Set(defs.prefixes.map(p => p.name))
let added = 0
for (const entry of incoming) {
const name = String(entry.name)
if (known.has(name)) continue
known.add(name)
defs.prefixes.push({ name, created: Number(entry.created) || Date.now(), locked: name === prefixes.MAIN ? true : !!entry.locked })
added++
}
if (added) prefixes.savePrefixes(defs)
return added
}
// Per-app configuration ("Sync settings"): apps are matched by id and only
// apps that already exist here are patched - a settings sync cannot create an
// app whose files it does not carry. A wine id or prefix this device does not
// know is skipped instead of silently breaking the app (it would fall back to
// System Wine / the default prefix), and prefix-import entries keep their own
// prefix no matter what the sender has (updateGame pins it).
function applyAppSettings(payload) {
const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : []
if (!incoming.length) return 0
const byId = new Map(incoming.filter(a => a && a.id).map(a => [String(a.id), a]))
const knownPrefixes = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
let patched = 0
for (const game of library.loadGames()) {
const from = byId.get(game.id)
if (!from) continue
const patch = {}
if (typeof from.name === 'string' && from.name && from.name !== game.name) patch.name = from.name
if (from.wine !== undefined) {
const wanted = String(from.wine || 'default')
if ((wanted === 'default' || wanted === 'system' || wines.getWine(wanted)) && wanted !== (game.wine || 'default')) patch.wine = wanted
}
if (from.prefix !== undefined && game.importSource !== 'prefix') {
const wanted = String(from.prefix || '')
if ((!wanted || knownPrefixes.has(wanted)) && wanted !== (game.prefix || '')) patch.prefix = wanted
}
if (typeof from.args === 'string' && from.args !== (game.args || '')) patch.args = from.args
if (from.env && typeof from.env === 'object' && JSON.stringify(from.env) !== JSON.stringify(game.env || {})) patch.env = from.env
if (Array.isArray(from.dllOverrides) && JSON.stringify(from.dllOverrides) !== JSON.stringify(game.dllOverrides || [])) patch.dllOverrides = from.dllOverrides
if (from.disableUmu !== undefined && !!from.disableUmu !== !!game.disableUmu) patch.disableUmu = !!from.disableUmu
if (Object.keys(patch).length) {
library.updateGame(game.id, patch)
patched++
}
}
return patched
}
// The library ORDER ("Sync settings"): reorderGames keeps entries this device
// has that the sender does not know (they stay at the end), so a settings sync
// can reorder a library but never drop an app from it.
function applyAppOrder(payload) {
const order = Array.isArray(payload.appOrder) ? payload.appOrder.filter(id => typeof id === 'string' && id) : []
if (!order.length) return 0
const current = library.loadGames().map(g => g.id)
if (current.length === order.length && current.every((id, i) => id === order[i])) return 0
library.reorderGames(order)
return order.length
}
// Everything the "Sync settings" category carries. Order matters: prefix names
// are registered before the default prefix is resolved and before app configs
// are checked against them; the app order is applied last so the patched
// library is written in the sender's order.
function applySettings(payload, cats) {
if (!cats.has('settings')) return null
return {
prefixes: applyPrefixSettings(payload),
wines: applyWineSettings(payload),
apps: applyAppSettings(payload),
order: applyAppOrder(payload),
defaults: applyDefaults(payload)
}
}
// The launcher-wide defaults ("Sync settings"): the wine every app falls back
// to and the prefix new imports land in. Only values that exist HERE are taken
// - a default pointing at a build or prefix this device does not have would be
// worse than keeping the current one.
function applyDefaults(payload) {
const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {} const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {}
const applied = {} const applied = {}
if (wanted.defaultWine) { if (wanted.defaultWine) {
@ -1214,7 +1067,6 @@ function createHttpServer(state, hooks) {
if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks) if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks)
const session = authorize(state, url.searchParams.get('token')) const session = authorize(state, url.searchParams.get('token'))
if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' }) if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' })
if (route === '/plwl/ping' && req.method === 'POST') return handlePing(session, res, hooks)
if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res, hooks) if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res, hooks)
if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks) if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks)
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks) if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
@ -1364,11 +1216,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
compared++ compared++
comparedBytes += entry.size || 0 comparedBytes += entry.size || 0
session.barBytes = comparedBytes session.barBytes = comparedBytes
// the file counter is not touched here: compared items include dirs and announce({ unit: label, done: compared, total: allowed.length })
// links, and until the upload starts nobody knows how many files will
// actually be written - the popup's counter freezes instead of jumping
// backwards when the copy phase starts its own count
announce({ unit: label })
return same === true return same === true
}) })
saveHashCache() saveHashCache()
@ -1410,7 +1258,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
// is planned, so the later file events can name it in the popup // is planned, so the later file events can name it in the popup
session.unitLabel = label session.unitLabel = label
sendJson(res, 200, { ok: true, upload, same, entries: paths.size }) sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
announce({ unit: label }, true) announce({ unit: label, done: allowed.length, total: allowed.length }, true)
} }
async function handlePut(session, url, req, res, hooks) { async function handlePut(session, url, req, res, hooks) {
@ -1477,15 +1325,13 @@ async function handleCommit(session, url, res, hooks) {
async function handleApply(session, req, res, hooks) { async function handleApply(session, req, res, hooks) {
const payload = await readJsonBody(req) const payload = await readJsonBody(req)
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' }) if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
// the categories come from another machine (and an older PLWL names the const cats = new Set(Array.isArray(payload.categories) ? payload.categories : [])
// settings category "defaults"), so they go through the aliases here too
const cats = new Set(normalizeCategories(payload.categories))
const appsDeleted = applyApps(payload, cats) const appsDeleted = applyApps(payload, cats)
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0 const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('settings')) : 0 const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('defaults')) : 0
// after the registries are in place, so a synced build/prefix can actually // after the registries are in place, so a synced build/prefix can actually be
// be chosen as the default and app configs can be checked against them // chosen as the default
const settings = applySettings(payload, cats) const defaults = applyDefaults(payload, cats)
const removed = appsDeleted + winesDeleted + prefixesDeleted const removed = appsDeleted + winesDeleted + prefixesDeleted
emitReceive(hooks, session, 'registry', { deleted: removed }) emitReceive(hooks, session, 'registry', { deleted: removed })
if (hooks.onApplied) { if (hooks.onApplied) {
@ -1497,8 +1343,7 @@ async function handleApply(session, req, res, hooks) {
bytes: session.bytes || 0, bytes: session.bytes || 0,
items: { ...(session.items || {}) }, items: { ...(session.items || {}) },
deleted: removed, deleted: removed,
settings, defaults
defaults: settings ? settings.defaults : null
}) })
} }
sendJson(res, 200, { sendJson(res, 200, {
@ -1507,8 +1352,7 @@ async function handleApply(session, req, res, hooks) {
apps: { deleted: appsDeleted }, apps: { deleted: appsDeleted },
wines: { deleted: winesDeleted }, wines: { deleted: winesDeleted },
prefixes: { deleted: prefixesDeleted }, prefixes: { deleted: prefixesDeleted },
settings, defaults
defaults: settings ? settings.defaults : null
}) })
} }
@ -1519,18 +1363,6 @@ function handleEnd(state, session, res, hooks) {
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 }) emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
} }
// Liveness ping from the pushing device. It hashes whole units before it
// sends them and that can take many minutes, during which no other request
// arrives: without this the receiving popup would go quiet and its watchdog
// would eventually close it while the sync is still very much alive. The
// event only re-arms that watchdog (the renderer ignores its stage), so a
// transfer in progress keeps showing what it is doing.
function handlePing(session, res, hooks) {
session.lastAt = Date.now()
emitReceive(hooks, session, 'wait')
sendJson(res, 200, { ok: true })
}
// --- discovery -------------------------------------------------------------- // --- discovery --------------------------------------------------------------
let beaconSocket = null let beaconSocket = null

View file

@ -17,12 +17,6 @@ const path = require('path')
// small files a game or prefix is mostly made of. // small files a game or prefix is mostly made of.
const UPLOAD_CONCURRENCY = 4 const UPLOAD_CONCURRENCY = 4
// Keeps the receiving side's session and popup alive while this device hashes
// 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
function createPusher(api) { function createPusher(api) {
const { const {
CATEGORIES, CATEGORIES,
@ -137,15 +131,9 @@ function createPusher(api) {
units.set(root, { key: `wine:${root.slice('wines/'.length)}`, kind: 'wine', id: root.slice('wines/'.length), label: label || root, roots: [root] }) units.set(root, { key: `wine:${root.slice('wines/'.length)}`, kind: 'wine', id: root.slice('wines/'.length), label: label || root, roots: [root] })
} }
for (const wine of wines.getWines()) { for (const wine of wines.getWines()) {
const rel = String(toPortablePath(wine.path) || '') const rel = toPortablePath(wine.path)
const parts = rel.split('/') const parts = String(rel).split('/')
// a build folder can sit one level down (zip-extracted layouts are add(parts.length === 2 && parts[0] === 'wines' ? rel : `wines/${path.basename(wine.path)}`, wine.label)
// 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)
} }
for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`) for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`)
return [...units.values()] return [...units.values()]
@ -190,39 +178,14 @@ function createPusher(api) {
// the receiver prunes its own builds down to this list and rescans // the receiver prunes its own builds down to this list and rescans
payload.wines = wineUnits().map(unit => ({ name: unit.id })) payload.wines = wineUnits().map(unit => ({ name: unit.id }))
} }
if (cats.has('settings')) { if (cats.has('defaults')) {
// "Sync settings": every registry-level value that describes how the // "Default settings": which wine everything falls back to and where a new
// launcher is set up here - the defaults, the names/flags the user gave // import lands. Opt-in on purpose - a sync of apps must not silently
// wine builds, the prefix registry, and each app's configuration plus // change the other machine's defaults.
// 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 games = library.loadGames()
payload.defaults = { payload.defaults = {
defaultWine: (library.loadSettings().defaultWine) || 'system', defaultWine: (library.loadSettings().defaultWine) || 'system',
defaultPrefix: defs.default || '' defaultPrefix: prefixes.listPrefixes().default || ''
} }
payload.wineSettings = wines
.getWines()
.filter(w => w.type !== 'system')
.map(w => ({ name: w.id, displayName: w.displayName || '', disabled: !!w.disabled }))
payload.prefixSettings = {
default: defs.default || '',
prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
}
payload.appSettings = games.map(g => ({
id: g.id,
name: g.name,
wine: g.wine || 'default',
prefix: g.prefix || '',
args: g.args || '',
env: g.env || {},
dllOverrides: g.dllOverrides || [],
disableUmu: !!g.disableUmu
}))
payload.appOrder = games.map(g => g.id)
} }
if (cats.has('prefixes')) { if (cats.has('prefixes')) {
const defs = prefixes.listPrefixes() const defs = prefixes.listPrefixes()
@ -328,14 +291,6 @@ function createPusher(api) {
// typing (a rejected code is never stored) // typing (a rejected code is never stored)
rememberDevice(device.id || deviceId, code, device.name) rememberDevice(device.id || deviceId, code, device.name)
// 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(() => {
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {})
}, HEARTBEAT_MS)
if (heartbeat.unref) heartbeat.unref()
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 }
// bytes the units planned so far will put on the wire: the popup can then // bytes the units planned so far will put on the wire: the popup can then
@ -426,6 +381,7 @@ function createPusher(api) {
pending.push(entry) pending.push(entry)
plannedBytes += entry.size || 0 plannedBytes += entry.size || 0
} }
allTotal += pending.length
// a few files in flight at once: with thousands of small ones the // a few files in flight at once: with thousands of small ones the
// round trip to the other machine costs more than the bytes do // round trip to the other machine costs more than the bytes do
// the files that were already identical never move, so the bar counts // the files that were already identical never move, so the bar counts
@ -483,8 +439,6 @@ function createPusher(api) {
} catch (err) { } catch (err) {
await end() await end()
return fail(String((err && err.message) || err), summary, events) return fail(String((err && err.message) || err), summary, events)
} finally {
clearInterval(heartbeat)
} }
return syncResult(true, null, summary, events) return syncResult(true, null, summary, events)
} }

View file

@ -217,9 +217,6 @@ function newGame({ id, name, exePath, workdir, mode }) {
prefix: '', prefix: '',
args: '', args: '',
dllOverrides: [], dllOverrides: [],
// 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,
env: {}, env: {},
iconSource: '', iconSource: '',
playSeconds: 0, playSeconds: 0,

View file

@ -26,7 +26,6 @@ let tray = null
let quitting = false let quitting = false
let headless = false // started via --launch: no window until the user asks let headless = false // started via --launch: no window until the user asks
let autoLaunchId = null // game id from --launch <id> let autoLaunchId = null // game id from --launch <id>
let autoLaunchFile = null // file from --file ("Open with" on an app shortcut)
let autoOpenPath = null // file path from --open ("Open with PLWL") let autoOpenPath = null // file path from --open ("Open with PLWL")
let autoQuitWhenDone = false // headless boot: quit after that game exits let autoQuitWhenDone = false // headless boot: quit after that game exits
const getWindow = () => win const getWindow = () => win
@ -55,10 +54,10 @@ if (!gotLock) {
} else { } else {
app.on('second-instance', (e, argv) => { app.on('second-instance', (e, argv) => {
trace(`second-instance argv=${argv.join(' ')}`) trace(`second-instance argv=${argv.join(' ')}`)
const { launchId, openPath, launchFile } = parseArgv(argv) const { launchId, openPath } = parseArgv(argv)
lastHandoffAt = Date.now() lastHandoffAt = Date.now()
if (openPath) openWith(openPath) if (openPath) openWith(openPath)
else if (launchId) launchGame(launchId, launchFile) else if (launchId) launchGame(launchId)
else showMainWindow() else showMainWindow()
}) })
} }
@ -81,7 +80,6 @@ async function openWith(filePath) {
const auto = parseArgv(process.argv) const auto = parseArgv(process.argv)
autoLaunchId = auto.launchId autoLaunchId = auto.launchId
autoLaunchFile = auto.launchFile
autoOpenPath = auto.openPath autoOpenPath = auto.openPath
headless = !!autoLaunchId headless = !!autoLaunchId
autoQuitWhenDone = headless // booting just to run one game -> exit when it ends autoQuitWhenDone = headless // booting just to run one game -> exit when it ends
@ -201,10 +199,10 @@ function wireWindow(w) {
// --- game auto-launch (shortcuts) ----------------------------------------------- // --- game auto-launch (shortcuts) -----------------------------------------------
let gamesService = null let gamesService = null
async function launchGame(id, openFile) { async function launchGame(id) {
trace(`launchGame ${id} openFile=${openFile || ''} serviceReady=${!!gamesService}`) trace(`launchGame ${id} serviceReady=${!!gamesService}`)
if (!gamesService) return if (!gamesService) return
const res = await gamesService.start(getWindow(), id, openFile) const res = await gamesService.start(getWindow(), id)
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()
@ -264,7 +262,7 @@ app.whenReady().then(async () => {
} }
if (headless) { if (headless) {
// shortcut boot: run the game without opening the launcher window // shortcut boot: run the game without opening the launcher window
launchGame(autoLaunchId, autoLaunchFile) launchGame(autoLaunchId)
} else { } else {
win = createMainWindow() win = createMainWindow()
wireWindow(win) wireWindow(win)

View file

@ -73,7 +73,6 @@ function makeTarget(openPath, wineId, prefixName) {
prefix: prefixName || '', prefix: prefixName || '',
args: '', args: '',
dllOverrides: [], dllOverrides: [],
disableUmu: false,
env: {}, env: {},
iconSource: '', iconSource: '',
playSeconds: 0, playSeconds: 0,

View file

@ -15,7 +15,6 @@ function seed() {
if (!defs || !Array.isArray(defs.prefixes) || !defs.prefixes.length) { if (!defs || !Array.isArray(defs.prefixes) || !defs.prefixes.length) {
const seeded = { default: MAIN, prefixes: [{ name: MAIN, created: Date.now(), locked: true }] } const seeded = { default: MAIN, prefixes: [{ name: MAIN, created: Date.now(), locked: true }] }
if (!fs.existsSync(prefixPath(MAIN))) fs.mkdirSync(prefixPath(MAIN), { recursive: true }) if (!fs.existsSync(prefixPath(MAIN))) fs.mkdirSync(prefixPath(MAIN), { recursive: true })
ensureUnifiedPrefix(MAIN)
writeJson(REGISTRY, seeded) writeJson(REGISTRY, seeded)
} }
} }
@ -43,65 +42,22 @@ function prefixExists(name) {
} }
// Every Windows C: drive a prefix has: plain wine keeps one at the top // Every Windows C: drive a prefix has: plain wine keeps one at the top
// (WINEPREFIX=<prefix>), legacy Proton-managed prefixes nest one under pfx/ // (WINEPREFIX=<prefix>), Proton-managed prefixes nest one under pfx/
// (WINEPREFIX=<prefix>/pfx). A prefix used with BOTH wine kinds has both, and // (WINEPREFIX=<prefix>/pfx). A prefix used with BOTH wine kinds has both,
// an import may come from either one - so callers that can accept several get // and an import may come from either one - so callers that can accept
// them all, top level first. A unified prefix (pfx -> .) reaches the same // several get them all, top level first.
// drive twice; it is reported once.
function prefixDriveRoots(name) { function prefixDriveRoots(name) {
const root = prefixPath(name) const root = prefixPath(name)
const found = [] const found = []
const seen = new Set()
for (const base of [root, path.join(root, 'pfx')]) { for (const base of [root, path.join(root, 'pfx')]) {
try { try {
const driveC = path.join(base, 'drive_c') const driveC = path.join(base, 'drive_c')
if (!fs.statSync(driveC).isDirectory()) continue if (fs.statSync(driveC).isDirectory()) found.push(driveC)
const real = fs.realpathSync(driveC)
if (seen.has(real)) continue
seen.add(real)
found.push(driveC)
} catch {} } catch {}
} }
return found return found
} }
function lstatOrNull(p) {
try {
return fs.lstatSync(p)
} catch {
return null
}
}
// Wine keeps its C: drive at <prefix>/drive_c, Proton always runs its prefix
// from <compatdata>/pfx - and PLWL points compatdata at the launcher's prefix
// 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) {
if (!name) return false
const pfx = path.join(prefixPath(name), 'pfx')
if (lstatOrNull(pfx)) return false
try {
fs.symlinkSync('.', pfx, 'dir')
return true
} catch {
// the prefix folder may not exist yet - the link is added on creation
return false
}
}
// True when a path IS one of a prefix's own roots: the prefix folder, its // True when a path IS one of a prefix's own roots: the prefix folder, its
// pfx/ or either drive_c. A copy/move import of such a folder would move the // 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 // prefix's C: drive (or the whole prefix) into files/apps, gutting it - the
@ -126,7 +82,6 @@ function createPrefix(name) {
if (clean.toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'alreadyExists' } if (clean.toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'alreadyExists' }
if (fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' } if (fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' }
fs.mkdirSync(prefixPath(clean), { recursive: true }) fs.mkdirSync(prefixPath(clean), { recursive: true })
ensureUnifiedPrefix(clean)
const defs = listPrefixes() const defs = listPrefixes()
defs.prefixes.push({ name: clean, created: Date.now() }) defs.prefixes.push({ name: clean, created: Date.now() })
if (!defs.prefixes.some(p => p.name === defs.default)) defs.default = clean if (!defs.prefixes.some(p => p.name === defs.default)) defs.default = clean
@ -198,4 +153,4 @@ function setDefaultPrefix(name) {
return { ok: true } return { ok: true }
} }
module.exports = { 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, MAIN }

View file

@ -2,14 +2,13 @@
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
const os = require('os')
const { spawn } = require('child_process') const { spawn } = require('child_process')
const { DIRS } = require('./paths') const { DIRS } = require('./paths')
const { prefixPath, ensureUnifiedPrefix } = require('./prefixes') const { prefixPath } = require('./prefixes')
const { prefixNameForGame } = require('./library') const { prefixNameForGame } = require('./library')
// The fake Steam install PLWL falls back to when no real Steam client is // The fake Steam install Proton is pointed at through
// found (see findSteamClientDir). Both folders Proton links into its prefix // STEAM_COMPAT_CLIENT_INSTALL_PATH. Both folders Proton links into its prefix
// have to exist: it symlinks <prefix>/drive_c/Program Files (x86)/Steam/ // 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 // 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. // missing folder is a dangling link in a tree that gets synced.
@ -20,73 +19,6 @@ function ensureFakeSteam() {
return fake return fake
} }
// Proton expects the Steam client it is normally started by at
// STEAM_COMPAT_CLIENT_INSTALL_PATH: it copies the client's legacy DLLs from
// <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 = [
['legacycompat', 'steamclient.dll'],
['ubuntu12_32', 'steamclient.so']
]
function findSteamClientDir() {
const home = os.homedir()
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share')
const candidates = [
process.env.STEAM_COMPAT_CLIENT_INSTALL_PATH,
path.join(home, '.steam', 'steam'),
path.join(home, '.steam', 'root'),
path.join(dataHome, 'Steam'),
// flatpak Steam keeps its install inside the sandbox's data dir
path.join(home, '.var', 'app', 'com.valvesoftware.Steam', '.local', 'share', 'Steam')
]
for (const dir of candidates) {
if (!dir) continue
for (const marker of STEAM_CLIENT_MARKERS) {
if (fs.existsSync(path.join(dir, ...marker))) return dir
}
}
return null
}
function steamClientDir() {
return findSteamClientDir() || ensureFakeSteam()
}
// Module names travel in a semicolon-separated WINEDLLOVERRIDES list where
// '=' 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) {
const clean = String(name || '').trim().replace(/\.dll$/i, '')
return /^[^;=\s]+$/.test(clean) ? clean : ''
}
// Wine replaces an earlier entry for the same module with a later one, so an
// 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) {
const parts = []
const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '')
if (inherited) parts.push(inherited)
for (const entry of game.dllOverrides || []) {
if (!entry) continue
const dll = sanitizeDllName(entry.dll)
if (!dll) continue
const setting = String(entry.setting == null ? '' : entry.setting).trim()
if (!/^[a-z,]*$/i.test(setting)) continue
parts.push(`${dll}=${setting}`)
}
return parts.join(';')
}
// Wine only ever repairs links that are BROKEN: a prefix that came from // Wine only ever repairs links that are BROKEN: a prefix that came from
// another machine (or from an older PLWL) can still hold absolute links into // 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. // its build that happen to resolve here, and those would be synced out again.
@ -142,41 +74,23 @@ function buildEnv({ game, wine }) {
const isProton = wine && wine.type === 'proton' const isProton = wine && wine.type === 'proton'
// same fallback the games service snapshots into its running entries // same fallback the games service snapshots into its running entries
// (library.prefixNameForGame), so in-use checks always name this folder // (library.prefixNameForGame), so in-use checks always name this folder
const prefixName = prefixNameForGame(game) const prefixDir = prefixPath(prefixNameForGame(game))
// 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 {
ensureUnifiedPrefix(prefixName)
} catch {}
const prefixDir = prefixPath(prefixName)
if (isProton) { if (isProton) {
const fakeSteam = ensureFakeSteam()
const buildRoot = path.resolve(wine.path, '..')
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = fakeSteam
env.STEAM_COMPAT_DATA_PATH = path.join(DIRS.DATA, 'compat', game.id) env.STEAM_COMPAT_DATA_PATH = path.join(DIRS.DATA, 'compat', game.id)
env.STEAM_COMPAT_APP_ID = '0' env.STEAM_COMPAT_APP_ID = '0'
env.SteamAppId = '0' env.SteamAppId = '0'
env.SteamGameId = '0' env.SteamGameId = '0'
// Proton's session init reads STEAM_COMPAT_CLIENT_INSTALL_PATH on EVERY // Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID
// verb: its appcache sync (g_compatdata.sync_appcache) runs even for // set, newer Protons (incl. DW Proton 11, GE-Proton 10+) run the game via
// runinprefix and does os.environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] as // their built-in umu.exe instead of the steam.exe helper, which would
// soon as SteamGameId is set, so the variable must never be dropped - a // otherwise try to load Steam's steamclient.so and crash with an
// missing one aborts the launch with a KeyError before wine even starts. // "Assertion failed \"!status\"" dialog (steamclient_main.c) when no
// Only what it POINTS AT depends on the mode: a UMU run may use a real // Steam client is installed.
// client found on this machine, a disableUmu run always gets the empty if (!env.UMU_ID) env.UMU_ID = 'umu-0'
// fake folder (runinprefix copies nothing out of it; no steam.exe and no
// umu.exe is started there).
if (game.disableUmu) {
delete env.UMU_ID
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam()
} else {
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir()
// Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID
// 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'
}
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 })
@ -194,25 +108,16 @@ function buildEnv({ game, wine }) {
env.WINEPREFIX = prefixDir env.WINEPREFIX = prefixDir
} }
const overrides = dllOverridesFor(game, env) env.WINEDLLOVERRIDES = (game.dllOverrides || [])
if (overrides) env.WINEDLLOVERRIDES = overrides .filter(o => o && o.dll && o.setting)
else delete env.WINEDLLOVERRIDES .map(o => `${o.dll}=${o.setting}`)
.join(';')
if (!env.WINEDLLOVERRIDES) delete env.WINEDLLOVERRIDES
// 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 the build's umu.exe/steam.exe launcher,
// 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) {
return game.disableUmu ? 'runinprefix' : 'run'
}
function parseArgs(args) { function parseArgs(args) {
const out = [] const out = []
const re = /"([^"]*)"|'([^']*)'|(\S+)/g const re = /"([^"]*)"|'([^']*)'|(\S+)/g
@ -221,25 +126,6 @@ function parseArgs(args) {
return out return out
} }
// A file handed over from the Linux file manager ("Open with" on an app
// 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) {
let abs = String(file || '')
try {
abs = fs.realpathSync(abs)
} catch {}
try {
const driveC = fs.realpathSync(path.join(prefixDir, 'drive_c'))
const rel = path.relative(driveC, abs)
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return 'C:\\' + rel.split(path.sep).join('\\')
}
} catch {}
return `Z:${abs.split(path.sep).join('\\')}`
}
const GRACE_MS = 60000 const GRACE_MS = 60000
// Live playtime: while the game runs, update ONLY the tiny playtime file // Live playtime: while the game runs, update ONLY the tiny playtime file
@ -269,21 +155,19 @@ function openLatestLog(gameId) {
return fs.createWriteStream(logFile, { flags: 'w' }) return fs.createWriteStream(logFile, { flags: 'w' })
} }
async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) { async function runGame({ game, wine, systemWine, onExit }) {
await healPrefixOnce(game) await healPrefixOnce(game)
const isProton = wine && wine.type === 'proton' const isProton = wine && wine.type === 'proton'
const { env, prefixDir } = buildEnv({ game, wine }) const { env } = buildEnv({ game, wine })
const exe = game.exePath const exe = game.exePath
const workdir = game.workdir || path.dirname(exe) const workdir = game.workdir || path.dirname(exe)
const argList = parseArgs(game.args) const argList = parseArgs(game.args)
// an opened file is an argument to the exe, exactly like Windows' "Open with"
if (openFile) argList.push(windowsPathFor(openFile, prefixDir))
let cmd let cmd
let base let base
if (isProton) { if (isProton) {
cmd = wine.wineBin || path.join(wine.path, 'proton') cmd = wine.wineBin || path.join(wine.path, 'proton')
base = [protonVerb(game), exe, ...argList] base = ['run', exe, ...argList]
} else { } else {
cmd = systemWine ? 'wine' : wine.wineBin cmd = systemWine ? 'wine' : wine.wineBin
base = [exe, ...argList] base = [exe, ...argList]
@ -296,10 +180,7 @@ 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 (a file given to an already running app) is not tracked: stdio: ['ignore', 'pipe', 'pipe'],
// its pipes are left unread, so the output goes nowhere instead of
// filling up and blocking the app
stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'],
detached: true detached: true
}) })
} catch (err) { } catch (err) {
@ -317,21 +198,8 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal
}) })
child.once('spawn', () => { child.once('spawn', () => {
started = true started = true
// 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) {
child.unref()
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 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] WINEPREFIX=${env.WINEPREFIX}\n`)
if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\n`)
if (isProton) ws.write(`[plwl] proton launcher: ${game.disableUmu ? 'direct wine (no UMU, no Steam)' : `umu.exe (UMU_ID=${env.UMU_ID})`}\n`)
child.stdout.pipe(ws) child.stdout.pipe(ws)
child.stderr.pipe(ws) child.stderr.pipe(ws)
child.unref() child.unref()
@ -369,7 +237,7 @@ async function runTool({ game, wine, tool, args, systemWine }) {
let base let base
if (isProton) { if (isProton) {
cmd = wine.wineBin || path.join(wine.path, 'proton') cmd = wine.wineBin || path.join(wine.path, 'proton')
base = [protonVerb(game), tool, ...argList] base = ['run', tool, ...argList]
} else if (systemWine) { } else if (systemWine) {
cmd = tool cmd = tool
base = argList base = argList
@ -395,4 +263,4 @@ function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) {
return runGame({ game, wine, systemWine, onExit }) return runGame({ game, wine, systemWine, onExit })
} }
module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor } module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath }

View file

@ -44,14 +44,8 @@ function execToken(token) {
return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped
} }
// 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)] return [...launcherArgv(), ...extraArgs.split(' ').filter(Boolean).map(execToken)].map(execToken).join(' ')
return [...tokens.map(execToken), '%f'].join(' ')
} }
function sanitizeName(name) { function sanitizeName(name) {
@ -77,7 +71,7 @@ function buildDesktopEntry(game) {
'Type=Application', 'Type=Application',
`Name=${name}`, `Name=${name}`,
'Comment=Launch via PLWL', 'Comment=Launch via PLWL',
`Exec=${execLine(`--launch ${game.id} --file`)}`, `Exec=${execLine(`--launch ${game.id}`)}`,
'Terminal=false', 'Terminal=false',
'Categories=Game;' 'Categories=Game;'
] ]

View file

@ -16,11 +16,7 @@
// //
// Everything here rewires a link to a RELATIVE path when the file it points at // 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 // 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 // the executable bit on the binaries a wine/proton build needs.
// 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')
@ -120,11 +116,10 @@ function relativeTarget(link, target, roots) {
return null return null
} }
// Rewire the symlinks under `root` that can be expressed relative to `roots` // Rewire every absolute symlink under `root` that can be expressed relative to
// (usually [root, DIRS.FILES]): absolute ones always, relative ones only when // `roots` (usually [root, DIRS.FILES]). Returns how many links changed. A link
// they no longer resolve. Returns how many links changed. A link may only // may only become rewirable once another link it chains through has been
// become rewirable once another link it chains through has been fixed, so the // fixed, so the pass repeats until nothing changes.
// 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)]
@ -140,13 +135,8 @@ async function rewireSymlinks(root, roots = []) {
} catch { } catch {
continue continue
} }
const absolute = path.isAbsolute(target) if (!path.isAbsolute(target)) continue // already relocatable
// a relative link that still resolves is already relocatable - only a const next = relativeTarget(link, path.normalize(target), candidates)
// 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
const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target)
const next = relativeTarget(link, from, candidates)
if (!next || next === target) continue if (!next || next === target) continue
try { try {
await fsp.unlink(link) await fsp.unlink(link)

View file

@ -167,31 +167,14 @@ function applyDisplayName(w) {
// A build keeps its folder and id (apps point at the id, and prefix symlinks // 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. // point inside the folder), so "renaming" only changes the shown label.
function setWineDisplayName(id, name) { function setWineDisplayName(id, name) {
const clean = normalizeLabel(name)
if (!clean) return { ok: false, error: 'invalidName' }
return writeWineLabel(id, clean)
}
// 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) {
return writeWineLabel(id, '')
}
function writeWineLabel(id, clean) {
const wines = getWines() const wines = getWines()
const w = wines.find(x => x.id === id) const w = wines.find(x => x.id === id)
if (!w) return { ok: false, error: 'notFound' } if (!w) return { ok: false, error: 'notFound' }
if (w.type === 'system') return { ok: false, error: 'isSystem' } if (w.type === 'system') return { ok: false, error: 'isSystem' }
if (clean) { const clean = normalizeLabel(name)
w.displayName = clean if (!clean) return { ok: false, error: 'invalidName' }
w.label = clean w.displayName = clean
} else { applyDisplayName(w)
if (!w.displayName) return { ok: true, displayName: '' }
delete w.displayName
w.label = labelFor(w.type, w.path, w.version)
}
writeJson(REGISTRY, wines.map(wineRel)) writeJson(REGISTRY, wines.map(wineRel))
return { ok: true, displayName: clean } return { ok: true, displayName: clean }
} }
@ -258,16 +241,6 @@ async function fixBuildSymlinks(root) {
// Everything a freshly arrived build needs before it can run: relative links // Everything a freshly arrived build needs before it can run: relative links
// and executable binaries. Returns what was changed, for the callers' reports. // and executable binaries. Returns what was changed, for the callers' reports.
async function healBuild(root) { async function healBuild(root) {
// 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
try {
st = fs.lstatSync(root)
} catch {
return { links: 0, executables: 0 }
}
if (!st.isDirectory()) return { links: 0, executables: 0 }
const links = await fixBuildSymlinks(root) const links = await fixBuildSymlinks(root)
const executables = await restoreExecutableBits(root) const executables = await restoreExecutableBits(root)
return { links, executables } return { links, executables }
@ -343,4 +316,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 }
} }
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild } module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }