diff --git a/README.md b/README.md index 9d42d9b..0c00d17 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ 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 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 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): @@ -55,21 +57,22 @@ 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. - **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 **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. +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. -**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. +**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. The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Hashes are remembered per file (size + modification time), so a repeat sync of a library that did not change reads nothing at all. **Wine builds and prefixes survive the trip.** Permissions travel with a sync, so the `proton`, `wine` and `wineserver` binaries are still executable on the other machine (a build whose executable bit was lost is additionally repaired at startup, without re-importing it), and symlinks are sent as RELATIVE paths whenever the file they point at belongs to the launcher: a Proton prefix is full of links into its build, and the receiving side gets links that resolve to its own copy instead of the sender's `/home/...` folders. Links that leave the launcher on purpose (`dosdevices` drive mappings, a mounted drive, the user's home folder) stay absolute, and links into a folder that does not exist yet on the other machine are still written relative, so they start working the moment that folder appears. Wine and Proton rewrite a prefix's links with local absolute targets while they update it; PLWL relativizes them again on the wire, on import and before every run. -A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The 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. +A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The **Sync settings** category carries the registry-level part of that even when no files move: the launcher's **default Wine build and default prefix**, the custom **names** you gave Wine/Proton builds (and their disabled flags), the **prefix registry**, every **app's configuration**, and the **order of the library**. It only patches what already exists on the other machine - an unknown Wine id or prefix is ignored instead of leaving a dangling default or a broken app behind, apps and prefixes the sender does not know stay where they are, and a settings-only sync deletes nothing (that is what the file categories are for). Playtime only ever grows - the higher counter wins - so pushing from a freshly installed machine cannot wipe a library's play history. Everything else stays local: language and window settings, logs, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix. ## Wine & Proton support - **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. -- **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 `/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`). +- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` is a per-build compat folder linked to the launcher's own prefix, and `STEAM_COMPAT_CLIENT_INSTALL_PATH` points at an installed Steam client when there is one (Proton reads its legacy DLLs and `steamclient.so` from there, read-only) and at the empty `files/data/fake-steam` otherwise. GE builds work out of the box: PLWL launches through Proton's built-in UMU path (`UMU_ID`), which needs no Steam client at all. An app that breaks under umu.exe has a per-app **Disable UMU** switch in its config — PLWL then runs the build's own wine directly (Proton's `runinprefix` verb): no umu.exe/steam.exe launcher and no Proton prefix setup take part, so the run stays as close to plain wine as possible. (Proton's session init still reads `STEAM_COMPAT_CLIENT_INSTALL_PATH` on every verb; a disableUmu run gets the empty `fake-steam` folder and nothing is copied out of it.) +- **One C: drive per prefix** — Proton normally keeps its prefix one level deeper (`/pfx/drive_c`) than wine (`/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 diff --git a/development.md b/development.md index ebafbfd..358dc02 100644 --- a/development.md +++ b/development.md @@ -102,9 +102,20 @@ handlers in src/ipc/. Keep new APIs in that pattern. looks finished the moment it starts. Hashing and copying walk tens of thousands of files, so every per-file update goes through progressGate (~10/s, phase changes forced) - never send one IPC message per file. -- The launcher's own defaults are opt-in (the "defaults" sync category): - payload.defaults carries the default wine and prefix, and the receiver only - applies values that exist there (an unknown wine id or prefix is skipped). +- Registry-level settings are opt-in (the "settings" sync category, shown as + "Sync settings"; the old name "defaults" is still accepted as an alias on + BOTH sides - the receiver normalizes payload.categories, so a sender from an + older version keeps working). payload carries defaults (default wine and + prefix), wineSettings (displayName + disabled per build id), prefixSettings + (the registry names; a settings sync never renames or creates folders - + that is the prefixes category), appSettings (name, wine, prefix, args, env, + dllOverrides, disableUmu per app id) and appOrder. The receiver only patches + things that already exist there: an unknown wine id / prefix / app is + skipped, nothing is created or deleted, and the order merge keeps local + entries the sender does not know at the end. Values are written through the + normal helpers (setWineDisplayName clears a custom label when the sender has + none, library.updateGame pins the prefix of prefix imports, reorderGames + merges by id). - LAN sync remembers file hashes in files/data/lansync-hashes.json, keyed by path with size + mtime (machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stat's the tree. Hashing and the @@ -122,6 +133,34 @@ handlers in src/ipc/. Keep new APIs in that pattern. relative target) and after a unit lands (healUnitRoots). dosdevices drive mappings and targets that leave files/ (a mount, a system path, the user's home) stay absolute - they are machine-specific by design. +- A prefix that has NO `pfx` yet gets ONE C: drive: `pfx` is a relative + symlink to the prefix itself, so Proton's `/pfx` + (runner.linkPrefix) resolves to `` and shares `drive_c`, the + registry and `dosdevices` with plain wine. `prefixes.ensureUnifiedPrefix` + only ever CREATES that link; an existing layout (a real `pfx/` folder from + an older PLWL, a both-drives prefix) is left exactly as it is. Hoisting a + prefix's files one level up was tried and removed: it breaks every relative + link written against the old depth (the `umu.exe` link into the build is the + one that kills a Proton launch), and moving a live Windows install is not + the launcher's call. `healPrefix`/`rewireSymlinks` repairs such a prefix by + re-anchoring broken relative links through files/. Never assume a single + drive_c path: use `prefixDriveRoots()` (deduplicated) and compare realpaths + when deciding whether a folder is inside a prefix's C: drive. +- App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an + ordered semicolon list: inherited value first, the app's overrides last, + because Wine lets a later entry replace an earlier one for the same module. + An empty setting (`dll=`) is Wine's "disabled" and must survive the filter. + Proton appends its own dxvk/nvapi/steam.exe defaults AFTER everything, so an + app override only wins where Proton has no default. `disableUmu` (per app) + drops UMU_ID and switches Proton to its `runinprefix` verb, so no + umu.exe/steam.exe launcher and no Proton prefix setup take part - the + build's own wine runs as close to plain wine as it gets. + STEAM_COMPAT_CLIENT_INSTALL_PATH must be set in BOTH modes: Proton's session + init reads it on every verb (sync_appcache runs even for runinprefix, and + with SteamGameId set a missing variable aborts the launch with a KeyError + before wine starts). On the default path it prefers a real Steam client + found on the machine; a disableUmu run always gets the empty + files/data/fake-steam, whose contents are never read. - Wine and Proton create prefix links with ABSOLUTE local targets whenever they update a prefix (font/dll stubs, Steam appcache), so on-disk links are not always relative after a run: the sync payload relativizes them anyway, and @@ -132,6 +171,41 @@ handlers in src/ipc/. Keep new APIs in that pattern. - File permissions travel with a sync (manifest entry `mode`): the receiving side applies them after writing the bytes and repairs a file that has the same hash but the wrong mode without re-uploading it. +- A sync must never walk through a symlinked ROOT. pruneTree/healUnitRoots + (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat their root + first and treat anything that is not a real directory as the link (or file) + it is: a build, prefix or app folder kept on another disk must not have its + target deleted, pruned or rewritten through the link. `listDirs()` in the + sync counts symlinked folders as units, so such a folder still travels - as + the link it is (`wineUnits` maps nested builds to their TOP folder under + wines/, otherwise a zip-extracted build becomes a phantom unit whose root + does not exist and the "Wine versions" totals grow). +- Generated app shortcuts are file handlers too: `Exec= + --launch --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 --file ` arrives as + `--launch --file --allow-file-access-from-files `. argv.js pairs + options with positionals IN ORDER (inline `--name=value` wins, the dev app + path is skipped); reading the next token used to take the launch id as the + opened file, so an app started without its image. + `electron test/argv-handoff.test.js` proves the real serialization. +- A sender hashing a big unit is minutes of network silence: it pings + `/plwl/ping` (+token) every HEARTBEAT_MS for the whole sync, the receiver's + handler re-arms its popup watchdog (stage 'wait' changes nothing on screen), + and a receiving popup its watchdog closed reopens on the next real event - + only the 'done' stage ends a sync for good, and reopening NEVER asks for + consent again: the Accept/Decline gate is the onIncoming popup at verify + time (a fresh session), while the progress popup is driven by the same + token's events. A 'wait' event that reopens a closed popup fills the phase + line (`recvWaiting`) instead of leaving every line blank. - Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to a temp dir, check(), exit 1 on failure. Add one when fixing a bug. diff --git a/files/translations/en-us.json b/files/translations/en-us.json index f3648ff..c5b1806 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -35,7 +35,6 @@ "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.tools": "Wine tools", - "library.viewLogs": "View logs", "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.mode.copy": "Copy into launcher (portable)", @@ -81,15 +80,20 @@ "config.prefixDefault": "Default prefix ({0})", "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.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.overrides.add": "Add override", "config.overrides.dll": "DLL name", "config.overrides.setting": "Setting", "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.shortcut": "Create shortcut", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", "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.back": "Back to library", "config.browse": "Browse", @@ -190,7 +194,7 @@ "lansync.appsPrefix": "Apps imported from a prefix", "lansync.wines": "Wine / Proton versions", "lansync.prefixes": "Wine prefixes", - "lansync.defaults": "Default settings (default Wine and prefix)", + "lansync.settings": "Sync settings (names, app configs and order)", "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.incomingMirror": "The other device is the source of truth: files and items on this device that do not exist there will be deleted.", diff --git a/files/translations/pl.json b/files/translations/pl.json index e00eae0..4329d0e 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -35,7 +35,6 @@ "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.tools": "Narzędzia Wine", - "library.viewLogs": "Podejrzyj logi", "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.mode.copy": "Skopiuj do launchera (przenośne)", @@ -81,15 +80,20 @@ "config.prefixDefault": "Prefiks domyślny ({0})", "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.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.overrides.add": "Dodaj nadpisanie", "config.overrides.dll": "Nazwa DLL", "config.overrides.setting": "Ustawienie", "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.shortcut": "Utwórz skrót", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", "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.back": "Wróć do biblioteki", "config.browse": "Przeglądaj", @@ -190,7 +194,7 @@ "lansync.appsPrefix": "Aplikacje importowane z prefiksu", "lansync.wines": "Wersje Wine / Proton", "lansync.prefixes": "Prefiksy Wine", - "lansync.defaults": "Ustawienia domyślne (domyślne Wine i prefiks)", + "lansync.settings": "Ustawienia (nazwy, konfiguracje i kolejność aplikacji)", "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.incomingMirror": "Drugie urządzenie jest źródłem prawdy: pliki i elementy na tym urządzeniu, których tam nie ma, zostaną usunięte.", diff --git a/package.json b/package.json index 77ac71b..0152daa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "PLWL", "productName": "PLWL", - "version": "0.3.1", + "version": "0.4.0", "description": "Portable Linux Wine Launcher", "main": "src/main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index 5e4888b..0f8aca2 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -45,7 +45,6 @@ const ICONS = { folder: () => '', trash: () => '', terminal: () => '', - logs: () => '', star: () => '', download: () => '', chevronDown: () => '' @@ -598,7 +597,6 @@ function renderLibrary() { - ` @@ -619,11 +617,6 @@ function renderLibrary() { } card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id) 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 () => { const note = game.mode === 'copy' || game.mode === 'move' ? t('library.removeFilesNote') @@ -1235,6 +1228,7 @@ async function openGameConfig(id) { const lockHint = qs('#gc-prefix-lock-hint') if (lockHint) lockHint.style.display = prefixLocked ? '' : 'none' renderOverrides(game.dllOverrides || []) + qs('#gc-disable-umu').checked = !!game.disableUmu renderTools() } @@ -1249,6 +1243,16 @@ 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) { const box = qs('#gc-overrides') box.innerHTML = '' @@ -1257,10 +1261,12 @@ 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 = [ + { value: 'native,builtin', label: 'native,builtin' }, { value: 'native', label: 'native' }, { value: 'builtin', label: 'builtin' }, - { value: 'native,builtin', label: 'native,builtin' }, { value: 'builtin,native', label: 'builtin,native' }, { value: '', label: 'disabled' } ] @@ -1274,23 +1280,48 @@ function buildOverrideRow(o) { ` row.querySelector('.dll').value = o.dll || '' const selectHost = document.createElement('span') - new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting || 'native') + // a stored empty setting IS the "disabled" choice - a `||` fallback would + // turn every disabled override back into the default on reopen + new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting == null ? 'native,builtin' : o.setting) row.insertBefore(selectHost, row.querySelector('[data-act=del]')) row.querySelector('[data-act=del]').onclick = () => row.remove() return row } function addOverrideRow() { - qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native' })) + qs('#gc-overrides').appendChild(buildOverrideRow({ dll: '', setting: 'native,builtin' })) const rows = qsa('#gc-overrides .override-row') 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() { - return qsa('#gc-overrides .override-row').map(row => ({ - dll: row.querySelector('.dll').value.trim(), - setting: row.querySelector('.custom-select').dataset.value ?? 'native' - })).filter(o => o.dll) + return qsa('#gc-overrides .override-row').map(row => { + let dll = row.querySelector('.dll').value.trim() + let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin' + // "dinput8=native" pasted into the name box is the form every guide uses - + // split it here instead of storing a name with '=' that Wine cannot parse + const eq = dll.indexOf('=') + 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() { @@ -1322,10 +1353,14 @@ async function saveGameConfig() { wine: selects['gc-wine'].value, prefix, env, - dllOverrides: readOverrides() + dllOverrides: readOverrides(), + disableUmu: qs('#gc-disable-umu').checked } await window.plwl.gamesUpdate(game.id, patch) 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') if (prefixLockedHint) toast(t('config.prefixLocked'), 'error') if (state.currentView === 'gameconfig') { @@ -1369,8 +1404,10 @@ const LAN_CATEGORIES = [ { id: 'apps-prefix', label: 'lansync.appsPrefix' }, { id: 'wines', label: 'lansync.wines' }, { id: 'prefixes', label: 'lansync.prefixes' }, - // opt-in: without it a sync never touches the other machine's defaults - { id: 'defaults', label: 'lansync.defaults' } + // registry-level settings: default wine/prefix, custom names of wine builds + // and prefixes, and each app's configuration plus its library order. No + // files move with this category - it only patches what already exists. + { id: 'settings', label: 'lansync.settings' } ] // Byte sizes for the progress popups. Decimal units, the way a file manager @@ -1710,7 +1747,11 @@ function onLanIncoming(info) { } let receiving = false -let receiveClosed = false +// true only once the sender's 'done' event ended the transfer. A popup the +// safety watchdog closed while the sender was still hashing must be allowed to +// reopen when events resume - permanently closing it made a running sync +// invisible even though the transfer continued. +let receiveEnded = false let receiveWatchdog = null let receiveStall = null let receiveCloseTimer = null @@ -1745,7 +1786,6 @@ function endReceiving(message) { } const wasReceiving = receiving receiving = false - receiveClosed = true if (!wasReceiving) return if (message) toast(message, 'error') closeModal() @@ -1769,13 +1809,14 @@ function armReceiveWatchdog() { function onLanReceiving(info) { if (!info || !info.stage) return if (state.lanPushing) return // our own push owns the (single) popup - if (info.stage === 'start') receiveClosed = false + if (info.stage === 'start') receiveEnded = false + if (info.stage === 'done') receiveEnded = true if (!receiving) { // The popup also opens mid-sync: the sender may have been hashing for // minutes when this window starts listening, and a popup that the safety - // timeout closed has to come back for the rest of the transfer. A sync + // timeout closed comes back for the rest of the transfer. Only a sync // that already ended stays closed. - if (receiveClosed && info.stage !== 'start') return + if (receiveEnded) return if (qs('#modal-overlay').style.display !== 'none') return receiving = true const modal = showModal(` @@ -1799,6 +1840,15 @@ function onLanReceiving(info) { function renderReceiving(info) { const parts = receiveModalParts() 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 // is being received, not sent. const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage @@ -1923,6 +1973,7 @@ function wireEvents() { qs('#btn-gc-back').addEventListener('click', () => showView('library')) qs('#btn-gc-save').addEventListener('click', saveGameConfig) qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut) + qs('#btn-gc-logs').addEventListener('click', showAppLogs) qs('#gc-override-add').addEventListener('click', addOverrideRow) qs('#gc-exe-browse').addEventListener('click', async () => { // start from the field being edited (exe picker from the exe field) and diff --git a/renderer/index.html b/renderer/index.html index 40f3631..d654566 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -180,15 +180,23 @@ + +

+
+

+
diff --git a/src/argv.js b/src/argv.js index cf93cc0..699f54c 100644 --- a/src/argv.js +++ b/src/argv.js @@ -1,43 +1,101 @@ 'use strict' +const path = require('path') +const fs = require('fs') + // Shared command-line parsing for the launcher process itself. Both boot // (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 // flags are handled the same way everywhere. // // Supported forms: -// --launch | --launch= auto-start that library app -// --open | --open= open an .exe with the wine picker -// a bare existing-file path same as --open (plumbing for Exec %f) +// --launch | --launch= auto-start that library app +// --file | --file= hand this file to the --launch app +// --open | --open= open an .exe with the wine picker +// a bare existing-file path same as --open (plumbing for Exec %f) +// +// Electron hands the second instance's argv over RE-SERIALIZED: every switch +// comes first and its value follows as a positional argument, e.g. a shortcut +// `--launch --file ` arrives as +// `--launch --file --allow-file-access-from-files `. 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. -function firstNonFlagAfter(args, i) { - // Chromium injects its own flags into the second instance's argv and they - // can land BETWEEN the option and its value (e.g. `--launch - // --allow-file-access-from-files 1790414564627`), so skip anything that - // looks like a flag and take the first non-flag token. - for (let j = i + 1; j < args.length; j++) { - if (!args[j].startsWith('--')) return args[j] +const VALUE_OPTIONS = ['--launch', '--file', '--open'] + +// The options that carry a value, the positional arguments that follow the +// switches, and the values written inline (`--name=value`, which keeps its +// value no matter how the rest is ordered). +// +// Tokens before the first switch are the executable - never values. In dev +// mode (`electron ...`) 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 ` 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 } -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) { const args = Array.isArray(argv) ? argv : process.argv - const launchId = findValue(args, ['--launch']) - let openPath = findValue(args, ['--open']) + const parsed = splitArgv(args) + const launchId = optionValue(parsed, '--launch') + const launchFile = launchFileOf(parsed, launchId) + let openPath = optionValue(parsed, '--open') if (!openPath && !launchId) { // "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 @@ -47,11 +105,11 @@ function parseArgv(argv) { a => !a.startsWith('-') && /\.(exe|msi|bat|cmd|lnk)$/i.test(a) && - !/(electron|olcxja\.plwl)(\.exe)?$/i.test(a) + !/\b(electron|olcxja\.plwl)(\.exe)?$/i.test(a) ) if (bare) openPath = bare } - return { launchId, openPath } + return { launchId, openPath, launchFile } } -module.exports = { parseArgv, findValue } +module.exports = { parseArgv } diff --git a/src/fsutil.js b/src/fsutil.js index 119dce1..6966007 100644 --- a/src/fsutil.js +++ b/src/fsutil.js @@ -57,6 +57,15 @@ function progressGate(emit) { async function treeSize(root) { let files = 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 => { let entries try { @@ -150,6 +159,24 @@ async function removeTree(root, onProgress) { const tick = () => { 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 => { let entries try { diff --git a/src/importer.js b/src/importer.js index 78cba15..5a19765 100644 --- a/src/importer.js +++ b/src/importer.js @@ -1,6 +1,7 @@ 'use strict' -const fsp = require('fs').promises +const fs = require('fs') +const fsp = fs.promises const path = require('path') const { DIRS, resolvePortablePath } = require('./paths') const { newGame, newGameId, addGame } = require('./library') @@ -82,8 +83,22 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) { const driveRoots = prefixDriveRoots(prefix) if (!driveRoots.length) return { ok: false, error: 'no-drive-c' } 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 rel = path.relative(root, src) + let realRoot + try { + realRoot = fs.realpathSync(root) + } catch { + return false + } + const rel = path.relative(realRoot, realSrc) return rel && !rel.startsWith('..') && !path.isAbsolute(rel) }) if (!insideDrive) return { ok: false, error: 'outside-drive-c' } diff --git a/src/ipc/games.js b/src/ipc/games.js index e9d620d..29e44fc 100644 --- a/src/ipc/games.js +++ b/src/ipc/games.js @@ -107,21 +107,33 @@ function createGamesService({ getWindow, onGamesChanged }) { if (onGameExit) onGameExit(id) } - async function start(win, id) { + async function start(win, id, openFile) { const game = library.getGame(id) if (!game) return { ok: false, error: 'game-not-found' } prune() - if (running.has(id)) return { ok: false, error: 'already-running' } let wine try { wine = resolveWine(game) } catch { 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 }) startedAt.set(id, Date.now()) try { - const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) }) + const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id), openFile }) // stop was requested while the process was still starting: kill it and // leave nothing in the running set if (killRequests.has(id)) { diff --git a/src/lansync.js b/src/lansync.js index 0d39e71..55b1c90 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -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 // categories the way the UI/README does. -const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'defaults'] +const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings'] const CATEGORY_ALIASES = { // plain "apps" means the whole app library: portable ones AND the ones @@ -74,12 +74,15 @@ const CATEGORY_ALIASES = { wineprefixes: ['prefixes'], prefixes: ['prefixes'], prefix: ['prefixes'], - // the launcher's own defaults ("Default settings"): which wine everything - // falls back to and where a new import lands - defaults: ['defaults'], - default: ['defaults'], - defaultsettings: ['defaults'], - settings: ['defaults'] + // the launcher's own settings ("Sync settings"): the defaults, the names and + // disabled flags of wine builds, the prefix registry, and every app's + // configuration plus its place in the library. "defaults" is what this + // category used to be called - an older sender still names it that way. + settings: ['settings'], + syncsettings: ['settings'], + defaults: ['settings'], + default: ['settings'], + defaultsettings: ['settings'] } // Which sync category an app entry belongs to. Portable copy/move imports own @@ -316,26 +319,33 @@ function saveHashCache() { } 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 // are the same, and the digest from an earlier sync still describes them. function cachedHash(rel, abs, size, mtimeMs) { - const cache = loadHashCache() - 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++ + const known = hashCacheHit(rel, size, mtimeMs) + if (known) return Promise.resolve(known) return hashFile(abs).then(hash => { if (hash) { - cache[rel] = [size, mtimeMs, hash, Date.now()] + loadHashCache()[rel] = [size, mtimeMs, hash, Date.now()] hashCacheDirty = true } return hash @@ -499,26 +509,18 @@ async function buildManifest(roots, force, onProgress) { // start empty and fill with the bytes actually read const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) if (!force && files.length) { - const cache = loadHashCache() let done = 0 let hashed = 0 await mapLimit(files, HASH_CONCURRENCY, async entry => { const abs = filesPath(entry.path) try { - // inline cache hit: identical size and mtime means identical bytes, so - // 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 + // identical size and mtime means identical bytes, so the digest from + // an earlier sync still describes this file const mtimeMs = mtimes.get(entry.path) || 0 - const known = cache[entry.path] - if (abs && known && known[0] === entry.size && known[1] === mtimeMs && typeof known[2] === 'string') { - hashStats.hits++ - if (Date.now() - (known[3] || 0) > 86400000) { - known[3] = Date.now() - hashCacheDirty = true - } - entry.hash = known[2] + const known = hashCacheHit(entry.path, entry.size, mtimeMs) + if (known && abs) { + entry.hash = known } else { - hashStats.misses++ entry.hash = abs ? await hashFile(abs, bytes => { hashed += bytes @@ -526,7 +528,7 @@ async function buildManifest(roots, force, onProgress) { }) : null if (entry.hash) { - cache[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()] + loadHashCache()[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()] hashCacheDirty = true } } @@ -732,6 +734,10 @@ async function healUnitRoots(roots) { const abs = filesPath(ownedRoot(rel)) if (!abs) continue 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]) if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs) } catch {} @@ -795,19 +801,43 @@ function removeOrphans(dir, keep) { 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) { + const out = [] + let entries try { - return fs - .readdirSync(dir, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name) + entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { - return [] + return out } + 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. 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 try { entries = await fsp.readdir(abs, { withFileTypes: true }) @@ -950,12 +980,129 @@ function applyPrefixes(payload, useSenderDefault) { return removed } -// The launcher-wide defaults, asked for with the "Default settings" category: -// 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, cats) { - if (!cats.has('defaults')) return null +// Wine display names and disabled flags ("Sync settings"): registry-only +// values, matched by build id. Only builds registered HERE are touched, and an +// empty name CLEARS a custom label - syncing from a machine where the build +// was never renamed restores the folder-derived label there, exactly like the +// sender shows. System Wine has no registry entry and is never renamed. +function applyWineSettings(payload) { + 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 applied = {} if (wanted.defaultWine) { @@ -1067,6 +1214,7 @@ function createHttpServer(state, hooks) { if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks) const session = authorize(state, url.searchParams.get('token')) 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/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) @@ -1216,7 +1364,11 @@ async function handlePlan(state, session, url, req, res, hooks) { compared++ comparedBytes += entry.size || 0 session.barBytes = comparedBytes - announce({ unit: label, done: compared, total: allowed.length }) + // the file counter is not touched here: compared items include dirs and + // 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 }) saveHashCache() @@ -1258,7 +1410,7 @@ async function handlePlan(state, session, url, req, res, hooks) { // is planned, so the later file events can name it in the popup session.unitLabel = label sendJson(res, 200, { ok: true, upload, same, entries: paths.size }) - announce({ unit: label, done: allowed.length, total: allowed.length }, true) + announce({ unit: label }, true) } async function handlePut(session, url, req, res, hooks) { @@ -1325,13 +1477,15 @@ async function handleCommit(session, url, res, hooks) { async function handleApply(session, req, res, hooks) { const payload = await readJsonBody(req) if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' }) - const cats = new Set(Array.isArray(payload.categories) ? payload.categories : []) + // the categories come from another machine (and an older PLWL names the + // settings category "defaults"), so they go through the aliases here too + const cats = new Set(normalizeCategories(payload.categories)) const appsDeleted = applyApps(payload, cats) const winesDeleted = cats.has('wines') ? await applyWines(payload) : 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 be - // chosen as the default - const defaults = applyDefaults(payload, cats) + const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('settings')) : 0 + // after the registries are in place, so a synced build/prefix can actually + // be chosen as the default and app configs can be checked against them + const settings = applySettings(payload, cats) const removed = appsDeleted + winesDeleted + prefixesDeleted emitReceive(hooks, session, 'registry', { deleted: removed }) if (hooks.onApplied) { @@ -1343,7 +1497,8 @@ async function handleApply(session, req, res, hooks) { bytes: session.bytes || 0, items: { ...(session.items || {}) }, deleted: removed, - defaults + settings, + defaults: settings ? settings.defaults : null }) } sendJson(res, 200, { @@ -1352,7 +1507,8 @@ async function handleApply(session, req, res, hooks) { apps: { deleted: appsDeleted }, wines: { deleted: winesDeleted }, prefixes: { deleted: prefixesDeleted }, - defaults + settings, + defaults: settings ? settings.defaults : null }) } @@ -1363,6 +1519,18 @@ function handleEnd(state, session, res, hooks) { 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 -------------------------------------------------------------- let beaconSocket = null diff --git a/src/lansyncpush.js b/src/lansyncpush.js index 7f5b7d8..da34f4a 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -17,6 +17,12 @@ const path = require('path') // small files a game or prefix is mostly made of. 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) { const { CATEGORIES, @@ -131,9 +137,15 @@ function createPusher(api) { 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()) { - const rel = toPortablePath(wine.path) - const parts = String(rel).split('/') - add(parts.length === 2 && parts[0] === 'wines' ? rel : `wines/${path.basename(wine.path)}`, wine.label) + const rel = String(toPortablePath(wine.path) || '') + const parts = rel.split('/') + // a build folder can sit one level down (zip-extracted layouts are + // detected at any depth): the unit is always the TOP folder under + // wines/ - that is the folder mirrored as a whole, and listDirs() + // names it as well. Using the basename here would point the unit at a + // folder that does not exist and inflate the "Wine versions" totals + // with a phantom unit. + add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label) } for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`) return [...units.values()] @@ -178,14 +190,39 @@ function createPusher(api) { // the receiver prunes its own builds down to this list and rescans payload.wines = wineUnits().map(unit => ({ name: unit.id })) } - if (cats.has('defaults')) { - // "Default settings": which wine everything falls back to and where a new - // import lands. Opt-in on purpose - a sync of apps must not silently - // change the other machine's defaults. + if (cats.has('settings')) { + // "Sync settings": every registry-level value that describes how the + // launcher is set up here - the defaults, the names/flags the user gave + // wine builds, the prefix registry, and each app's configuration plus + // its position in the library. The receiver only patches things it + // already has; this category creates and deletes no files. Opt-in on + // purpose - a sync of apps must not silently rewrite the other + // machine's settings. + const defs = prefixes.listPrefixes() + const games = library.loadGames() payload.defaults = { defaultWine: (library.loadSettings().defaultWine) || 'system', - defaultPrefix: prefixes.listPrefixes().default || '' + defaultPrefix: defs.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')) { const defs = prefixes.listPrefixes() @@ -291,6 +328,14 @@ function createPusher(api) { // typing (a rejected code is never stored) 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 finished = { apps: 0, wines: 0, prefixes: 0 } // bytes the units planned so far will put on the wire: the popup can then @@ -381,7 +426,6 @@ function createPusher(api) { pending.push(entry) plannedBytes += entry.size || 0 } - allTotal += pending.length // 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 // the files that were already identical never move, so the bar counts @@ -439,6 +483,8 @@ function createPusher(api) { } catch (err) { await end() return fail(String((err && err.message) || err), summary, events) + } finally { + clearInterval(heartbeat) } return syncResult(true, null, summary, events) } diff --git a/src/library.js b/src/library.js index 309d3c9..7aa98af 100644 --- a/src/library.js +++ b/src/library.js @@ -217,6 +217,9 @@ function newGame({ id, name, exePath, workdir, mode }) { prefix: '', args: '', 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: {}, iconSource: '', playSeconds: 0, diff --git a/src/main.js b/src/main.js index 01f39ba..bb8fbe0 100644 --- a/src/main.js +++ b/src/main.js @@ -26,6 +26,7 @@ let tray = null let quitting = false let headless = false // started via --launch: no window until the user asks let autoLaunchId = null // game id from --launch +let autoLaunchFile = null // file from --file ("Open with" on an app shortcut) let autoOpenPath = null // file path from --open ("Open with PLWL") let autoQuitWhenDone = false // headless boot: quit after that game exits const getWindow = () => win @@ -54,10 +55,10 @@ if (!gotLock) { } else { app.on('second-instance', (e, argv) => { trace(`second-instance argv=${argv.join(' ')}`) - const { launchId, openPath } = parseArgv(argv) + const { launchId, openPath, launchFile } = parseArgv(argv) lastHandoffAt = Date.now() if (openPath) openWith(openPath) - else if (launchId) launchGame(launchId) + else if (launchId) launchGame(launchId, launchFile) else showMainWindow() }) } @@ -80,6 +81,7 @@ async function openWith(filePath) { const auto = parseArgv(process.argv) autoLaunchId = auto.launchId +autoLaunchFile = auto.launchFile autoOpenPath = auto.openPath headless = !!autoLaunchId autoQuitWhenDone = headless // booting just to run one game -> exit when it ends @@ -199,10 +201,10 @@ function wireWindow(w) { // --- game auto-launch (shortcuts) ----------------------------------------------- let gamesService = null -async function launchGame(id) { - trace(`launchGame ${id} serviceReady=${!!gamesService}`) +async function launchGame(id, openFile) { + trace(`launchGame ${id} openFile=${openFile || ''} serviceReady=${!!gamesService}`) if (!gamesService) return - const res = await gamesService.start(getWindow(), id) + const res = await gamesService.start(getWindow(), id, openFile) trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`) if (res.ok) { rebuildTrayMenu() @@ -262,7 +264,7 @@ app.whenReady().then(async () => { } if (headless) { // shortcut boot: run the game without opening the launcher window - launchGame(autoLaunchId) + launchGame(autoLaunchId, autoLaunchFile) } else { win = createMainWindow() wireWindow(win) diff --git a/src/openwith.js b/src/openwith.js index fb256a1..520c4de 100644 --- a/src/openwith.js +++ b/src/openwith.js @@ -73,6 +73,7 @@ function makeTarget(openPath, wineId, prefixName) { prefix: prefixName || '', args: '', dllOverrides: [], + disableUmu: false, env: {}, iconSource: '', playSeconds: 0, diff --git a/src/prefixes.js b/src/prefixes.js index 1a7b237..854b5e5 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -15,6 +15,7 @@ function seed() { if (!defs || !Array.isArray(defs.prefixes) || !defs.prefixes.length) { const seeded = { default: MAIN, prefixes: [{ name: MAIN, created: Date.now(), locked: true }] } if (!fs.existsSync(prefixPath(MAIN))) fs.mkdirSync(prefixPath(MAIN), { recursive: true }) + ensureUnifiedPrefix(MAIN) writeJson(REGISTRY, seeded) } } @@ -42,22 +43,65 @@ function prefixExists(name) { } // Every Windows C: drive a prefix has: plain wine keeps one at the top -// (WINEPREFIX=), Proton-managed prefixes nest one under pfx/ -// (WINEPREFIX=/pfx). A prefix used with BOTH wine kinds has both, -// and an import may come from either one - so callers that can accept -// several get them all, top level first. +// (WINEPREFIX=), legacy Proton-managed prefixes nest one under pfx/ +// (WINEPREFIX=/pfx). A prefix used with BOTH wine kinds has both, and +// an import may come from either one - so callers that can accept several get +// them all, top level first. A unified prefix (pfx -> .) reaches the same +// drive twice; it is reported once. function prefixDriveRoots(name) { const root = prefixPath(name) const found = [] + const seen = new Set() for (const base of [root, path.join(root, 'pfx')]) { try { const driveC = path.join(base, 'drive_c') - if (fs.statSync(driveC).isDirectory()) found.push(driveC) + if (!fs.statSync(driveC).isDirectory()) continue + const real = fs.realpathSync(driveC) + if (seen.has(real)) continue + seen.add(real) + found.push(driveC) } catch {} } return found } +function lstatOrNull(p) { + try { + return fs.lstatSync(p) + } catch { + return null + } +} + +// Wine keeps its C: drive at /drive_c, Proton always runs its prefix +// from /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 +// /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 // 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 @@ -82,6 +126,7 @@ function createPrefix(name) { if (clean.toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'alreadyExists' } if (fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' } fs.mkdirSync(prefixPath(clean), { recursive: true }) + ensureUnifiedPrefix(clean) const defs = listPrefixes() defs.prefixes.push({ name: clean, created: Date.now() }) if (!defs.prefixes.some(p => p.name === defs.default)) defs.default = clean @@ -153,4 +198,4 @@ function setDefaultPrefix(name) { return { ok: true } } -module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, MAIN } +module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN } diff --git a/src/runner.js b/src/runner.js index 635febc..867aa38 100644 --- a/src/runner.js +++ b/src/runner.js @@ -2,13 +2,14 @@ const path = require('path') const fs = require('fs') +const os = require('os') const { spawn } = require('child_process') const { DIRS } = require('./paths') -const { prefixPath } = require('./prefixes') +const { prefixPath, ensureUnifiedPrefix } = require('./prefixes') const { prefixNameForGame } = require('./library') -// The fake Steam install Proton is pointed at through -// STEAM_COMPAT_CLIENT_INSTALL_PATH. Both folders Proton links into its prefix +// The fake Steam install PLWL falls back to when no real Steam client is +// found (see findSteamClientDir). Both folders Proton links into its prefix // have to exist: it symlinks /drive_c/Program Files (x86)/Steam/ // appcache -> /appcache while setting the prefix up, and a link into a // missing folder is a dangling link in a tree that gets synced. @@ -19,6 +20,73 @@ function ensureFakeSteam() { 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 +// /legacycompat into the prefix, its built-in steam.exe loads the +// client's own steamclient.so, and media libs come from /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 // 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. @@ -74,23 +142,41 @@ function buildEnv({ game, wine }) { const isProton = wine && wine.type === 'proton' // same fallback the games service snapshots into its running entries // (library.prefixNameForGame), so in-use checks always name this folder - const prefixDir = prefixPath(prefixNameForGame(game)) + const prefixName = 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) { - 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_APP_ID = '0' env.SteamAppId = '0' env.SteamGameId = '0' - // Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID - // set, newer Protons (incl. DW Proton 11, GE-Proton 10+) run the game via - // their built-in umu.exe instead of the steam.exe helper, which would - // otherwise try to load Steam's steamclient.so and crash with an - // "Assertion failed \"!status\"" dialog (steamclient_main.c) when no - // Steam client is installed. - if (!env.UMU_ID) env.UMU_ID = 'umu-0' + // Proton's session init reads STEAM_COMPAT_CLIENT_INSTALL_PATH on EVERY + // verb: its appcache sync (g_compatdata.sync_appcache) runs even for + // runinprefix and does os.environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] as + // soon as SteamGameId is set, so the variable must never be dropped - a + // missing one aborts the launch with a KeyError before wine even starts. + // Only what it POINTS AT depends on the mode: a UMU run may use a real + // client found on this machine, a disableUmu run always gets the empty + // fake folder (runinprefix copies nothing out of it; no steam.exe and no + // umu.exe is started there). + if (game.disableUmu) { + 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.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs') fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true }) @@ -108,16 +194,25 @@ function buildEnv({ game, wine }) { env.WINEPREFIX = prefixDir } - env.WINEDLLOVERRIDES = (game.dllOverrides || []) - .filter(o => o && o.dll && o.setting) - .map(o => `${o.dll}=${o.setting}`) - .join(';') - if (!env.WINEDLLOVERRIDES) delete env.WINEDLLOVERRIDES + const overrides = dllOverridesFor(game, env) + if (overrides) env.WINEDLLOVERRIDES = overrides + else 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 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) { const out = [] const re = /"([^"]*)"|'([^']*)'|(\S+)/g @@ -126,6 +221,25 @@ function parseArgs(args) { 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 // Live playtime: while the game runs, update ONLY the tiny playtime file @@ -155,19 +269,21 @@ function openLatestLog(gameId) { return fs.createWriteStream(logFile, { flags: 'w' }) } -async function runGame({ game, wine, systemWine, onExit }) { +async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) { await healPrefixOnce(game) const isProton = wine && wine.type === 'proton' - const { env } = buildEnv({ game, wine }) + const { env, prefixDir } = buildEnv({ game, wine }) const exe = game.exePath const workdir = game.workdir || path.dirname(exe) 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 base if (isProton) { cmd = wine.wineBin || path.join(wine.path, 'proton') - base = ['run', exe, ...argList] + base = [protonVerb(game), exe, ...argList] } else { cmd = systemWine ? 'wine' : wine.wineBin base = [exe, ...argList] @@ -180,7 +296,10 @@ async function runGame({ game, wine, systemWine, onExit }) { child = spawn(cmd, base, { cwd: workdir, env, - stdio: ['ignore', 'pipe', 'pipe'], + // a handoff (a file given to an already running app) is not tracked: + // its pipes are left unread, so the output goes nowhere instead of + // filling up and blocking the app + stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'], detached: true }) } catch (err) { @@ -198,8 +317,21 @@ async function runGame({ game, wine, systemWine, onExit }) { }) child.once('spawn', () => { 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 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.stderr.pipe(ws) child.unref() @@ -237,7 +369,7 @@ async function runTool({ game, wine, tool, args, systemWine }) { let base if (isProton) { cmd = wine.wineBin || path.join(wine.path, 'proton') - base = ['run', tool, ...argList] + base = [protonVerb(game), tool, ...argList] } else if (systemWine) { cmd = tool base = argList @@ -263,4 +395,4 @@ function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) { return runGame({ game, wine, systemWine, onExit }) } -module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath } +module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor } diff --git a/src/shortcuts.js b/src/shortcuts.js index 831e556..33899c0 100644 --- a/src/shortcuts.js +++ b/src/shortcuts.js @@ -44,8 +44,14 @@ function execToken(token) { 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) { - return [...launcherArgv(), ...extraArgs.split(' ').filter(Boolean).map(execToken)].map(execToken).join(' ') + const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)] + return [...tokens.map(execToken), '%f'].join(' ') } function sanitizeName(name) { @@ -71,7 +77,7 @@ function buildDesktopEntry(game) { 'Type=Application', `Name=${name}`, 'Comment=Launch via PLWL', - `Exec=${execLine(`--launch ${game.id}`)}`, + `Exec=${execLine(`--launch ${game.id} --file`)}`, 'Terminal=false', 'Categories=Game;' ] diff --git a/src/symlinks.js b/src/symlinks.js index cf724d9..e1f774c 100644 --- a/src/symlinks.js +++ b/src/symlinks.js @@ -16,7 +16,11 @@ // // Everything here rewires a link to a RELATIVE path when the file it points at // can be found locally, so the tree stays relocatable forever, and restores -// the executable bit on the binaries a wine/proton build needs. +// the executable bit on the binaries a wine/proton build needs. An absolute +// link is always rewritten; a relative one is only touched when it no longer +// resolves, because a tree that changed layout (a prefix whose contents moved +// one level up, see prefixes.ensureUnifiedPrefix) keeps relative links written +// against the old depth and every one of them breaks at once. const fsp = require('fs').promises const fs = require('fs') @@ -116,10 +120,11 @@ function relativeTarget(link, target, roots) { return null } -// Rewire every absolute symlink under `root` that can be expressed relative to -// `roots` (usually [root, DIRS.FILES]). Returns how many links changed. A link -// may only become rewirable once another link it chains through has been -// fixed, so the pass repeats until nothing changes. +// Rewire the symlinks under `root` that can be expressed relative to `roots` +// (usually [root, DIRS.FILES]): absolute ones always, relative ones only when +// they no longer resolve. Returns how many links changed. A link may only +// become rewirable once another link it chains through has been fixed, so the +// pass repeats until nothing changes. async function rewireSymlinks(root, roots = []) { if (!root) return 0 const candidates = [root, ...roots.filter(r => r && r !== root)] @@ -135,8 +140,13 @@ async function rewireSymlinks(root, roots = []) { } catch { continue } - if (!path.isAbsolute(target)) continue // already relocatable - const next = relativeTarget(link, path.normalize(target), candidates) + const absolute = path.isAbsolute(target) + // a relative link that still resolves is already relocatable - only a + // broken one is worth touching, and the suffix search in relativeTarget + // finds the file it was pointing at before the layout changed + if (!absolute && exists(path.resolve(path.dirname(link), target))) continue + const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target) + const next = relativeTarget(link, from, candidates) if (!next || next === target) continue try { await fsp.unlink(link) diff --git a/src/wines.js b/src/wines.js index 5f7bf72..671b37a 100644 --- a/src/wines.js +++ b/src/wines.js @@ -167,14 +167,31 @@ function applyDisplayName(w) { // A build keeps its folder and id (apps point at the id, and prefix symlinks // point inside the folder), so "renaming" only changes the shown label. function setWineDisplayName(id, name) { + 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 w = wines.find(x => x.id === id) if (!w) return { ok: false, error: 'notFound' } if (w.type === 'system') return { ok: false, error: 'isSystem' } - const clean = normalizeLabel(name) - if (!clean) return { ok: false, error: 'invalidName' } - w.displayName = clean - applyDisplayName(w) + if (clean) { + w.displayName = clean + w.label = clean + } else { + if (!w.displayName) return { ok: true, displayName: '' } + delete w.displayName + w.label = labelFor(w.type, w.path, w.version) + } writeJson(REGISTRY, wines.map(wineRel)) return { ok: true, displayName: clean } } @@ -241,6 +258,16 @@ async function fixBuildSymlinks(root) { // Everything a freshly arrived build needs before it can run: relative links // and executable binaries. Returns what was changed, for the callers' reports. async function healBuild(root) { + // 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 executables = await restoreExecutableBits(root) return { links, executables } @@ -316,4 +343,4 @@ async function importWineFolder(srcDir, onProgress) { return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables } } -module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild } +module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }