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
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
@ -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.
- **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
- **System Wine** — uses the `wine` binary from PATH.
- **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically).
- **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically). After the copy, PLWL rewires the build's absolute symlinks to relative ones and restores the executable bit on the binaries it needs to run (`proton`, `wine`, `wineserver`, `winetricks`), so a build that arrived from a zip or another machine works without further fiddling.
- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_CLIENT_INSTALL_PATH` points to a fake steam client folder, `STEAM_COMPAT_DATA_PATH` is a per-build compat folder, and the prefix lives at `<compat>/pfx` which PLWL links to its own prefix folder. No Steam installation required (GE builds work out of the box; for a few Valve builds that refuse to run without a client, PLWL fakes a minimal steam folder in `files/data/fake-steam`).
## Translations

View file

@ -17,6 +17,8 @@ src/ main process (Node/Electron backend)
library.js apps registry (apps.json) + settings + playtime files
wines.js wine/proton build scan, registry, portable symlink healing
prefixes.js prefix registry (files/wineprefixes)
symlinks.js relative symlink rewiring + executable-bit restoration
(shared by imports, prefixes and LAN sync)
importer.js app import (copy/move/leave/prefix)
openwith.js "Open with PLWL": ad-hoc runs, no library entry
shortcuts.js .desktop shortcut generation
@ -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
sides report their own progress (the receiver through the onReceive hook),
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:
showModal(html, { locked: true }) ignores the backdrop and Escape, because
the user cannot cancel the operation by dismissing it.
- Symlinks inside everything PLWL owns (a wine/proton build, a prefix, an
imported app) are stored RELATIVE whenever the target can be expressed
locally: a Proton prefix is full of links into its build, and an absolute one
points at the machine that wrote it. symlinks.js owns this (rewireSymlinks,
plus restoreExecutableBits for the executables a content copy drops) and
every writer calls it: importWineFolder/healBuild, importer after copy/move,
healPrefix, and the sync both on the wire (portableLinkTarget sends the
relative target) and after a unit lands (healUnitRoots). dosdevices drive
mappings and targets that leave files/ (a mount, a system path, the user's
home) stay absolute - they are machine-specific by design.
- Wine and Proton create prefix links with ABSOLUTE local targets whenever they
update a prefix (font/dll stubs, Steam appcache), so on-disk links are not
always relative after a run: the sync payload relativizes them anyway, and
refreshWines()/healPrefix() repair what arrived from an older PLWL. A build
whose binaries lost their executable bit is detected by a cheap audit in
refreshWines() and healed once, because spawn() on a non-executable `proton`
fails with EACCES and the game would look like it started while nothing ran.
- File permissions travel with a sync (manifest entry `mode`): the receiving
side applies them after writing the bytes and repairs a file that has the
same hash but the wrong mode without re-uploading it.
- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to
a temp dir, check(), exit 1 on failure. Add one when fixing a bug.
@ -83,5 +120,13 @@ node --check <changed js files> # syntax
node test/import.test.js # ...and the other test files
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
node test/lansync-receiving.test.js # receive-side progress events (the popup)
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
node test/lansync-links.test.js # wire: exec bits + relative links in a sync
node test/lansync-pending-links.test.js # links to files created later, startup heal
node test/bench-sync.js # timing: phases of a cold and a cached sync
npm start # click through the flow you touched
# Repairing / testing a real instance (test/ scripts are gitignored helpers):
PLWL_BUILD_DIR=/path/to/instance node test/heal-instance.js # links + exec bits
PLWL_BUILD_DIR=/path/to/instance node test/launch-instance.js <id> # really start a game
```

View file

@ -164,6 +164,7 @@
"lansync.appsPrefix": "Apps imported from a prefix",
"lansync.wines": "Wine / Proton versions",
"lansync.prefixes": "Wine prefixes",
"lansync.defaults": "Default settings (default Wine and prefix)",
"lansync.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.start": "Start sync",
@ -172,17 +173,20 @@
"lansync.phase.hash": "Hashing {0}",
"lansync.phase.plan": "Comparing {0}",
"lansync.phase.copy": "Sending {0}",
"lansync.recvPhase.copy": "Receiving {0}",
"lansync.phase.registry": "Updating the library",
"lansync.phase.commit": "Removing extra files",
"lansync.phase.done": "Finishing up",
"lansync.progressFiles": "{0} / {1} files",
"lansync.progressBytes": "{0} / {1} 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.statsWines": "Wine versions: {0}",
"lansync.receiving": "Receiving from {0}…",
"lansync.recvWaiting": "Waiting for the file list…",
"lansync.recvStalled": "Waiting for the other device…",
"lansync.recvIdle": "The other device stopped responding - the sync was interrupted.",
"lansync.done": "Synced to {0}: {1} sent ({4}), {2} already up to date, {3} removed",
"lansync.failed": "Sync failed: {0}",

View file

@ -164,6 +164,7 @@
"lansync.appsPrefix": "Aplikacje importowane z prefiksu",
"lansync.wines": "Wersje Wine / Proton",
"lansync.prefixes": "Prefiksy Wine",
"lansync.defaults": "Ustawienia domyślne (domyślne Wine i prefiks)",
"lansync.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.start": "Rozpocznij synchronizację",
@ -172,17 +173,20 @@
"lansync.phase.hash": "Haszowanie {0}",
"lansync.phase.plan": "Porównywanie {0}",
"lansync.phase.copy": "Wysyłanie {0}",
"lansync.recvPhase.copy": "Odbieranie {0}",
"lansync.phase.registry": "Aktualizowanie biblioteki",
"lansync.phase.commit": "Usuwanie zbędnych plików",
"lansync.phase.done": "Kończenie",
"lansync.progressFiles": "{0} / {1} plików",
"lansync.progressBytes": "{0} / {1} przesłane",
"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.statsWines": "Wersje Wine: {0}",
"lansync.receiving": "Odbieranie od {0}…",
"lansync.recvWaiting": "Oczekiwanie na listę plików…",
"lansync.recvStalled": "Oczekiwanie na drugie urządzenie…",
"lansync.recvIdle": "Drugie urządzenie przestało odpowiadać - synchronizacja została przerwana.",
"lansync.done": "Zsynchronizowano na {0}: wysłano {1} ({4}), aktualnych {2}, usunięto {3}",
"lansync.failed": "Synchronizacja nie powiodła się: {0}",

View file

@ -1144,7 +1144,9 @@ const LAN_CATEGORIES = [
{ id: 'apps-portable', label: 'lansync.appsPortable' },
{ id: 'apps-prefix', label: 'lansync.appsPrefix' },
{ id: 'wines', label: 'lansync.wines' },
{ id: 'prefixes', label: 'lansync.prefixes' }
{ id: 'prefixes', label: 'lansync.prefixes' },
// opt-in: without it a sync never touches the other machine's defaults
{ id: 'defaults', label: 'lansync.defaults' }
]
// Byte sizes for the progress popups. Decimal units, the way a file manager
@ -1158,19 +1160,45 @@ function formatBytes(bytes) {
return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB`
}
// "Games: 3 · Prefixes: 1 · Wine versions: 2" - only the categories this
// sync actually carries are listed.
function lanStatsLine(items) {
if (!items) return ''
// "Apps: 2/5 · Prefixes: 1/2 · Wine versions: 0/1" - how much of each category
// is done out of how much the sync carries, so the line says what is still
// coming instead of only listing what has already been walked over. A peer
// that does not announce its totals falls back to the plain count.
function lanStatsLine(items, itemsDone, totals) {
const rows = [
[items.apps, 'lansync.statsApps'],
[items.prefixes, 'lansync.statsPrefixes'],
[items.wines, 'lansync.statsWines']
['apps', 'lansync.statsApps'],
['prefixes', 'lansync.statsPrefixes'],
['wines', 'lansync.statsWines']
]
return rows
.filter(([count]) => count > 0)
.map(([count, key]) => t(key, count))
.join(' · ')
const out = []
for (const [kind, key] of rows) {
const listed = Number(items && items[kind]) || 0
const total = Number(totals && totals[kind]) || 0
const known = itemsDone && Number.isFinite(Number(itemsDone[kind]))
const done = known ? Number(itemsDone[kind]) : listed
if (total > 0) out.push(t(key, `${Math.min(done, total)}/${total}`))
else if (listed > 0) out.push(t(key, listed))
}
return out.join(' · ')
}
// The bar follows BYTES, not file counts: a sync spends its time on the big
// files, and a file-count bar sits at 99% while the last gigabyte crawls in.
// It also follows the PHASE it is in (barBytes/barTotalBytes): a phase starts
// from an empty bar instead of inheriting a full one from the phase before, so
// hashing never looks finished while it has just begun.
function renderProgressBar(bar, bytes, totalBytes) {
const sent = Math.max(0, Number(bytes) || 0)
const total = Math.max(0, Number(totalBytes) || 0)
bar.max = Math.max(total, 1)
bar.value = Math.min(sent, bar.max)
}
// Older peers only send the overall byte totals - then those drive the bar.
function barValues(info) {
const bytes = info.barBytes == null ? info.bytes : info.barBytes
const total = info.barTotalBytes == null ? info.totalBytes : info.barTotalBytes
return [bytes, total]
}
// "340 MB / 1.2 GB" once the total is known (the sending side learns it as it
@ -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-stats"></p>
<p class="subtitle" id="ls-run-bytes"></p>
<p class="subtitle sync-warning" id="ls-run-warning"></p>
`, { locked: true })
modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name)
modal.querySelector('#ls-run-warning').textContent = t('lansync.keepOpen')
const bar = modal.querySelector('progress')
const phase = modal.querySelector('#ls-run-phase')
const count = modal.querySelector('#ls-run-count')
@ -1307,17 +1337,14 @@ async function runLanSync(device, code, categories, force) {
state.lanPushing = true
window.plwl.onLanSyncProgress(data => {
if (!active) return
const key = 'lansync.phase.' + data.phase
phase.textContent = t(key, data.label || '')
if (data.total > 0) {
bar.max = data.total
bar.value = data.done
count.textContent = t('lansync.progressFiles', data.done, data.total)
} else {
bar.removeAttribute('value')
count.textContent = ''
}
stats.textContent = lanStatsLine(data.items)
// Every line keeps its value while the sync moves between phases: hashing
// reports its own file counter, copying its own, and the bar follows the
// bytes the whole way through
phase.textContent = t('lansync.phase.' + data.phase, data.label || '').trim()
renderProgressBar(bar, ...barValues(data))
if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total)
else count.textContent = ''
stats.textContent = lanStatsLine(data.items, data.itemsDone, data.totals)
bytes.textContent = lanBytesLine(data.bytes, data.totalBytes)
})
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
// driven by the events the receiving server emits, and equally not closable
// 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 receiveClosed = false
let receiveWatchdog = null
let receiveStall = null
let receiveCloseTimer = null
function receiveModalParts() {
@ -1378,32 +1415,47 @@ function endReceiving(message) {
clearTimeout(receiveWatchdog)
receiveWatchdog = null
}
if (receiveStall) {
clearTimeout(receiveStall)
receiveStall = null
}
if (receiveCloseTimer) {
clearTimeout(receiveCloseTimer)
receiveCloseTimer = null
}
const wasReceiving = receiving
receiving = false
receiveClosed = true
if (!wasReceiving) return
if (message) toast(message, 'error')
closeModal()
}
// A sender that crashed halfway through must not leave this window locked: the
// popup closes itself if no event arrives for a while (the server drops the
// session after the same idle time).
// popup closes itself if nothing at all arrives for a long time (the server
// drops the session after the same idle time). While it waits, the phase line
// says so rather than the popup disappearing mid-transfer.
function armReceiveWatchdog() {
if (receiveWatchdog) clearTimeout(receiveWatchdog)
receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS)
if (receiveStall) clearTimeout(receiveStall)
receiveStall = setTimeout(() => {
receiveStall = null
const parts = receiveModalParts()
if (parts) parts.phase.textContent = t('lansync.recvStalled')
}, RECEIVE_STALL_MS)
}
function onLanReceiving(info) {
if (!info || !info.stage) return
if (state.lanPushing) return // our own push owns the (single) popup
if (info.stage === 'start') receiveClosed = false
if (!receiving) {
// open the popup only for the start of a sync, and only when the user is
// not in the middle of another dialog (there is one modal at a time)
if (info.stage !== 'start') return
// The popup also opens mid-sync: the sender may have been hashing for
// minutes when this window starts listening, and a popup that the safety
// timeout closed has to come back for the rest of the transfer. A sync
// that already ended stays closed.
if (receiveClosed && info.stage !== 'start') return
if (qs('#modal-overlay').style.display !== 'none') return
receiving = true
const modal = showModal(`
@ -1413,8 +1465,10 @@ function onLanReceiving(info) {
<p class="subtitle" id="ls-recv-count"></p>
<p class="subtitle" id="ls-recv-stats"></p>
<p class="subtitle" id="ls-recv-bytes"></p>
<p class="subtitle sync-warning" id="ls-recv-warning"></p>
`, { locked: true })
modal.querySelector('#ls-recv-title').textContent = t('lansync.receiving', info.from || info.deviceId || '')
modal.querySelector('#ls-recv-warning').textContent = t('lansync.keepOpen')
armReceiveWatchdog()
return renderReceiving(info)
}
@ -1425,17 +1479,14 @@ function onLanReceiving(info) {
function renderReceiving(info) {
const parts = receiveModalParts()
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()
if (info.total > 0) {
parts.bar.max = info.total
parts.bar.value = info.done
parts.count.textContent = t('lansync.progressFiles', info.done, info.total)
} else {
parts.bar.removeAttribute('value')
parts.count.textContent = ''
}
parts.stats.textContent = lanStatsLine(info.items)
renderProgressBar(parts.bar, ...barValues(info))
if (info.total > 0) parts.count.textContent = t('lansync.progressFiles', info.done, info.total)
else parts.count.textContent = ''
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
if (info.stage === 'done') {
// let the totals be read, then get out of the way (the library reloads

View file

@ -191,6 +191,10 @@ content {
}
.subtitle { color: var(--darker-text-color); font-size: 0.9rem; margin-top: 0.2rem; }
/* "Do not close the app while this runs" - the one line on a sync popup the
user has to notice, so it stands out from the subtitles around it. */
.sync-warning { color: var(--big-red); margin-top: 0.6rem; }
.button {
height: var(--button-height);
padding: 0 1rem;

View file

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

View file

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

View file

@ -16,6 +16,7 @@ const fsp = fs.promises
const readline = require('readline')
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
const library = require('./library')
const wines = require('./wines')
const prefixes = require('./prefixes')
@ -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
// categories the way the UI/README does.
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes']
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'defaults']
const CATEGORY_ALIASES = {
// plain "apps" means the whole app library: portable ones AND the ones
@ -64,7 +65,13 @@ const CATEGORY_ALIASES = {
wineprefix: ['prefixes'],
wineprefixes: ['prefixes'],
prefixes: ['prefixes'],
prefix: ['prefixes']
prefix: ['prefixes'],
// the launcher's own defaults ("Default settings"): which wine everything
// falls back to and where a new import lands
defaults: ['defaults'],
default: ['defaults'],
defaultsettings: ['defaults'],
settings: ['defaults']
}
// Which sync category an app entry belongs to. Portable copy/move imports own
@ -231,10 +238,92 @@ const HASH_ALGO = (() => {
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) {
return new Promise((resolve, reject) => {
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('data', chunk => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex')))
@ -259,6 +348,45 @@ function ownedRoot(rel) {
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
}
// Progress is cheap to produce and expensive to render: hashing or copying a
// prefix can emit tens of thousands of events, and forwarding every single one
// over IPC makes the window stutter instead of showing progress. Events are
// rate limited to ~10/s per phase; `force` lets a caller push the value that
// matters (the end of a unit) through immediately.
const PROGRESS_MIN_MS = 100
function progressGate(emit) {
let lastAt = 0
let pending = null
let timer = null
const flush = () => {
timer = null
if (!pending) return
const data = pending
pending = null
lastAt = Date.now()
emit(data)
}
return (data, force) => {
const now = Date.now()
if (force || now - lastAt >= PROGRESS_MIN_MS) {
if (timer) {
clearTimeout(timer)
timer = null
}
pending = null
lastAt = now
emit(data)
return
}
pending = data
if (!timer) {
timer = setTimeout(flush, PROGRESS_MIN_MS - (now - lastAt))
if (timer.unref) timer.unref()
}
}
}
async function mapLimit(items, limit, fn) {
const out = new Array(items.length)
let next = 0
@ -272,10 +400,27 @@ async function mapLimit(items, limit, fn) {
return out
}
// Symlink targets travel RELATIVE whenever they point inside the launcher data
// (a prefix linking into its wine build, an app linking to its own assets): the
// mirrored trees have the same internal layout on both machines, so a relative
// target is correct over there while an absolute one would point at the
// sender's own folders - which is exactly how a synced prefix ends up with
// hundreds of dangling links. Targets outside files/ (a system font, a mounted
// drive, /) are machine-specific and stay as they are.
function portableLinkTarget(linkAbs, target) {
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
const rel = path.relative(path.dirname(linkAbs), target)
if (!rel || path.isAbsolute(rel)) return target
const root = path.resolve(DIRS.FILES)
const resolved = path.resolve(path.dirname(linkAbs), rel)
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
return rel
}
// Walk without following symlinks: builds and prefixes contain links that are
// part of what is being synced (dosdevices, build internals), and following
// them would both loop and copy the wrong thing.
async function scanTree(abs, rel, out) {
async function scanTree(abs, rel, out, mtimes) {
let entries
try {
entries = await fsp.readdir(abs, { withFileTypes: true })
@ -290,18 +435,22 @@ async function scanTree(abs, rel, out) {
try {
target = await fsp.readlink(absChild)
} catch {}
out.push({ path: relChild, type: 'link', target })
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
} else if (entry.isDirectory()) {
out.push({ path: relChild, type: 'dir' })
await scanTree(absChild, relChild, out)
await scanTree(absChild, relChild, out, mtimes)
} else if (entry.isFile()) {
let size = 0
let st = null
try {
size = (await fsp.stat(absChild)).size
st = await fsp.stat(absChild)
} catch {
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").
async function buildManifest(roots, force, onProgress) {
const entries = []
const mtimes = new Map()
for (const rel of roots) {
const abs = filesPath(rel)
if (!abs) continue
@ -324,27 +474,35 @@ async function buildManifest(roots, force, onProgress) {
try {
target = await fsp.readlink(abs)
} catch {}
entries.push({ path: rel, type: 'link', target })
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
} else if (st.isDirectory()) {
entries.push({ path: rel, type: 'dir' })
await scanTree(abs, rel, entries)
await scanTree(abs, rel, entries, mtimes)
} else if (st.isFile()) {
entries.push({ path: rel, type: 'file', size: st.size, hash: null })
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
mtimes.set(rel, st.mtimeMs || 0)
}
}
const files = entries.filter(e => e.type === 'file')
// the unit's total size is known before a byte is hashed, so the bar can
// start empty and fill with the bytes actually read
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
if (!force && files.length) {
let done = 0
await mapLimit(files, 4, async entry => {
let hashed = 0
await mapLimit(files, HASH_CONCURRENCY, async entry => {
const abs = filesPath(entry.path)
try {
entry.hash = await hashFile(filesPath(entry.path))
entry.hash = abs ? await cachedHash(entry.path, abs, entry.size, mtimes.get(entry.path) || 0) : null
} catch {
entry.hash = null
}
done++
if (onProgress) onProgress(done, files.length)
hashed += entry.size || 0
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
}
saveHashCache()
return entries
}
@ -498,11 +656,18 @@ async function entrySame(entry) {
return false
}
if (!st.isFile() || st.size !== entry.size) return false
let same = false
try {
return (await hashFile(abs)) === entry.hash
same = (await cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
} catch {
return false
}
if (!same) return false
// Same bytes, wrong permissions (a build synced before modes travelled, or a
// copy that dropped the bit): repaired right here instead of pulling the
// whole file over the network again.
if (typeof entry.mode === 'number' && (st.mode & 0o777) !== entry.mode) applyMode(abs, entry.mode)
return true
}
function ensureDir(abs) {
@ -517,6 +682,24 @@ function ensureDir(abs) {
} catch {}
}
// After a unit has landed, its links and permissions have to be this machine's
// (the sender stored whatever was true over there): absolute symlinks pointing
// into the launcher data become relative, and a build gets back the executable
// bit a content copy drops. Without this a synced prefix/proton build is dead
// weight - the launcher cannot start it.
async function healUnitRoots(roots) {
const healed = { links: 0, executables: 0 }
for (const rel of Array.isArray(roots) ? roots : []) {
const abs = filesPath(ownedRoot(rel))
if (!abs) continue
try {
healed.links += await rewireSymlinks(abs, [DIRS.FILES])
if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs)
} catch {}
}
return healed
}
function ensureLink(abs, target) {
if (typeof target !== 'string' || !target) return
let current = null
@ -599,7 +782,16 @@ async function pruneTree(abs, rel, keep) {
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) => {
let st = null
try {
@ -629,6 +821,11 @@ function writeStream(abs, stream) {
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)
})
}
@ -650,12 +847,22 @@ function applyApps(payload, cats) {
}
kept.push(game)
}
// playtime is per machine, the sender does not push any - keep the counters
// of apps this device already had
const merged = [...kept, ...incoming.map(g => {
// Playtime and the app's own settings (wine build, prefix, args, env,
// dllOverrides) travel with the app: paths are left exactly as the sender
// stored them (portable/prefix-relative), so a synced entry keeps working.
// Playtime only ever GROWS - the higher counter wins, so pushing from a
// freshly installed machine cannot wipe a library's play history.
const merged = [...kept]
for (const g of incoming) {
const prev = existing.find(x => x.id === g.id)
return prev ? { ...g, playSeconds: prev.playSeconds, lastPlayed: prev.lastPlayed } : g
})]
const hasPlay = Number.isFinite(Number(g.playSeconds))
const playSeconds = hasPlay ? Math.max(Number(prev && prev.playSeconds) || 0, Number(g.playSeconds)) : prev && prev.playSeconds
const lastPlayed = Math.max(Number(prev && prev.lastPlayed) || 0, Number(g.lastPlayed) || 0)
merged.push({ ...g, playSeconds, lastPlayed })
// the per-app playtime file wins over the registry, so a synced counter
// has to be written there too or loadGames would keep reporting the old one
if (hasPlay) library.writePlaySeconds(g.id, playSeconds)
}
library.saveGames(merged)
let removed = 0
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
@ -677,7 +884,7 @@ async function applyWines(payload) {
return removed
}
function applyPrefixes(payload) {
function applyPrefixes(payload, useSenderDefault) {
const sender = payload.prefixes && typeof payload.prefixes === 'object' ? payload.prefixes : {}
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
const existing = prefixes.listPrefixes()
@ -690,11 +897,39 @@ function applyPrefixes(payload) {
}
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked }))
const def = keep.has(sender.default) ? sender.default : prefixes.MAIN
// Which prefix is the default only moves when the user asked for the default
// settings to be synced; otherwise this device keeps its own (as long as the
// prefix still exists here).
const wanted = useSenderDefault ? sender.default : existing.default
const def = keep.has(wanted) ? wanted : prefixes.MAIN
prefixes.savePrefixes({ default: def, prefixes: list })
return removed
}
// The launcher-wide defaults, asked for with the "Default settings" category:
// the wine every app falls back to and the prefix new imports land in. Only
// values that exist HERE are taken - a default pointing at a build or prefix
// this device does not have would be worse than keeping the current one.
function applyDefaults(payload, cats) {
if (!cats.has('defaults')) return null
const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {}
const applied = {}
if (wanted.defaultWine) {
const res = wines.setDefaultWine(String(wanted.defaultWine))
applied.defaultWine = res && res.ok ? String(wanted.defaultWine) : null
}
if (wanted.defaultPrefix) {
const defs = prefixes.listPrefixes()
if (defs.prefixes.some(p => p.name === wanted.defaultPrefix)) {
prefixes.setDefaultPrefix(String(wanted.defaultPrefix))
applied.defaultPrefix = String(wanted.defaultPrefix)
} else {
applied.defaultPrefix = null
}
}
return applied
}
// --- HTTP server (what other devices talk to) -------------------------------
function createServerState() {
@ -719,11 +954,19 @@ function emitReceive(hooks, session, stage, extra = {}) {
deviceId: session.deviceId || '',
unit: extra.unit || '',
label: extra.unit || '',
done: session.received || 0,
total: session.planned || 0,
done: extra.done == null ? session.received || 0 : extra.done,
total: extra.total == null ? session.planned || 0 : extra.total,
bytes: session.bytes || 0,
totalBytes: session.plannedBytes || 0,
// items: units this sync announced, itemsDone: units fully written,
// totals: everything the sender picked (so the popup can say "2/5")
items: { ...(session.items || {}) },
itemsDone: { ...(session.itemsDone || {}) },
totals: { ...(session.totals || {}) },
// what the BAR shows: the phase being worked on right now, so hashing
// starts empty instead of continuing a full bar from earlier
barBytes: session.barBytes || 0,
barTotalBytes: session.barTotal || 0,
deleted: extra.deleted || 0
})
} catch {}
@ -799,6 +1042,9 @@ async function handleVerify(state, req, res, hooks) {
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' })
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 = {
token,
deviceId: from,
@ -813,7 +1059,15 @@ async function handleVerify(state, req, res, hooks) {
planned: 0,
bytes: 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.session = session
@ -843,21 +1097,52 @@ async function handlePlan(state, session, url, req, res, hooks) {
await readNdjson(req, entry => {
if (inlineSame(entry)) entries.push(entry)
})
// the mode each file has to end up with on this side
const modes = new Map()
for (const entry of entries) if (entry.type === 'file' && typeof entry.mode === 'number') modes.set(entry.path, entry.mode)
// a force sync compares nothing: every entry counts as different
const forced = url.searchParams.get('force') === '1'
// how many apps/prefixes/wines the sender picked in total: without it the
// popup here could only ever say what has already arrived
try {
const totals = JSON.parse(url.searchParams.get('totals') || 'null')
if (totals && typeof totals === 'object') {
session.totals = {
apps: Number(totals.apps) || 0,
wines: Number(totals.wines) || 0,
prefixes: Number(totals.prefixes) || 0
}
}
} catch {}
const allowed = entries.filter(entry => withinRoots(entry.path, owned))
// Comparing this unit is a phase of its own: the bar fills with the bytes
// checked here, then starts over empty for the files that follow.
const unitTotalBytes = allowed.reduce((sum, entry) => sum + (entry.size || 0), 0)
const announce = progressGate((data, force) => emitReceive(hooks, session, 'plan', data))
let compared = 0
let comparedBytes = 0
session.barTotal = unitTotalBytes
session.barBytes = 0
const identical = await mapLimit(allowed, HASH_CONCURRENCY, async entry => {
const same = forced ? false : await entrySame(entry)
compared++
comparedBytes += entry.size || 0
session.barBytes = comparedBytes
announce({ unit: label, done: compared, total: allowed.length })
return same === true
})
saveHashCache()
const paths = new Set()
const upload = []
const same = []
let uploadBytes = 0
for (const entry of entries) {
if (!withinRoots(entry.path, owned)) continue
allowed.forEach((entry, index) => {
const abs = filesPath(entry.path)
if (!abs) continue
if (!abs) return
paths.add(entry.path)
const identical = forced ? false : await entrySame(entry)
if (identical) {
if (identical[index]) {
same.push(entry.path)
continue
return
}
if (entry.type === 'dir') ensureDir(abs)
else if (entry.type === 'link') ensureLink(abs, entry.target)
@ -865,12 +1150,17 @@ async function handlePlan(state, session, url, req, res, hooks) {
upload.push(entry.path)
uploadBytes += entry.size || 0
}
}
session.units.set(key, { roots: owned, paths })
})
session.units.set(key, { roots: owned, paths, label, modes })
// what this unit adds to the transfer: the popup reports files and bytes
// against these as they arrive
session.planned += upload.length
session.plannedBytes += uploadBytes
// the files are next: the bar counts the bytes about to be written, from an
// empty bar - a unit that needs nothing at all is simply finished
session.unitWritten = 0
session.barTotal = uploadBytes
session.barBytes = 0
const kind = UNIT_KINDS[String(key).split(':')[0]]
if (kind && !session.counted.has(key)) {
session.counted.add(key)
@ -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
session.unitLabel = label
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) {
@ -892,12 +1182,24 @@ async function handlePut(session, url, req, res, hooks) {
req.resume()
return sendJson(res, 400, { ok: false, error: 'bad-path' })
}
// Bytes are reported while the file is still arriving: waiting for the whole
// file to land would leave the popup frozen for the entire transfer of a
// single big asset (that is what made it look like it only moved once per
// received item).
const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data))
try {
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.bytes = (session.bytes || 0) + (written || 0)
sendJson(res, 200, { ok: true })
emitReceive(hooks, session, 'copy', { unit: session.unitLabel || '' })
announce({ unit: session.unitLabel || '' }, true)
} catch (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)
if (abs) removed += await pruneTree(abs, root, unit.paths)
}
// the unit is complete - rewiring its links is only worth doing once the
// whole tree is there (a prefix's links chain through its own files)
try {
await healUnitRoots(unit.roots)
} catch {}
session.units.delete(key)
session.deleted = (session.deleted || 0) + removed
// a finished unit: this is what turns "Apps: 2/5" from the sender's totals
// and the units written here
const kind = UNIT_KINDS[String(key).split(':')[0]]
if (kind) session.itemsDone[kind] = (session.itemsDone[kind] || 0) + 1
// a finished unit leaves a finished bar behind (even one that needed no bytes)
session.barTotal = session.barTotal || 1
session.barBytes = session.barTotal
sendJson(res, 200, { ok: true, deleted: removed })
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
}
@ -924,7 +1238,10 @@ async function handleApply(session, req, res, hooks) {
const cats = new Set(Array.isArray(payload.categories) ? payload.categories : [])
const appsDeleted = applyApps(payload, cats)
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 0
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('defaults')) : 0
// after the registries are in place, so a synced build/prefix can actually be
// chosen as the default
const defaults = applyDefaults(payload, cats)
const removed = appsDeleted + winesDeleted + prefixesDeleted
emitReceive(hooks, session, 'registry', { deleted: removed })
if (hooks.onApplied) {
@ -935,7 +1252,8 @@ async function handleApply(session, req, res, hooks) {
files: session.received || 0,
bytes: session.bytes || 0,
items: { ...(session.items || {}) },
deleted: removed
deleted: removed,
defaults
})
}
sendJson(res, 200, {
@ -943,7 +1261,8 @@ async function handleApply(session, req, res, hooks) {
deleted: removed,
apps: { deleted: appsDeleted },
wines: { deleted: winesDeleted },
prefixes: { deleted: prefixesDeleted }
prefixes: { deleted: prefixesDeleted },
defaults
})
}
@ -1159,6 +1478,9 @@ function getPusher() {
buildManifest,
listDirs,
listDevices,
mapLimit,
progressGate,
hashFile,
DIRS,
toPortablePath,
library,
@ -1224,6 +1546,11 @@ const exported = {
sameCode,
HASH_ALGO,
hashFile,
hashCacheStats,
resetHashCacheStats,
forgetHashCache,
HASH_CONCURRENCY,
progressGate,
filesPath,
ownedRoot,
buildManifest,

View file

@ -13,6 +13,10 @@ const os = require('os')
const fs = require('fs')
const path = require('path')
// Files sent at once: one at a time wastes most of a LAN round trip on the
// small files a game or prefix is mostly made of.
const UPLOAD_CONCURRENCY = 4
function createPusher(api) {
const {
CATEGORIES,
@ -34,9 +38,12 @@ function createPusher(api) {
// Manifest in, list of files to upload out. NDJSON keeps a prefix with tens
// of thousands of entries from being one giant string in memory either side.
function planUnit(device, token, unit, entries, force) {
function planUnit(device, token, unit, entries, force, meta) {
// the receiver is told how many apps/prefixes/wines the whole sync carries
// up front, which is what lets its popup say "Apps: 2/5"
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
return new Promise((resolve, reject) => {
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&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(
{
host: device.address,
@ -142,12 +149,11 @@ function createPusher(api) {
// --- registry payload -----------------------------------------------------
// Everything the app config holds travels: paths (portableGame rewrites them
// to the FILES/prefix relative form both machines understand), the wine
// build and prefix it runs in, args/env/dllOverrides and the play counters.
function portableApp(game) {
const out = library.portableGame(game)
// playtime/lastPlayed are per machine, the receiver keeps its own counters
delete out.playSeconds
delete out.lastPlayed
return out
return library.portableGame(game)
}
function buildRegistryPayload(categories) {
@ -166,6 +172,15 @@ function createPusher(api) {
// the receiver prunes its own builds down to this list and rescans
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
}
if (cats.has('defaults')) {
// "Default settings": which wine everything falls back to and where a new
// import lands. Opt-in on purpose - a sync of apps must not silently
// change the other machine's defaults.
payload.defaults = {
defaultWine: (library.loadSettings().defaultWine) || 'system',
defaultPrefix: prefixes.listPrefixes().default || ''
}
}
if (cats.has('prefixes')) {
const defs = prefixes.listPrefixes()
payload.prefixes = {
@ -230,6 +245,13 @@ function createPusher(api) {
if (!device || !device.address || !device.port) return fail('offline')
const cats = new Set(normalizeCategories(categories))
if (!cats.size) return fail('nothing-selected')
// The units are known before anything is read, so how many apps / wine
// builds / prefixes this sync carries is announced with the verification:
// the other side can show "Apps: 0/5" from its first event.
const units = buildUnits(cats)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const totals = { apps: 0, wines: 0, prefixes: 0 }
for (const unit of units) totals[bucketOf(unit.kind)] += 1
// every progress event is kept (and handed to onProgress when given) so
// callers can report on the transfer without subscribing first
const events = []
@ -241,7 +263,7 @@ function createPusher(api) {
let verified
try {
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
})
} catch (err) {
@ -260,15 +282,17 @@ function createPusher(api) {
// typing (a rejected code is never stored)
rememberDevice(device.id || deviceId, code, device.name)
const units = buildUnits(cats)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const summary = newSummary(units.length)
const finished = { apps: 0, wines: 0, prefixes: 0 }
// bytes the units planned so far will put on the wire: the popup can then
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
let plannedBytes = 0
let itemsSeen = 0
const emit = (phase, unit, done, total) =>
progress({
// the bar tracks the PHASE being worked on (its own bytes), never a
// leftover from the phase before: hashing starts from an empty bar
let barBytes = 0
let barTotal = 0
const snapshot = (phase, unit, done, total) => ({
stage: phase,
phase,
kind: (unit && unit.kind) || '',
@ -283,8 +307,17 @@ function createPusher(api) {
totalBytes: plannedBytes,
units: summary.units,
unitsDone: itemsSeen,
items: { ...summary.items }
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 () => {
try {
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
@ -294,13 +327,20 @@ function createPusher(api) {
for (const unit of units) {
const roots = unit.roots.filter(ownedRoot)
if (!roots.length) continue
const bucket = summary[bucketOf(unit.kind)]
summary.items[bucketOf(unit.kind)] += 1
const kind = bucketOf(unit.kind)
const bucket = summary[kind]
summary.items[kind] += 1
itemsSeen += 1
emit('scan', unit, 0, 0)
const entries = await buildManifest(roots, !!force, (done, total) => emit('hash', unit, done, total))
emit('plan', unit, 0, 0)
const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force)
barBytes = 0
barTotal = 0
emit('scan', unit, 0, 0, true)
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}`)
const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || [])
@ -308,31 +348,56 @@ function createPusher(api) {
// already identical (the plan reports both)
bucket.copied += entries.length - same.size
bucket.skipped += same.size
for (const entry of entries) {
if (entry.type === 'file' && upload.has(entry.path)) plannedBytes += entry.size || 0
}
let done = 0
const pending = []
for (const entry of entries) {
if (entry.type !== 'file') continue
if (!upload.has(entry.path)) {
summary.skipped++
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))
if (res.status !== 200) throw new Error(`upload-failed-${res.status}`)
if (res.status !== 200) {
failed = new Error(`upload-failed-${res.status}`)
return
}
done++
summary.uploaded++
summary.bytes += entry.size || 0
barBytes += entry.size || 0
emit('copy', unit, 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: {} })
if (commit.data && commit.data.deleted) {
summary.deleted += commit.data.deleted
bucket.deleted += commit.data.deleted
}
finished[kind] += 1
// a finished unit leaves a finished bar behind, even one that sent none
barTotal = barTotal || 1
barBytes = barTotal
emit('commit', unit, 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 })
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
for (const key of ['apps', 'wines', 'prefixes']) {
@ -340,7 +405,7 @@ function createPusher(api) {
if (deleted) summary[key].deleted += deleted
}
if (applied.data.deleted) summary.deleted += applied.data.deleted
emit('done', null, summary.uploaded, summary.uploaded)
emit('done', null, summary.uploaded, summary.uploaded, true)
await end()
} catch (err) {
await end()

View file

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

View file

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

194
src/symlinks.js Normal file
View file

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

View file

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