Compare commits

..

9 commits

Author SHA1 Message Date
096e36a9fa bump to 0.1.3 2026-10-01 23:14:50 +02:00
a1f34f79fd rounded arrows on sync icon 2026-10-01 23:14:34 +02:00
07eae34e2c update sync icon 2026-10-01 22:18:47 +02:00
b087db1b4d sync fixes:
- file counter
- popup text
- default settings not syncing
2026-10-01 20:57:45 +02:00
f0e097fa76 optimize sync & add accept popup 2026-10-01 19:57:05 +02:00
c9f604863a make small buttons look sharper 2026-10-01 17:42:03 +02:00
c6c754c55b small translation update 2026-10-01 17:15:11 +02:00
806e173e43 a LOT sync fixes... 2026-10-01 17:12:53 +02:00
c0f6e802ed more accurate sync progress report 2026-10-01 14:58:58 +02:00
20 changed files with 1733 additions and 294 deletions

View file

@ -41,7 +41,7 @@ cd PLWL
## Importing from a wine prefix
Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix.
Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix. Imports keep the folder as it was: symlinks are recreated (rewritten to relative whenever the target is part of the launcher) and file permissions are carried over, so an imported game or build runs straight away.
## "Open with PLWL" in the file manager
@ -52,17 +52,23 @@ The desktop entry (`MimeType` + `Exec %f`) makes PLWL show up in Dolphin/Nautilu
Two PLWL installs on the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings):
- **Device ID** - 24 characters, generated once per install (current timestamp + random characters) and re-rollable at any time.
- **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.
- **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** and **prefixes**. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently.
Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Default settings**. Only what is ticked travels - a sync without **Default settings** never touches the other machine's defaults. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently.
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. Everything else stays local: settings, logs, playtime/last played, 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.
**Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing; it closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has vanished.
The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Hashes are remembered per file (size + modification time), so a repeat sync of a library that did not change reads nothing at all.
**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.
## 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).
- **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.
- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_CLIENT_INSTALL_PATH` points to a fake steam client folder, `STEAM_COMPAT_DATA_PATH` is a per-build compat folder, and the prefix lives at `<compat>/pfx` which PLWL links to its own prefix folder. No Steam installation required (GE builds work out of the box; for a few Valve builds that refuse to run without a client, PLWL fakes a minimal steam folder in `files/data/fake-steam`).
## Translations

View file

@ -17,13 +17,16 @@ src/ main process (Node/Electron backend)
library.js apps registry (apps.json) + settings + playtime files
wines.js wine/proton build scan, registry, portable symlink healing
prefixes.js prefix registry (files/wineprefixes)
symlinks.js relative symlink rewiring + executable-bit restoration
(shared by imports, prefixes and LAN sync)
importer.js app import (copy/move/leave/prefix)
openwith.js "Open with PLWL": ad-hoc runs, no library entry
shortcuts.js .desktop shortcut generation
paths.js portable layout, FILES-relative path helpers, translation sync
i18n.js translation loading + {0} placeholder filling
preload.js contextBridge API exposed to the renderer
lansync.js LAN sync: identity + pairing, hashing, HTTP receiver, UDP discovery
lansync.js LAN sync: identity + pairing, hashing, HTTP receiver (with its
receive-progress hook), UDP discovery
lansyncpush.js the pushing side (createPusher factory, keeps helper state out)
appicon.js window/taskbar icon
renderer/ UI (plain HTML/CSS/JS, no framework)
@ -66,7 +69,53 @@ handlers in src/ipc/. Keep new APIs in that pattern.
the flex gap of the container, not per-element magic numbers.
- LAN sync has two sides: the receiver lives in lansync.js, the sender in
lansyncpush.js and is built by createPusher(helpers) - several launcher
states share the module, so nothing may be kept in its module scope.
states share the module, so nothing may be kept in its module scope. Both
sides report their own progress (the receiver through the onReceive hook),
so each machine can show the transfer.
- An incoming sync needs CONSENT: a correct code opens the gate, but the
receiver's onIncoming hook (registered in main.js) shows an Accept/Decline
popup and only a click - answered through lansync:respondIncoming - starts
the session. Unanswered requests expire after INCOMING_TIMEOUT_MS; a decline
returns 'rejected' to the sender and KEEPS the pairing (the code was proven).
Without an onIncoming hook (tests, headless) the gate stays open.
- Sync progress is bytes plus per-category counters (items = announced units,
itemsDone = finished ones, totals = everything the sender picked), never raw
file counts: the popup says "Apps: 2/5". The bar follows the CURRENT PHASE's
bytes (barBytes/barTotalBytes, not the overall bytes/totalBytes) so a phase
starts from an empty bar instead of inheriting a full one - otherwise hashing
looks finished the moment it starts. Hashing and copying walk tens of
thousands of files, so every per-file update goes through progressGate
(~10/s, phase changes forced) - never send one IPC message per file.
- 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).
- LAN sync remembers file hashes in files/data/lansync-hashes.json, keyed by
path with size + mtime (machine-local, never synced): equal size and mtime
means equal bytes, so a repeat sync only stat's the tree. Hashing and the
receiving side's comparison both run HASH_CONCURRENCY files at a time.
- A modal showing a running operation (a sync) opens locked:
showModal(html, { locked: true }) ignores the backdrop and Escape, because
the user cannot cancel the operation by dismissing it.
- Symlinks inside everything PLWL owns (a wine/proton build, a prefix, an
imported app) are stored RELATIVE whenever the target can be expressed
locally: a Proton prefix is full of links into its build, and an absolute one
points at the machine that wrote it. symlinks.js owns this (rewireSymlinks,
plus restoreExecutableBits for the executables a content copy drops) and
every writer calls it: importWineFolder/healBuild, importer after copy/move,
healPrefix, and the sync both on the wire (portableLinkTarget sends the
relative target) and after a unit lands (healUnitRoots). dosdevices drive
mappings and targets that leave files/ (a mount, a system path, the user's
home) stay absolute - they are machine-specific by design.
- Wine and Proton create prefix links with ABSOLUTE local targets whenever they
update a prefix (font/dll stubs, Steam appcache), so on-disk links are not
always relative after a run: the sync payload relativizes them anyway, and
refreshWines()/healPrefix() repair what arrived from an older PLWL. A build
whose binaries lost their executable bit is detected by a cheap audit in
refreshWines() and healed once, because spawn() on a non-executable `proton`
fails with EACCES and the game would look like it started while nothing ran.
- File permissions travel with a sync (manifest entry `mode`): the receiving
side applies them after writing the bytes and repairs a file that has the
same hash but the wrong mode without re-uploading it.
- 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.
@ -76,5 +125,15 @@ handlers in src/ipc/. Keep new APIs in that pattern.
node --check <changed js files> # syntax
node test/import.test.js # ...and the other test files
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
node test/lansync-receiving.test.js # receive-side progress events (the popup)
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
node test/lansync-links.test.js # wire: exec bits + relative links in a sync
node test/lansync-pending-links.test.js # links to files created later, startup heal
node test/lansync-consent.test.js # incoming sync: accept / decline / timeout gate
node test/bench-sync.js # timing: phases of a cold and a cached sync
npm start # click through the flow you touched
# Repairing / testing a real instance (test/ scripts are gitignored helpers):
PLWL_BUILD_DIR=/path/to/instance node test/heal-instance.js # links + exec bits
PLWL_BUILD_DIR=/path/to/instance node test/launch-instance.js <id> # really start a game
```

View file

@ -151,7 +151,7 @@
"lansync.deviceId": "Device ID",
"lansync.syncCode": "Sync code",
"lansync.reroll": "Reroll",
"lansync.codeHint": "Type this code on the other device so it may sync to this one. It is case-insensitive and only has to be entered once per device.",
"lansync.codeHint": "Type this code on the other device so it may sync to this one.",
"lansync.devices": "Devices on your network",
"lansync.devicesHint": "Devices show up here while PLWL is running on them.",
"lansync.empty": "No devices found. Make sure PLWL is running on the other machine and both are on the same network.",
@ -164,23 +164,46 @@
"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.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.",
"lansync.start": "Start sync",
"lansync.progress": "Syncing to {0}…",
"lansync.phase.scan": "Preparing {0}",
"lansync.phase.hash": "Hashing {0}",
"lansync.phase.plan": "Comparing {0}",
"lansync.phase.copy": "Sending {0}",
"lansync.recvPhase.copy": "Receiving {0}",
"lansync.phase.registry": "Updating the library",
"lansync.phase.commit": "Removing extra files",
"lansync.phase.done": "Finishing up",
"lansync.progressFiles": "{0} / {1} files",
"lansync.done": "Synced to {0}: {1} sent, {2} already up to date, {3} removed",
"lansync.progressBytes": "{0} / {1} transferred",
"lansync.progressSent": "{0} transferred",
"lansync.keepOpen": "Do not close PLWL until this finishes.",
"lansync.statsApps": "Apps: {0}",
"lansync.statsPrefixes": "Prefixes: {0}",
"lansync.statsWines": "Wine versions: {0}",
"lansync.receiving": "Receiving from {0}…",
"lansync.incomingTitle": "Incoming sync from {0}",
"lansync.incomingText": "{0} wants to sync its apps, Wine versions and prefixes onto this device.",
"lansync.incomingNothing": "Nothing was selected, or the totals have not arrived yet.",
"lansync.incomingTimedOut": "The incoming sync request timed out.",
"lansync.accept": "Accept",
"lansync.decline": "Decline",
"lansync.rejected": "{0} declined the sync.",
"lansync.unknownDevice": "Unknown device",
"lansync.recvWaiting": "Waiting for the file list…",
"lansync.recvStalled": "Waiting for the other device…",
"lansync.recvIdle": "The other device stopped responding, the sync was interrupted.",
"lansync.done": "Synced to {0}: {1} sent ({4}), {2} already up to date, {3} removed",
"lansync.failed": "Sync failed: {0}",
"lansync.badCode": "Wrong sync code. Enter the code shown on the other device.",
"lansync.busy": "The other device is busy (another sync or a running app).",
"lansync.offline": "That device is no longer on the network.",
"lansync.noneSelected": "Pick at least one thing to sync.",
"lansync.applied": "Synced from {0}: {1} files received",
"lansync.applied": "Synced from {0}: {1} files received ({2})",
"lansync.idRerolled": "New device ID generated",
"lansync.codeRerolled": "New sync code generated",
"lansync.unknownError": "unknown error",

View file

@ -151,7 +151,7 @@
"lansync.deviceId": "ID urządzenia",
"lansync.syncCode": "Kod synchronizacji",
"lansync.reroll": "Wygeneruj nowy",
"lansync.codeHint": "Wpisz ten kod na drugim urządzeniu, żeby mogło synchronizować do tego. Wielkość liter nie ma znaczenia, kod wpisuje się tylko raz na urządzenie.",
"lansync.codeHint": "Wpisz ten kod na drugim urządzeniu, żeby mogło synchronizować do tego.",
"lansync.devices": "Urządzenia w sieci",
"lansync.devicesHint": "Urządzenia pojawiają się tutaj, gdy PLWL jest na nich uruchomiony.",
"lansync.empty": "Nie znaleziono urządzeń. Upewnij się, że PLWL działa na drugiej maszynie i oba komputery są w tej samej sieci.",
@ -164,23 +164,46 @@
"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.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.",
"lansync.start": "Rozpocznij synchronizację",
"lansync.progress": "Synchronizowanie na {0}…",
"lansync.phase.scan": "Przygotowywanie {0}",
"lansync.phase.hash": "Haszowanie {0}",
"lansync.phase.plan": "Porównywanie {0}",
"lansync.phase.copy": "Wysyłanie {0}",
"lansync.recvPhase.copy": "Odbieranie {0}",
"lansync.phase.registry": "Aktualizowanie biblioteki",
"lansync.phase.commit": "Usuwanie zbędnych plików",
"lansync.phase.done": "Kończenie",
"lansync.progressFiles": "{0} / {1} plików",
"lansync.done": "Zsynchronizowano na {0}: wysłano {1}, aktualnych {2}, usunięto {3}",
"lansync.progressBytes": "{0} / {1} przesłane",
"lansync.progressSent": "przesłano {0}",
"lansync.keepOpen": "Nie zamykaj PLWL, dopóki to się nie zakończy.",
"lansync.statsApps": "Aplikacje: {0}",
"lansync.statsPrefixes": "Prefiksy: {0}",
"lansync.statsWines": "Wersje Wine: {0}",
"lansync.receiving": "Odbieranie od {0}…",
"lansync.incomingTitle": "Przychodząca synchronizacja od {0}",
"lansync.incomingText": "{0} chce przysłać swoje aplikacje, wersje Wine i prefiksy na to urządzenie.",
"lansync.incomingNothing": "Nic nie zaznaczono albo podsumowanie jeszcze nie dotarło.",
"lansync.incomingTimedOut": "Upłynął czas na odpowiedź na przychodzącą synchronizację.",
"lansync.accept": "Akceptuj",
"lansync.decline": "Odrzuć",
"lansync.rejected": "{0} odrzucił synchronizację.",
"lansync.unknownDevice": "Nieznane urządzenie",
"lansync.recvWaiting": "Oczekiwanie na listę plików…",
"lansync.recvStalled": "Oczekiwanie na drugie urządzenie…",
"lansync.recvIdle": "Drugie urządzenie przestało odpowiadać, synchronizacja została przerwana.",
"lansync.done": "Zsynchronizowano na {0}: wysłano {1} ({4}), aktualnych {2}, usunięto {3}",
"lansync.failed": "Synchronizacja nie powiodła się: {0}",
"lansync.badCode": "Błędny kod synchronizacji. Wpisz kod pokazany na drugim urządzeniu.",
"lansync.busy": "Drugie urządzenie jest zajęte (inna synchronizacja lub uruchomiona aplikacja).",
"lansync.offline": "To urządzenie zniknęło z sieci.",
"lansync.noneSelected": "Wybierz przynajmniej jedną rzecz do synchronizacji.",
"lansync.applied": "Zsynchronizowano z {0}: odebrano {1} plików",
"lansync.applied": "Zsynchronizowano z {0}: odebrano {1} plików ({2})",
"lansync.idRerolled": "Wygenerowano nowe ID urządzenia",
"lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji",
"lansync.unknownError": "nieznany błąd",

View file

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

View file

@ -15,6 +15,10 @@ const state = {
games: [],
currentGame: null,
lansync: null,
// true while this device pushes a sync (and while it shows the progress
// popup): only one such popup exists, the pushing side wins over an
// incoming sync
lanPushing: false,
currentView: 'library'
}
@ -35,7 +39,7 @@ const ICONS = {
wines: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 22h8"/><path d="M12 15v7"/><path d="M7 10h10"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z"/></svg>',
settings: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.5 2 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z"/></svg>',
// nodes connected to each other - devices on the local network
lansync: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="4.5" r="2.5"/><circle cx="5" cy="19" r="2.5"/><circle cx="19" cy="19" r="2.5"/><path d="M10.6 6.8 6.5 16.7M13.4 6.8l4.1 9.9M7.5 19h9"/></svg>',
lansync: () => '<svg viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><g transform="translate(-1.44,26.17) scale(0.028)"><path transform="translate(182.4,-402.4) scale(0.62)" stroke="currentColor" stroke-width="34" stroke-linejoin="round" stroke-linecap="round" d="M160-160v-80h110l-16-14q-52-46-73-105t-21-119q0-111 66.5-197.5T400-790v84q-72 26-116 88.5T240-478q0 45 17 87.5t53 78.5l10 10v-98h80v240H160Zm400-10v-84q72-26 116-88.5T720-482q0-45-17-87.5T650-648l-10-10v98h-80v-240h240v80H690l16 14q49 49 71.5 106.5T800-482q0 111-66.5 197.5T560-170Z"/><path d="M200-120q-33 0-56.5-23.5T120-200v-160q0-33 23.5-56.5T200-440h560q33 0 56.5 23.5T840-360v160q0 33-23.5 56.5T760-120H200Zm0-80h560v-160H200v160Z"/><circle cx="340" cy="-280" r="40"/><circle cx="480" cy="-280" r="40"/><circle cx="620" cy="-280" r="40"/></g></svg>',
// arrow into a tray - import
config: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>',
folder: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
@ -114,8 +118,13 @@ function toast(message, kind) {
// cancels any pending teardown and clears the closing state.
let modalTeardownTimer = null
let modalSwapTimer = null
// A running sync must not be thrown away by a stray click: a locked modal
// ignores the backdrop and Escape, only its own buttons (or the end of the
// operation) close it.
let modalLocked = false
function showModal(html) {
function showModal(html, opts) {
modalLocked = !!(opts && opts.locked)
if (modalTeardownTimer) {
clearTimeout(modalTeardownTimer)
modalTeardownTimer = null
@ -189,6 +198,7 @@ function closeModal() {
modal.classList.remove('closing', 'swapping', 'resizing')
modal.style.height = ''
modal.innerHTML = ''
modalLocked = false
}, 160)
}
@ -1134,9 +1144,72 @@ const LAN_CATEGORIES = [
{ id: 'apps-portable', label: 'lansync.appsPortable' },
{ id: 'apps-prefix', label: 'lansync.appsPrefix' },
{ id: 'wines', label: 'lansync.wines' },
{ id: 'prefixes', label: 'lansync.prefixes' }
{ id: 'prefixes', label: 'lansync.prefixes' },
// opt-in: without it a sync never touches the other machine's defaults
{ id: 'defaults', label: 'lansync.defaults' }
]
// Byte sizes for the progress popups. Decimal units, the way a file manager
// or a download shows them, because that is what a "1.2 GB" on screen means
// to the user.
function formatBytes(bytes) {
const value = Math.max(0, Number(bytes) || 0)
if (value < 1000) return `${Math.round(value)} B`
if (value < 1000 * 1000) return `${(value / 1000).toFixed(1)} kB`
if (value < 1000 * 1000 * 1000) return `${(value / 1000 / 1000).toFixed(1)} MB`
return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB`
}
// "Apps: 2/5 · Prefixes: 1/2 · Wine versions: 0/1" - how much of each category
// is done out of how much the sync carries, so the line says what is still
// coming instead of only listing what has already been walked over. A peer
// that does not announce its totals falls back to the plain count.
function lanStatsLine(items, itemsDone, totals) {
const rows = [
['apps', 'lansync.statsApps'],
['prefixes', 'lansync.statsPrefixes'],
['wines', 'lansync.statsWines']
]
const out = []
for (const [kind, key] of rows) {
const listed = Number(items && items[kind]) || 0
const total = Number(totals && totals[kind]) || 0
const known = itemsDone && Number.isFinite(Number(itemsDone[kind]))
const done = known ? Number(itemsDone[kind]) : listed
if (total > 0) out.push(t(key, `${Math.min(done, total)}/${total}`))
else if (listed > 0) out.push(t(key, listed))
}
return out.join(' · ')
}
// The bar follows BYTES, not file counts: a sync spends its time on the big
// files, and a file-count bar sits at 99% while the last gigabyte crawls in.
// It also follows the PHASE it is in (barBytes/barTotalBytes): a phase starts
// from an empty bar instead of inheriting a full one from the phase before, so
// hashing never looks finished while it has just begun.
function renderProgressBar(bar, bytes, totalBytes) {
const sent = Math.max(0, Number(bytes) || 0)
const total = Math.max(0, Number(totalBytes) || 0)
bar.max = Math.max(total, 1)
bar.value = Math.min(sent, bar.max)
}
// Older peers only send the overall byte totals - then those drive the bar.
function barValues(info) {
const bytes = info.barBytes == null ? info.bytes : info.barBytes
const total = info.barTotalBytes == null ? info.totalBytes : info.barTotalBytes
return [bytes, total]
}
// "340 MB / 1.2 GB" once the total is known (the sending side learns it as it
// plans the units, the receiving side as the manifests arrive), otherwise
// just what has moved so far - so the line is never a lying zero.
function lanBytesLine(bytes, totalBytes) {
const sent = formatBytes(bytes)
if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : ''
return t('lansync.progressBytes', sent, formatBytes(totalBytes))
}
async function renderLanSync() {
const info = await window.plwl.lansyncGet()
state.lansync = info
@ -1243,36 +1316,50 @@ async function openLanSyncModal(device, errorKey) {
}
async function runLanSync(device, code, categories, force) {
// locked: a running sync is not cancellable by a stray click on the backdrop
const modal = showModal(`
<h2 id="ls-run-title"></h2>
<div class="progress-row"><progress></progress></div>
<p class="subtitle" id="ls-run-phase"></p>
<p class="subtitle" id="ls-run-count"></p>
`)
<p class="subtitle" id="ls-run-stats"></p>
<p class="subtitle" id="ls-run-bytes"></p>
<p class="subtitle sync-warning" id="ls-run-warning"></p>
`, { locked: true })
modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name)
modal.querySelector('#ls-run-warning').textContent = t('lansync.keepOpen')
const bar = modal.querySelector('progress')
const phase = modal.querySelector('#ls-run-phase')
const count = modal.querySelector('#ls-run-count')
const stats = modal.querySelector('#ls-run-stats')
const bytes = modal.querySelector('#ls-run-bytes')
let active = true
state.lanPushing = true
window.plwl.onLanSyncProgress(data => {
if (!active) return
const key = 'lansync.phase.' + data.phase
phase.textContent = t(key, data.label || '')
if (data.total > 0) {
bar.max = data.total
bar.value = data.done
count.textContent = t('lansync.progressFiles', data.done, data.total)
} else {
bar.removeAttribute('value')
count.textContent = ''
}
// Every line keeps its value while the sync moves between phases: the
// file counter is CUMULATIVE across units (it never resets to 0/N with
// the next app) and phases that move no files simply do not touch it -
// only the byte bar follows the phase being worked on
phase.textContent = t('lansync.phase.' + data.phase, data.label || '').trim()
renderProgressBar(bar, ...barValues(data))
if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total)
stats.textContent = lanStatsLine(data.items, data.itemsDone, data.totals)
bytes.textContent = lanBytesLine(data.bytes, data.totalBytes)
})
const res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force })
let res = null
try {
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force })
} catch (err) {
res = { ok: false, error: (err && err.message) || '' }
} finally {
active = false
state.lanPushing = false
}
closeModal()
if (res && res.ok) {
const summary = res.summary || {}
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0), 'success')
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0, formatBytes(summary.bytes || 0)), 'success')
await renderLanSync()
return
}
@ -1283,24 +1370,219 @@ async function runLanSync(device, code, categories, force) {
openLanSyncModal({ ...device, code: '' }, 'lansync.badCode')
return
}
const message = err === 'busy' ? t('lansync.busy') : err === 'offline' ? t('lansync.offline') : (err || t('lansync.unknownError'))
const message =
err === 'busy' ? t('lansync.busy')
: err === 'offline' ? t('lansync.offline')
: err === 'rejected' ? t('lansync.rejected')
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
: (err || t('lansync.unknownError'))
toast(t('lansync.failed', message), 'error')
}
// --- receiving a sync (the other device is the source of truth) -------------
// A sync pushed onto THIS device must also be visible here - otherwise the
// library changes under the user's hands with no explanation. Same popup, but
// driven by the events the receiving server emits, and equally not closable
// by a click.
// The sending machine does its hashing before anything is written here, which
// can be minutes of complete silence - so the popup switches to "waiting"
// instead of closing, and only a really long silence drops it. Events after
// that reopen it (the sync is still running, the user just cannot see it).
const RECEIVE_WATCHDOG_MS = 15 * 60 * 1000
const RECEIVE_STALL_MS = 6 * 1000
// phases whose wording differs on this side (everything else is neutral:
// "Comparing X", "Updating the library", "Finishing up")
const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' }
// A device wants to push to us: the popup asks for consent before anything
// moves. One pending request at a time (the server enforces that too).
let incomingRequest = null
let incomingTimer = null
function onLanIncoming(info) {
if (!info || !info.requestId) return
if (state.lanPushing || receiving) {
// our own transfer owns the modal slot - answer so the sender fails
// fast instead of waiting out its timeout
window.plwl.lansyncRespondIncoming(info.requestId, false)
return
}
incomingRequest = info
const name = info.name || info.deviceId || t('lansync.unknownDevice')
const totals = info.totals || {}
const parts = []
if (totals.apps > 0) parts.push(t('lansync.statsApps', totals.apps))
if (totals.wines > 0) parts.push(t('lansync.statsWines', totals.wines))
if (totals.prefixes > 0) parts.push(t('lansync.statsPrefixes', totals.prefixes))
const modal = showModal(`
<h2 id="ls-incoming-title"></h2>
<p id="ls-incoming-text"></p>
<p class="subtitle" id="ls-incoming-what"></p>
<p class="subtitle sync-warning" id="ls-incoming-warning"></p>
<div class="modal-actions">
<button class="button" id="ls-incoming-decline"></button>
<button class="button primary" id="ls-incoming-accept"></button>
</div>
`, { locked: true })
modal.querySelector('#ls-incoming-title').textContent = t('lansync.incomingTitle', name)
modal.querySelector('#ls-incoming-text').textContent = t('lansync.incomingText', name)
modal.querySelector('#ls-incoming-what').textContent = parts.length ? parts.join(' · ') : t('lansync.incomingNothing')
// the SENDER is the source of truth: the warning is phrased from this
// (receiving) side's point of view, not the send modal's mirrorWarning
modal.querySelector('#ls-incoming-warning').textContent = t('lansync.incomingMirror')
modal.querySelector('#ls-incoming-decline').textContent = t('lansync.decline')
modal.querySelector('#ls-incoming-accept').textContent = t('lansync.accept')
const answer = accept => {
if (!incomingRequest) return
const requestId = incomingRequest.requestId
incomingRequest = null
if (incomingTimer) {
clearTimeout(incomingTimer)
incomingTimer = null
}
closeModal()
window.plwl.lansyncRespondIncoming(requestId, accept)
}
modal.querySelector('#ls-incoming-accept').onclick = () => answer(true)
modal.querySelector('#ls-incoming-decline').onclick = () => answer(false)
// mirror the server's own expiry so the popup never outlives its request
incomingTimer = setTimeout(() => {
incomingTimer = null
incomingRequest = null
closeModal()
toast(t('lansync.incomingTimedOut'), 'error')
}, 60000)
}
let receiving = false
let receiveClosed = false
let receiveWatchdog = null
let receiveStall = null
let receiveCloseTimer = null
function receiveModalParts() {
const modal = qs('#modal')
if (!receiving || !modal) return null
const title = modal.querySelector('#ls-recv-title')
if (!title) return null
return {
title,
bar: modal.querySelector('#ls-recv-modal progress'),
phase: modal.querySelector('#ls-recv-phase'),
count: modal.querySelector('#ls-recv-count'),
stats: modal.querySelector('#ls-recv-stats'),
bytes: modal.querySelector('#ls-recv-bytes')
}
}
function endReceiving(message) {
if (receiveWatchdog) {
clearTimeout(receiveWatchdog)
receiveWatchdog = null
}
if (receiveStall) {
clearTimeout(receiveStall)
receiveStall = null
}
if (receiveCloseTimer) {
clearTimeout(receiveCloseTimer)
receiveCloseTimer = null
}
const wasReceiving = receiving
receiving = false
receiveClosed = true
if (!wasReceiving) return
if (message) toast(message, 'error')
closeModal()
}
// A sender that crashed halfway through must not leave this window locked: the
// popup closes itself if nothing at all arrives for a long time (the server
// drops the session after the same idle time). While it waits, the phase line
// says so rather than the popup disappearing mid-transfer.
function armReceiveWatchdog() {
if (receiveWatchdog) clearTimeout(receiveWatchdog)
receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS)
if (receiveStall) clearTimeout(receiveStall)
receiveStall = setTimeout(() => {
receiveStall = null
const parts = receiveModalParts()
if (parts) parts.phase.textContent = t('lansync.recvStalled')
}, RECEIVE_STALL_MS)
}
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 (!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
// that already ended stays closed.
if (receiveClosed && info.stage !== 'start') return
if (qs('#modal-overlay').style.display !== 'none') return
receiving = true
const modal = showModal(`
<h2 id="ls-recv-title"></h2>
<div class="progress-row" id="ls-recv-modal"><progress></progress></div>
<p class="subtitle" id="ls-recv-phase"></p>
<p class="subtitle" id="ls-recv-count"></p>
<p class="subtitle" id="ls-recv-stats"></p>
<p class="subtitle" id="ls-recv-bytes"></p>
<p class="subtitle sync-warning" id="ls-recv-warning"></p>
`, { locked: true })
modal.querySelector('#ls-recv-title').textContent = t('lansync.receiving', info.from || info.deviceId || '')
modal.querySelector('#ls-recv-warning').textContent = t('lansync.keepOpen')
armReceiveWatchdog()
return renderReceiving(info)
}
armReceiveWatchdog()
renderReceiving(info)
}
function renderReceiving(info) {
const parts = receiveModalParts()
if (!parts) 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
parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim()
renderProgressBar(parts.bar, ...barValues(info))
if (info.total > 0) parts.count.textContent = t('lansync.progressFiles', info.done, info.total)
else parts.count.textContent = ''
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
if (info.stage === 'done') {
// let the totals be read, then get out of the way (the library reloads
// itself through lansync:applied)
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
receiveCloseTimer = setTimeout(() => {
receiveCloseTimer = null
endReceiving('')
}, 4000)
}
}
// Data received from another device changed the local files: reload every
// list so the views show the merged library instead of stale state.
// list so the views show the merged library instead of stale state. Settings
// too - a sync with "Default settings" rewrote the default wine/prefix on
// disk, and the settings view would keep showing the values from before.
async function reloadLocalData() {
const [wines, prefixes, games] = await Promise.all([
const [wines, prefixes, games, settings] = await Promise.all([
window.plwl.winesList(),
window.plwl.prefixesList(),
window.plwl.gamesList()
window.plwl.gamesList(),
window.plwl.settingsGet()
])
state.wines = wines
state.prefixes = prefixes
state.games = games
state.settings = settings
if (state.currentView === 'library') renderLibrary()
else if (state.currentView === 'wines') renderWines()
else if (state.currentView === 'prefixes') renderPrefixes()
else if (state.currentView === 'settings') renderSettings()
}
function wireLanSync() {
@ -1308,8 +1590,10 @@ function wireLanSync() {
if (state.lansync) state.lansync.devices = devices
if (state.currentView === 'lansync') renderLanDevices(devices)
})
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
window.plwl.onLanSyncApplied(async info => {
toast(t('lansync.applied', info.from || '', info.files || 0), 'success')
toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
await reloadLocalData()
})
qs('#ls-reroll-id').addEventListener('click', async () => {
@ -1422,10 +1706,11 @@ function wireEvents() {
toast(t('config.playtimeCleared'), 'success')
})
qs('#modal-overlay').addEventListener('click', e => {
if (modalLocked) return // a sync in progress: the backdrop is not a cancel button
if (e.target === qs('#modal-overlay')) closeModal()
})
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal()
if (e.key === 'Escape' && !modalLocked) closeModal()
})
}

View file

@ -191,6 +191,10 @@ content {
}
.subtitle { color: var(--darker-text-color); font-size: 0.9rem; margin-top: 0.2rem; }
/* "Do not close the app while this runs" - the one line on a sync popup the
user has to notice, so it stands out from the subtitles around it. */
.sync-warning { color: var(--big-red); margin-top: 0.6rem; }
.button {
height: var(--button-height);
padding: 0 1rem;
@ -204,6 +208,9 @@ content {
gap: 0.4rem;
cursor: pointer;
white-space: nowrap;
/* only Regular and Black faces are bundled, so >=600 resolves to the same
bold rendering on every button; primary just adds the filled style */
font-weight: 700;
backface-visibility: hidden;
transform: translateZ(0);
will-change: transform;
@ -464,6 +471,10 @@ select {
white-space: nowrap;
user-select: text;
}
/* the reroll button shares the row with the code boxes, so it has to be just
as tall as them and carry the same font size (the shared .small button is
shorter and its 0.85rem text looks lost next to the code value) */
.code-row .button { height: var(--button-height); font-size: 1rem; }
.code-row #ls-sync-code { font-size: 1.25rem; font-weight: 700; letter-spacing: 0.3em; }
.check-group { display: flex; flex-direction: column; }
.check-group .check-label { margin-top: 0.2rem; margin-bottom: 0.2rem; }

View file

@ -6,20 +6,38 @@ const { DIRS, resolvePortablePath } = require('./paths')
const { newGame, newGameId, addGame } = require('./library')
const { extractIconFromExe } = require('./icons')
const { listPrefixes, prefixDriveRoot } = require('./prefixes')
const { rewireSymlinks } = require('./symlinks')
// Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
// A game folder has to arrive as it was: symlinks are recreated (copyFile
// silently dropped them, so a linked asset or save folder vanished) and the
// permission bits are carried over (copyFile writes 0644, killing scripts and
// anything the game executes itself).
function copyRecursive(src, dst, onProgress) {
return fsp.readdir(src, { withFileTypes: true }).then(async entries => {
await fsp.mkdir(dst, { recursive: true })
for (const e of entries) {
const s = path.join(src, e.name)
const d = path.join(dst, e.name)
if (e.isDirectory()) {
if (e.isSymbolicLink()) {
let target = null
try {
target = await fsp.readlink(s)
} catch {}
if (!target) continue
try {
await fsp.symlink(target, d)
} catch {}
if (onProgress) onProgress(s)
} else if (e.isDirectory()) {
await copyRecursive(s, d, onProgress)
} else if (e.isFile()) {
await fsp.copyFile(s, d)
try {
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
} catch {}
if (onProgress) onProgress(s)
}
}
@ -47,6 +65,12 @@ async function importGame(srcDir, mode, onProgress) {
if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress)
else await moveFolder(srcDir, dst, onProgress)
workdir = dst
// anything the game linked to INSIDE the launcher data (its own folder, an
// icon, a shared prefix file) becomes a relative link, so the imported app
// survives the launcher being moved - the same healing wine builds get
try {
await rewireSymlinks(dst, [DIRS.FILES])
} catch {}
}
const exePath = (await findDefaultExe(workdir)) || preExe
if (!exePath) {

View file

@ -20,16 +20,50 @@ function sendToWindow(win, channel, data) {
// Game-process state: id -> pid (or null while the process is starting).
// onGamesChanged fires in the MAIN process whenever the running-games set
// changes (the tray menu rebuilds itself from it).
const STARTUP_GRACE_MS = 60000
function createGamesService({ getWindow, onGamesChanged }) {
const running = new Map()
const startedAt = new Map()
const killRequests = new Set()
let onGameExit = null
const notifyChanged = () => {
if (typeof onGamesChanged === 'function') onGamesChanged()
}
const isRunning = id => running.has(id)
const listRunning = () => [...running.keys()]
function alive(pid) {
if (!pid) return false
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
// A game is "running" only while its process really is. An entry left behind
// by a launch that never got a pid (the binary could not be executed, so the
// spawn failed) or whose pid is gone (the game crashed, the launcher was
// restarted) would otherwise answer every later Play with "already
// running".
function prune() {
for (const [id, pid] of [...running]) {
if (alive(pid)) continue
const age = Date.now() - (startedAt.get(id) || 0)
if (!pid && age < STARTUP_GRACE_MS) continue // may still be starting
running.delete(id)
startedAt.delete(id)
}
}
const isRunning = id => {
prune()
return running.has(id)
}
const listRunning = () => {
prune()
return [...running.keys()]
}
// "Open with PLWL" handoff: queue the file for the renderer (picker popup
// with wine version + prefix). Safe to call at any time - if the window is
@ -45,6 +79,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
function handleExit(id) {
running.delete(id)
startedAt.delete(id)
sendToWindow(getWindow(), 'games:changed')
notifyChanged()
if (onGameExit) onGameExit(id)
@ -53,42 +88,59 @@ function createGamesService({ getWindow, onGamesChanged }) {
async function start(win, id) {
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' }
running.set(id, null)
startedAt.set(id, Date.now())
let wine
try {
wine = resolveWine(game)
} catch {
running.delete(id)
startedAt.delete(id)
return { ok: false, error: 'wine-not-found' }
}
try {
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) })
if (res.pid) running.set(id, res.pid)
// stop was requested while the process was still starting
// stop was requested while the process was still starting: kill it and
// leave nothing in the running set
if (killRequests.has(id)) {
killRequests.delete(id)
if (res.pid) killProcessGroup(res.pid)
}
running.delete(id)
startedAt.delete(id)
sendToWindow(win, 'games:changed')
notifyChanged()
return { ok: true, pid: res.pid || null, logFile: res.logFile || null }
return { ok: true, pid: null, logFile: res.logFile || null }
}
// no pid means the process was not started after all - never keep such
// an entry, it would block the next launch with "already running"
if (!res.pid) {
running.delete(id)
startedAt.delete(id)
return { ok: false, error: 'not-started' }
}
running.set(id, res.pid)
sendToWindow(win, 'games:changed')
notifyChanged()
return { ok: true, pid: res.pid, logFile: res.logFile || null }
} catch (err) {
running.delete(id)
startedAt.delete(id)
return { ok: false, error: String((err && err.message) || err) }
}
}
// Stop always clears the state: whether the process was running, still
// starting (kill request for as soon as it has a pid) or already gone.
async function kill(id) {
const pid = running.get(id)
if (pid) {
killProcessGroup(pid)
if (pid) killProcessGroup(pid)
else if (running.has(id)) killRequests.add(id)
running.delete(id)
startedAt.delete(id)
sendToWindow(getWindow(), 'games:changed')
notifyChanged()
} else if (running.has(id)) {
killRequests.add(id)
}
return { ok: true }
}

View file

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

View file

@ -16,6 +16,7 @@ const fsp = fs.promises
const readline = require('readline')
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
const library = require('./library')
const wines = require('./wines')
const prefixes = require('./prefixes')
@ -27,8 +28,11 @@ const CONFIG = path.join(DIRS.DATA, 'lansync.json')
const DISCOVERY_PORT = Number(process.env.PLWL_LANSYNC_PORT) || 47821
const BEACON_TAG = 'plwl-lansync'
const PROTOCOL = 1
const BEACON_MS = 2000
const DEVICE_TTL_MS = 7000
// A device is only visible while it beacons; 2.5 s against a 6 s TTL leaves
// one missed datagram of headroom (the old 2 s/7 s pair woke the process just
// as often but dropped peers on the first lost packet).
const BEACON_MS = 2500
const DEVICE_TTL_MS = 6000
// Every verification costs this much and a device may only have one in
// flight - together they make guessing an 8 character code over the LAN
@ -36,6 +40,10 @@ const DEVICE_TTL_MS = 7000
const VERIFY_DELAY_MS = 2000
const TOKEN_TTL_MS = 30 * 60 * 1000
const SESSION_IDLE_MS = 5 * 60 * 1000
// How long the receiving user has to click Accept before the request expires:
// the sender waits for this decision, so its own verify timeout has to be
// longer than this.
const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) || 60 * 1000
// The only trees a sync may touch. Everything else under files/ is local
// state (settings, translations, playtime, logs) that must never move
@ -44,7 +52,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']
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'defaults']
const CATEGORY_ALIASES = {
// plain "apps" means the whole app library: portable ones AND the ones
@ -64,7 +72,13 @@ const CATEGORY_ALIASES = {
wineprefix: ['prefixes'],
wineprefixes: ['prefixes'],
prefixes: ['prefixes'],
prefix: ['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']
}
// Which sync category an app entry belongs to. Portable copy/move imports own
@ -135,9 +149,18 @@ function makeSyncCode() {
return randomChars(CODE_ALPHABET, 8)
}
// The identity file is read once and then kept in memory: the beacon calls
// identity() every couple of seconds (and every pairing call in between), and
// a synchronous read per call is pure overhead. Nothing else writes this file
// (the ipc handlers all go through the same functions), so the cache cannot
// go stale.
let identityCache = null
function readConfig() {
if (identityCache) return identityCache
const cfg = readJson(CONFIG, {})
return cfg && typeof cfg === 'object' ? cfg : {}
identityCache = cfg && typeof cfg === 'object' ? cfg : {}
return identityCache
}
// The id and the code are generated on FIRST use and then kept forever:
@ -231,13 +254,106 @@ const HASH_ALGO = (() => {
return 'sha256'
})()
function hashFile(file) {
// Hashing a tree is dominated by opening the (often tiny) files, not by the
// digest itself: a few in flight at once keeps the disk and the cpu busy
// instead of waiting on one file at a time.
const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4))
// Hash results are remembered per file (size + mtime), so a library that did
// not change since the last sync is never read again - only stat'ed.
const HASH_CACHE_FILE = path.join(DIRS.DATA, 'lansync-hashes.json')
const HASH_CACHE_MAX = 250000
let hashCache = null
let hashCacheDirty = false
let hashCacheSavedAt = 0
const hashStats = { hits: 0, misses: 0 }
function hashCacheStats() {
return { ...hashStats, size: hashCache ? Object.keys(hashCache).length : 0, algo: HASH_ALGO }
}
function resetHashCacheStats() {
hashStats.hits = 0
hashStats.misses = 0
}
// Dropping the in-memory copy makes the next lookup re-read the file from
// disk - what tests use to prove a file was hashed instead of remembered.
function forgetHashCache() {
hashCache = null
hashCacheDirty = false
}
function loadHashCache() {
if (!hashCache) {
const stored = readJson(HASH_CACHE_FILE, null)
hashCache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
}
return hashCache
}
// Written once per operation at most: this file can hold a quarter million
// entries, and rewriting it per hashed file would cost more than the hashing.
// Compact JSON (the entries are already arrays) keeps a full cache at tens of
// milliseconds instead of the seconds pretty-printed JSON needs.
function saveHashCache() {
if (!hashCacheDirty || !hashCache) return
const now = Date.now()
if (now - hashCacheSavedAt < 60000) return
const names = Object.keys(hashCache)
if (names.length > HASH_CACHE_MAX) {
names.sort((a, b) => (hashCache[b][3] || 0) - (hashCache[a][3] || 0))
const trimmed = {}
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = hashCache[name]
hashCache = trimmed
}
hashCacheDirty = false
hashCacheSavedAt = now
try {
fs.mkdirSync(path.dirname(HASH_CACHE_FILE), { recursive: true })
fs.writeFileSync(HASH_CACHE_FILE, JSON.stringify({ v: 1, algo: HASH_ALGO, files: hashCache }))
} catch {}
}
// 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++
return hashFile(abs).then(hash => {
if (hash) {
cache[rel] = [size, mtimeMs, hash, Date.now()]
hashCacheDirty = true
}
return hash
})
}
// Streaming through one callback instead of accumulating 'data' chunks keeps
// memory flat for huge files, and a small highWaterMark gives the read pump
// real backpressure. Digest speed does not care about the chunk size, so the
// old 1 MB reads only ever paid for themselves in memory, not in time.
function hashFile(file, onBytes) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(HASH_ALGO)
const stream = fs.createReadStream(file)
const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
stream.on('error', reject)
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
stream.on('data', chunk => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex')))
stream.resume()
})
}
@ -259,6 +375,45 @@ function ownedRoot(rel) {
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
}
// Progress is cheap to produce and expensive to render: hashing or copying a
// prefix can emit tens of thousands of events, and forwarding every single one
// over IPC makes the window stutter instead of showing progress. Events are
// rate limited to ~10/s per phase; `force` lets a caller push the value that
// matters (the end of a unit) through immediately.
const PROGRESS_MIN_MS = 100
function progressGate(emit) {
let lastAt = 0
let pending = null
let timer = null
const flush = () => {
timer = null
if (!pending) return
const data = pending
pending = null
lastAt = Date.now()
emit(data)
}
return (data, force) => {
const now = Date.now()
if (force || now - lastAt >= PROGRESS_MIN_MS) {
if (timer) {
clearTimeout(timer)
timer = null
}
pending = null
lastAt = now
emit(data)
return
}
pending = data
if (!timer) {
timer = setTimeout(flush, PROGRESS_MIN_MS - (now - lastAt))
if (timer.unref) timer.unref()
}
}
}
async function mapLimit(items, limit, fn) {
const out = new Array(items.length)
let next = 0
@ -272,37 +427,80 @@ async function mapLimit(items, limit, fn) {
return out
}
// Symlink targets travel RELATIVE whenever they point inside the launcher data
// (a prefix linking into its wine build, an app linking to its own assets): the
// mirrored trees have the same internal layout on both machines, so a relative
// target is correct over there while an absolute one would point at the
// sender's own folders - which is exactly how a synced prefix ends up with
// hundreds of dangling links. Targets outside files/ (a system font, a mounted
// drive, /) are machine-specific and stay as they are.
function portableLinkTarget(linkAbs, target) {
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
const rel = path.relative(path.dirname(linkAbs), target)
if (!rel || path.isAbsolute(rel)) return target
const root = path.resolve(DIRS.FILES)
const resolved = path.resolve(path.dirname(linkAbs), rel)
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
return rel
}
// Walk without following symlinks: builds and prefixes contain links that are
// part of what is being synced (dosdevices, build internals), and following
// them would both loop and copy the wrong thing.
async function scanTree(abs, rel, out) {
//
// Iterative (a queue instead of an await-per-directory recursion) and with the
// stat/readlink calls of one directory in flight together: walking a prefix is
// dominated by those two syscalls, and serializing them costs more than the
// readdir itself. Regular files are re-verified with one stat (size, mode,
// mtime feed both the manifest and the hash cache), everything else is skipped.
const SCAN_CONCURRENCY = Math.max(4, HASH_CONCURRENCY)
async function scanTree(abs, rel, out, mtimes) {
const queue = [[abs, rel]]
while (queue.length) {
const [dirAbs, dirRel] = queue.pop()
let entries
try {
entries = await fsp.readdir(abs, { withFileTypes: true })
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
} catch {
return
continue
}
const dirs = []
const leaves = []
for (const entry of entries) {
const absChild = path.join(abs, entry.name)
const relChild = `${rel}/${entry.name}`
const relChild = `${dirRel}/${entry.name}`
if (entry.isDirectory()) {
out.push({ path: relChild, type: 'dir' })
dirs.push([path.join(dirAbs, entry.name), relChild])
} else {
leaves.push(entry)
}
}
await mapLimit(leaves, SCAN_CONCURRENCY, async entry => {
const absChild = path.join(dirAbs, entry.name)
const relChild = `${dirRel}/${entry.name}`
if (entry.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(absChild)
} catch {}
out.push({ path: relChild, type: 'link', target })
} else if (entry.isDirectory()) {
out.push({ path: relChild, type: 'dir' })
await scanTree(absChild, relChild, out)
} else if (entry.isFile()) {
let size = 0
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
return
}
let st
try {
size = (await fsp.stat(absChild)).size
st = await fsp.stat(absChild)
} catch {
continue
}
out.push({ path: relChild, type: 'file', size, hash: null })
return
}
if (!st.isFile()) return
// mode travels with the file: a wine build's `proton`/`wine` binary is
// useless on the other side without its executable bit
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
// kept out of the manifest: only this machine can use its own mtimes
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
})
queue.push(...dirs)
}
}
@ -310,6 +508,7 @@ async function scanTree(abs, rel, out) {
// then treats every file as different ("force sync all files").
async function buildManifest(roots, force, onProgress) {
const entries = []
const mtimes = new Map()
for (const rel of roots) {
const abs = filesPath(rel)
if (!abs) continue
@ -324,27 +523,60 @@ async function buildManifest(roots, force, onProgress) {
try {
target = await fsp.readlink(abs)
} catch {}
entries.push({ path: rel, type: 'link', target })
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
} else if (st.isDirectory()) {
entries.push({ path: rel, type: 'dir' })
await scanTree(abs, rel, entries)
await scanTree(abs, rel, entries, mtimes)
} else if (st.isFile()) {
entries.push({ path: rel, type: 'file', size: st.size, hash: null })
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
mtimes.set(rel, st.mtimeMs || 0)
}
}
const files = entries.filter(e => e.type === 'file')
// the unit's total size is known before a byte is hashed, so the bar can
// 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
await mapLimit(files, 4, async entry => {
let hashed = 0
await mapLimit(files, HASH_CONCURRENCY, async entry => {
const abs = filesPath(entry.path)
try {
entry.hash = await hashFile(filesPath(entry.path))
// 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
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]
} else {
hashStats.misses++
entry.hash = abs
? await hashFile(abs, bytes => {
hashed += bytes
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
: null
if (entry.hash) {
cache[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()]
hashCacheDirty = true
}
}
} catch {
entry.hash = null
}
done++
if (onProgress) onProgress(done, files.length)
if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
}
saveHashCache()
return entries
}
@ -491,18 +723,28 @@ async function entrySame(entry) {
}
}
if (!entry.hash) return false // forced sync (or unreadable file) -> replace it
// A size mismatch alone settles it - reading the local file just to hash it
// would only confirm what the sizes already say. lstatSync because the
// answer (is it the regular file the manifest describes) needs no thread.
let st
try {
st = await fsp.lstat(abs)
st = fs.lstatSync(abs)
} catch {
return false
}
if (!st.isFile() || st.size !== entry.size) return false
let same = false
try {
return (await hashFile(abs)) === entry.hash
same = (await cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
} catch {
return false
}
if (!same) return false
// Same bytes, wrong permissions (a build synced before modes travelled, or a
// copy that dropped the bit): repaired right here instead of pulling the
// whole file over the network again.
if (typeof entry.mode === 'number' && (st.mode & 0o777) !== entry.mode) applyMode(abs, entry.mode)
return true
}
function ensureDir(abs) {
@ -517,6 +759,24 @@ function ensureDir(abs) {
} catch {}
}
// After a unit has landed, its links and permissions have to be this machine's
// (the sender stored whatever was true over there): absolute symlinks pointing
// into the launcher data become relative, and a build gets back the executable
// bit a content copy drops. Without this a synced prefix/proton build is dead
// weight - the launcher cannot start it.
async function healUnitRoots(roots) {
const healed = { links: 0, executables: 0 }
for (const rel of Array.isArray(roots) ? roots : []) {
const abs = filesPath(ownedRoot(rel))
if (!abs) continue
try {
healed.links += await rewireSymlinks(abs, [DIRS.FILES])
if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs)
} catch {}
}
return healed
}
function ensureLink(abs, target) {
if (typeof target !== 'string' || !target) return
let current = null
@ -549,7 +809,13 @@ function removeAbs(abs) {
function removeTree(rel) {
const abs = filesPath(rel)
return abs && removeAbs(abs) ? 1 : 0
if (!abs) return 0
try {
if (!fs.lstatSync(abs)) return 0 // nothing to delete -> do not count it
} catch {
return 0
}
return removeAbs(abs) ? 1 : 0
}
function removeOrphans(dir, keep) {
@ -599,13 +865,17 @@ async function pruneTree(abs, rel, keep) {
return removed
}
function writeStream(abs, stream) {
return new Promise((resolve, reject) => {
let st = null
// Modes are applied after the bytes are in place: createWriteStream cannot set
// one, and a wine build without its executable bit is unusable.
function applyMode(abs, mode) {
if (typeof mode !== 'number' || !mode) return
try {
st = fs.lstatSync(abs)
fs.chmodSync(abs, mode & 0o777)
} catch {}
if (st && st.isDirectory()) removeAbs(abs)
}
function writeStream(abs, stream, onBytes) {
return new Promise((resolve, reject) => {
try {
fs.mkdirSync(path.dirname(abs), { recursive: true })
} catch {}
@ -621,14 +891,23 @@ function writeStream(abs, stream) {
})
out.on('finish', () => {
try {
if (st && st.isSymbolicLink()) removeAbs(abs)
if (fs.lstatSync(abs).isDirectory()) removeAbs(abs)
} catch {} // nothing there (the common case) or a link - both fine to rename over
try {
fs.renameSync(tmp, abs)
resolve()
resolve(out.bytesWritten || 0)
} catch (err) {
// a sync race (the path changed between open and rename) must not
// undo the transfer itself - the next sync would just re-upload it
removeAbs(tmp)
reject(err)
resolve(0)
}
})
if (onBytes) {
// the pipe in flowing mode delivers every chunk to each 'data' listener,
// so counting here does not steal the bytes from the file being written
stream.on('data', chunk => onBytes(chunk.length))
}
stream.pipe(out)
})
}
@ -650,12 +929,22 @@ function applyApps(payload, cats) {
}
kept.push(game)
}
// playtime is per machine, the sender does not push any - keep the counters
// of apps this device already had
const merged = [...kept, ...incoming.map(g => {
// Playtime and the app's own settings (wine build, prefix, args, env,
// dllOverrides) travel with the app: paths are left exactly as the sender
// stored them (portable/prefix-relative), so a synced entry keeps working.
// Playtime only ever GROWS - the higher counter wins, so pushing from a
// freshly installed machine cannot wipe a library's play history.
const merged = [...kept]
for (const g of incoming) {
const prev = existing.find(x => x.id === g.id)
return prev ? { ...g, playSeconds: prev.playSeconds, lastPlayed: prev.lastPlayed } : g
})]
const hasPlay = Number.isFinite(Number(g.playSeconds))
const playSeconds = hasPlay ? Math.max(Number(prev && prev.playSeconds) || 0, Number(g.playSeconds)) : prev && prev.playSeconds
const lastPlayed = Math.max(Number(prev && prev.lastPlayed) || 0, Number(g.lastPlayed) || 0)
merged.push({ ...g, playSeconds, lastPlayed })
// the per-app playtime file wins over the registry, so a synced counter
// has to be written there too or loadGames would keep reporting the old one
if (hasPlay) library.writePlaySeconds(g.id, playSeconds)
}
library.saveGames(merged)
let removed = 0
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
@ -677,7 +966,7 @@ async function applyWines(payload) {
return removed
}
function applyPrefixes(payload) {
function applyPrefixes(payload, useSenderDefault) {
const sender = payload.prefixes && typeof payload.prefixes === 'object' ? payload.prefixes : {}
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
const existing = prefixes.listPrefixes()
@ -690,15 +979,93 @@ function applyPrefixes(payload) {
}
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked }))
const def = keep.has(sender.default) ? sender.default : prefixes.MAIN
// Which prefix is the default only moves when the user asked for the default
// settings to be synced; otherwise this device keeps its own (as long as the
// prefix still exists here).
const wanted = useSenderDefault ? sender.default : existing.default
const def = keep.has(wanted) ? wanted : prefixes.MAIN
prefixes.savePrefixes({ default: def, prefixes: list })
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
const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {}
const applied = {}
if (wanted.defaultWine) {
const res = wines.setDefaultWine(String(wanted.defaultWine))
applied.defaultWine = res && res.ok ? String(wanted.defaultWine) : null
}
if (wanted.defaultPrefix) {
const defs = prefixes.listPrefixes()
if (defs.prefixes.some(p => p.name === wanted.defaultPrefix)) {
prefixes.setDefaultPrefix(String(wanted.defaultPrefix))
applied.defaultPrefix = String(wanted.defaultPrefix)
} else {
applied.defaultPrefix = null
}
}
return applied
}
// --- HTTP server (what other devices talk to) -------------------------------
function createServerState() {
return { tokens: new Map(), verifying: new Set(), session: null }
return { tokens: new Map(), verifying: new Set(), pending: new Map(), session: null }
}
// The user's answer to an incoming-sync popup. Resolves the verification that
// is waiting for it; unknown/expired ids are a no-op (the request already
// timed out server-side).
let serverState = null
function respondIncoming(requestId, accept) {
const key = String(requestId || '')
const resolve = serverState && serverState.pending ? serverState.pending.get(key) : null
if (!resolve) return false
serverState.pending.delete(key)
resolve(accept ? 'accept' : 'reject')
return true
}
// Which counter a sync unit feeds on the receiving side (the unit key looks
// like "app:<id>", "wine:<name>" or "prefix:<name>").
const UNIT_KINDS = { app: 'apps', wine: 'wines', prefix: 'prefixes' }
// Progress of a sync THIS device receives. The pushing device drives the
// transfer, but the user here also needs to see what is being written to
// their library: the launcher forwards this to its window, which shows a
// "Receiving from X…" popup with a bar.
function emitReceive(hooks, session, stage, extra = {}) {
if (!hooks || !hooks.onReceive) return
try {
hooks.onReceive({
stage,
phase: stage,
from: session.name || '',
deviceId: session.deviceId || '',
unit: extra.unit || '',
label: extra.unit || '',
done: extra.done == null ? session.received || 0 : extra.done,
total: extra.total == null ? session.planned || 0 : extra.total,
bytes: session.bytes || 0,
totalBytes: session.plannedBytes || 0,
// items: units this sync announced, itemsDone: units fully written,
// totals: everything the sender picked (so the popup can say "2/5")
items: { ...(session.items || {}) },
itemsDone: { ...(session.itemsDone || {}) },
totals: { ...(session.totals || {}) },
// what the BAR shows: the phase being worked on right now, so hashing
// starts empty instead of continuing a full bar from earlier
barBytes: session.barBytes || 0,
barTotalBytes: session.barTotal || 0,
deleted: extra.deleted || 0
})
} catch {}
}
function activeSession(state) {
@ -738,11 +1105,11 @@ 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/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res)
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)
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
if (route === '/plwl/apply' && req.method === 'POST') return handleApply(session, req, res, hooks)
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res)
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res, hooks)
sendJson(res, 404, { ok: false, error: 'not-found' })
} catch (err) {
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
@ -765,11 +1132,44 @@ async function handleVerify(state, req, res, hooks) {
if (state.verifying.has(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
const running = activeSession(state)
if (running && running.deviceId !== from) return sendJson(res, 429, { ok: false, error: 'busy' })
// one UNANSWERED consent popup at a time - a second sender would either
// overwrite the first popup or silently wait out its 60 seconds
if (state.pending.size) return sendJson(res, 429, { ok: false, error: 'busy' })
if (hooks.isBusy && hooks.isBusy(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
state.verifying.add(from)
try {
await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS))
if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' })
// the sender may announce its totals with the verification, which is how
// the popup here can show "Apps: 0/5" before the first file list arrives
const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {}
// Nothing moves without the user behind this window accepting the push:
// a correct code only proves the sender read the code, it is not consent
// to overwrite this library. Without a UI to ask (tests, headless use)
// the gate cannot be answered and stays open - the launcher registers
// onIncoming whenever it shows a window.
if (typeof hooks.onIncoming === 'function') {
const requestId = crypto.randomBytes(12).toString('hex')
const decision = await new Promise(resolve => {
const timer = setTimeout(() => {
state.pending.delete(requestId)
resolve('timeout')
}, INCOMING_TIMEOUT_MS)
if (timer.unref) timer.unref()
state.pending.set(requestId, resolve)
try {
hooks.onIncoming({
requestId,
deviceId: from,
name: String(payload.name || ''),
totals: { apps: Number(announced.apps) || 0, wines: Number(announced.wines) || 0, prefixes: Number(announced.prefixes) || 0 }
})
} catch {
resolve('reject') // no window to answer with -> never auto-accept silently
}
})
if (decision !== 'accept') return sendJson(res, 403, { ok: false, error: decision === 'reject' ? 'rejected' : 'incoming-timeout' })
}
const token = crypto.randomBytes(24).toString('hex')
const session = {
token,
@ -777,10 +1177,27 @@ async function handleVerify(state, req, res, hooks) {
name: String(payload.name || ''),
expires: Date.now() + TOKEN_TTL_MS,
lastAt: Date.now(),
units: new Map()
units: new Map(),
// counters behind the "Receiving from X…" popup: files written, bytes
// written, and the same totals the sender planned (learned per unit)
counted: new Set(),
received: 0,
planned: 0,
bytes: 0,
plannedBytes: 0,
items: { apps: 0, wines: 0, prefixes: 0 },
itemsDone: { apps: 0, wines: 0, prefixes: 0 },
barBytes: 0,
barTotal: 0,
totals: {
apps: Number(announced.apps) || 0,
wines: Number(announced.wines) || 0,
prefixes: Number(announced.prefixes) || 0
}
}
state.tokens.set(token, session)
state.session = session
emitReceive(hooks, session, 'start')
sendJson(res, 200, {
ok: true,
token,
@ -793,8 +1210,9 @@ async function handleVerify(state, req, res, hooks) {
}
}
async function handlePlan(state, session, url, req, res) {
async function handlePlan(state, session, url, req, res, hooks) {
const key = url.searchParams.get('unit') || ''
const label = url.searchParams.get('label') || ''
let roots = []
try {
roots = JSON.parse(url.searchParams.get('roots') || '[]')
@ -805,48 +1223,117 @@ async function handlePlan(state, session, url, req, res) {
await readNdjson(req, entry => {
if (inlineSame(entry)) entries.push(entry)
})
// the mode each file has to end up with on this side
const modes = new Map()
for (const entry of entries) if (entry.type === 'file' && typeof entry.mode === 'number') modes.set(entry.path, entry.mode)
// a force sync compares nothing: every entry counts as different
const forced = url.searchParams.get('force') === '1'
// how many apps/prefixes/wines the sender picked in total: without it the
// popup here could only ever say what has already arrived
try {
const totals = JSON.parse(url.searchParams.get('totals') || 'null')
if (totals && typeof totals === 'object') {
session.totals = {
apps: Number(totals.apps) || 0,
wines: Number(totals.wines) || 0,
prefixes: Number(totals.prefixes) || 0
}
}
} catch {}
const allowed = entries.filter(entry => withinRoots(entry.path, owned))
// Comparing this unit is a phase of its own: the bar fills with the bytes
// checked here, then starts over empty for the files that follow.
const unitTotalBytes = allowed.reduce((sum, entry) => sum + (entry.size || 0), 0)
const announce = progressGate((data, force) => emitReceive(hooks, session, 'plan', data))
let compared = 0
let comparedBytes = 0
session.barTotal = unitTotalBytes
session.barBytes = 0
const identical = await mapLimit(allowed, HASH_CONCURRENCY, async entry => {
const same = forced ? false : await entrySame(entry)
compared++
comparedBytes += entry.size || 0
session.barBytes = comparedBytes
announce({ unit: label, done: compared, total: allowed.length })
return same === true
})
saveHashCache()
const paths = new Set()
const upload = []
const same = []
for (const entry of entries) {
if (!withinRoots(entry.path, owned)) continue
let uploadBytes = 0
allowed.forEach((entry, index) => {
const abs = filesPath(entry.path)
if (!abs) continue
if (!abs) return
paths.add(entry.path)
const identical = forced ? false : await entrySame(entry)
if (identical) {
if (identical[index]) {
same.push(entry.path)
continue
return
}
if (entry.type === 'dir') ensureDir(abs)
else if (entry.type === 'link') ensureLink(abs, entry.target)
else upload.push(entry.path)
else {
upload.push(entry.path)
uploadBytes += entry.size || 0
}
session.units.set(key, { roots: owned, paths })
})
session.units.set(key, { roots: owned, paths, label, modes })
// what this unit adds to the transfer: the popup reports files and bytes
// against these as they arrive
session.planned += upload.length
session.plannedBytes += uploadBytes
// the files are next: the bar counts the bytes about to be written, from an
// empty bar - a unit that needs nothing at all is simply finished
session.unitWritten = 0
session.barTotal = uploadBytes
session.barBytes = 0
const kind = UNIT_KINDS[String(key).split(':')[0]]
if (kind && !session.counted.has(key)) {
session.counted.add(key)
session.items[kind] = (session.items[kind] || 0) + 1
}
// the unit's human label (game / prefix / wine name) is announced while it
// 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)
}
async function handlePut(session, url, req, res, hooks) {
const key = url.searchParams.get('unit') || ''
const rel = url.searchParams.get('path') || ''
// unit and path come as headers: they are constant per request, and routing
// them through the query made the receiver parse a fresh URL per file
const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || ''
const rel = req.headers['x-plwl-path'] || url.searchParams.get('path') || ''
const unit = session.units.get(key)
const abs = filesPath(rel)
if (!unit || !abs || !unit.paths.has(rel)) {
req.resume()
return sendJson(res, 400, { ok: false, error: 'bad-path' })
}
// Bytes are reported while the file is still arriving: waiting for the whole
// file to land would leave the popup frozen for the entire transfer of a
// single big asset (that is what made it look like it only moved once per
// received item).
const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data))
try {
await writeStream(abs, req)
await writeStream(abs, req, delta => {
session.bytes = (session.bytes || 0) + delta
session.unitWritten = (session.unitWritten || 0) + delta
session.barBytes = session.unitWritten
announce({ unit: session.unitLabel || '' })
})
// permissions do not travel with the bytes: the executable bit of a wine
// build's binaries has to be restored from the sender's manifest
applyMode(abs, unit.modes.get(rel))
session.received = (session.received || 0) + 1
sendJson(res, 200, { ok: true })
announce({ unit: session.unitLabel || '' }, true)
} catch (err) {
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
}
}
async function handleCommit(session, url, res) {
async function handleCommit(session, url, res, hooks) {
const key = url.searchParams.get('unit') || ''
const unit = session.units.get(key)
if (!unit) return sendJson(res, 400, { ok: false, error: 'no-unit' })
@ -855,8 +1342,22 @@ async function handleCommit(session, url, res) {
const abs = filesPath(root)
if (abs) removed += await pruneTree(abs, root, unit.paths)
}
// the unit is complete - rewiring its links is only worth doing once the
// whole tree is there (a prefix's links chain through its own files)
try {
await healUnitRoots(unit.roots)
} catch {}
session.units.delete(key)
session.deleted = (session.deleted || 0) + removed
// a finished unit: this is what turns "Apps: 2/5" from the sender's totals
// and the units written here
const kind = UNIT_KINDS[String(key).split(':')[0]]
if (kind) session.itemsDone[kind] = (session.itemsDone[kind] || 0) + 1
// a finished unit leaves a finished bar behind (even one that needed no bytes)
session.barTotal = session.barTotal || 1
session.barBytes = session.barTotal
sendJson(res, 200, { ok: true, deleted: removed })
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
}
async function handleApply(session, req, res, hooks) {
@ -865,24 +1366,39 @@ async function handleApply(session, req, res, hooks) {
const cats = new Set(Array.isArray(payload.categories) ? payload.categories : [])
const appsDeleted = applyApps(payload, cats)
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(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 removed = appsDeleted + winesDeleted + prefixesDeleted
emitReceive(hooks, session, 'registry', { deleted: removed })
if (hooks.onApplied) {
hooks.onApplied({ from: session.name, deviceId: session.deviceId, categories: [...cats], files: session.received || 0, deleted: removed })
hooks.onApplied({
from: session.name,
deviceId: session.deviceId,
categories: [...cats],
files: session.received || 0,
bytes: session.bytes || 0,
items: { ...(session.items || {}) },
deleted: removed,
defaults
})
}
sendJson(res, 200, {
ok: true,
deleted: removed,
apps: { deleted: appsDeleted },
wines: { deleted: winesDeleted },
prefixes: { deleted: prefixesDeleted }
prefixes: { deleted: prefixesDeleted },
defaults
})
}
function handleEnd(state, session, res) {
function handleEnd(state, session, res, hooks) {
state.tokens.delete(session.token)
if (state.session && state.session.token === session.token) state.session = null
sendJson(res, 200, { ok: true })
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
}
// --- discovery --------------------------------------------------------------
@ -1019,6 +1535,7 @@ function startServer(options = {}) {
if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options }
if (listening) return listening
const state = createServerState()
serverState = state // respondIncoming() answers the popups of THIS server
httpServer = createHttpServer(state, serverHooks)
httpServer.on('error', () => {})
const port = Number(options && options.port) || 0
@ -1045,6 +1562,12 @@ function stopServer() {
} catch {}
httpServer = null
}
// a waiting popup would never be answered: resolve it as declined
if (serverState && serverState.pending) {
for (const resolve of serverState.pending.values()) resolve('reject')
serverState.pending.clear()
}
serverState = null
listening = null
httpPort = 0
}
@ -1090,6 +1613,9 @@ function getPusher() {
buildManifest,
listDirs,
listDevices,
mapLimit,
progressGate,
hashFile,
DIRS,
toPortablePath,
library,
@ -1152,9 +1678,15 @@ const exported = {
knownPeers,
rememberDevice,
forgetDevice,
respondIncoming,
sameCode,
HASH_ALGO,
hashFile,
hashCacheStats,
resetHashCacheStats,
forgetHashCache,
HASH_CONCURRENCY,
progressGate,
filesPath,
ownedRoot,
buildManifest,

View file

@ -13,6 +13,10 @@ const os = require('os')
const fs = require('fs')
const path = require('path')
// Files sent at once: one at a time wastes most of a LAN round trip on the
// small files a game or prefix is mostly made of.
const UPLOAD_CONCURRENCY = 4
function createPusher(api) {
const {
CATEGORIES,
@ -34,9 +38,12 @@ function createPusher(api) {
// Manifest in, list of files to upload out. NDJSON keeps a prefix with tens
// of thousands of entries from being one giant string in memory either side.
function planUnit(device, token, unit, entries, force) {
function planUnit(device, token, unit, entries, force, meta) {
// the receiver is told how many apps/prefixes/wines the whole sync carries
// up front, which is what lets its popup say "Apps: 2/5"
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
return new Promise((resolve, reject) => {
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}`
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}${totals}`
const req = http.request(
{
host: device.address,
@ -75,14 +82,20 @@ function createPusher(api) {
function putFile(device, token, unitKey, rel, abs) {
return new Promise((resolve, reject) => {
const route = `/plwl/unit/file?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unitKey)}&path=${encodeURIComponent(rel)}`
// unit and path travel as headers: the receiver used to parse a fresh
// query string per file for values that are constant per request
const req = http.request(
{
host: device.address,
port: device.port,
path: route,
path: `/plwl/unit/file?token=${encodeURIComponent(token)}`,
method: 'PUT',
headers: { 'content-type': 'application/octet-stream', 'content-length': fs.statSync(abs).size },
headers: {
'content-type': 'application/octet-stream',
'content-length': fs.statSync(abs).size,
'x-plwl-unit': unitKey,
'x-plwl-path': rel
},
timeout: 0
},
res => {
@ -142,12 +155,11 @@ function createPusher(api) {
// --- registry payload -----------------------------------------------------
// Everything the app config holds travels: paths (portableGame rewrites them
// to the FILES/prefix relative form both machines understand), the wine
// build and prefix it runs in, args/env/dllOverrides and the play counters.
function portableApp(game) {
const out = library.portableGame(game)
// playtime/lastPlayed are per machine, the receiver keeps its own counters
delete out.playSeconds
delete out.lastPlayed
return out
return library.portableGame(game)
}
function buildRegistryPayload(categories) {
@ -166,6 +178,15 @@ 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.
payload.defaults = {
defaultWine: (library.loadSettings().defaultWine) || 'system',
defaultPrefix: prefixes.listPrefixes().default || ''
}
}
if (cats.has('prefixes')) {
const defs = prefixes.listPrefixes()
payload.prefixes = {
@ -185,6 +206,10 @@ function createPusher(api) {
skipped: 0,
deleted: 0,
bytes: 0,
// how many apps / wine builds / prefixes this sync walked over: the
// progress popup reports "3 games, 1 prefix" instead of file counts,
// which mean nothing to the user
items: { apps: 0, wines: 0, prefixes: 0 },
apps: { copied: 0, skipped: 0, deleted: 0 },
wines: { copied: 0, skipped: 0, deleted: 0 },
prefixes: { copied: 0, skipped: 0, deleted: 0 }
@ -226,6 +251,13 @@ function createPusher(api) {
if (!device || !device.address || !device.port) return fail('offline')
const cats = new Set(normalizeCategories(categories))
if (!cats.size) return fail('nothing-selected')
// The units are known before anything is read, so how many apps / wine
// builds / prefixes this sync carries is announced with the verification:
// the other side can show "Apps: 0/5" from its first event.
const units = buildUnits(cats)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const totals = { apps: 0, wines: 0, prefixes: 0 }
for (const unit of units) totals[bucketOf(unit.kind)] += 1
// every progress event is kept (and handed to onProgress when given) so
// callers can report on the transfer without subscribing first
const events = []
@ -233,34 +265,24 @@ function createPusher(api) {
events.push(data)
if (onProgress) onProgress(data)
}
const emit = (phase, unit, done, total) =>
progress({
stage: phase,
phase,
kind: (unit && unit.kind) || '',
id: (unit && unit.id) || '',
key: (unit && unit.key) || '',
unit: (unit && unit.label) || '',
label: (unit && unit.label) || '',
name: (unit && unit.label) || '',
done,
total
})
let verified
try {
// longer than the receiver's incoming-request timeout: the user over
// there gets a popup and this side waits for the click
verified = await httpJson(device, 'POST', '/plwl/verify', {
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase() },
timeout: 20000
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
timeout: 90000
})
} catch (err) {
return fail(String((err && err.message) || err))
}
if (!verified.data || !verified.data.ok) {
const error = (verified.data && verified.data.error) || `http-${verified.status}`
// the peer rejected the code (it was rotated there): drop the stored
// pairing, so the user is asked for the new code instead of every later
// sync failing against a stale one
// the peer rotated its code: drop the stored pairing, so the next
// attempt asks for the new one instead of failing silently. A plain
// 'rejected' (the user over there declined the popup) keeps the pairing
// - the code was proven correct, the popup is the consent gate.
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
return fail(error)
}
@ -269,9 +291,56 @@ function createPusher(api) {
// typing (a rejected code is never stored)
rememberDevice(device.id || deviceId, code, device.name)
const units = buildUnits(cats)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
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
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
let plannedBytes = 0
let itemsSeen = 0
// files uploaded/announced in PREVIOUS units: the popup's file counter is
// cumulative across the whole sync (a per-unit counter resets with every
// next app, which reads like progress going backwards)
let allDone = 0
let allTotal = 0
// the bar tracks the PHASE being worked on (its own bytes), never a
// leftover from the phase before: hashing starts from an empty bar
let barBytes = 0
let barTotal = 0
const snapshot = (phase, unit, done, total) => {
const data = {
stage: phase,
phase,
kind: (unit && unit.kind) || '',
id: (unit && unit.id) || '',
key: (unit && unit.key) || '',
unit: (unit && unit.label) || '',
label: (unit && unit.label) || '',
name: (unit && unit.label) || '',
bytes: summary.bytes,
totalBytes: plannedBytes,
units: summary.units,
unitsDone: itemsSeen,
items: { ...summary.items },
itemsDone: { ...finished },
totals: { ...totals },
barBytes,
barTotalBytes: barTotal
}
// only phases that actually move the file counter carry one (the copy
// phase passes absolute, cumulative values); the rest omit it, so the
// popup freezes the last count instead of jumping backwards while this
// unit is still being scanned or compared
if (Number.isFinite(done) && Number.isFinite(total)) {
data.done = done
data.total = total
}
return data
}
// hashing and copying walk thousands of files: the state is snapshotted on
// every file but forwarded a few times per second (phase changes always
// go through, so nothing on screen ever jumps backwards)
const gate = typeof api.progressGate === 'function' ? api.progressGate(progress) : data => progress(data)
const emit = (phase, unit, done, total, force) => gate(snapshot(phase, unit, done, total), !!force)
const end = async () => {
try {
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
@ -281,40 +350,83 @@ function createPusher(api) {
for (const unit of units) {
const roots = unit.roots.filter(ownedRoot)
if (!roots.length) continue
emit('scan', unit, 0, 0)
const entries = await buildManifest(roots, !!force, (done, total) => emit('hash', unit, done, total))
emit('plan', unit, 0, 0)
const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force)
const kind = bucketOf(unit.kind)
const bucket = summary[kind]
summary.items[kind] += 1
itemsSeen += 1
barBytes = 0
barTotal = 0
emit('scan', unit, null, null, true)
const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => {
barBytes = hashedBytes
barTotal = totalBytes
// the file counter is not touched here: until the plan comes back
// nobody knows how many of this unit's files will actually move
emit('hash', unit, null, null)
})
emit('plan', unit, null, null, true)
const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force, { totals })
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || [])
const bucket = summary[bucketOf(unit.kind)]
// per category every manifest entry counts: created/overwritten vs
// already identical (the plan reports both)
bucket.copied += entries.length - same.size
bucket.skipped += same.size
let done = 0
summary.skipped += same.size
const pending = []
for (const entry of entries) {
if (entry.type !== 'file') continue
if (!upload.has(entry.path)) {
summary.skipped++
continue
if (!upload.has(entry.path)) continue // identical -> the receiver keeps its copy
pending.push(entry)
plannedBytes += entry.size || 0
}
emit('copy', unit, done, upload.size)
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
// only the bytes that do - and starts empty for the transfer phase
if (pending.length) {
barBytes = 0
barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 0)
emit('copy', unit, allDone, allTotal + pending.length, true)
}
let failed = null
let done = 0
const send = async entry => {
if (failed) return
const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path))
if (res.status !== 200) throw new Error(`upload-failed-${res.status}`)
if (res.status !== 200) {
failed = new Error(`upload-failed-${res.status}`)
return
}
done++
summary.uploaded++
summary.bytes += entry.size || 0
barBytes += entry.size || 0
emit('copy', unit, allDone + done, allTotal + pending.length)
}
emit('copy', unit, done, upload.size)
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
else for (const entry of pending) await send(entry)
if (failed) throw failed
emit('copy', unit, allDone + done, allTotal + pending.length, true)
// this unit's files count towards the running totals only once they
// are really on the wire - the counter holds there while the receiver
// prunes and the next unit is scanned/compared
allDone += pending.length
allTotal += pending.length
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
if (commit.data && commit.data.deleted) {
summary.deleted += commit.data.deleted
bucket.deleted += commit.data.deleted
}
finished[kind] += 1
// a finished unit leaves a finished bar behind, even one that sent none
barTotal = barTotal || 1
barBytes = barTotal
emit('commit', unit, null, null, true)
}
emit('registry', null, 0, 0)
emit('registry', null, null, null, true)
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 })
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
for (const key of ['apps', 'wines', 'prefixes']) {
@ -322,7 +434,7 @@ function createPusher(api) {
if (deleted) summary[key].deleted += deleted
}
if (applied.data.deleted) summary.deleted += applied.data.deleted
emit('done', null, summary.uploaded, summary.uploaded)
emit('done', null, allDone, allTotal, true)
await end()
} catch (err) {
await end()

View file

@ -93,10 +93,16 @@ function trayIconPath() {
function createTray() {
const { Tray, Menu } = require('electron')
const icon = trayIconPath()
try {
tray = new Tray(trayIconPath())
} catch {
return // no icon available; window still works normally
tray = new Tray(icon)
} catch (err) {
// never fatal (the window keeps working), but say why the icon is missing
// instead of failing silently - a missing tray looks like a hung app when
// the launcher runs in the background
console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`)
tray = null
return
}
tray.setToolTip('PLWL')
rebuildTrayMenu()
@ -233,16 +239,27 @@ app.whenReady().then(async () => {
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
gamesService = games
// LAN sync runs whenever the launcher runs - that is what makes this
// device show up on the other machines' client lists
lansync.start({
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', info),
isBusy: () => !!(gamesService && gamesService.listRunning().length)
})
// the tray comes first: nothing (least of all the network) may keep it from
// coming up - a launcher without a tray icon looks simply broken
createTray()
// LAN sync runs whenever the launcher runs - that is what makes this
// device show up on the other machines' client lists
const lanHooks = {
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', info),
onReceive: info => sendToWindow(win, 'lansync:receiving', info),
// the consent popup for an incoming sync (see ipc/lansync.js)
onIncoming: info => sendToWindow(win, 'lansync:incoming', info),
isBusy: () => !!(gamesService && gamesService.listRunning().length)
}
try {
Promise.resolve(lansync.start(lanHooks)).catch(err => {
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
})
} catch (err) {
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
}
if (headless) {
// shortcut boot: run the game without opening the launcher window
launchGame(autoLaunchId)
@ -259,5 +276,7 @@ app.whenReady().then(async () => {
app.on('before-quit', () => {
quitting = true
try {
lansync.stop()
} catch {}
})

View file

@ -115,21 +115,47 @@ function resolvePortablePath(p) {
}
// The launcher's own icon (renderer/plwl.png), used by the tray, the app
// icon and as the .desktop shortcut fallback. Cached: it never moves at
// runtime.
// icon and as the .desktop shortcut fallback.
//
// The module-relative copy comes FIRST: it is the only candidate that exists
// in every layout, because a packaged build keeps renderer/ inside
// app.asar and Electron's patched fs reads straight out of the archive (a
// release only ships olcxja.plwl.png next to the portable root, so looking at
// APP_ROOT alone made the tray icon vanish in installed builds). Cached: it
// never moves at runtime.
function launcherIconCandidates() {
const bundled = path.join(__dirname, '..', 'renderer')
return [
path.join(bundled, 'plwl.png'),
path.join(bundled, 'plwl.svg'),
path.join(APP_ROOT, 'renderer', 'plwl.png'),
path.join(APP_ROOT, 'renderer', 'plwl.svg'),
path.join(APP_ROOT, 'olcxja.plwl.png')
]
}
function firstExisting(candidates) {
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) return candidate
} catch {}
}
return null
}
let launcherIcon = null
function launcherIconPath() {
if (launcherIcon === null) {
const p = path.join(APP_ROOT, 'renderer', 'plwl.png')
try {
launcherIcon = fs.existsSync(p) ? p : ''
} catch {
launcherIcon = ''
}
}
if (launcherIcon === null) launcherIcon = firstExisting(launcherIconCandidates()) || ''
return launcherIcon || null
}
// The same icon, but only as a real file on disk: a .desktop entry's Icon= is
// resolved by the desktop environment, which cannot look inside app.asar.
function launcherIconDiskPath() {
const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`))
return firstExisting(onDisk)
}
function readJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
@ -145,4 +171,4 @@ function writeJson(file, data) {
fs.renameSync(tmp, file)
}
module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, readJson, writeJson, toPortablePath, resolvePortablePath }
module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, launcherIconDiskPath, readJson, writeJson, toPortablePath, resolvePortablePath }

View file

@ -3,6 +3,7 @@
const path = require('path')
const fs = require('fs')
const { DIRS, readJson, writeJson } = require('./paths')
const { rewireSymlinks } = require('./symlinks')
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
const MAIN = 'Main'
@ -85,6 +86,15 @@ function deletePrefix(name) {
return { ok: true }
}
// A prefix that arrived from another machine (or a launcher that was moved) is
// full of absolute symlinks into its wine/proton build - fonts, built-in
// program stubs, dlls. Rewire them to relative paths so the prefix keeps
// working here and stays relocatable (same healing wine builds get on import).
async function healPrefix(name) {
if (!name) return 0
return rewireSymlinks(prefixPath(name), [DIRS.FILES])
}
function setDefaultPrefix(name) {
const defs = listPrefixes()
if (name && !defs.prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' }
@ -93,4 +103,4 @@ function setDefaultPrefix(name) {
return { ok: true }
}
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, normalizeName, MAIN }
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, healPrefix, normalizeName, MAIN }

View file

@ -23,6 +23,13 @@ let lanSyncProgressCb = null
ipcRenderer.on('lansync:progress', (e, data) => {
if (lanSyncProgressCb) lanSyncProgressCb(data)
})
// Incoming sync consent: the receiver's Accept/Decline popup. Single slot for
// the same reason - one pending request at a time (the server enforces it).
let lanSyncIncomingCb = null
ipcRenderer.on('lansync:incoming', (e, data) => {
if (lanSyncIncomingCb) lanSyncIncomingCb(data)
})
const fs = require('fs')
const path = require('path')
@ -91,9 +98,14 @@ contextBridge.exposeInMainWorld('plwl', {
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
onLanSyncProgress: cb => {
lanSyncProgressCb = cb
},
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data))
onLanSyncIncoming: cb => {
lanSyncIncomingCb = cb
},
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
})

View file

@ -6,18 +6,66 @@ const { spawn } = require('child_process')
const { DIRS } = require('./paths')
const { prefixPath } = require('./prefixes')
// The fake Steam install Proton is pointed at through
// STEAM_COMPAT_CLIENT_INSTALL_PATH. Both folders Proton links into its prefix
// have to exist: it symlinks <prefix>/drive_c/Program Files (x86)/Steam/
// appcache -> <fake>/appcache while setting the prefix up, and a link into a
// missing folder is a dangling link in a tree that gets synced.
function ensureFakeSteam() {
const fake = DIRS.FAKE_STEAM
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
fs.mkdirSync(path.join(fake, 'appcache'), { recursive: true })
return fake
}
// The prefix a run will use, by NAME (buildEnv resolves the same fallback).
function prefixNameFor(game) {
if (game && game.prefix) return game.prefix
const defs = require('./prefixes').listPrefixes()
return defs.default || 'default'
}
// 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.
// Healing once per prefix per launch keeps the whole tree relocatable and
// costs a single walk in the common (already relative) case.
const healedPrefixes = new Set()
async function healPrefixOnce(game) {
const name = prefixNameFor(game)
const key = `${name}\u0000${DIRS.BUILD}`
if (!name || healedPrefixes.has(key)) return 0
healedPrefixes.add(key)
try {
return await require('./prefixes').healPrefix(name)
} catch {
return 0
}
}
// Proton gets its prefix through STEAM_COMPAT_DATA_PATH, which has to be a
// symlink to the launcher's own prefix folder. The parent directory is created
// FIRST: symlink() fails with ENOENT when compat/ does not exist yet, and the
// old fallback then created a plain empty directory instead - Proton silently
// started with an empty prefix (the game never saw its own data). A leftover
// EMPTY directory from exactly that situation is safe to replace with the
// link; a populated one is left alone.
function linkPrefix(compatData, prefixDir) {
try {
fs.mkdirSync(path.dirname(compatData), { recursive: true })
} catch {}
try {
const st = fs.lstatSync(compatData)
if (st.isSymbolicLink()) {
if (fs.readlinkSync(compatData) === prefixDir) return
fs.unlinkSync(compatData)
} else if (st.isDirectory()) {
let names = []
try {
names = fs.readdirSync(compatData)
} catch {}
if (names.length) return
fs.rmdirSync(compatData)
} else {
return
}
@ -110,7 +158,8 @@ function openLatestLog(gameId) {
return fs.createWriteStream(logFile, { flags: 'w' })
}
function runGame({ game, wine, systemWine, onExit }) {
async function runGame({ game, wine, systemWine, onExit }) {
await healPrefixOnce(game)
const isProton = wine && wine.type === 'proton'
const { env } = buildEnv({ game, wine })
const exe = game.exePath
@ -140,11 +189,22 @@ function runGame({ game, wine, systemWine, onExit }) {
} catch (err) {
return reject(err)
}
// spawn() returns before the OS actually starts the program: when the
// binary is missing or has no executable bit (a wine build copied without
// its permissions, a wrong path) the failure only arrives as an 'error'
// event, AFTER this function would have resolved. Resolving there marked
// games that never started as running forever, so the promise only settles
// once the process really exists ('spawn').
let started = false
child.once('error', err => {
if (!started) reject(err)
})
child.once('spawn', () => {
started = true
const logFile = latestLogPath(game.id)
const ws = openLatestLog(game.id)
child.stdout.pipe(ws)
child.stderr.pipe(ws)
child.on('error', reject)
child.unref()
const startedAt = Date.now()
// read the LIVE playtime file, not the (possibly shared/stale) registry
@ -165,9 +225,11 @@ function runGame({ game, wine, systemWine, onExit }) {
})
resolve({ pid: child.pid, logFile })
})
})
}
function runTool({ game, wine, tool, args, systemWine }) {
async function runTool({ game, wine, tool, args, systemWine }) {
await healPrefixOnce(game)
const isProton = wine && wine.type === 'proton'
const { env } = buildEnv({ game, wine })
const exe = game.exePath

View file

@ -7,7 +7,7 @@
const fs = require('fs')
const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths')
const { DIRS, resolvePortablePath, launcherIconDiskPath } = require('./paths')
const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA
@ -62,8 +62,9 @@ function buildDesktopEntry(game) {
if (found) icon = path.join(iconDir, found)
} catch {}
if (!icon) {
// fall back to the launcher icon so the shortcut is never naked
icon = require('./paths').launcherIconPath() || ''
// fall back to the launcher icon so the shortcut is never naked (the
// on-disk copy: the desktop environment cannot read inside app.asar)
icon = launcherIconDiskPath() || ''
}
const lines = [
'[Desktop Entry]',

194
src/symlinks.js Normal file
View file

@ -0,0 +1,194 @@
'use strict'
// Symlink and permission healing, shared by wine builds, prefixes, game
// imports and LAN sync.
//
// Two things break when a tree is copied between machines (or the launcher is
// just moved):
// - symlinks stored ABSOLUTE keep pointing at the path they had on the
// machine that wrote them. A Proton prefix is full of them (fonts, built-in
// program stubs, dlls, all pointing into its build), and a wine build's
// bundled default prefix has the same problem.
// - the executable bit is dropped by every content-piping copy
// (createWriteStream, fs.copyFile), which makes an imported `proton` or
// `wine` binary unrunnable - the launcher then cannot start that build at
// all.
//
// Everything here rewires a link to a RELATIVE path when the file it points at
// can be found locally, so the tree stays relocatable forever, and restores
// the executable bit on the binaries a wine/proton build needs.
const fsp = require('fs').promises
const fs = require('fs')
const path = require('path')
function exists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
// dosdevices/ holds the prefix's drive mappings (c:, z:, d:, com1 ...). They
// are *supposed* to point outside the prefix - at the filesystem root, a
// mounted CD or a serial port - and wine rewrites them itself, so they are
// never touched.
const SKIP_DIRS = new Set(['dosdevices'])
async function collectSymlinks(dir, out) {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) out.push(full)
else if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) await collectSymlinks(full, out)
}
}
// A target that does not exist here YET can only be something PLWL itself
// creates later: Proton links files/data/fake-steam/appcache into its prefix,
// a prefix links into files/wines/<build>/... (see the fallback pass below).
// Restricting the fallback to these trees keeps an unrelated /usr/lib/... link
// from being mistaken for a wine build's own files/lib/ directory.
const ANCHOR_DIRS = new Set(['wines', 'wineprefixes', 'apps', 'data', 'translations'])
// True when `candidate` sits under `root` and its deepest EXISTING parent is at
// least two components inside it. `files/data/fake-steam/appcache` passes even
// before appcache is created, because files/data/fake-steam is already there;
// `files/bin/<x>` does not, because a single-component parent is no evidence
// that the link really belongs to the launcher.
function anchoredInside(root, candidate) {
let p = candidate
while (p.length > root.length + 1) {
p = path.dirname(p)
if (p === root || !p.startsWith(root + path.sep)) return false
if (exists(p)) return path.relative(root, p).split(path.sep).length >= 2
}
return false
}
// The new target for an absolute link: the same file, reached from the link's
// own directory. `roots` are the trees the target may live in - the tree being
// healed first, then (for a prefix linking into its build) the launcher's
// files/ root. When the target is not directly inside one of them, the longest
// suffix that exists here is used instead, which is what makes a link written
// on another machine work on this one.
function relativeTarget(link, target, roots) {
const dir = path.dirname(link)
for (const root of roots) {
if (!root) continue
if (target.startsWith(root + path.sep)) {
const rel = path.relative(dir, target)
if (rel && !path.isAbsolute(rel)) return rel
}
}
// Second chance for links whose file is not here yet (or whose parent chain
// only partially exists): the relative path is still the right one, and
// writing it now beats leaving a link that can never resolve.
for (const requireLeaf of [true, false]) {
for (const root of roots) {
if (!root) continue
let i = target.indexOf(path.sep, 1)
while (i !== -1) {
const rest = target.slice(i + 1)
if (rest && !rest.split(path.sep).includes('..')) {
const candidate = path.join(root, rest)
if (candidate.startsWith(root + path.sep)) {
const ok = requireLeaf
? exists(candidate)
: ANCHOR_DIRS.has(rest.replace(/\\/g, '/').split('/')[0]) && anchoredInside(root, candidate)
if (ok) {
const rel = path.relative(dir, candidate)
if (rel && !path.isAbsolute(rel)) return rel
}
}
}
i = target.indexOf(path.sep, i + 1)
}
}
}
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.
async function rewireSymlinks(root, roots = []) {
if (!root) return 0
const candidates = [root, ...roots.filter(r => r && r !== root)]
const links = []
await collectSymlinks(root, links)
let fixed = 0
for (let pass = 0; pass < 5; pass++) {
let changed = 0
for (const link of links) {
let target
try {
target = await fsp.readlink(link)
} catch {
continue
}
if (!path.isAbsolute(target)) continue // already relocatable
const next = relativeTarget(link, path.normalize(target), candidates)
if (!next || next === target) continue
try {
await fsp.unlink(link)
await fsp.symlink(next, link)
fixed++
changed++
} catch {}
}
if (!changed) break
}
return fixed
}
// Binaries a wine/proton build has to be able to execute. A copy that pipes
// file contents (the LAN sync, fs.copyFile) drops the bit, and without it
// `spawn` fails with EACCES and the build can never run. Windows .exe files
// are NOT in here on purpose: wine starts those itself, they need no bit.
const EXEC_NAMES = new Set(['proton', 'wine', 'wine64', 'wine-preloader', 'wine64-preloader', 'wineserver', 'winetricks', 'wineboot'])
// Give the exec bit back to a build's binaries (and anything in a bin/ dir).
// Used after a sync or an import - and to repair a build that was synced
// before the sync knew about permissions, without re-copying it.
async function restoreExecutableBits(root) {
if (!root) return 0
let fixed = 0
const walk = async dir => {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
await walk(full)
continue
}
if (!entry.isFile()) continue
const inBin = path.basename(dir) === 'bin'
if (!inBin && !EXEC_NAMES.has(entry.name) && !entry.name.endsWith('.sh')) continue
try {
const st = await fsp.stat(full)
if (st.mode & 0o111) continue
await fsp.chmod(full, (st.mode & 0o777) | 0o755)
fixed++
} catch {}
}
}
await walk(root)
return fixed
}
module.exports = { rewireSymlinks, restoreExecutableBits, collectSymlinks, relativeTarget, EXEC_NAMES }

View file

@ -5,6 +5,7 @@ const fsp = fs.promises
const path = require('path')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { dedupeDir } = require('./fsutil')
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
const WINES_DIR = DIRS.WINES
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
@ -109,12 +110,17 @@ async function refreshWines() {
const prev = readJson(REGISTRY, []).map(wineAbs)
const prevById = new Map(prev.map(w => [w.id, w]))
// A build whose path changed since last scan was moved (or the launcher
// itself was) - rewire its absolute symlinks to the current location.
// itself was) - rewire its absolute symlinks to the current location. A
// build that cannot be executed (no exec bit on proton/wine - exactly what
// a sync from a PLWL that predates permission transfer leaves behind) is
// healed here instead: two access() calls per build, and only a broken build
// pays for the walk. Without it such a build stays dead weight forever.
for (const w of found) {
const old = prevById.get(w.id)
if (!old || old.path !== w.path) {
const moved = !old || old.path !== w.path
if (moved || buildAuditFailed(w.path, w.wineBin)) {
try {
await fixBuildSymlinks(w.path)
await healBuild(w.path)
} catch {}
}
}
@ -190,77 +196,58 @@ function deleteWine(id) {
// that point to absolute paths from wherever the build lived before (zip
// layout, previous install dir). After the build or the whole launcher is
// moved those links dangle and Proton crashes during prefix setup
// (try_copy -> FileNotFoundError). Rewire every absolute symlink whose content
// can be located inside the build, always storing the new target as a
// *relative* path so the build stays relocatable forever.
async function collectSymlinks(dir, out) {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isSymbolicLink()) {
out.push(full)
} else if (e.isDirectory() && e.name !== 'dosdevices') {
// dosdevices/ holds prefix drive mappings (c:, z:, ...) which are
// *supposed* to point outside the build - never touch them.
await collectSymlinks(full, out)
}
}
}
function rewireTarget(root, link, target) {
if (!path.isAbsolute(target)) return null // already relocatable
// Absolute link that lives inside the build: make it relative.
if (target.startsWith(root + path.sep)) {
return path.relative(path.dirname(link), target)
}
// Absolute link to an old location: find the longest suffix of the target
// that exists inside the new build and point there instead.
let i = target.indexOf(path.sep, 1)
while (i !== -1) {
const rel = target.slice(i + 1)
if (rel) {
const candidate = path.join(root, rel)
if (!rel.split(path.sep).includes('..') && candidate.startsWith(root + path.sep) && exists(candidate)) {
return path.relative(path.dirname(link), candidate)
}
}
i = target.indexOf(path.sep, i + 1)
}
return null
}
// (try_copy -> FileNotFoundError). The rewiring itself lives in symlinks.js
// because prefixes, synced apps and game imports need exactly the same thing.
async function fixBuildSymlinks(root) {
const links = []
await collectSymlinks(root, links)
let fixed = 0
// A link may only become rewirable once another link it chains through has
// been fixed, so repeat until a full pass changes nothing.
for (let pass = 0; pass < 5; pass++) {
let changed = 0
for (const link of links) {
let target
return rewireSymlinks(root, [DIRS.FILES])
}
// 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) {
const links = await fixBuildSymlinks(root)
const executables = await restoreExecutableBits(root)
return { links, executables }
}
function isExec(p) {
try {
target = await fsp.readlink(link)
fs.accessSync(p, fs.constants.X_OK)
return true
} catch {
return false
}
}
// Where a wine/proton build keeps the programs wine itself spawns (wine,
// wineserver, wineboot, ...) and the loaders next to the shared libraries.
const AUDIT_DIRS = ['files/bin', 'bin', 'dist/bin', 'files/lib/wine/x86_64-unix', 'files/lib/wine/i386-unix']
// Is this build missing a permission it needs to run? A build synced from a
// PLWL that predates permission transfer has no executable bit anywhere, and
// that stays invisible until the user presses Play (spawn fails with EACCES).
// Auditing is a handful of directory listings - only a build that FAILS it
// pays for the full walk, and the audit says nothing about a healthy build on
// every later start.
function buildAuditFailed(root, wineBin) {
if (!root) return false
if (wineBin && !isExec(wineBin)) return true
for (const rel of AUDIT_DIRS) {
const dir = path.join(root, rel)
let entries = []
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
const newTarget = rewireTarget(root, link, path.normalize(target))
if (!newTarget || newTarget === target) continue
try {
await fsp.unlink(link)
await fsp.symlink(newTarget, link)
fixed++
changed++
} catch {}
const inBin = path.basename(rel) === 'bin'
for (const entry of entries) {
if (!entry.isFile()) continue
if (!inBin && !EXEC_NAMES.has(entry.name)) continue
if (!isExec(path.join(dir, entry.name))) return true
}
if (!changed) break
}
return fixed
return false
}
async function importWineFolder(srcDir) {
@ -277,13 +264,13 @@ async function importWineFolder(srcDir) {
}
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
await fsp.cp(copySrc, dst, { recursive: true, verbatimSymlinks: true })
let symlinksFixed = 0
let healed = { links: 0, executables: 0 }
try {
symlinksFixed = await fixBuildSymlinks(dst)
healed = await healBuild(dst)
} catch {}
await refreshWines()
// the toast shows a portable-looking path (files/...) like everywhere else
return { ok: true, path: toPortablePath(dst), symlinksFixed }
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
}
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks }
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }