diff --git a/README.md b/README.md index 5b4b36c..5a8241c 100644 --- a/README.md +++ b/README.md @@ -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 `/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 diff --git a/development.md b/development.md index 7859e51..c89404f 100644 --- a/development.md +++ b/development.md @@ -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 # 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 # really start a game ``` diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 6223fd3..32fd4cb 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -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}", diff --git a/files/translations/pl.json b/files/translations/pl.json index e3c2235..d1c1220 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -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}", diff --git a/renderer/app.js b/renderer/app.js index 7091330..90a8a81 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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) {

+

`, { 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) {

+

`, { 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 diff --git a/renderer/style.css b/renderer/style.css index 6249cce..26a10d3 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -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; diff --git a/src/importer.js b/src/importer.js index ffe4167..ffd10e4 100644 --- a/src/importer.js +++ b/src/importer.js @@ -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) { diff --git a/src/ipc/games.js b/src/ipc/games.js index 70f215b..b72c8bb 100644 --- a/src/ipc/games.js +++ b/src/ipc/games.js @@ -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: 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 || null, logFile: res.logFile || null } + 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) - running.delete(id) - sendToWindow(getWindow(), 'games:changed') - notifyChanged() - } else if (running.has(id)) { - killRequests.add(id) - } + if (pid) killProcessGroup(pid) + else if (running.has(id)) killRequests.add(id) + running.delete(id) + startedAt.delete(id) + sendToWindow(getWindow(), 'games:changed') + notifyChanged() return { ok: true } } diff --git a/src/lansync.js b/src/lansync.js index 64a5073..8c5356b 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -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, diff --git a/src/lansyncpush.js b/src/lansyncpush.js index 8fff75f..2703e0c 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -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,31 +282,42 @@ 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({ - stage: phase, - phase, - kind: (unit && unit.kind) || '', - id: (unit && unit.id) || '', - key: (unit && unit.key) || '', - unit: (unit && unit.label) || '', - label: (unit && unit.label) || '', - name: (unit && unit.label) || '', - done, - total, - bytes: summary.bytes, - totalBytes: plannedBytes, - units: summary.units, - unitsDone: itemsSeen, - items: { ...summary.items } - }) + // 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) || '', + id: (unit && unit.id) || '', + key: (unit && unit.key) || '', + unit: (unit && unit.label) || '', + label: (unit && unit.label) || '', + name: (unit && unit.label) || '', + done, + total, + bytes: summary.bytes, + 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 () => { 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() diff --git a/src/prefixes.js b/src/prefixes.js index 2d8a9bf..ddbfdd9 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -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 } diff --git a/src/runner.js b/src/runner.js index 2dee951..897249d 100644 --- a/src/runner.js +++ b/src/runner.js @@ -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 /drive_c/Program Files (x86)/Steam/ +// appcache -> /appcache while setting the prefix up, and a link into a +// missing folder is a dangling link in a tree that gets synced. 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,34 +189,47 @@ function runGame({ game, wine, systemWine, onExit }) { } catch (err) { return reject(err) } - 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 - // value - ad-hoc "open with" runs share one id and must not mix playtime - const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0) - 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) + // 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.unref() + const startedAt = Date.now() + // read the LIVE playtime file, not the (possibly shared/stale) registry + // value - ad-hoc "open with" runs share one id and must not mix playtime + const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0) + 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 { env } = buildEnv({ game, wine }) const exe = game.exePath diff --git a/src/symlinks.js b/src/symlinks.js new file mode 100644 index 0000000..cf724d9 --- /dev/null +++ b/src/symlinks.js @@ -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//... (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/` 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 } diff --git a/src/wines.js b/src/wines.js index abdece1..2905856 100644 --- a/src/wines.js +++ b/src/wines.js @@ -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 - try { - target = await fsp.readlink(link) - } 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 {} - } - if (!changed) break + 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 { + fs.accessSync(p, fs.constants.X_OK) + return true + } catch { + return false } - 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) { @@ -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 }