a LOT sync fixes...

This commit is contained in:
olcxja 2026-10-01 17:12:53 +02:00
commit 806e173e43
14 changed files with 1073 additions and 240 deletions

View file

@ -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

View file

@ -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
``` ```

View file

@ -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}",

View file

@ -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}",

View file

@ -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

View file

@ -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;

View file

@ -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) {

View file

@ -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 }
} }

View file

@ -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,

View file

@ -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()

View file

@ -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 }

View file

@ -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
View file

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

View file

@ -5,6 +5,7 @@ const fsp = fs.promises
const path = require('path') const 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 }