a LOT sync fixes...
This commit is contained in:
parent
c0f6e802ed
commit
806e173e43
14 changed files with 1073 additions and 240 deletions
14
README.md
14
README.md
|
|
@ -41,7 +41,7 @@ cd PLWL
|
||||||
|
|
||||||
## Importing from a wine prefix
|
## 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
|
## "Open with PLWL" in the file manager
|
||||||
|
|
||||||
|
|
@ -55,16 +55,20 @@ Two PLWL installs on the same LAN can mirror each other's content - no cloud, no
|
||||||
- **Sync code** - 8 characters, letters and digits, case-insensitive. A device only accepts a connection when the caller proves it knows *its own* code; each verification costs two seconds and a device may only have one in flight, so guessing over the LAN is pointless. Both the ID and the code can be re-rolled.
|
- **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.
|
||||||
- **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed.
|
- **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed.
|
||||||
|
|
||||||
Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds** 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.
|
||||||
|
|
||||||
**Both machines show what is happening.** The sender gets a progress popup (phase, files, how many games / prefixes / Wine builds take part, and the transferred vs. planned size in GB); the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. A running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing, the popup closes itself when the transfer ends - or after five minutes without progress, when the other side has vanished.
|
**Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing; 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. 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.
|
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
|
## Wine & Proton support
|
||||||
|
|
||||||
- **System Wine** — uses the `wine` binary from PATH.
|
- **System Wine** — uses the `wine` binary from PATH.
|
||||||
- **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically).
|
- **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`).
|
- **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
|
## Translations
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ src/ main process (Node/Electron backend)
|
||||||
library.js apps registry (apps.json) + settings + playtime files
|
library.js apps registry (apps.json) + settings + playtime files
|
||||||
wines.js wine/proton build scan, registry, portable symlink healing
|
wines.js wine/proton build scan, registry, portable symlink healing
|
||||||
prefixes.js prefix registry (files/wineprefixes)
|
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)
|
importer.js app import (copy/move/leave/prefix)
|
||||||
openwith.js "Open with PLWL": ad-hoc runs, no library entry
|
openwith.js "Open with PLWL": ad-hoc runs, no library entry
|
||||||
shortcuts.js .desktop shortcut generation
|
shortcuts.js .desktop shortcut generation
|
||||||
|
|
@ -70,9 +72,44 @@ handlers in src/ipc/. Keep new APIs in that pattern.
|
||||||
states share the module, so nothing may be kept in its module scope. Both
|
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),
|
sides report their own progress (the receiver through the onReceive hook),
|
||||||
so each machine can show the transfer.
|
so each machine can show the transfer.
|
||||||
|
- 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:
|
- A modal showing a running operation (a sync) opens locked:
|
||||||
showModal(html, { locked: true }) ignores the backdrop and Escape, because
|
showModal(html, { locked: true }) ignores the backdrop and Escape, because
|
||||||
the user cannot cancel the operation by dismissing it.
|
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
|
- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to
|
||||||
a temp dir, check(), exit 1 on failure. Add one when fixing a bug.
|
a temp dir, check(), exit 1 on failure. Add one when fixing a bug.
|
||||||
|
|
||||||
|
|
@ -83,5 +120,13 @@ node --check <changed js files> # syntax
|
||||||
node test/import.test.js # ...and the other test files
|
node test/import.test.js # ...and the other test files
|
||||||
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
|
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
|
||||||
node test/lansync-receiving.test.js # receive-side progress events (the popup)
|
node test/lansync-receiving.test.js # receive-side progress events (the popup)
|
||||||
|
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
|
||||||
|
node test/lansync-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/bench-sync.js # timing: phases of a cold and a cached sync
|
||||||
npm start # click through the flow you touched
|
npm start # click through the flow you touched
|
||||||
|
|
||||||
|
# Repairing / testing a real instance (test/ scripts are gitignored helpers):
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@
|
||||||
"lansync.appsPrefix": "Apps imported from a prefix",
|
"lansync.appsPrefix": "Apps imported from a prefix",
|
||||||
"lansync.wines": "Wine / Proton versions",
|
"lansync.wines": "Wine / Proton versions",
|
||||||
"lansync.prefixes": "Wine prefixes",
|
"lansync.prefixes": "Wine prefixes",
|
||||||
|
"lansync.defaults": "Default settings (default Wine and prefix)",
|
||||||
"lansync.force": "Force sync all files (skip the hash check and overwrite everything)",
|
"lansync.force": "Force sync all files (skip the hash check and overwrite everything)",
|
||||||
"lansync.mirrorWarning": "This device is the source of truth: on the other device, files and items that do not exist here will be deleted.",
|
"lansync.mirrorWarning": "This device is the source of truth: on the other device, files and items that do not exist here will be deleted.",
|
||||||
"lansync.start": "Start sync",
|
"lansync.start": "Start sync",
|
||||||
|
|
@ -172,17 +173,20 @@
|
||||||
"lansync.phase.hash": "Hashing {0}",
|
"lansync.phase.hash": "Hashing {0}",
|
||||||
"lansync.phase.plan": "Comparing {0}",
|
"lansync.phase.plan": "Comparing {0}",
|
||||||
"lansync.phase.copy": "Sending {0}",
|
"lansync.phase.copy": "Sending {0}",
|
||||||
|
"lansync.recvPhase.copy": "Receiving {0}",
|
||||||
"lansync.phase.registry": "Updating the library",
|
"lansync.phase.registry": "Updating the library",
|
||||||
"lansync.phase.commit": "Removing extra files",
|
"lansync.phase.commit": "Removing extra files",
|
||||||
"lansync.phase.done": "Finishing up",
|
"lansync.phase.done": "Finishing up",
|
||||||
"lansync.progressFiles": "{0} / {1} files",
|
"lansync.progressFiles": "{0} / {1} files",
|
||||||
"lansync.progressBytes": "{0} / {1} transferred",
|
"lansync.progressBytes": "{0} / {1} transferred",
|
||||||
"lansync.progressSent": "{0} transferred",
|
"lansync.progressSent": "{0} transferred",
|
||||||
"lansync.statsApps": "Games: {0}",
|
"lansync.keepOpen": "Do not close PLWL until this finishes.",
|
||||||
|
"lansync.statsApps": "Apps: {0}",
|
||||||
"lansync.statsPrefixes": "Prefixes: {0}",
|
"lansync.statsPrefixes": "Prefixes: {0}",
|
||||||
"lansync.statsWines": "Wine versions: {0}",
|
"lansync.statsWines": "Wine versions: {0}",
|
||||||
"lansync.receiving": "Receiving from {0}…",
|
"lansync.receiving": "Receiving from {0}…",
|
||||||
"lansync.recvWaiting": "Waiting for the file list…",
|
"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.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.done": "Synced to {0}: {1} sent ({4}), {2} already up to date, {3} removed",
|
||||||
"lansync.failed": "Sync failed: {0}",
|
"lansync.failed": "Sync failed: {0}",
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@
|
||||||
"lansync.appsPrefix": "Aplikacje importowane z prefiksu",
|
"lansync.appsPrefix": "Aplikacje importowane z prefiksu",
|
||||||
"lansync.wines": "Wersje Wine / Proton",
|
"lansync.wines": "Wersje Wine / Proton",
|
||||||
"lansync.prefixes": "Prefiksy Wine",
|
"lansync.prefixes": "Prefiksy Wine",
|
||||||
|
"lansync.defaults": "Ustawienia domyślne (domyślne Wine i prefiks)",
|
||||||
"lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)",
|
"lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)",
|
||||||
"lansync.mirrorWarning": "To urządzenie jest źródłem prawdy: na drugim urządzeniu pliki i elementy, których tu nie ma, zostaną usunięte.",
|
"lansync.mirrorWarning": "To urządzenie jest źródłem prawdy: na drugim urządzeniu pliki i elementy, których tu nie ma, zostaną usunięte.",
|
||||||
"lansync.start": "Rozpocznij synchronizację",
|
"lansync.start": "Rozpocznij synchronizację",
|
||||||
|
|
@ -172,17 +173,20 @@
|
||||||
"lansync.phase.hash": "Haszowanie {0}",
|
"lansync.phase.hash": "Haszowanie {0}",
|
||||||
"lansync.phase.plan": "Porównywanie {0}",
|
"lansync.phase.plan": "Porównywanie {0}",
|
||||||
"lansync.phase.copy": "Wysyłanie {0}",
|
"lansync.phase.copy": "Wysyłanie {0}",
|
||||||
|
"lansync.recvPhase.copy": "Odbieranie {0}",
|
||||||
"lansync.phase.registry": "Aktualizowanie biblioteki",
|
"lansync.phase.registry": "Aktualizowanie biblioteki",
|
||||||
"lansync.phase.commit": "Usuwanie zbędnych plików",
|
"lansync.phase.commit": "Usuwanie zbędnych plików",
|
||||||
"lansync.phase.done": "Kończenie",
|
"lansync.phase.done": "Kończenie",
|
||||||
"lansync.progressFiles": "{0} / {1} plików",
|
"lansync.progressFiles": "{0} / {1} plików",
|
||||||
"lansync.progressBytes": "{0} / {1} przesłane",
|
"lansync.progressBytes": "{0} / {1} przesłane",
|
||||||
"lansync.progressSent": "przesłano {0}",
|
"lansync.progressSent": "przesłano {0}",
|
||||||
"lansync.statsApps": "Gry: {0}",
|
"lansync.keepOpen": "Nie zamykaj PLWL, dopóki to się nie zakończy.",
|
||||||
|
"lansync.statsApps": "Aplikacje: {0}",
|
||||||
"lansync.statsPrefixes": "Prefiksy: {0}",
|
"lansync.statsPrefixes": "Prefiksy: {0}",
|
||||||
"lansync.statsWines": "Wersje Wine: {0}",
|
"lansync.statsWines": "Wersje Wine: {0}",
|
||||||
"lansync.receiving": "Odbieranie od {0}…",
|
"lansync.receiving": "Odbieranie od {0}…",
|
||||||
"lansync.recvWaiting": "Oczekiwanie na listę plików…",
|
"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.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.done": "Zsynchronizowano na {0}: wysłano {1} ({4}), aktualnych {2}, usunięto {3}",
|
||||||
"lansync.failed": "Synchronizacja nie powiodła się: {0}",
|
"lansync.failed": "Synchronizacja nie powiodła się: {0}",
|
||||||
|
|
|
||||||
129
renderer/app.js
129
renderer/app.js
|
|
@ -1144,7 +1144,9 @@ const LAN_CATEGORIES = [
|
||||||
{ id: 'apps-portable', label: 'lansync.appsPortable' },
|
{ id: 'apps-portable', label: 'lansync.appsPortable' },
|
||||||
{ id: 'apps-prefix', label: 'lansync.appsPrefix' },
|
{ id: 'apps-prefix', label: 'lansync.appsPrefix' },
|
||||||
{ id: 'wines', label: 'lansync.wines' },
|
{ id: 'wines', label: 'lansync.wines' },
|
||||||
{ id: 'prefixes', label: 'lansync.prefixes' }
|
{ id: 'prefixes', label: 'lansync.prefixes' },
|
||||||
|
// 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
|
// Byte sizes for the progress popups. Decimal units, the way a file manager
|
||||||
|
|
@ -1158,19 +1160,45 @@ function formatBytes(bytes) {
|
||||||
return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB`
|
return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB`
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Games: 3 · Prefixes: 1 · Wine versions: 2" - only the categories this
|
// "Apps: 2/5 · Prefixes: 1/2 · Wine versions: 0/1" - how much of each category
|
||||||
// sync actually carries are listed.
|
// is done out of how much the sync carries, so the line says what is still
|
||||||
function lanStatsLine(items) {
|
// coming instead of only listing what has already been walked over. A peer
|
||||||
if (!items) return ''
|
// that does not announce its totals falls back to the plain count.
|
||||||
|
function lanStatsLine(items, itemsDone, totals) {
|
||||||
const rows = [
|
const rows = [
|
||||||
[items.apps, 'lansync.statsApps'],
|
['apps', 'lansync.statsApps'],
|
||||||
[items.prefixes, 'lansync.statsPrefixes'],
|
['prefixes', 'lansync.statsPrefixes'],
|
||||||
[items.wines, 'lansync.statsWines']
|
['wines', 'lansync.statsWines']
|
||||||
]
|
]
|
||||||
return rows
|
const out = []
|
||||||
.filter(([count]) => count > 0)
|
for (const [kind, key] of rows) {
|
||||||
.map(([count, key]) => t(key, count))
|
const listed = Number(items && items[kind]) || 0
|
||||||
.join(' · ')
|
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
|
// "340 MB / 1.2 GB" once the total is known (the sending side learns it as it
|
||||||
|
|
@ -1296,8 +1324,10 @@ async function runLanSync(device, code, categories, force) {
|
||||||
<p class="subtitle" id="ls-run-count"></p>
|
<p class="subtitle" id="ls-run-count"></p>
|
||||||
<p class="subtitle" id="ls-run-stats"></p>
|
<p class="subtitle" id="ls-run-stats"></p>
|
||||||
<p class="subtitle" id="ls-run-bytes"></p>
|
<p class="subtitle" id="ls-run-bytes"></p>
|
||||||
|
<p class="subtitle sync-warning" id="ls-run-warning"></p>
|
||||||
`, { locked: true })
|
`, { locked: true })
|
||||||
modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name)
|
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 bar = modal.querySelector('progress')
|
||||||
const phase = modal.querySelector('#ls-run-phase')
|
const phase = modal.querySelector('#ls-run-phase')
|
||||||
const count = modal.querySelector('#ls-run-count')
|
const count = modal.querySelector('#ls-run-count')
|
||||||
|
|
@ -1307,17 +1337,14 @@ async function runLanSync(device, code, categories, force) {
|
||||||
state.lanPushing = true
|
state.lanPushing = true
|
||||||
window.plwl.onLanSyncProgress(data => {
|
window.plwl.onLanSyncProgress(data => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const key = 'lansync.phase.' + data.phase
|
// Every line keeps its value while the sync moves between phases: hashing
|
||||||
phase.textContent = t(key, data.label || '')
|
// reports its own file counter, copying its own, and the bar follows the
|
||||||
if (data.total > 0) {
|
// bytes the whole way through
|
||||||
bar.max = data.total
|
phase.textContent = t('lansync.phase.' + data.phase, data.label || '').trim()
|
||||||
bar.value = data.done
|
renderProgressBar(bar, ...barValues(data))
|
||||||
count.textContent = t('lansync.progressFiles', data.done, data.total)
|
if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total)
|
||||||
} else {
|
else count.textContent = ''
|
||||||
bar.removeAttribute('value')
|
stats.textContent = lanStatsLine(data.items, data.itemsDone, data.totals)
|
||||||
count.textContent = ''
|
|
||||||
}
|
|
||||||
stats.textContent = lanStatsLine(data.items)
|
|
||||||
bytes.textContent = lanBytesLine(data.bytes, data.totalBytes)
|
bytes.textContent = lanBytesLine(data.bytes, data.totalBytes)
|
||||||
})
|
})
|
||||||
let res = null
|
let res = null
|
||||||
|
|
@ -1353,9 +1380,19 @@ async function runLanSync(device, code, categories, force) {
|
||||||
// library changes under the user's hands with no explanation. Same popup, but
|
// 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
|
// driven by the events the receiving server emits, and equally not closable
|
||||||
// by a click.
|
// by a click.
|
||||||
const RECEIVE_WATCHDOG_MS = 5 * 60 * 1000
|
// 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' }
|
||||||
let receiving = false
|
let receiving = false
|
||||||
|
let receiveClosed = false
|
||||||
let receiveWatchdog = null
|
let receiveWatchdog = null
|
||||||
|
let receiveStall = null
|
||||||
let receiveCloseTimer = null
|
let receiveCloseTimer = null
|
||||||
|
|
||||||
function receiveModalParts() {
|
function receiveModalParts() {
|
||||||
|
|
@ -1378,32 +1415,47 @@ function endReceiving(message) {
|
||||||
clearTimeout(receiveWatchdog)
|
clearTimeout(receiveWatchdog)
|
||||||
receiveWatchdog = null
|
receiveWatchdog = null
|
||||||
}
|
}
|
||||||
|
if (receiveStall) {
|
||||||
|
clearTimeout(receiveStall)
|
||||||
|
receiveStall = null
|
||||||
|
}
|
||||||
if (receiveCloseTimer) {
|
if (receiveCloseTimer) {
|
||||||
clearTimeout(receiveCloseTimer)
|
clearTimeout(receiveCloseTimer)
|
||||||
receiveCloseTimer = null
|
receiveCloseTimer = null
|
||||||
}
|
}
|
||||||
const wasReceiving = receiving
|
const wasReceiving = receiving
|
||||||
receiving = false
|
receiving = false
|
||||||
|
receiveClosed = true
|
||||||
if (!wasReceiving) return
|
if (!wasReceiving) return
|
||||||
if (message) toast(message, 'error')
|
if (message) toast(message, 'error')
|
||||||
closeModal()
|
closeModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
// A sender that crashed halfway through must not leave this window locked: the
|
// A sender that crashed halfway through must not leave this window locked: the
|
||||||
// popup closes itself if no event arrives for a while (the server drops the
|
// popup closes itself if nothing at all arrives for a long time (the server
|
||||||
// session after the same idle time).
|
// drops the session after the same idle time). While it waits, the phase line
|
||||||
|
// says so rather than the popup disappearing mid-transfer.
|
||||||
function armReceiveWatchdog() {
|
function armReceiveWatchdog() {
|
||||||
if (receiveWatchdog) clearTimeout(receiveWatchdog)
|
if (receiveWatchdog) clearTimeout(receiveWatchdog)
|
||||||
receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS)
|
receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS)
|
||||||
|
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) {
|
function onLanReceiving(info) {
|
||||||
if (!info || !info.stage) return
|
if (!info || !info.stage) return
|
||||||
if (state.lanPushing) return // our own push owns the (single) popup
|
if (state.lanPushing) return // our own push owns the (single) popup
|
||||||
|
if (info.stage === 'start') receiveClosed = false
|
||||||
if (!receiving) {
|
if (!receiving) {
|
||||||
// open the popup only for the start of a sync, and only when the user is
|
// The popup also opens mid-sync: the sender may have been hashing for
|
||||||
// not in the middle of another dialog (there is one modal at a time)
|
// minutes when this window starts listening, and a popup that the safety
|
||||||
if (info.stage !== 'start') return
|
// 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
|
if (qs('#modal-overlay').style.display !== 'none') return
|
||||||
receiving = true
|
receiving = true
|
||||||
const modal = showModal(`
|
const modal = showModal(`
|
||||||
|
|
@ -1413,8 +1465,10 @@ function onLanReceiving(info) {
|
||||||
<p class="subtitle" id="ls-recv-count"></p>
|
<p class="subtitle" id="ls-recv-count"></p>
|
||||||
<p class="subtitle" id="ls-recv-stats"></p>
|
<p class="subtitle" id="ls-recv-stats"></p>
|
||||||
<p class="subtitle" id="ls-recv-bytes"></p>
|
<p class="subtitle" id="ls-recv-bytes"></p>
|
||||||
|
<p class="subtitle sync-warning" id="ls-recv-warning"></p>
|
||||||
`, { locked: true })
|
`, { locked: true })
|
||||||
modal.querySelector('#ls-recv-title').textContent = t('lansync.receiving', info.from || info.deviceId || '')
|
modal.querySelector('#ls-recv-title').textContent = t('lansync.receiving', info.from || info.deviceId || '')
|
||||||
|
modal.querySelector('#ls-recv-warning').textContent = t('lansync.keepOpen')
|
||||||
armReceiveWatchdog()
|
armReceiveWatchdog()
|
||||||
return renderReceiving(info)
|
return renderReceiving(info)
|
||||||
}
|
}
|
||||||
|
|
@ -1425,17 +1479,14 @@ function onLanReceiving(info) {
|
||||||
function renderReceiving(info) {
|
function renderReceiving(info) {
|
||||||
const parts = receiveModalParts()
|
const parts = receiveModalParts()
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
const phaseKey = 'lansync.phase.' + info.stage
|
// 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()
|
parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim()
|
||||||
if (info.total > 0) {
|
renderProgressBar(parts.bar, ...barValues(info))
|
||||||
parts.bar.max = info.total
|
if (info.total > 0) parts.count.textContent = t('lansync.progressFiles', info.done, info.total)
|
||||||
parts.bar.value = info.done
|
else parts.count.textContent = ''
|
||||||
parts.count.textContent = t('lansync.progressFiles', info.done, info.total)
|
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
|
||||||
} else {
|
|
||||||
parts.bar.removeAttribute('value')
|
|
||||||
parts.count.textContent = ''
|
|
||||||
}
|
|
||||||
parts.stats.textContent = lanStatsLine(info.items)
|
|
||||||
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
|
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
|
||||||
if (info.stage === 'done') {
|
if (info.stage === 'done') {
|
||||||
// let the totals be read, then get out of the way (the library reloads
|
// let the totals be read, then get out of the way (the library reloads
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,10 @@ content {
|
||||||
}
|
}
|
||||||
.subtitle { color: var(--darker-text-color); font-size: 0.9rem; margin-top: 0.2rem; }
|
.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 {
|
.button {
|
||||||
height: var(--button-height);
|
height: var(--button-height);
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,38 @@ const { DIRS, resolvePortablePath } = require('./paths')
|
||||||
const { newGame, newGameId, addGame } = require('./library')
|
const { newGame, newGameId, addGame } = require('./library')
|
||||||
const { extractIconFromExe } = require('./icons')
|
const { extractIconFromExe } = require('./icons')
|
||||||
const { listPrefixes, prefixDriveRoot } = require('./prefixes')
|
const { listPrefixes, prefixDriveRoot } = require('./prefixes')
|
||||||
|
const { rewireSymlinks } = require('./symlinks')
|
||||||
|
|
||||||
// Directories that typically hold installers/redistributables, not the game.
|
// Directories that typically hold installers/redistributables, not the game.
|
||||||
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
|
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
|
||||||
|
|
||||||
|
// 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) {
|
function copyRecursive(src, dst, onProgress) {
|
||||||
return fsp.readdir(src, { withFileTypes: true }).then(async entries => {
|
return fsp.readdir(src, { withFileTypes: true }).then(async entries => {
|
||||||
await fsp.mkdir(dst, { recursive: true })
|
await fsp.mkdir(dst, { recursive: true })
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const s = path.join(src, e.name)
|
const s = path.join(src, e.name)
|
||||||
const d = path.join(dst, 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)
|
await copyRecursive(s, d, onProgress)
|
||||||
} else if (e.isFile()) {
|
} else if (e.isFile()) {
|
||||||
await fsp.copyFile(s, d)
|
await fsp.copyFile(s, d)
|
||||||
|
try {
|
||||||
|
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
|
||||||
|
} catch {}
|
||||||
if (onProgress) onProgress(s)
|
if (onProgress) onProgress(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +65,12 @@ async function importGame(srcDir, mode, onProgress) {
|
||||||
if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress)
|
if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress)
|
||||||
else await moveFolder(srcDir, dst, onProgress)
|
else await moveFolder(srcDir, dst, onProgress)
|
||||||
workdir = dst
|
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
|
const exePath = (await findDefaultExe(workdir)) || preExe
|
||||||
if (!exePath) {
|
if (!exePath) {
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,50 @@ function sendToWindow(win, channel, data) {
|
||||||
// Game-process state: id -> pid (or null while the process is starting).
|
// Game-process state: id -> pid (or null while the process is starting).
|
||||||
// onGamesChanged fires in the MAIN process whenever the running-games set
|
// onGamesChanged fires in the MAIN process whenever the running-games set
|
||||||
// changes (the tray menu rebuilds itself from it).
|
// changes (the tray menu rebuilds itself from it).
|
||||||
|
const STARTUP_GRACE_MS = 60000
|
||||||
|
|
||||||
function createGamesService({ getWindow, onGamesChanged }) {
|
function createGamesService({ getWindow, onGamesChanged }) {
|
||||||
const running = new Map()
|
const running = new Map()
|
||||||
|
const startedAt = new Map()
|
||||||
const killRequests = new Set()
|
const killRequests = new Set()
|
||||||
let onGameExit = null
|
let onGameExit = null
|
||||||
const notifyChanged = () => {
|
const notifyChanged = () => {
|
||||||
if (typeof onGamesChanged === 'function') onGamesChanged()
|
if (typeof onGamesChanged === 'function') onGamesChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRunning = id => running.has(id)
|
function alive(pid) {
|
||||||
const listRunning = () => [...running.keys()]
|
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
|
// "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
|
// 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) {
|
function handleExit(id) {
|
||||||
running.delete(id)
|
running.delete(id)
|
||||||
|
startedAt.delete(id)
|
||||||
sendToWindow(getWindow(), 'games:changed')
|
sendToWindow(getWindow(), 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
if (onGameExit) onGameExit(id)
|
if (onGameExit) onGameExit(id)
|
||||||
|
|
@ -53,42 +88,59 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
||||||
async function start(win, id) {
|
async function start(win, id) {
|
||||||
const game = library.getGame(id)
|
const game = library.getGame(id)
|
||||||
if (!game) return { ok: false, error: 'game-not-found' }
|
if (!game) return { ok: false, error: 'game-not-found' }
|
||||||
|
prune()
|
||||||
if (running.has(id)) return { ok: false, error: 'already-running' }
|
if (running.has(id)) return { ok: false, error: 'already-running' }
|
||||||
running.set(id, null)
|
running.set(id, null)
|
||||||
|
startedAt.set(id, Date.now())
|
||||||
let wine
|
let wine
|
||||||
try {
|
try {
|
||||||
wine = resolveWine(game)
|
wine = resolveWine(game)
|
||||||
} catch {
|
} catch {
|
||||||
running.delete(id)
|
running.delete(id)
|
||||||
|
startedAt.delete(id)
|
||||||
return { ok: false, error: 'wine-not-found' }
|
return { ok: false, error: 'wine-not-found' }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) })
|
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) })
|
||||||
if (res.pid) running.set(id, res.pid)
|
// stop was requested while the process was still starting: kill it and
|
||||||
// stop was requested while the process was still starting
|
// leave nothing in the running set
|
||||||
if (killRequests.has(id)) {
|
if (killRequests.has(id)) {
|
||||||
killRequests.delete(id)
|
killRequests.delete(id)
|
||||||
if (res.pid) killProcessGroup(res.pid)
|
if (res.pid) killProcessGroup(res.pid)
|
||||||
|
running.delete(id)
|
||||||
|
startedAt.delete(id)
|
||||||
|
sendToWindow(win, 'games:changed')
|
||||||
|
notifyChanged()
|
||||||
|
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')
|
sendToWindow(win, 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
return { ok: true, pid: res.pid || null, logFile: res.logFile || null }
|
return { ok: true, pid: res.pid, logFile: res.logFile || null }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
running.delete(id)
|
running.delete(id)
|
||||||
|
startedAt.delete(id)
|
||||||
return { ok: false, error: String((err && err.message) || err) }
|
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) {
|
async function kill(id) {
|
||||||
const pid = running.get(id)
|
const pid = running.get(id)
|
||||||
if (pid) {
|
if (pid) killProcessGroup(pid)
|
||||||
killProcessGroup(pid)
|
else if (running.has(id)) killRequests.add(id)
|
||||||
running.delete(id)
|
running.delete(id)
|
||||||
sendToWindow(getWindow(), 'games:changed')
|
startedAt.delete(id)
|
||||||
notifyChanged()
|
sendToWindow(getWindow(), 'games:changed')
|
||||||
} else if (running.has(id)) {
|
notifyChanged()
|
||||||
killRequests.add(id)
|
|
||||||
}
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
411
src/lansync.js
411
src/lansync.js
|
|
@ -16,6 +16,7 @@ const fsp = fs.promises
|
||||||
const readline = require('readline')
|
const readline = require('readline')
|
||||||
|
|
||||||
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
|
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
|
||||||
|
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
|
||||||
const library = require('./library')
|
const library = require('./library')
|
||||||
const wines = require('./wines')
|
const wines = require('./wines')
|
||||||
const prefixes = require('./prefixes')
|
const prefixes = require('./prefixes')
|
||||||
|
|
@ -44,7 +45,7 @@ const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
|
||||||
|
|
||||||
// What a sync can carry, plus lenient aliases so callers can name the
|
// What a sync can carry, plus lenient aliases so callers can name the
|
||||||
// categories the way the UI/README does.
|
// categories the way the UI/README does.
|
||||||
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes']
|
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'defaults']
|
||||||
|
|
||||||
const CATEGORY_ALIASES = {
|
const CATEGORY_ALIASES = {
|
||||||
// plain "apps" means the whole app library: portable ones AND the ones
|
// plain "apps" means the whole app library: portable ones AND the ones
|
||||||
|
|
@ -64,7 +65,13 @@ const CATEGORY_ALIASES = {
|
||||||
wineprefix: ['prefixes'],
|
wineprefix: ['prefixes'],
|
||||||
wineprefixes: ['prefixes'],
|
wineprefixes: ['prefixes'],
|
||||||
prefixes: ['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
|
// Which sync category an app entry belongs to. Portable copy/move imports own
|
||||||
|
|
@ -231,10 +238,92 @@ const HASH_ALGO = (() => {
|
||||||
return 'sha256'
|
return 'sha256'
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
// One megabyte per read: a 64 kB stream chunk spends most of its time in
|
||||||
|
// callbacks when the file is large.
|
||||||
|
const HASH_CHUNK = 1024 * 1024
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 {
|
||||||
|
writeJson(HASH_CACHE_FILE, { 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++
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function hashFile(file) {
|
function hashFile(file) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const hash = crypto.createHash(HASH_ALGO)
|
const hash = crypto.createHash(HASH_ALGO)
|
||||||
const stream = fs.createReadStream(file)
|
const stream = fs.createReadStream(file, { highWaterMark: HASH_CHUNK })
|
||||||
stream.on('error', reject)
|
stream.on('error', reject)
|
||||||
stream.on('data', chunk => hash.update(chunk))
|
stream.on('data', chunk => hash.update(chunk))
|
||||||
stream.on('end', () => resolve(hash.digest('hex')))
|
stream.on('end', () => resolve(hash.digest('hex')))
|
||||||
|
|
@ -259,6 +348,45 @@ function ownedRoot(rel) {
|
||||||
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
|
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) {
|
async function mapLimit(items, limit, fn) {
|
||||||
const out = new Array(items.length)
|
const out = new Array(items.length)
|
||||||
let next = 0
|
let next = 0
|
||||||
|
|
@ -272,10 +400,27 @@ async function mapLimit(items, limit, fn) {
|
||||||
return out
|
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
|
// Walk without following symlinks: builds and prefixes contain links that are
|
||||||
// part of what is being synced (dosdevices, build internals), and following
|
// part of what is being synced (dosdevices, build internals), and following
|
||||||
// them would both loop and copy the wrong thing.
|
// them would both loop and copy the wrong thing.
|
||||||
async function scanTree(abs, rel, out) {
|
async function scanTree(abs, rel, out, mtimes) {
|
||||||
let entries
|
let entries
|
||||||
try {
|
try {
|
||||||
entries = await fsp.readdir(abs, { withFileTypes: true })
|
entries = await fsp.readdir(abs, { withFileTypes: true })
|
||||||
|
|
@ -290,18 +435,22 @@ async function scanTree(abs, rel, out) {
|
||||||
try {
|
try {
|
||||||
target = await fsp.readlink(absChild)
|
target = await fsp.readlink(absChild)
|
||||||
} catch {}
|
} catch {}
|
||||||
out.push({ path: relChild, type: 'link', target })
|
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
|
||||||
} else if (entry.isDirectory()) {
|
} else if (entry.isDirectory()) {
|
||||||
out.push({ path: relChild, type: 'dir' })
|
out.push({ path: relChild, type: 'dir' })
|
||||||
await scanTree(absChild, relChild, out)
|
await scanTree(absChild, relChild, out, mtimes)
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
let size = 0
|
let st = null
|
||||||
try {
|
try {
|
||||||
size = (await fsp.stat(absChild)).size
|
st = await fsp.stat(absChild)
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out.push({ path: relChild, type: 'file', size, hash: null })
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -310,6 +459,7 @@ async function scanTree(abs, rel, out) {
|
||||||
// then treats every file as different ("force sync all files").
|
// then treats every file as different ("force sync all files").
|
||||||
async function buildManifest(roots, force, onProgress) {
|
async function buildManifest(roots, force, onProgress) {
|
||||||
const entries = []
|
const entries = []
|
||||||
|
const mtimes = new Map()
|
||||||
for (const rel of roots) {
|
for (const rel of roots) {
|
||||||
const abs = filesPath(rel)
|
const abs = filesPath(rel)
|
||||||
if (!abs) continue
|
if (!abs) continue
|
||||||
|
|
@ -324,27 +474,35 @@ async function buildManifest(roots, force, onProgress) {
|
||||||
try {
|
try {
|
||||||
target = await fsp.readlink(abs)
|
target = await fsp.readlink(abs)
|
||||||
} catch {}
|
} catch {}
|
||||||
entries.push({ path: rel, type: 'link', target })
|
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
|
||||||
} else if (st.isDirectory()) {
|
} else if (st.isDirectory()) {
|
||||||
entries.push({ path: rel, type: 'dir' })
|
entries.push({ path: rel, type: 'dir' })
|
||||||
await scanTree(abs, rel, entries)
|
await scanTree(abs, rel, entries, mtimes)
|
||||||
} else if (st.isFile()) {
|
} 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')
|
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) {
|
if (!force && files.length) {
|
||||||
let done = 0
|
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 {
|
try {
|
||||||
entry.hash = await hashFile(filesPath(entry.path))
|
entry.hash = abs ? await cachedHash(entry.path, abs, entry.size, mtimes.get(entry.path) || 0) : null
|
||||||
} catch {
|
} catch {
|
||||||
entry.hash = null
|
entry.hash = null
|
||||||
}
|
}
|
||||||
done++
|
done++
|
||||||
if (onProgress) onProgress(done, files.length)
|
hashed += entry.size || 0
|
||||||
|
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
saveHashCache()
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -498,11 +656,18 @@ async function entrySame(entry) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!st.isFile() || st.size !== entry.size) return false
|
if (!st.isFile() || st.size !== entry.size) return false
|
||||||
|
let same = false
|
||||||
try {
|
try {
|
||||||
return (await hashFile(abs)) === entry.hash
|
same = (await cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
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) {
|
function ensureDir(abs) {
|
||||||
|
|
@ -517,6 +682,24 @@ function ensureDir(abs) {
|
||||||
} catch {}
|
} 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) {
|
function ensureLink(abs, target) {
|
||||||
if (typeof target !== 'string' || !target) return
|
if (typeof target !== 'string' || !target) return
|
||||||
let current = null
|
let current = null
|
||||||
|
|
@ -599,7 +782,16 @@ async function pruneTree(abs, rel, keep) {
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStream(abs, stream) {
|
// 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 {
|
||||||
|
fs.chmodSync(abs, mode & 0o777)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStream(abs, stream, onBytes) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let st = null
|
let st = null
|
||||||
try {
|
try {
|
||||||
|
|
@ -629,6 +821,11 @@ function writeStream(abs, stream) {
|
||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
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)
|
stream.pipe(out)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -650,12 +847,22 @@ function applyApps(payload, cats) {
|
||||||
}
|
}
|
||||||
kept.push(game)
|
kept.push(game)
|
||||||
}
|
}
|
||||||
// playtime is per machine, the sender does not push any - keep the counters
|
// Playtime and the app's own settings (wine build, prefix, args, env,
|
||||||
// of apps this device already had
|
// dllOverrides) travel with the app: paths are left exactly as the sender
|
||||||
const merged = [...kept, ...incoming.map(g => {
|
// 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)
|
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)
|
library.saveGames(merged)
|
||||||
let removed = 0
|
let removed = 0
|
||||||
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
|
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
|
||||||
|
|
@ -677,7 +884,7 @@ async function applyWines(payload) {
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPrefixes(payload) {
|
function applyPrefixes(payload, useSenderDefault) {
|
||||||
const sender = payload.prefixes && typeof payload.prefixes === 'object' ? payload.prefixes : {}
|
const sender = payload.prefixes && typeof payload.prefixes === 'object' ? payload.prefixes : {}
|
||||||
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
|
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
|
||||||
const existing = prefixes.listPrefixes()
|
const existing = prefixes.listPrefixes()
|
||||||
|
|
@ -690,11 +897,39 @@ function applyPrefixes(payload) {
|
||||||
}
|
}
|
||||||
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
|
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 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 })
|
prefixes.savePrefixes({ default: def, prefixes: list })
|
||||||
return removed
|
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) -------------------------------
|
// --- HTTP server (what other devices talk to) -------------------------------
|
||||||
|
|
||||||
function createServerState() {
|
function createServerState() {
|
||||||
|
|
@ -719,11 +954,19 @@ function emitReceive(hooks, session, stage, extra = {}) {
|
||||||
deviceId: session.deviceId || '',
|
deviceId: session.deviceId || '',
|
||||||
unit: extra.unit || '',
|
unit: extra.unit || '',
|
||||||
label: extra.unit || '',
|
label: extra.unit || '',
|
||||||
done: session.received || 0,
|
done: extra.done == null ? session.received || 0 : extra.done,
|
||||||
total: session.planned || 0,
|
total: extra.total == null ? session.planned || 0 : extra.total,
|
||||||
bytes: session.bytes || 0,
|
bytes: session.bytes || 0,
|
||||||
totalBytes: session.plannedBytes || 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 || {}) },
|
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
|
deleted: extra.deleted || 0
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -799,6 +1042,9 @@ async function handleVerify(state, req, res, hooks) {
|
||||||
await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS))
|
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' })
|
if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' })
|
||||||
const token = crypto.randomBytes(24).toString('hex')
|
const token = crypto.randomBytes(24).toString('hex')
|
||||||
|
// 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 : {}
|
||||||
const session = {
|
const session = {
|
||||||
token,
|
token,
|
||||||
deviceId: from,
|
deviceId: from,
|
||||||
|
|
@ -813,7 +1059,15 @@ async function handleVerify(state, req, res, hooks) {
|
||||||
planned: 0,
|
planned: 0,
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
plannedBytes: 0,
|
plannedBytes: 0,
|
||||||
items: { apps: 0, wines: 0, prefixes: 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.tokens.set(token, session)
|
||||||
state.session = session
|
state.session = session
|
||||||
|
|
@ -843,21 +1097,52 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
await readNdjson(req, entry => {
|
await readNdjson(req, entry => {
|
||||||
if (inlineSame(entry)) entries.push(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
|
// a force sync compares nothing: every entry counts as different
|
||||||
const forced = url.searchParams.get('force') === '1'
|
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 paths = new Set()
|
||||||
const upload = []
|
const upload = []
|
||||||
const same = []
|
const same = []
|
||||||
let uploadBytes = 0
|
let uploadBytes = 0
|
||||||
for (const entry of entries) {
|
allowed.forEach((entry, index) => {
|
||||||
if (!withinRoots(entry.path, owned)) continue
|
|
||||||
const abs = filesPath(entry.path)
|
const abs = filesPath(entry.path)
|
||||||
if (!abs) continue
|
if (!abs) return
|
||||||
paths.add(entry.path)
|
paths.add(entry.path)
|
||||||
const identical = forced ? false : await entrySame(entry)
|
if (identical[index]) {
|
||||||
if (identical) {
|
|
||||||
same.push(entry.path)
|
same.push(entry.path)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
if (entry.type === 'dir') ensureDir(abs)
|
if (entry.type === 'dir') ensureDir(abs)
|
||||||
else if (entry.type === 'link') ensureLink(abs, entry.target)
|
else if (entry.type === 'link') ensureLink(abs, entry.target)
|
||||||
|
|
@ -865,12 +1150,17 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
upload.push(entry.path)
|
upload.push(entry.path)
|
||||||
uploadBytes += entry.size || 0
|
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
|
// what this unit adds to the transfer: the popup reports files and bytes
|
||||||
// against these as they arrive
|
// against these as they arrive
|
||||||
session.planned += upload.length
|
session.planned += upload.length
|
||||||
session.plannedBytes += uploadBytes
|
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]]
|
const kind = UNIT_KINDS[String(key).split(':')[0]]
|
||||||
if (kind && !session.counted.has(key)) {
|
if (kind && !session.counted.has(key)) {
|
||||||
session.counted.add(key)
|
session.counted.add(key)
|
||||||
|
|
@ -880,7 +1170,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
// is planned, so the later file events can name it in the popup
|
// is planned, so the later file events can name it in the popup
|
||||||
session.unitLabel = label
|
session.unitLabel = label
|
||||||
sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
|
sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
|
||||||
emitReceive(hooks, session, 'plan', { unit: label })
|
announce({ unit: label, done: allowed.length, total: allowed.length }, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePut(session, url, req, res, hooks) {
|
async function handlePut(session, url, req, res, hooks) {
|
||||||
|
|
@ -892,12 +1182,24 @@ async function handlePut(session, url, req, res, hooks) {
|
||||||
req.resume()
|
req.resume()
|
||||||
return sendJson(res, 400, { ok: false, error: 'bad-path' })
|
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 {
|
try {
|
||||||
const written = 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
|
session.received = (session.received || 0) + 1
|
||||||
session.bytes = (session.bytes || 0) + (written || 0)
|
|
||||||
sendJson(res, 200, { ok: true })
|
sendJson(res, 200, { ok: true })
|
||||||
emitReceive(hooks, session, 'copy', { unit: session.unitLabel || '' })
|
announce({ unit: session.unitLabel || '' }, true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
||||||
}
|
}
|
||||||
|
|
@ -912,8 +1214,20 @@ async function handleCommit(session, url, res, hooks) {
|
||||||
const abs = filesPath(root)
|
const abs = filesPath(root)
|
||||||
if (abs) removed += await pruneTree(abs, root, unit.paths)
|
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.units.delete(key)
|
||||||
session.deleted = (session.deleted || 0) + removed
|
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 })
|
sendJson(res, 200, { ok: true, deleted: removed })
|
||||||
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
|
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
|
||||||
}
|
}
|
||||||
|
|
@ -924,7 +1238,10 @@ async function handleApply(session, req, res, hooks) {
|
||||||
const cats = new Set(Array.isArray(payload.categories) ? payload.categories : [])
|
const cats = new Set(Array.isArray(payload.categories) ? payload.categories : [])
|
||||||
const appsDeleted = applyApps(payload, cats)
|
const appsDeleted = applyApps(payload, cats)
|
||||||
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
|
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
|
||||||
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 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
|
const removed = appsDeleted + winesDeleted + prefixesDeleted
|
||||||
emitReceive(hooks, session, 'registry', { deleted: removed })
|
emitReceive(hooks, session, 'registry', { deleted: removed })
|
||||||
if (hooks.onApplied) {
|
if (hooks.onApplied) {
|
||||||
|
|
@ -935,7 +1252,8 @@ async function handleApply(session, req, res, hooks) {
|
||||||
files: session.received || 0,
|
files: session.received || 0,
|
||||||
bytes: session.bytes || 0,
|
bytes: session.bytes || 0,
|
||||||
items: { ...(session.items || {}) },
|
items: { ...(session.items || {}) },
|
||||||
deleted: removed
|
deleted: removed,
|
||||||
|
defaults
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
|
|
@ -943,7 +1261,8 @@ async function handleApply(session, req, res, hooks) {
|
||||||
deleted: removed,
|
deleted: removed,
|
||||||
apps: { deleted: appsDeleted },
|
apps: { deleted: appsDeleted },
|
||||||
wines: { deleted: winesDeleted },
|
wines: { deleted: winesDeleted },
|
||||||
prefixes: { deleted: prefixesDeleted }
|
prefixes: { deleted: prefixesDeleted },
|
||||||
|
defaults
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1159,6 +1478,9 @@ function getPusher() {
|
||||||
buildManifest,
|
buildManifest,
|
||||||
listDirs,
|
listDirs,
|
||||||
listDevices,
|
listDevices,
|
||||||
|
mapLimit,
|
||||||
|
progressGate,
|
||||||
|
hashFile,
|
||||||
DIRS,
|
DIRS,
|
||||||
toPortablePath,
|
toPortablePath,
|
||||||
library,
|
library,
|
||||||
|
|
@ -1224,6 +1546,11 @@ const exported = {
|
||||||
sameCode,
|
sameCode,
|
||||||
HASH_ALGO,
|
HASH_ALGO,
|
||||||
hashFile,
|
hashFile,
|
||||||
|
hashCacheStats,
|
||||||
|
resetHashCacheStats,
|
||||||
|
forgetHashCache,
|
||||||
|
HASH_CONCURRENCY,
|
||||||
|
progressGate,
|
||||||
filesPath,
|
filesPath,
|
||||||
ownedRoot,
|
ownedRoot,
|
||||||
buildManifest,
|
buildManifest,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ const os = require('os')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
// Files sent at once: one at a time wastes 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) {
|
function createPusher(api) {
|
||||||
const {
|
const {
|
||||||
CATEGORIES,
|
CATEGORIES,
|
||||||
|
|
@ -34,9 +38,12 @@ function createPusher(api) {
|
||||||
|
|
||||||
// Manifest in, list of files to upload out. NDJSON keeps a prefix with tens
|
// 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.
|
// 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}`
|
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(
|
const req = http.request(
|
||||||
{
|
{
|
||||||
host: device.address,
|
host: device.address,
|
||||||
|
|
@ -142,12 +149,11 @@ function createPusher(api) {
|
||||||
|
|
||||||
// --- registry payload -----------------------------------------------------
|
// --- 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) {
|
function portableApp(game) {
|
||||||
const out = library.portableGame(game)
|
return library.portableGame(game)
|
||||||
// playtime/lastPlayed are per machine, the receiver keeps its own counters
|
|
||||||
delete out.playSeconds
|
|
||||||
delete out.lastPlayed
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegistryPayload(categories) {
|
function buildRegistryPayload(categories) {
|
||||||
|
|
@ -166,6 +172,15 @@ function createPusher(api) {
|
||||||
// the receiver prunes its own builds down to this list and rescans
|
// the receiver prunes its own builds down to this list and rescans
|
||||||
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
|
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
|
||||||
}
|
}
|
||||||
|
if (cats.has('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')) {
|
if (cats.has('prefixes')) {
|
||||||
const defs = prefixes.listPrefixes()
|
const defs = prefixes.listPrefixes()
|
||||||
payload.prefixes = {
|
payload.prefixes = {
|
||||||
|
|
@ -230,6 +245,13 @@ function createPusher(api) {
|
||||||
if (!device || !device.address || !device.port) return fail('offline')
|
if (!device || !device.address || !device.port) return fail('offline')
|
||||||
const cats = new Set(normalizeCategories(categories))
|
const cats = new Set(normalizeCategories(categories))
|
||||||
if (!cats.size) return fail('nothing-selected')
|
if (!cats.size) return fail('nothing-selected')
|
||||||
|
// 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
|
// every progress event is kept (and handed to onProgress when given) so
|
||||||
// callers can report on the transfer without subscribing first
|
// callers can report on the transfer without subscribing first
|
||||||
const events = []
|
const events = []
|
||||||
|
|
@ -241,7 +263,7 @@ function createPusher(api) {
|
||||||
let verified
|
let verified
|
||||||
try {
|
try {
|
||||||
verified = await httpJson(device, 'POST', '/plwl/verify', {
|
verified = await httpJson(device, 'POST', '/plwl/verify', {
|
||||||
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase() },
|
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -260,31 +282,42 @@ function createPusher(api) {
|
||||||
// typing (a rejected code is never stored)
|
// typing (a rejected code is never stored)
|
||||||
rememberDevice(device.id || deviceId, code, device.name)
|
rememberDevice(device.id || deviceId, code, device.name)
|
||||||
|
|
||||||
const units = buildUnits(cats)
|
|
||||||
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
|
|
||||||
const summary = newSummary(units.length)
|
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
|
// 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
|
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
|
||||||
let plannedBytes = 0
|
let plannedBytes = 0
|
||||||
let itemsSeen = 0
|
let itemsSeen = 0
|
||||||
const emit = (phase, unit, done, total) =>
|
// the bar tracks the PHASE being worked on (its own bytes), never a
|
||||||
progress({
|
// leftover from the phase before: hashing starts from an empty bar
|
||||||
stage: phase,
|
let barBytes = 0
|
||||||
phase,
|
let barTotal = 0
|
||||||
kind: (unit && unit.kind) || '',
|
const snapshot = (phase, unit, done, total) => ({
|
||||||
id: (unit && unit.id) || '',
|
stage: phase,
|
||||||
key: (unit && unit.key) || '',
|
phase,
|
||||||
unit: (unit && unit.label) || '',
|
kind: (unit && unit.kind) || '',
|
||||||
label: (unit && unit.label) || '',
|
id: (unit && unit.id) || '',
|
||||||
name: (unit && unit.label) || '',
|
key: (unit && unit.key) || '',
|
||||||
done,
|
unit: (unit && unit.label) || '',
|
||||||
total,
|
label: (unit && unit.label) || '',
|
||||||
bytes: summary.bytes,
|
name: (unit && unit.label) || '',
|
||||||
totalBytes: plannedBytes,
|
done,
|
||||||
units: summary.units,
|
total,
|
||||||
unitsDone: itemsSeen,
|
bytes: summary.bytes,
|
||||||
items: { ...summary.items }
|
totalBytes: plannedBytes,
|
||||||
})
|
units: summary.units,
|
||||||
|
unitsDone: itemsSeen,
|
||||||
|
items: { ...summary.items },
|
||||||
|
itemsDone: { ...finished },
|
||||||
|
totals: { ...totals },
|
||||||
|
barBytes,
|
||||||
|
barTotalBytes: barTotal
|
||||||
|
})
|
||||||
|
// 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 () => {
|
const end = async () => {
|
||||||
try {
|
try {
|
||||||
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
||||||
|
|
@ -294,13 +327,20 @@ function createPusher(api) {
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const roots = unit.roots.filter(ownedRoot)
|
const roots = unit.roots.filter(ownedRoot)
|
||||||
if (!roots.length) continue
|
if (!roots.length) continue
|
||||||
const bucket = summary[bucketOf(unit.kind)]
|
const kind = bucketOf(unit.kind)
|
||||||
summary.items[bucketOf(unit.kind)] += 1
|
const bucket = summary[kind]
|
||||||
|
summary.items[kind] += 1
|
||||||
itemsSeen += 1
|
itemsSeen += 1
|
||||||
emit('scan', unit, 0, 0)
|
barBytes = 0
|
||||||
const entries = await buildManifest(roots, !!force, (done, total) => emit('hash', unit, done, total))
|
barTotal = 0
|
||||||
emit('plan', unit, 0, 0)
|
emit('scan', unit, 0, 0, true)
|
||||||
const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force)
|
const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => {
|
||||||
|
barBytes = hashedBytes
|
||||||
|
barTotal = totalBytes
|
||||||
|
emit('hash', unit, done, total)
|
||||||
|
})
|
||||||
|
emit('plan', unit, 0, 0, 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}`)
|
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
||||||
const upload = new Set(plan.data.upload || [])
|
const upload = new Set(plan.data.upload || [])
|
||||||
const same = new Set(plan.data.same || [])
|
const same = new Set(plan.data.same || [])
|
||||||
|
|
@ -308,31 +348,56 @@ function createPusher(api) {
|
||||||
// already identical (the plan reports both)
|
// already identical (the plan reports both)
|
||||||
bucket.copied += entries.length - same.size
|
bucket.copied += entries.length - same.size
|
||||||
bucket.skipped += same.size
|
bucket.skipped += same.size
|
||||||
for (const entry of entries) {
|
const pending = []
|
||||||
if (entry.type === 'file' && upload.has(entry.path)) plannedBytes += entry.size || 0
|
|
||||||
}
|
|
||||||
let done = 0
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.type !== 'file') continue
|
if (entry.type !== 'file') continue
|
||||||
if (!upload.has(entry.path)) {
|
if (!upload.has(entry.path)) {
|
||||||
summary.skipped++
|
summary.skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
emit('copy', unit, done, upload.size)
|
pending.push(entry)
|
||||||
|
plannedBytes += entry.size || 0
|
||||||
|
}
|
||||||
|
// 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, 0, 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))
|
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++
|
done++
|
||||||
summary.uploaded++
|
summary.uploaded++
|
||||||
summary.bytes += entry.size || 0
|
summary.bytes += entry.size || 0
|
||||||
|
barBytes += entry.size || 0
|
||||||
|
emit('copy', unit, done, 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, done, pending.length, true)
|
||||||
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
|
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
|
||||||
if (commit.data && commit.data.deleted) {
|
if (commit.data && commit.data.deleted) {
|
||||||
summary.deleted += commit.data.deleted
|
summary.deleted += commit.data.deleted
|
||||||
bucket.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, 0, 0, true)
|
||||||
}
|
}
|
||||||
emit('registry', null, 0, 0)
|
emit('registry', null, 0, 0, true)
|
||||||
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 })
|
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}`)
|
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
|
||||||
for (const key of ['apps', 'wines', 'prefixes']) {
|
for (const key of ['apps', 'wines', 'prefixes']) {
|
||||||
|
|
@ -340,7 +405,7 @@ function createPusher(api) {
|
||||||
if (deleted) summary[key].deleted += deleted
|
if (deleted) summary[key].deleted += deleted
|
||||||
}
|
}
|
||||||
if (applied.data.deleted) summary.deleted += applied.data.deleted
|
if (applied.data.deleted) summary.deleted += applied.data.deleted
|
||||||
emit('done', null, summary.uploaded, summary.uploaded)
|
emit('done', null, summary.uploaded, summary.uploaded, true)
|
||||||
await end()
|
await end()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await end()
|
await end()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const { DIRS, readJson, writeJson } = require('./paths')
|
const { DIRS, readJson, writeJson } = require('./paths')
|
||||||
|
const { rewireSymlinks } = require('./symlinks')
|
||||||
|
|
||||||
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
|
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
|
||||||
const MAIN = 'Main'
|
const MAIN = 'Main'
|
||||||
|
|
@ -85,6 +86,15 @@ function deletePrefix(name) {
|
||||||
return { ok: true }
|
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) {
|
function setDefaultPrefix(name) {
|
||||||
const defs = listPrefixes()
|
const defs = listPrefixes()
|
||||||
if (name && !defs.prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' }
|
if (name && !defs.prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' }
|
||||||
|
|
@ -93,4 +103,4 @@ function setDefaultPrefix(name) {
|
||||||
return { ok: true }
|
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 }
|
||||||
|
|
|
||||||
112
src/runner.js
112
src/runner.js
|
|
@ -6,18 +6,66 @@ const { spawn } = require('child_process')
|
||||||
const { DIRS } = require('./paths')
|
const { DIRS } = require('./paths')
|
||||||
const { prefixPath } = require('./prefixes')
|
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() {
|
function ensureFakeSteam() {
|
||||||
const fake = DIRS.FAKE_STEAM
|
const fake = DIRS.FAKE_STEAM
|
||||||
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
|
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
|
||||||
|
fs.mkdirSync(path.join(fake, 'appcache'), { recursive: true })
|
||||||
return fake
|
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) {
|
function linkPrefix(compatData, prefixDir) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(compatData), { recursive: true })
|
||||||
|
} catch {}
|
||||||
try {
|
try {
|
||||||
const st = fs.lstatSync(compatData)
|
const st = fs.lstatSync(compatData)
|
||||||
if (st.isSymbolicLink()) {
|
if (st.isSymbolicLink()) {
|
||||||
if (fs.readlinkSync(compatData) === prefixDir) return
|
if (fs.readlinkSync(compatData) === prefixDir) return
|
||||||
fs.unlinkSync(compatData)
|
fs.unlinkSync(compatData)
|
||||||
|
} else if (st.isDirectory()) {
|
||||||
|
let names = []
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(compatData)
|
||||||
|
} catch {}
|
||||||
|
if (names.length) return
|
||||||
|
fs.rmdirSync(compatData)
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +158,8 @@ function openLatestLog(gameId) {
|
||||||
return fs.createWriteStream(logFile, { flags: 'w' })
|
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 isProton = wine && wine.type === 'proton'
|
||||||
const { env } = buildEnv({ game, wine })
|
const { env } = buildEnv({ game, wine })
|
||||||
const exe = game.exePath
|
const exe = game.exePath
|
||||||
|
|
@ -140,34 +189,47 @@ function runGame({ game, wine, systemWine, onExit }) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reject(err)
|
return reject(err)
|
||||||
}
|
}
|
||||||
const logFile = latestLogPath(game.id)
|
// spawn() returns before the OS actually starts the program: when the
|
||||||
const ws = openLatestLog(game.id)
|
// binary is missing or has no executable bit (a wine build copied without
|
||||||
child.stdout.pipe(ws)
|
// its permissions, a wrong path) the failure only arrives as an 'error'
|
||||||
child.stderr.pipe(ws)
|
// event, AFTER this function would have resolved. Resolving there marked
|
||||||
child.on('error', reject)
|
// games that never started as running forever, so the promise only settles
|
||||||
child.unref()
|
// once the process really exists ('spawn').
|
||||||
const startedAt = Date.now()
|
let started = false
|
||||||
// read the LIVE playtime file, not the (possibly shared/stale) registry
|
child.once('error', err => {
|
||||||
// value - ad-hoc "open with" runs share one id and must not mix playtime
|
if (!started) reject(err)
|
||||||
const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0)
|
})
|
||||||
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds })
|
child.once('spawn', () => {
|
||||||
// stamp "last played" once at start so it is correct even if we crash later
|
started = true
|
||||||
require('./library').updateGame(game.id, { lastPlayed: startedAt })
|
const logFile = latestLogPath(game.id)
|
||||||
child.on('exit', (code, signal) => {
|
const ws = openLatestLog(game.id)
|
||||||
stopTicker()
|
child.stdout.pipe(ws)
|
||||||
ws.end()
|
child.stderr.pipe(ws)
|
||||||
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
|
child.unref()
|
||||||
require('./library').updateGame(game.id, {
|
const startedAt = Date.now()
|
||||||
lastPlayed: startedAt,
|
// read the LIVE playtime file, not the (possibly shared/stale) registry
|
||||||
playSeconds: basePlaySeconds + counted
|
// value - ad-hoc "open with" runs share one id and must not mix playtime
|
||||||
})
|
const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0)
|
||||||
if (onExit) onExit(code, signal)
|
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds })
|
||||||
|
// stamp "last played" once at start so it is correct even if we crash later
|
||||||
|
require('./library').updateGame(game.id, { lastPlayed: startedAt })
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
stopTicker()
|
||||||
|
ws.end()
|
||||||
|
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
|
||||||
|
require('./library').updateGame(game.id, {
|
||||||
|
lastPlayed: startedAt,
|
||||||
|
playSeconds: basePlaySeconds + counted
|
||||||
|
})
|
||||||
|
if (onExit) onExit(code, signal)
|
||||||
|
})
|
||||||
|
resolve({ pid: child.pid, logFile })
|
||||||
})
|
})
|
||||||
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 isProton = wine && wine.type === 'proton'
|
||||||
const { env } = buildEnv({ game, wine })
|
const { env } = buildEnv({ game, wine })
|
||||||
const exe = game.exePath
|
const exe = game.exePath
|
||||||
|
|
|
||||||
194
src/symlinks.js
Normal file
194
src/symlinks.js
Normal 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 }
|
||||||
139
src/wines.js
139
src/wines.js
|
|
@ -5,6 +5,7 @@ const fsp = fs.promises
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
||||||
const { dedupeDir } = require('./fsutil')
|
const { dedupeDir } = require('./fsutil')
|
||||||
|
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
|
||||||
|
|
||||||
const WINES_DIR = DIRS.WINES
|
const WINES_DIR = DIRS.WINES
|
||||||
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
|
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
|
||||||
|
|
@ -109,12 +110,17 @@ async function refreshWines() {
|
||||||
const prev = readJson(REGISTRY, []).map(wineAbs)
|
const prev = readJson(REGISTRY, []).map(wineAbs)
|
||||||
const prevById = new Map(prev.map(w => [w.id, w]))
|
const prevById = new Map(prev.map(w => [w.id, w]))
|
||||||
// A build whose path changed since last scan was moved (or the launcher
|
// 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) {
|
for (const w of found) {
|
||||||
const old = prevById.get(w.id)
|
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 {
|
try {
|
||||||
await fixBuildSymlinks(w.path)
|
await healBuild(w.path)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -190,77 +196,58 @@ function deleteWine(id) {
|
||||||
// that point to absolute paths from wherever the build lived before (zip
|
// that point to absolute paths from wherever the build lived before (zip
|
||||||
// layout, previous install dir). After the build or the whole launcher is
|
// layout, previous install dir). After the build or the whole launcher is
|
||||||
// moved those links dangle and Proton crashes during prefix setup
|
// moved those links dangle and Proton crashes during prefix setup
|
||||||
// (try_copy -> FileNotFoundError). Rewire every absolute symlink whose content
|
// (try_copy -> FileNotFoundError). The rewiring itself lives in symlinks.js
|
||||||
// can be located inside the build, always storing the new target as a
|
// because prefixes, synced apps and game imports need exactly the same thing.
|
||||||
// *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
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fixBuildSymlinks(root) {
|
async function fixBuildSymlinks(root) {
|
||||||
const links = []
|
return rewireSymlinks(root, [DIRS.FILES])
|
||||||
await collectSymlinks(root, links)
|
}
|
||||||
let fixed = 0
|
|
||||||
// A link may only become rewirable once another link it chains through has
|
// Everything a freshly arrived build needs before it can run: relative links
|
||||||
// been fixed, so repeat until a full pass changes nothing.
|
// and executable binaries. Returns what was changed, for the callers' reports.
|
||||||
for (let pass = 0; pass < 5; pass++) {
|
async function healBuild(root) {
|
||||||
let changed = 0
|
const links = await fixBuildSymlinks(root)
|
||||||
for (const link of links) {
|
const executables = await restoreExecutableBits(root)
|
||||||
let target
|
return { links, executables }
|
||||||
try {
|
}
|
||||||
target = await fsp.readlink(link)
|
|
||||||
} catch {
|
function isExec(p) {
|
||||||
continue
|
try {
|
||||||
}
|
fs.accessSync(p, fs.constants.X_OK)
|
||||||
const newTarget = rewireTarget(root, link, path.normalize(target))
|
return true
|
||||||
if (!newTarget || newTarget === target) continue
|
} catch {
|
||||||
try {
|
return false
|
||||||
await fsp.unlink(link)
|
|
||||||
await fsp.symlink(newTarget, link)
|
|
||||||
fixed++
|
|
||||||
changed++
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (!changed) break
|
|
||||||
}
|
}
|
||||||
return fixed
|
}
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importWineFolder(srcDir) {
|
async function importWineFolder(srcDir) {
|
||||||
|
|
@ -277,13 +264,13 @@ async function importWineFolder(srcDir) {
|
||||||
}
|
}
|
||||||
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
|
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
|
||||||
await fsp.cp(copySrc, dst, { recursive: true, verbatimSymlinks: true })
|
await fsp.cp(copySrc, dst, { recursive: true, verbatimSymlinks: true })
|
||||||
let symlinksFixed = 0
|
let healed = { links: 0, executables: 0 }
|
||||||
try {
|
try {
|
||||||
symlinksFixed = await fixBuildSymlinks(dst)
|
healed = await healBuild(dst)
|
||||||
} catch {}
|
} catch {}
|
||||||
await refreshWines()
|
await refreshWines()
|
||||||
// the toast shows a portable-looking path (files/...) like everywhere else
|
// 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 }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue