From 7d8ab87eee4b7194ee0ea6877a532d9e9407e593 Mon Sep 17 00:00:00 2001 From: olcxja Date: Mon, 5 Oct 2026 15:42:44 +0200 Subject: [PATCH] cleanup comments, update translations, bump to 0.4.1 --- development.md | 4 +- files/translations/en-us.json | 4 +- files/translations/pl.json | 4 +- package.json | 2 +- renderer/app.js | 232 ++++++++-------------------- src/argv.js | 52 ++----- src/fsutil.js | 36 ++--- src/hashcache.js | 40 ++--- src/icons.js | 10 +- src/importer.js | 42 ++---- src/ipc/games.js | 48 ++---- src/ipc/index.js | 47 ++---- src/lansync-discovery.js | 6 +- src/lansync-identity.js | 33 ++-- src/lansync-manifest.js | 42 ++---- src/lansync.js | 277 +++++++++------------------------- src/lansyncpush.js | 116 ++++---------- src/library.js | 69 +++------ src/main.js | 35 ++--- src/names.js | 6 +- src/openwith.js | 20 +-- src/paths.js | 38 ++--- src/prefixes.js | 57 ++----- src/preload.js | 7 +- src/runner.js | 139 +++++------------ src/shortcuts.js | 25 +-- src/symlinks.js | 74 ++------- src/wines.js | 61 +++----- 28 files changed, 412 insertions(+), 1114 deletions(-) diff --git a/development.md b/development.md index a21a42d..c4cea56 100644 --- a/development.md +++ b/development.md @@ -54,7 +54,9 @@ Renderer never touches Node/Electron directly — everything goes through the `w ## Rules -- Keep it simple. Plain functions over classes, no abstractions "for later". Comments explain the WHY (quirks, workarounds, races), not the WHAT — none at all when nothing needs explaining. +- Keep it simple. Plain functions over classes, no abstractions "for later". +- Structure: one topic per file. Extract a module once a file grows or logic repeats (see lansync-identity/manifest/discovery, hashcache). Factories (createX(...)) carry per-state helpers so module scope stays clean when several states share a module. +- Comments are RARE and SHORT, always in English. One line, two at most. Explain only the WHY (a quirk, a workaround, a race) — never the WHAT. No comment at all when nothing needs explaining; no banners, no restating the next line, no changelog notes. - No duplicate logic: the second time you copy something, move it into a helper and call it from both places. - Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js. - Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/. diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 058b1a7..d16e330 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -81,13 +81,13 @@ "config.prefixLocked": "This app was imported from a prefix, its prefix cannot be changed", "config.prefixLockedHint": "Pinned to the prefix it was imported from (its paths are stored relative to it)", "config.disableUmu": "Disable UMU (launch with wine directly)", - "config.disableUmuHint": "Runs the game with the build's own wine binary instead of its umu.exe/steam.exe launcher - no UMU and no Steam client is started, and Proton's prefix setup is skipped too. This is the closest to a plain wine run.", + "config.disableUmuHint": "Runs the game with the build's own wine binary instead of its umu.exe/steam.exe launcher. This is the closest to a plain wine run.", "config.dllOverrides": "DLL overrides", "config.overrides.add": "Add override", "config.overrides.dll": "DLL name", "config.overrides.setting": "Setting", "config.overrides.remove": "Remove", - "config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module. 'name=setting' pasted into the name box is split automatically.", + "config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module.", "config.save": "Save changes", "config.shortcut": "Create shortcut", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", diff --git a/files/translations/pl.json b/files/translations/pl.json index ad1c0ca..58806a7 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -81,13 +81,13 @@ "config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu, nie można zmienić jej prefiksu", "config.prefixLockedHint": "Przypięta do prefiksu, z którego została zaimportowana (ścieżki trzymane są względem niego)", "config.disableUmu": "Wyłącz UMU (uruchamiaj bezpośrednio wine)", - "config.disableUmuHint": "Uruchamia grę bezpośrednio binarką wine z builda, z pominięciem launchera umu.exe/steam.exe — bez UMU i bez uruchamiania klienta Steam, z pominięciem przygotowania prefiksu przez Protona. To najbliżej zwykłego uruchomienia wine.", + "config.disableUmuHint": "Uruchamia grę bezpośrednio binarką wine z builda, z pominięciem launchera umu.exe/steam.exe. To najbliżej zwykłego uruchomienia wine.", "config.dllOverrides": "Nadpisania DLL", "config.overrides.add": "Dodaj nadpisanie", "config.overrides.dll": "Nazwa DLL", "config.overrides.setting": "Ustawienie", "config.overrides.remove": "Usuń", - "config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł. Wklejone 'nazwa=ustawienie' zostanie rozdzielone automatycznie.", + "config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł.", "config.save": "Zapisz zmiany", "config.shortcut": "Utwórz skrót", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", diff --git a/package.json b/package.json index 0152daa..bbdc0ce 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "PLWL", "productName": "PLWL", - "version": "0.4.0", + "version": "0.4.1", "description": "Portable Linux Wine Launcher", "main": "src/main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index dc53847..857cdf5 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -15,9 +15,7 @@ const state = { games: [], currentGame: null, lansync: null, - // true while this device pushes a sync (and while it shows the progress - // popup): only one such popup exists, the pushing side wins over an - // incoming sync + // true while this device pushes a sync: it owns the single popup lanPushing: false, currentView: 'library' } @@ -78,8 +76,7 @@ function setButtonIcons() { } } -// Smoothly animate elements of `container` between their previous and new -// positions after `mutate()` changes the layout (FLIP technique). +// FLIP: animate elements between their previous and new layout positions. function flipShift(container, mutate) { const entries = [...container.children].map(el => ({ el, top: el.getBoundingClientRect().top })) mutate() @@ -111,20 +108,13 @@ function toast(message, kind) { }) } -// The teardown of a closing modal is delayed (for the fade-out) - an -// eagerly armed timer must never kill a modal opened in the meantime (e.g. -// import mode -> prefix picker happens within milliseconds), so showModal -// cancels any pending teardown and clears the closing state. +// A delayed teardown must never kill a modal opened in the meantime. let modalTeardownTimer = null let modalSwapTimer = null -// A running sync must not be thrown away by a stray click: a locked modal -// ignores the backdrop and Escape, only its own buttons (or the end of the -// operation) close it. +// A locked modal ignores backdrop/Escape; only its own buttons close it. let modalLocked = false -// Resolver of the current promise-based modal (confirm/prompt). Escape, the -// backdrop or another modal opening must settle it, or the awaiting caller -// would never continue. +// Settles the promise-based modal (confirm/prompt) so callers never hang. let modalSettle = null function settleModal(value) { @@ -143,9 +133,7 @@ function showModal(html, opts) { const overlay = qs('#modal-overlay') const modal = qs('#modal') modal.classList.remove('closing') - // Opening over an already-visible modal (import mode -> prefix picker): - // morph instead of close-then-open - the box glides to the new height - // while old content dissolves into the new one. + // morph instead of close-then-open when a modal opens over a visible one const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim() if (swapping) swapModalContent(modal, html) else modal.innerHTML = html @@ -154,10 +142,8 @@ function showModal(html, opts) { return qs('#modal') } -// Animated content swap while the modal stays open. The new HTML is put in -// SYNCHRONOUSLY (callers wire their buttons right after showModal returns); -// an opaque snapshot of the old content is laid on top and fades out while -// the box animates to the new content's height. Same feel as switching tabs. +// Animated content swap: new HTML goes in synchronously, the old one fades +// out on top while the box glides to the new height. function swapModalContent(modal, html) { if (modalSwapTimer) { clearTimeout(modalSwapTimer) @@ -234,8 +220,7 @@ function confirmModal(message, okLabel) { }) } -// Shared "type a name" modal (prefix rename, wine display name): Enter -// submits, Cancel/Escape/backdrop resolve null. +// Shared "type a name" modal; Cancel/Escape/backdrop resolve null. function promptModal({ title, label, value, confirm, hint }) { return new Promise(resolve => { const modal = showModal(` @@ -419,10 +404,7 @@ function iconUrl(id) { return `plwl-icon://${encodeURIComponent(id)}/icon` } -// Paths of imported (copy/move) apps live inside FILES/ and are stored -// relative to it in the registry (portable mode) - show them prefixed with -// files/ instead of the absolute install location. External (leave) apps -// keep their absolute path. +// Portable apps show files/-relative paths; external apps keep absolute ones. function displayPath(p) { if (!p) return '' const files = window.plwl.FILES_DIR @@ -430,8 +412,7 @@ function displayPath(p) { return p } -// Inverse of displayPath for input fields: accept a files/-prefixed (or bare, -// FILES-relative) path and resolve it to an absolute one. +// Inverse of displayPath: files/-prefixed or bare path -> absolute. function absPathInput(v) { if (!v) return v const files = window.plwl.FILES_DIR @@ -441,9 +422,7 @@ function absPathInput(v) { return v } -// Prefix-imported apps: show paths the way wine sees them - relative to the -// prefix ("pfx/drive_c/..." or "drive_c/...") instead of the long -// files/wineprefixes//... form. +// Prefix-imported apps: show paths as wine sees them (prefix-relative). function displayGamePath(p, game) { if (game && game.importSource === 'prefix' && game.prefix) { const files = window.plwl.FILES_DIR @@ -453,9 +432,7 @@ function displayGamePath(p, game) { return displayPath(p) } -// Inverse of displayGamePath: accept a prefix-relative (drive_c/... or -// pfx/drive_c/...) path and resolve it under the game's prefix; anything -// else falls through to the normal FILES/absolute handling. +// Inverse of displayGamePath: prefix-relative path -> absolute under the prefix. function absGamePathInput(v, game) { if (game && game.importSource === 'prefix' && game.prefix && /^(pfx\/)?drive_c([\/]|$)/i.test(v || '')) { const files = window.plwl.FILES_DIR @@ -474,11 +451,8 @@ function fmtPlaytime(seconds) { } // --- reordering the library (drag & drop) ---------------------------------- -// The registry array IS the display order, so a drop reorders state.games and -// persists the whole id list; the main process appends any id the renderer did -// not know about, so a stale card list can never drop an entry. Cards are -// plain flex items, so the insertion point is the half of the hovered card the -// pointer is over. +// The registry array is the display order; a drop reorders state.games and +// persists the whole id list. let dragId = null function clearDropMarks(except) { @@ -550,8 +524,7 @@ function wireCardDrag(card, game) { } async function attachCardIcon(card, game) { - // placeholder first so every card has a stable icon slot; swapped for the - // real icon once it loads - games without one just keep the placeholder + // placeholder first, swapped for the real icon once it loads const ph = document.createElement('div') ph.className = 'game-icon placeholder' card.prepend(ph) @@ -708,9 +681,7 @@ async function startImportFlow() { }) } -// Import from a wine prefix, step 1: pick WHICH prefix - the app folder must -// live inside one so its paths can stay prefix-relative (wine then sees it as -// a regular C:\ install). +// Import step 1: pick the prefix the app folder lives in. async function startPrefixImportFlow() { try { state.prefixes = await window.plwl.prefixesList() @@ -763,8 +734,7 @@ async function doImport(srcDir, mode) { const bar = modal.querySelector('#import-progress') const pct = modal.querySelector('.progress-pct') const status = modal.querySelector('#import-status') - // byte totals drive the bar when the copy knows them; a rename or a - // symlink-only run sends plain path events and only bumps the count + // byte totals drive the bar when the copy knows them if (bar && data && data.totalBytes) { renderProgressBar(bar, data.bytes, data.totalBytes) if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' @@ -815,12 +785,9 @@ function isWindowsExecutable(p) { return /\.(exe|msi|bat|cmd|lnk)$/i.test(p || '') } -// The popup: wine version + prefix selects defaulting to the LAUNCHER's -// defaults (settings.defaultWine / prefixes.default), like the per-app -// config's "Default (...)" options - just without importing anything. +// "Open with" popup: wine + prefix selects defaulting to the launcher defaults. async function showOpenWithModal(openPath) { - // always fresh: wines/prefixes/default can change while the launcher sits - // in the tray + // always fresh: defaults can change while the launcher sits in the tray try { const [wines, prefixes, settings] = await Promise.all([ window.plwl.winesList(), @@ -892,21 +859,16 @@ async function handleOpenWith(openPath) { } function wireOpenWith() { - // Both delivery paths (live handoff and the boot queue) converge on - // openwithTake(): the main-process slot is drained exactly when a popup - // shows, so a reloaded window can never resurrect an old popup and two - // quick handoffs collapse into one (the newest file wins). + // both delivery paths converge on openwithTake(); newest file wins window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith)) // file queued before the window existed (boot handoff): pick it up once window.plwl.openwithTake().then(handleOpenWith) } -// Import from a prefix, step 2: register the app in place - nothing is -// copied, paths stay prefix-relative. +// Import step 2: register the app in place, nothing is copied. async function doPrefixImport(prefix, srcDir) { const name = srcDir.split('/').filter(Boolean).pop() - // nothing is copied here - the app is just registered in place, so there is - // no progress to show (no bar that would sit at zero) + // nothing copied: no progress to show showModal(`

${t('import.adding', name)}

${t('import.prefix.registering')}

@@ -934,9 +896,7 @@ async function doPrefixImport(prefix, srcDir) { } } -// Apps the launcher would name when it refuses a prefix rename/delete: a -// running app (blocking immediately) or a registered one (orphaned by a -// delete). Fetched fresh - the Prefixes view does not keep the library hot. +// Apps blocking a prefix rename/delete, fetched fresh for the warning. async function prefixUsers(name) { const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()]) const runningSet = new Set(running || []) @@ -1015,8 +975,7 @@ function renderPrefixes() { const delBtn = row.querySelector('[data-act=delete]') delBtn.innerHTML = `${ICONS.trash()}${t('prefixes.delete')}` delBtn.onclick = async () => { - // refused before the confirm prompt, with the reason: the folder is - // either held by a running app or still referenced by registered ones + // refused up front: a running app holds the folder, or apps reference it const users = await prefixUsers(p.name) if (users.all.length) return toast(t('prefixes.hasApps', p.name, users.all.map(g => g.name).join(', ')), 'error') const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete')) @@ -1175,8 +1134,7 @@ function renderPrefixOptions(selectId, selected) { { value: '', label: t('config.prefixDefault', defs.default) }, ...defs.prefixes.filter(p => p.name !== defs.default).map(p => ({ value: p.name, label: p.name })) ] - // prefix imports pin their prefix even when it IS the default (which the - // list above omits) - keep it visible instead of a blank select + // prefix imports pin their prefix even when it is the default if (selected && !opts.some(o => o.value === selected)) opts.push({ value: selected, label: selected }) if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, selected || '') else selects[selectId].update(opts, selected || '') @@ -1221,8 +1179,7 @@ async function openGameConfig(id) { qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n') renderWineOptions('gc-wine', game.wine) renderPrefixOptions('gc-prefix', game.prefix) - // apps imported from a prefix are pinned to it (their paths are prefix- - // relative) - the select is locked, not just corrected on save + // prefix-imported apps are pinned: the select is locked, not corrected on save const prefixLocked = game.importSource === 'prefix' selects['gc-prefix'].setDisabled(prefixLocked) const lockHint = qs('#gc-prefix-lock-hint') @@ -1243,8 +1200,7 @@ async function createAppShortcut() { } } -// Shows the newest run log in the file manager - it lives next to the app -// configuration, where the user is when something needs debugging. +// Shows the newest run log in the file manager. async function showAppLogs() { const game = state.currentGame if (!game) return @@ -1261,8 +1217,7 @@ function renderOverrides(overrides) { } } -// native,builtin first: that is the default a new override row starts on -// (load a native DLL when the game has one, fall back to Wine's builtin) +// native,builtin is the default a new override row starts on const OVERRIDE_OPTIONS = [ { value: 'native,builtin', label: 'native,builtin' }, { value: 'native', label: 'native' }, @@ -1280,8 +1235,7 @@ function buildOverrideRow(o) { ` row.querySelector('.dll').value = o.dll || '' const selectHost = document.createElement('span') - // a stored empty setting IS the "disabled" choice - a `||` fallback would - // turn every disabled override back into the default on reopen + // an empty setting IS the "disabled" choice - no || fallback here new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting == null ? 'native,builtin' : o.setting) row.insertBefore(selectHost, row.querySelector('[data-act=del]')) row.querySelector('[data-act=del]').onclick = () => row.remove() @@ -1294,9 +1248,7 @@ function addOverrideRow() { rows[rows.length - 1].querySelector('.dll').focus() } -// The setting a guide or a launch option uses ("n", "native,builtin", "d"...) -// mapped onto the values this select offers. Unknown text keeps the select's -// choice instead of inventing one. +// Guide-style settings ("n", "native,builtin", "d"...) mapped to select values. function normalizeOverrideSetting(raw) { const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '') if (!v || v === 'd' || v === 'disabled') return '' @@ -1311,8 +1263,7 @@ function readOverrides() { return qsa('#gc-overrides .override-row').map(row => { let dll = row.querySelector('.dll').value.trim() let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin' - // "dinput8=native" pasted into the name box is the form every guide uses - - // split it here instead of storing a name with '=' that Wine cannot parse + // split pasted "dinput8=native" into name + setting const eq = dll.indexOf('=') if (eq > 0) { const pasted = normalizeOverrideSetting(dll.slice(eq + 1)) @@ -1332,10 +1283,7 @@ async function saveGameConfig() { const idx = line.indexOf('=') if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim() }) - // prefix imports keep their paths relative to the prefix they were - // imported from - pointing them at another prefix would silently break - // exePath/workdir, so the original prefix stays locked. Other edits in - // the same save are kept; only the prefix snaps back (with a hint). + // prefix imports keep their prefix; only the prefix snaps back (with a hint) let prefixLockedHint = false let prefix = selects['gc-prefix'].value if (game.importSource === 'prefix') { @@ -1358,8 +1306,7 @@ async function saveGameConfig() { } await window.plwl.gamesUpdate(game.id, patch) state.games = await window.plwl.gamesList() - // show what was actually stored: a pasted "name=setting" was split and a - // .dll suffix stripped, so the rows would otherwise disagree with the file + // show what was actually stored (split/stripped) renderOverrides(patch.dllOverrides) toast(t('config.saved'), 'success') if (prefixLockedHint) toast(t('config.prefixLocked'), 'error') @@ -1404,15 +1351,11 @@ const LAN_CATEGORIES = [ { id: 'apps-prefix', label: 'lansync.appsPrefix' }, { id: 'wines', label: 'lansync.wines' }, { id: 'prefixes', label: 'lansync.prefixes' }, - // registry-level settings: default wine/prefix, custom names of wine builds - // and prefixes, and each app's configuration plus its library order. No - // files move with this category - it only patches what already exists. + // registry-level settings only; no files move with this category { id: 'settings', label: 'lansync.settings' } ] -// Byte sizes for the progress popups. Decimal units, the way a file manager -// or a download shows them, because that is what a "1.2 GB" on screen means -// to the user. +// Byte sizes in decimal units, as a file manager shows them. function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0) if (value < 1000) return `${Math.round(value)} B` @@ -1421,10 +1364,7 @@ function formatBytes(bytes) { return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB` } -// "Apps: 2/5 · Prefixes: 1/2 · Wine versions: 0/1" - how much of each category -// is done out of how much the sync carries, so the line says what is still -// coming instead of only listing what has already been walked over. A peer -// that does not announce its totals falls back to the plain count. +// "Apps: 2/5 · ..." per-category progress; no totals -> plain count. function lanStatsLine(items, itemsDone, totals) { const rows = [ ['apps', 'lansync.statsApps'], @@ -1443,11 +1383,7 @@ function lanStatsLine(items, itemsDone, totals) { 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. +// The bar follows bytes and the current phase (a phase starts empty). function renderProgressBar(bar, bytes, totalBytes) { const sent = Math.max(0, Number(bytes) || 0) const total = Math.max(0, Number(totalBytes) || 0) @@ -1462,12 +1398,8 @@ function barValues(info) { return [bytes, total] } -// One modal for operations with a measurable size (import, delete, long -// copies): the bar follows BYTES for the same reason the sync bar does, and a -// percent label sits next to it. Main-process work streams through the single -// import:progress slot (preload keeps only the newest listener), so the modal -// is the only thing showing progress at a time. `action` is the ipc call; -// the modal closes itself when it settles. +// Progress modal for byte-measured work; `action` is the ipc call, the modal +// closes itself when it settles. async function runWithProgress({ title, action }) { const modal = showModal(`

${title}

@@ -1479,8 +1411,7 @@ async function runWithProgress({ title, action }) { const status = modal.querySelector('#op-status') window.plwl.onImportProgress(data => { if (!data || !data.phase) return - // the counting pass reports its own phase with the totals; the bar starts - // moving once the real units arrive + // the counting pass reports its own phase with the totals if (data.phase === 'count') return renderProgressBar(bar, data.bytes, data.totalBytes) pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : '' @@ -1493,9 +1424,7 @@ async function runWithProgress({ title, action }) { } } -// "340 MB / 1.2 GB" once the total is known (the sending side learns it as it -// plans the units, the receiving side as the manifests arrive), otherwise -// just what has moved so far - so the line is never a lying zero. +// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes. function lanBytesLine(bytes, totalBytes) { const sent = formatBytes(bytes) if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : '' @@ -1544,8 +1473,7 @@ function renderLanDevices(devices) { } } -// Modal that collects the other device's sync code plus what to push. Shown -// again with a cleared code after a rejected one, so the user can retype it. +// Collects the other device's sync code plus what to push. async function openLanSyncModal(device, errorKey) { const modal = showModal(`

@@ -1629,10 +1557,7 @@ async function runLanSync(device, code, categories, force) { state.lanPushing = true window.plwl.onLanSyncProgress(data => { if (!active) return - // Every line keeps its value while the sync moves between phases: the - // file counter is CUMULATIVE across units (it never resets to 0/N with - // the next app) and phases that move no files simply do not touch it - - // only the byte bar follows the phase being worked on + // the file counter is cumulative across units; only the bar follows phases 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) @@ -1673,29 +1598,21 @@ async function runLanSync(device, code, categories, force) { // --- receiving a sync (the other device is the source of truth) ------------- -// A sync pushed onto THIS device must also be visible here - otherwise the -// library changes under the user's hands with no explanation. Same popup, but -// driven by the events the receiving server emits, and equally not closable -// by a click. -// The sending machine does its hashing before anything is written here, which -// can be minutes of complete silence - so the popup switches to "waiting" -// instead of closing, and only a really long silence drops it. Events after -// that reopen it (the sync is still running, the user just cannot see it). +// A sync pushed onto this device shows the same popup, driven by the +// receiving server's events. Hashing can be minutes of silence: the popup +// switches to "waiting" and events reopen it if the watchdog closed 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") +// phase wordings that differ on this side; the rest are neutral const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' } -// A device wants to push to us: the popup asks for consent before anything -// moves. One pending request at a time (the server enforces that too). +// Incoming push: consent popup before anything moves. let incomingRequest = null let incomingTimer = null function onLanIncoming(info) { if (!info || !info.requestId) return if (state.lanPushing || receiving) { - // our own transfer owns the modal slot - answer so the sender fails - // fast instead of waiting out its timeout + // our own transfer owns the modal slot: decline so the sender fails fast window.plwl.lansyncRespondIncoming(info.requestId, false) return } @@ -1719,8 +1636,7 @@ function onLanIncoming(info) { modal.querySelector('#ls-incoming-title').textContent = t('lansync.incomingTitle', name) modal.querySelector('#ls-incoming-text').textContent = t('lansync.incomingText', name) modal.querySelector('#ls-incoming-what').textContent = parts.length ? parts.join(' · ') : t('lansync.incomingNothing') - // the SENDER is the source of truth: the warning is phrased from this - // (receiving) side's point of view, not the send modal's mirrorWarning + // phrased from the receiving side's point of view modal.querySelector('#ls-incoming-warning').textContent = t('lansync.incomingMirror') modal.querySelector('#ls-incoming-decline').textContent = t('lansync.decline') modal.querySelector('#ls-incoming-accept').textContent = t('lansync.accept') @@ -1747,10 +1663,7 @@ function onLanIncoming(info) { } let receiving = false -// true only once the sender's 'done' event ended the transfer. A popup the -// safety watchdog closed while the sender was still hashing must be allowed to -// reopen when events resume - permanently closing it made a running sync -// invisible even though the transfer continued. +// Only the sender's 'done' ends this; a watchdog-closed popup may reopen. let receiveEnded = false let receiveWatchdog = null let receiveStall = null @@ -1791,10 +1704,7 @@ function endReceiving(message) { closeModal() } -// A sender that crashed halfway through must not leave this window locked: the -// popup closes itself if nothing at all arrives for a long time (the server -// drops the session after the same idle time). While it waits, the phase line -// says so rather than the popup disappearing mid-transfer. +// A crashed sender must not leave the window locked: close after a long silence. function armReceiveWatchdog() { if (receiveWatchdog) clearTimeout(receiveWatchdog) receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS) @@ -1812,10 +1722,7 @@ function onLanReceiving(info) { if (info.stage === 'start') receiveEnded = false if (info.stage === 'done') receiveEnded = true if (!receiving) { - // The popup also opens mid-sync: the sender may have been hashing for - // minutes when this window starts listening, and a popup that the safety - // timeout closed comes back for the rest of the transfer. Only a sync - // that already ended stays closed. + // reopen a watchdog-closed popup unless the sync already ended if (receiveEnded) return if (qs('#modal-overlay').style.display !== 'none') return receiving = true @@ -1840,17 +1747,12 @@ function onLanReceiving(info) { function renderReceiving(info) { const parts = receiveModalParts() if (!parts) return - // A liveness ping from the sender (it is busy hashing a unit): the watchdog - // was re-armed by the caller, but what is on screen stays as it is - the - // sync is not stalled, just quiet. The exception is the popup this very ping - // just reopened (the safety timeout had closed it): every line would sit - // blank, so the phase says what is actually happening. + // a ping keeps the screen as it is; only a reopened popup needs a phase line if (info.stage === 'wait') { if (!parts.phase.textContent) parts.phase.textContent = t('lansync.recvWaiting') return } - // The same phases read from this side's point of view: a file arriving here - // is being received, not sent. + // the same phases, worded for receiving const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim() renderProgressBar(parts.bar, ...barValues(info)) @@ -1859,8 +1761,7 @@ function renderReceiving(info) { parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals) parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes) if (info.stage === 'done') { - // let the totals be read, then get out of the way (the library reloads - // itself through lansync:applied) + // let the totals be read, then close (the library reloads itself) if (receiveCloseTimer) clearTimeout(receiveCloseTimer) receiveCloseTimer = setTimeout(() => { receiveCloseTimer = null @@ -1869,10 +1770,7 @@ function renderReceiving(info) { } } -// Data received from another device changed the local files: reload every -// list so the views show the merged library instead of stale state. Settings -// too - a sync with "Default settings" rewrote the default wine/prefix on -// disk, and the settings view would keep showing the values from before. +// A received sync changed local files: reload every list (settings included). async function reloadLocalData() { const [wines, prefixes, games, settings] = await Promise.all([ window.plwl.winesList(), @@ -1921,8 +1819,7 @@ function wireEvents() { }) qs('#btn-import-top').addEventListener('click', startImportFlow) qs('#btn-import-empty').addEventListener('click', startImportFlow) - // a drop on the empty area of the grid moves the card to the end; drops on a - // card are handled by the card itself (and stop there) + // a drop on the empty grid area moves the card to the end const gameList = qs('#game-list') gameList.addEventListener('dragover', e => { if (dragId) e.preventDefault() @@ -1982,10 +1879,7 @@ function wireEvents() { qs('#btn-gc-logs').addEventListener('click', showAppLogs) qs('#gc-override-add').addEventListener('click', addOverrideRow) qs('#gc-exe-browse').addEventListener('click', async () => { - // start from the field being edited (exe picker from the exe field) and - // resolve the SHOWN path (prefix apps display pfx/drive_c/...) to an - // absolute one - absPathInput alone would pass the prefix-relative form - // through and the picker would open at the process cwd ('/') + // resolve the SHOWN path to an absolute one, or the picker opens at cwd const cur = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame) const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR }) if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame) @@ -2001,14 +1895,12 @@ function wireEvents() { if (res.ok) refreshConfigIcon() } qs('#gc-icon-auto').addEventListener('click', async () => { - // extract from the exe the field shows, not the saved one - the user may - // have just pointed the app at another exe and expects its icon + // use the exe the field shows, not the saved one const exe = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame) applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id, exe)) }) qs('#gc-icon-browse').addEventListener('click', async () => { - // start in the app's own folder (prefix apps included) - that is where - // the exe with the embedded icon most likely lives + // start in the app's own folder, where the icon exe most likely lives const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame) const p = await window.plwl.dialogPickFile({ properties: ['openFile'], diff --git a/src/argv.js b/src/argv.js index 699f54c..ff4589f 100644 --- a/src/argv.js +++ b/src/argv.js @@ -3,35 +3,15 @@ const path = require('path') const fs = require('fs') -// Shared command-line parsing for the launcher process itself. Both boot -// (process.argv) and the single-instance handoff (second-instance argv) use -// it, so a shortcut, a "Open with PLWL" file-open and Chromium's own injected -// flags are handled the same way everywhere. -// -// Supported forms: -// --launch | --launch= auto-start that library app -// --file | --file= hand this file to the --launch app -// --open | --open= open an .exe with the wine picker -// a bare existing-file path same as --open (plumbing for Exec %f) -// -// Electron hands the second instance's argv over RE-SERIALIZED: every switch -// comes first and its value follows as a positional argument, e.g. a shortcut -// `--launch --file ` arrives as -// `--launch --file --allow-file-access-from-files `. Taking the -// token next to an option therefore picks the wrong value (it used to take -// the launch id as the opened file, so an app started without its image). -// Options and positionals are matched IN ORDER here instead. +// Command-line parsing shared by boot and the single-instance handoff. +// Supported: --launch , --file , --open , or a bare file path. +// Electron re-serializes handoff argv, so options/positionals match in order. const VALUE_OPTIONS = ['--launch', '--file', '--open'] -// The options that carry a value, the positional arguments that follow the -// switches, and the values written inline (`--name=value`, which keeps its -// value no matter how the rest is ordered). -// -// Tokens before the first switch are the executable - never values. In dev -// mode (`electron ...`) Electron re-serializes the app path ALONG WITH -// the values, so it is matched against the running app's own path and skipped: -// it would otherwise be paired with the first option and shift every value. +// Value options, the positionals that follow the switches, and inline +// `--name=value` pairs. Tokens before the first switch are the executable +// (matched against the running app path in dev mode and skipped). function splitArgv(argv) { const options = [] const positional = [] @@ -56,10 +36,8 @@ function splitArgv(argv) { return { options, positional, inline } } -// The value of an option: inline when it was written as `--name=value`, -// otherwise the positional argument with the SAME position as the option -// among our value options (Chromium pushes all switches before all values, so -// any other pairing would read another option's value). +// An option's value: inline `--name=value`, or the positional at the same +// in-order position among the value options. function optionValue(parsed, name) { if (parsed.inline.has(name)) return parsed.inline.get(name) const index = parsed.options.indexOf(name) @@ -68,16 +46,11 @@ function optionValue(parsed, name) { return value === undefined ? null : value } -// The file an app shortcut was asked to open: `--file ` when the -// generated shortcut passes it explicitly, or the path a file manager appends -// on its own to an Exec line without a file field (KDE does that). Only an -// absolute path counts in either place: the launch id, Chromium's injected -// flags and the launcher's own path can never be mistaken for the document. +// The file an app shortcut was asked to open; only an absolute path counts. const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/ function launchFileOf(parsed, launchId) { const explicit = optionValue(parsed, '--file') - // a desktop environment that does not expand field codes would hand over - // the literal token - never treat that as a file path + // a literal %f token is never a file path if (explicit && !FIELD_CODE_RE.test(explicit) && path.isAbsolute(explicit)) return explicit if (!launchId) return null for (let i = parsed.positional.length - 1; i >= 0; i--) { @@ -97,10 +70,7 @@ function parseArgv(argv) { const launchFile = launchFileOf(parsed, launchId) let openPath = optionValue(parsed, '--open') if (!openPath && !launchId) { - // "Open with" plumbing (Exec %f): the file path arrives as the only - // non-flag argument - never pick a bare token when a real flag carries - // the intent, and skip this process's own binary (electron dev mode - // passes it as the first argument). + // Exec %f: the file path arrives as the only non-flag argument const bare = args.find( a => !a.startsWith('-') && diff --git a/src/fsutil.js b/src/fsutil.js index 69b8e76..12db874 100644 --- a/src/fsutil.js +++ b/src/fsutil.js @@ -23,11 +23,7 @@ function dedupeDir(base, name) { return path.join(base, `${name} (${n})`) } -// Progress is cheap to produce and expensive to render: hashing or copying a -// tree 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; `force` lets a caller push the value that matters -// (the end of a unit) through immediately. +// Events are rate limited to ~10/s; `force` pushes the final value through. const PROGRESS_MIN_MS = 100 function progressGate(emit) { @@ -60,9 +56,7 @@ function progressGate(emit) { return push } -// Run `fn` over `items` with at most `limit` calls in flight, results in -// order. Walking a tree is dominated by stat/readlink/open syscalls: a few in -// flight keep the disk and cpu busy without exhausting file descriptors. +// Run `fn` over `items` with at most `limit` in flight, results in order. async function mapLimit(items, limit, fn) { const out = new Array(items.length) let next = 0 @@ -76,15 +70,12 @@ async function mapLimit(items, limit, fn) { return out } -// What a tree copy will have to move. Symlinks have no size of their own -// (they are recreated, not read), so they only count for the file total. -// `skipDir` is a single directory name to leave out (a game folder's -// redistributables) - walking the whole tree first is fast next to the copy. +// What a tree copy will move. Symlinks are recreated, not read. `skipDir` +// leaves out one directory name (redistributables). async function treeSize(root) { let files = 0 let bytes = 0 - // a symlink (or a plain file) as the root is the whole tree: walking it - // would measure whatever it points at, not what is about to be removed + // a symlinked root is the whole tree; never measure through it let rootStat = null try { rootStat = await fsp.lstat(root) @@ -117,11 +108,8 @@ async function treeSize(root) { return { files, bytes } } -// Copy a directory tree the way an imported game/wine build has to arrive: -// symlinks recreated as they are (copyFile would silently drop them) and file -// permissions carried over (copyFile writes 0644, killing scripts and any -// binary the game executes itself). Reports cumulative { done, bytes } after -// every file through `onProgress`, so long copies can drive a progress bar. +// Copy a tree with symlinks recreated and permissions carried (copyFile +// drops both); reports cumulative { done, bytes } through onProgress. async function copyTree(src, dst, onProgress) { let done = 0 let bytes = 0 @@ -173,10 +161,8 @@ async function copyTree(src, dst, onProgress) { return { done, bytes } } -// Delete a tree deepest-first so a long removal (a multi-GB prefix) can drive -// a progress bar instead of freezing the window on one rmSync. Symlinks are -// unlinked, never followed. Reports cumulative { files, bytes, dirs } through -// `onProgress`; a missing tree is simply done. +// Delete deepest-first with progress through onProgress; symlinks are +// unlinked, never followed. async function removeTree(root, onProgress) { let files = 0 let dirs = 0 @@ -185,9 +171,7 @@ async function removeTree(root, onProgress) { const tick = () => { if (gate) gate({ files, dirs, bytes }, false) } - // A symlink (or a file) as the root is removed as itself, never walked - // through: the root of an import/prefix can be a link to another disk, and - // deleting through it would take the target's files instead of the link. + // a symlinked root is removed as itself, never walked through let rootStat = null try { rootStat = await fsp.lstat(root) diff --git a/src/hashcache.js b/src/hashcache.js index 6adbf72..9e8524f 100644 --- a/src/hashcache.js +++ b/src/hashcache.js @@ -1,19 +1,14 @@ 'use strict' -// Content hashing for the LAN sync, plus the per-file digest cache. -// -// Syncing a library means hashing tens of thousands of files, so the digest of -// every file is remembered on disk (files/data/lansync-hashes.json), keyed by -// its FILES-relative path with the size and mtime it was computed from: a file -// whose size and mtime are unchanged is never read again, only stat'ed. The +// Content hashing for the LAN sync, plus the per-file digest cache +// (files/data/lansync-hashes.json, keyed by path with size + mtime). The // cache is machine-local and never travels in a sync. const crypto = require('crypto') const fs = require('fs') const path = require('path') -// BLAKE2b resists collisions like SHA-2 but is noticeably faster. Older -// runtimes fall back to SHA-2. +// BLAKE2b is faster than SHA-2; older runtimes fall back to SHA-2. const HASH_ALGO = (() => { for (const algo of ['blake2b512', 'sha512', 'sha256']) { try { @@ -24,9 +19,7 @@ const HASH_ALGO = (() => { return 'sha256' })() -// Streaming through one callback instead of accumulating 'data' chunks keeps -// memory flat for huge files, and a small highWaterMark gives the read pump -// real backpressure. Digest speed does not care about the chunk size. +// Streamed hashing keeps memory flat on huge files. function hashFile(file, onBytes) { return new Promise((resolve, reject) => { const hash = crypto.createHash(HASH_ALGO) @@ -39,11 +32,9 @@ function hashFile(file, onBytes) { }) } -// A quarter million entries is a whole game library; beyond that, entries the -// longest time untouched are dropped first. +// Beyond a quarter million entries, the least recently touched are dropped. const HASH_CACHE_MAX = 250000 -// Mid-sync saves are throttled to this (the file can hold tens of megabytes); -// callers that must not lose entries (sync end, shutdown) pass force=true. +// Mid-sync saves are throttled; callers that must not lose entries pass force=true. const SAVE_MIN_MS = 60000 function createHashCache(dataDir) { @@ -64,8 +55,7 @@ function createHashCache(dataDir) { return cache } - // Compact JSON (the entries are arrays) keeps a full cache at tens of - // milliseconds instead of the seconds pretty-printed JSON needs. + // Compact JSON: a full cache writes in tens of ms. function save(force) { if (!dirty || !cache) return if (!force && Date.now() - savedAt < SAVE_MIN_MS) return @@ -86,10 +76,8 @@ function createHashCache(dataDir) { } catch {} } - // The remembered digest of a file, or null when it has to be read again. - // A hit only marks the cache dirty when its timestamp is actually stale: - // marking it on every lookup would force a full rewrite of the file per - // sync even though nothing a second sync could reuse has changed. + // The remembered digest, or null when it has to be read again. A hit marks + // the cache dirty only when the timestamp is stale. function hit(rel, size, mtimeMs) { const known = load()[rel] if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') { @@ -109,9 +97,7 @@ function createHashCache(dataDir) { dirty = true } - // Digest 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. Otherwise the file is read (progress through onBytes) and stored. + // Unchanged size + mtime -> cached digest; otherwise read and store. async function cachedHash(rel, abs, size, mtimeMs, onBytes) { const known = hit(rel, size, mtimeMs) if (known) return known @@ -120,8 +106,7 @@ function createHashCache(dataDir) { return hash } - // The user can drop the cache from the settings view; a corrupted or stale - // file is also replaced wholesale this way. + // Settings view "clear cache" and stale files go through here. function clear() { cache = null dirty = false @@ -134,8 +119,7 @@ function createHashCache(dataDir) { } catch {} } - // 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. + // Dropping the memory copy makes the next lookup re-read from disk (tests). function forget() { cache = null dirty = false diff --git a/src/icons.js b/src/icons.js index 94a7265..b89b761 100644 --- a/src/icons.js +++ b/src/icons.js @@ -120,17 +120,13 @@ function pngSize(img) { return null } -// Raw RT_ICON payloads are BITMAPINFOHEADER-based bitmaps: biSize, biWidth, -// biHeight (2x the visible height - XOR + AND mask), biPlanes, biBitCount, -// ..., biClrUsed. Read the real dimensions from there instead of treating the -// first bytes as an ICONDIRENTRY (which yielded garbage like h=0, planes=48 -// and made Chromium decode an empty image). +// RT_ICON payloads are BITMAPINFOHEADER bitmaps; read real dimensions from +// there (ICONDIRENTRY-style first bytes yield garbage). function bmpDims(img) { if (img.length < 12) return null const biSize = img.readUInt32LE(0) if (biSize === 12) { - // BITMAPCOREHEADER (Win 2.x/3.x era icons): DWORD size, then WORD width, - // height, planes, bitcount + // BITMAPCOREHEADER (old icons): WORD width/height/planes/bitcount const w = img.readUInt16LE(4) const h = Math.floor(img.readUInt16LE(6) / 2) const planes = img.readUInt16LE(8) || 1 diff --git a/src/importer.js b/src/importer.js index 5a19765..22a4db7 100644 --- a/src/importer.js +++ b/src/importer.js @@ -13,9 +13,8 @@ const { copyTree, treeSize } = require('./fsutil') // Directories that typically hold installers/redistributables, not the game. const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc']) -// The copy itself lives in fsutil (copyTree): symlinks recreated, permissions -// carried over, byte-level progress. moveFolder only falls back to it across -// filesystems; a same-device rename reports nothing (nothing is copied). +// The copy itself lives in fsutil.copyTree; moveFolder falls back to it +// across filesystems only. function moveFolder(src, dst, onProgress) { const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true })) if (path.parse(src).root !== path.parse(dst).root) return fallback() @@ -23,16 +22,13 @@ function moveFolder(src, dst, onProgress) { } async function importGame(srcDir, mode, onProgress) { - // a prefix (or its drive_c / pfx) is not an app folder: copy/move would take - // the prefix's C: drive with it and leave the prefix gutted + // a prefix (or its drive_c/pfx) is not an app folder if (isPrefixRoot(srcDir)) return { ok: false, error: 'prefix-root' } const name = path.basename(srcDir) - // Validate before touching the user's files: refuse an import without any - // .exe (and don't half-move a folder we'd then have to roll back). + // validate before touching anything: no .exe -> refuse const preExe = await findDefaultExe(srcDir) if (!preExe) return { ok: false, error: 'no-exe-found' } - // The id doubles as the imported folder name, so generate it first and - // create files/apps/ - no dedupe suffixes, ids can never collide. + // the id doubles as the imported folder name const id = newGameId() let workdir = srcDir if (mode === 'copy' || mode === 'move') { @@ -47,9 +43,7 @@ async function importGame(srcDir, mode, onProgress) { await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) } 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 + // in-data link targets become relative, so the import survives a move try { await rewireSymlinks(dst, [DIRS.FILES]) } catch {} @@ -60,8 +54,7 @@ async function importGame(srcDir, mode, onProgress) { } const game = newGame({ id, name, exePath, workdir, mode }) game.imported = mode === 'copy' || mode === 'move' - // the exe was picked automatically -> grab its icon right away; a missing - // icon is not an import failure (game can still get one from config) + // auto-picked exe -> grab its icon; a missing icon is not a failure const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico') if (extractIconFromExe(resolvePortablePath(exePath), icon).ok) { game.iconSource = icon @@ -69,22 +62,14 @@ async function importGame(srcDir, mode, onProgress) { return addGame(game) } -// Import from a wine prefix: the folder is picked under the prefix's -// drive_c and left in place - the game paths are stored relative to the -// prefix, so wine sees the app exactly as if it had been installed -// normally into C:\... +// Import from a prefix: the folder stays in place, paths stored prefix-relative. async function importGameFromPrefix(prefix, srcDir, onProgress) { if (!listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' } - // The folder must live inside one of the prefix's C: drives so the C:\ - // mapping holds - drive_c sits at the prefix top for plain wine, under pfx/ - // for Proton-managed prefixes, and a prefix used with BOTH kinds has both. - // Every existing drive_c counts; the stored path keeps its drive_c segment - // either way, so it resolves in the layout the app will run in. + // the folder must live inside one of the prefix's C: drives const driveRoots = prefixDriveRoots(prefix) if (!driveRoots.length) return { ok: false, error: 'no-drive-c' } const src = path.resolve(String(srcDir)) - // realpath on both sides: a unified prefix reaches drive_c through the pfx - // symlink too, and that path is the same folder (see ensureUnifiedPrefix) + // realpath both sides: a unified prefix reaches drive_c through pfx too let realSrc try { realSrc = fs.realpathSync(src) @@ -108,8 +93,7 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) { const game = newGame({ id, name: path.basename(src), exePath: preExe, workdir: src, mode: 'prefix' }) game.importSource = 'prefix' game.prefix = prefix - // exe picked automatically -> grab its icon right away; a missing icon is - // not an import failure (game can still get one from config) + // auto-picked exe -> grab its icon; a missing icon is not a failure const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico') if (extractIconFromExe(resolvePortablePath(preExe), icon).ok) { game.iconSource = icon @@ -117,9 +101,7 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) { return addGame(game) } -// Largest .exe in the tree, skipping redistributable folders. Files are -// stat'ed in parallel and folders walked breadth-first so big games don't -// turn the picker into a sequential crawl. +// Largest .exe in the tree, skipping redistributable folders. async function findDefaultExe(dir) { let best = '' let bestSize = -1 diff --git a/src/ipc/games.js b/src/ipc/games.js index 29e44fc..1aba314 100644 --- a/src/ipc/games.js +++ b/src/ipc/games.js @@ -17,15 +17,9 @@ function sendToWindow(win, channel, data) { if (win && !win.isDestroyed()) win.webContents.send(channel, data) } -// Game-process state: id -> { pid (null while the process is starting), the -// prefix name and wine id the run was resolved to }. Both are frozen here: -// the default prefix/wine can be switched and the app's own config edited -// while it plays, and the in-use guards have to name what the process really -// locked - not what the registry says now. Editing an app's own prefix while -// it runs does not move the process, so the new folder is blocked next to the -// snapshot (a pre-edit prefix does not exist in it yet). -// onGamesChanged fires in the MAIN process whenever the running-games set -// changes (the tray menu rebuilds itself from it). +// Game-process state: id -> { pid, resolved prefix name and wine id }, frozen +// at launch (the in-use guards must name what the process really locked). +// onGamesChanged fires in the main process when the running set changes. const STARTUP_GRACE_MS = 60000 function createGamesService({ getWindow, onGamesChanged }) { @@ -47,11 +41,7 @@ function createGamesService({ getWindow, onGamesChanged }) { } } - // 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". + // a stale entry (no pid yet / dead pid) must not answer "already running" function prune() { for (const [id, entry] of [...running]) { if (alive(entry.pid)) continue @@ -70,11 +60,7 @@ function createGamesService({ getWindow, onGamesChanged }) { prune() return [...running.keys()] } - // What the still-running games actually use, for the "is this prefix/wine in - // use?" guards: [{ id, name, prefixes: [snapshot, ...], wine }]. The - // snapshot alone goes stale when a running app is pointed at another prefix - // (the process keeps its folder open); the live name alone misses the folder - // it currently holds. Together they name every folder the run touches. + // snapshot + live name together name every folder a run touches const runningInfo = () => { prune() const out = [] @@ -87,9 +73,7 @@ function createGamesService({ getWindow, onGamesChanged }) { return out } - // "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 - // not up yet, the renderer picks the file up right after boot. + // "Open with" handoff: queue the file for the renderer. function openExternally(openPath) { require('../openwith').setPending(openPath) sendToWindow(getWindow(), 'openwith:file', openPath) @@ -118,10 +102,7 @@ function createGamesService({ getWindow, onGamesChanged }) { return { ok: false, error: 'wine-not-found' } } if (running.has(id)) { - // Opening a document in an app that is already running is a HANDOFF, - // exactly like Windows: a second process receives the file and the app's - // own single-instance logic takes it over. It is not a tracked run - - // playtime, the tray and the kill switch belong to the first process. + // a document into a running app is a handoff, not a tracked run if (!openFile) return { ok: false, error: 'already-running' } try { await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true }) @@ -134,8 +115,7 @@ function createGamesService({ getWindow, onGamesChanged }) { startedAt.set(id, Date.now()) try { const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id), openFile }) - // stop was requested while the process was still starting: kill it and - // leave nothing in the running set + // stop requested while still starting: kill and clear if (killRequests.has(id)) { killRequests.delete(id) if (res.pid) killProcessGroup(res.pid) @@ -145,8 +125,7 @@ function createGamesService({ getWindow, onGamesChanged }) { 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" + // no pid: never keep the entry, it would block the next launch if (!res.pid) { running.delete(id) startedAt.delete(id) @@ -164,8 +143,7 @@ function createGamesService({ getWindow, onGamesChanged }) { } } - // 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. + // Stop always clears the state. async function kill(id) { const entry = running.get(id) if (entry) killProcessGroup(entry.pid) @@ -194,8 +172,7 @@ function createGamesService({ getWindow, onGamesChanged }) { } } - // subscribe to game exits (used by the tray/headless mode to e.g. quit when - // the game that auto-started the launcher has finished) + // subscribe to game exits (tray/headless auto-quit) function setOnGameExit(fn) { onGameExit = typeof fn === 'function' ? fn : null } @@ -204,8 +181,7 @@ function createGamesService({ getWindow, onGamesChanged }) { return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data)) } - // prefix import: nothing is copied, the game just gets registered with - // paths relative to the prefix (wine sees it as a normal C:\ install) + // prefix import: register in place, nothing copied async function importFromPrefix(win, prefix, srcDir) { return importGameFromPrefix(prefix, srcDir) } diff --git a/src/ipc/index.js b/src/ipc/index.js index e91b38e..c0ab20e 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -25,9 +25,7 @@ function errorResult(err) { const iconDir = id => path.join(DIRS.DATA, 'icons', id) -// `keep` spares one file (the icon just written): the icon protocol picks the -// LAST icon.* alphabetically, so a leftover icon.png from an earlier pick -// would otherwise keep showing the old icon. +// `keep` spares the icon just written: the protocol picks the LAST icon.*. function clearIconDir(id, keep) { try { for (const f of fs.readdirSync(iconDir(id))) { @@ -77,8 +75,7 @@ function registerIpc({ getWindow, onGamesChanged }) { ipcMain.handle('wines:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name)) ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id)) ipcMain.handle('wines:delete', async (e, id) => { - // the build's files back the running prefix (dlls, stub exes) - deleting - // it under a running game breaks that game + // the build's files back a running prefix; deleting it breaks that game if (library.isWineInUse(id, games.runningInfo())) { const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name) return { ok: false, error: 'inUse', apps } @@ -94,10 +91,7 @@ function registerIpc({ getWindow, onGamesChanged }) { }) // prefixes --------------------------------------------------------------------- - // A prefix folder must not move or disappear while a game runs in it: wine - // keeps the registry and user files open and would write into a folder that - // no longer exists (or, for a delete, lose them mid-run). The renderer shows - // these as a toast naming the blocking app. + // A prefix must not move/disappear while a game runs in it. const prefixInUse = name => { const apps = library.appsUsingPrefix(name, games.runningInfo()) return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null @@ -107,17 +101,14 @@ function registerIpc({ getWindow, onGamesChanged }) { ipcMain.handle('prefixes:delete', async (e, name) => { const busy = prefixInUse(name) if (busy) return busy - // deleting a prefix that still has apps registered in it would orphan - // them (they fall back to the default prefix and lose their files) + // deleting a prefix with registered apps would orphan them const users = library.gamesUsingPrefix(name).map(g => g.name) if (users.length) return { ok: false, error: 'hasApps', apps: users } return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data)) }) ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName)) ipcMain.handle('prefixes:setDefault', (e, name) => { - // apps without a prefix field of their own resolve the default at launch; - // switching it now would silently move their next run (a prefix import is - // pinned to its own name) + // switching the default would silently move apps that follow it const defs = prefixes.listPrefixes() if (name !== defs.default) { const affected = library.gamesFollowingDefault(games.runningInfo()) @@ -129,8 +120,7 @@ function registerIpc({ getWindow, onGamesChanged }) { // games: library ----------------------------------------------------------------- ipcMain.handle('games:list', () => library.loadGames()) ipcMain.handle('games:get', (e, id) => library.getGame(id)) - // prefix pin is enforced inside library.updateGame (prefix/importSource - // fields are ignored for prefix imports), so nothing extra needed here + // the prefix pin is enforced inside library.updateGame ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch)) ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids)) ipcMain.handle('games:remove', (e, id) => { @@ -187,8 +177,7 @@ function registerIpc({ getWindow, onGamesChanged }) { if (!game || !game.exePath) return { ok: false, error: 'bad-request' } return extractGameIcon(id, game.exePath) }) - // exePath comes from the config field, which may hold a path the user just - // picked but has not saved yet - the icon has to come from THAT exe + // use the exe the field holds (maybe picked but not saved yet) ipcMain.handle('games:autoIcon', (e, id, exePath) => { const game = library.getGame(id) if (!game) return { ok: false, error: 'bad-request' } @@ -198,9 +187,7 @@ function registerIpc({ getWindow, onGamesChanged }) { }) // "Open with PLWL" ------------------------------------------------------------------ - // The renderer asks for the file handed in via `--open` (Exec %f), then - // launches it with the wine/prefix picked in the popup. Nothing is imported - // into the library. + // `--open` (Exec %f) hands the file over; nothing is imported. ipcMain.handle('openwith:take', async () => { const p = openwith.getPending() if (!p) return null @@ -246,14 +233,9 @@ function registerIpc({ getWindow, onGamesChanged }) { return errorResult(err) } }) - // import from an existing wine prefix: pick the prefix first, then the app - // folder inside it (drive_c) - paths are stored prefix-relative so the app - // shows up for wine as a regular C:\ install + // import from an existing prefix: paths are stored prefix-relative ipcMain.handle('games:pickPrefixFolder', async (e, prefix) => { - // open the picker straight in the prefix's C: drive (plain wine: top - // level, Proton: under pfx/); a prefix used with BOTH kinds has two of - // them, so it opens at the prefix folder - the only spot that keeps - // drive_c and pfx/drive_c one click away + // open the picker in the prefix's C: drive (mixed layouts: prefix folder) const driveRoots = prefixes.prefixDriveRoots(prefix) const start = driveRoots.length === 1 ? driveRoots[0] : prefixes.prefixPath(prefix) const res = await dialog.showOpenDialog(getWindow(), { @@ -277,10 +259,7 @@ function registerIpc({ getWindow, onGamesChanged }) { // dialogs & shell --------------------------------------------------------------------------- ipcMain.handle('dialog:pickFile', async (e, opts) => { const o = { ...(opts || {}) } - // Electron resolves a RELATIVE defaultPath against the process cwd (the - // install root in packaged mode), which lands the picker somewhere - // random like '/'. Renderer paths are FILES-relative by convention, so - // anchor them there instead. + // anchor FILES-relative paths, or Electron resolves them against cwd if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) { o.defaultPath = path.join(DIRS.FILES, o.defaultPath) } @@ -306,9 +285,7 @@ function registerIpc({ getWindow, onGamesChanged }) { if (pick) file = path.join(iconDir(id), pick) } catch {} if (!file) return new Response('', { status: 404 }) - // a new icon lands at the SAME url, so the browser would keep its cached - // copy and the library card would only catch up after a restart - never - // let the icon request be cached + // the icon url never changes: send no-cache headers const res = await net.fetch('file://' + file) return new Response(await res.arrayBuffer(), { headers: { diff --git a/src/lansync-discovery.js b/src/lansync-discovery.js index 3aa6f65..bd045fe 100644 --- a/src/lansync-discovery.js +++ b/src/lansync-discovery.js @@ -1,9 +1,7 @@ 'use strict' -// UDP discovery for the LAN sync: every running PLWL beacons on the local -// network and listens for the same beacon, so the devices list fills itself. -// The beacon doubles as a liveness signal - a device drops off the list a few -// seconds after its PLWL is closed. +// UDP discovery for the LAN sync: beacons fill the devices list and double +// as liveness (a device drops off a few seconds after close). const dgram = require('dgram') const os = require('os') diff --git a/src/lansync-identity.js b/src/lansync-identity.js index f9e252c..5bedc3f 100644 --- a/src/lansync-identity.js +++ b/src/lansync-identity.js @@ -1,15 +1,12 @@ 'use strict' -// Device identity for the LAN sync: the id this install is known by, the code -// other devices must type to pair with it, and the list of paired devices. -// Exported as a factory over the config file, so every launcher state (and -// every test device) gets its own identity. +// Device identity for the LAN sync: id, pairing code, paired devices. Factory +// over the config file, one per launcher state. const crypto = require('crypto') const { readJson, writeJson } = require('./paths') -// The sync code is read out on one machine and typed on the other, so the -// alphabet skips the characters people mix up (0/O, 1/I/L). +// The code is read out and typed; the alphabet skips 0/O, 1/I/L. const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' @@ -24,9 +21,7 @@ function randomChars(alphabet, count) { return out } -// 24 characters: the current timestamp in base36 (unique per millisecond, -// 8 chars until 2089) plus random padding, so two machines generating an id -// within the same millisecond still get different ids. +// base36 timestamp + random padding: unique across machines. function makeDeviceId() { const stamp = Date.now().toString(36) return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24) @@ -37,11 +32,7 @@ function makeSyncCode() { } function createIdentity(configFile) { - // The identity file is read once and then kept in memory: the beacon calls - // identity() every couple of seconds (and every pairing call in between), - // and a synchronous read per call is pure overhead. Nothing else writes - // this file (the ipc handlers all go through these functions), so the cache - // cannot go stale. + // Read once, kept in memory: the beacon calls identity() constantly. let cached = null function readConfig() { @@ -51,9 +42,7 @@ function createIdentity(configFile) { return cached } - // The id and the code are generated on FIRST use and then kept forever: - // other devices remember the id (they pair with it) and the code (so it - // only has to be typed once). + // Id and code are generated on first use and kept forever. function identity() { const cfg = readConfig() let changed = false @@ -73,8 +62,7 @@ function createIdentity(configFile) { return cfg } - // Both rotations return the whole identity ({ deviceId, syncCode, known }), - // so callers get a consistent view of this device after the change. + // Rotations return the whole identity. function rotateDeviceId() { const cfg = identity() const previous = cfg.deviceId @@ -93,9 +81,7 @@ function createIdentity(configFile) { return cfg } - // A device the user synced with successfully: its code is stored next to - // its id, so the next sync needs no typing. A rejected code is forgotten - // again, which is what drives the "type the code again" flow. + // A paired device remembers its code; a rejected code is forgotten. function knownDevice(id) { return identity().known[id] || null } @@ -122,8 +108,7 @@ function createIdentity(configFile) { return { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode } } -// Compare two typed codes without leaking timing: the codes are short, but a -// device may only verify once per two seconds anyway. +// Constant-time code comparison. function sameCode(a, b) { const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase()) const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase()) diff --git a/src/lansync-manifest.js b/src/lansync-manifest.js index b3864c5..78b5b60 100644 --- a/src/lansync-manifest.js +++ b/src/lansync-manifest.js @@ -1,23 +1,15 @@ 'use strict' -// Sender-side hashing: walking sync units into a manifest of dirs, links and -// files, with the digest of every file. Exported as a factory over the path -// guards and the hash store, so every launcher state (and every test device) -// builds manifests for its own files. +// Sender-side hashing: walk sync units into a manifest of dirs, links, files. +// Factory over the path guards and hash store, one per launcher state. const path = require('path') const fs = require('fs') const fsp = fs.promises function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) { - // 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. + // Targets inside the launcher data travel relative (the mirrored layout is + // the same on both machines); outside targets 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) @@ -28,16 +20,8 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren 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. - // - // Iterative (a queue instead of an await-per-directory recursion) and with - // the stat/readlink calls of one directory in flight together: walking a - // prefix is dominated by those two syscalls, and serializing them costs more - // than the readdir itself. Regular files are re-verified with one stat - // (size, mode, mtime feed both the manifest and the hash cache), everything - // else is skipped. + // Walk without following symlinks (dosdevices etc. are part of the sync). + // Iterative, with one directory's stat/readlink calls in flight together. async function scanTree(abs, rel, out, mtimes) { const queue = [[abs, rel]] while (queue.length) { @@ -77,18 +61,16 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren return } if (!st.isFile()) return - // mode travels with the file: a wine build's `proton`/`wine` binary is - // useless on the other side without its executable bit + // mode travels with the file 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 + // mtimes are machine-local: kept out of the manifest if (mtimes) mtimes.set(relChild, st.mtimeMs || 0) }) queue.push(...dirs) } } - // Manifest of one sync unit. `force` skips hashing entirely - the receiver - // then treats every file as different ("force sync all files"). + // Manifest of one unit; `force` skips hashing (receiver replaces everything). async function buildManifest(roots, force, onProgress) { const entries = [] const mtimes = new Map() @@ -116,8 +98,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren } } 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 + // total size is known before hashing, so the bar starts empty const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) if (!force && files.length) { let done = 0 @@ -125,8 +106,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren await mapLimit(files, concurrency, async entry => { const abs = filesPath(entry.path) try { - // identical size and mtime means identical bytes, so the digest from - // an earlier sync still describes this file + // unchanged size + mtime: the cached digest still holds const mtimeMs = mtimes.get(entry.path) || 0 entry.hash = abs ? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => { diff --git a/src/lansync.js b/src/lansync.js index b36535f..fecc632 100644 --- a/src/lansync.js +++ b/src/lansync.js @@ -1,10 +1,7 @@ 'use strict' -// LAN syncing. Every running PLWL instance beacons on the local network and -// runs a small HTTP server other instances can push to. Whoever starts a sync -// is the source of truth: the receiver ends up with exactly the sender's -// files (extra files are deleted) so a half copied library is never left -// behind, and paths stored in the registries stay relative as they are. +// LAN sync: instances beacon and push to each other over HTTP. The sender's +// tree wins; extra receiver files are deleted. const http = require('http') const crypto = require('crypto') @@ -28,43 +25,30 @@ const { isPrefixRel } = prefixes const CONFIG = path.join(DIRS.DATA, 'lansync.json') -// The beacon doubles as a liveness signal: a device drops off the list a few -// seconds after its PLWL is closed. +// The beacon doubles as liveness: a device drops off a few seconds after close. const DISCOVERY_PORT = Number(process.env.PLWL_LANSYNC_PORT) || 47821 const BEACON_TAG = 'plwl-lansync' const PROTOCOL = 1 -// A device is only visible while it beacons; 2.5 s against a 6 s TTL leaves -// one missed datagram of headroom (the old 2 s/7 s pair woke the process just -// as often but dropped peers on the first lost packet). +// 2.5 s beacon against 6 s TTL: one lost datagram still fits. const BEACON_MS = 2500 const DEVICE_TTL_MS = 6000 -// Every verification costs this much and a device may only have one in -// flight - together they make guessing an 8 character code over the LAN -// pointless (and a failed guess cannot be retried in a tight loop). +// Cost + one-in-flight limit make guessing the 8 character code pointless. const VERIFY_DELAY_MS = 2000 -// How long a session token stays valid without any request. Env override for -// tests, which cannot wait half an hour to prove expiry. +// Session token TTL; env override for tests. const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 1000 const SESSION_IDLE_MS = 5 * 60 * 1000 -// How long the receiving user has to click Accept before the request expires: -// the sender waits for this decision, so its own verify timeout has to be -// longer than this. +// Receiver consent window; the sender's verify timeout must exceed it. const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) || 60 * 1000 -// The only trees a sync may touch. Everything else under files/ is local -// state (settings, translations, playtime, logs) that must never move -// between machines. +// The only trees a sync may touch; the rest of files/ is local state. 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. +// Sync categories plus lenient aliases for older senders. const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings'] const CATEGORY_ALIASES = { - // plain "apps" means the whole app library: portable ones AND the ones - // imported from a prefix (only the launcher entries, their files stay in - // the prefix) + // the whole app library: portable + prefix-import launcher entries apps: ['apps-portable', 'apps-prefix'], app: ['apps-portable', 'apps-prefix'], appsportable: ['apps-portable'], @@ -80,10 +64,7 @@ const CATEGORY_ALIASES = { wineprefixes: ['prefixes'], prefixes: ['prefixes'], prefix: ['prefixes'], - // the launcher's own settings ("Sync settings"): the defaults, the names and - // disabled flags of wine builds, the prefix registry, and every app's - // configuration plus its place in the library. "defaults" is what this - // category used to be called - an older sender still names it that way. + // launcher settings: defaults, wine labels, prefix registry, app configs settings: ['settings'], syncsettings: ['settings'], defaults: ['settings'], @@ -91,9 +72,7 @@ const CATEGORY_ALIASES = { defaultsettings: ['settings'] } -// Which sync category an app entry belongs to. Portable copy/move imports own -// their folder in files/apps; prefix imports only have a launcher entry (their -// files stay inside the prefix). +// An app entry's category: portable imports own their apps/ folder. function appCategory(game) { if (!game || typeof game !== 'object') return null if (game.importSource === 'prefix' || game.mode === 'prefix') return 'apps-prefix' @@ -101,16 +80,11 @@ function appCategory(game) { return null } -// Where an app entry's files live, read off its stored path: a portable -// import owns a folder under apps/ (FILES-relative), a prefix import points -// inside its prefix ("drive_c/..." for plain wine, "pfx/drive_c/..." for -// Proton-managed prefixes) and anything absolute or ".." points outside the -// launcher data, so it is never part of a sync. +// An app entry's files, read off its stored path; outside the data = not synced. function classifyApp(app) { if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir - // nothing that points anywhere inside the launcher data: the app is left - // where it is (an external entry), nothing of it can travel in a sync + // external entry: nothing to sync if (!raw || typeof raw !== 'string') return 'external' const value = raw.replace(/\\/g, '/') const outside = value.startsWith('..') || path.isAbsolute(raw) || /^[a-z]:\//i.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value) @@ -135,20 +109,14 @@ const { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, remem // --- files: hashing, paths, manifests --------------------------------------- -// 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. +// A few digests in flight keep the disk busy on many small files. const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4)) -// Digests are remembered per file (size + mtime), so a library that did not -// change since the last sync is never read again - only stat'ed. One store per -// launcher state (tests re-require this module per device). +// Digests cached per file (size + mtime); one store per launcher state. const hashStore = createHashCache(DIRS.DATA) const hashFile = hashStore.hashFile -// Protocol paths are FILES-relative with '/' separators. Anything escaping -// files/ (a '..' segment, an absolute path) is rejected - manifests come from -// another machine. +// Protocol paths are FILES-relative; escapes are rejected (untrusted). function filesPath(rel) { if (typeof rel !== 'string' || !rel || rel.includes('\0') || path.isAbsolute(rel)) return null const root = path.resolve(DIRS.FILES) @@ -171,8 +139,7 @@ const buildManifest = manifest.buildManifest // --- HTTP helpers (shared with the pusher) ---------------------------------- -// One-shot JSON request against another instance. `timeout: 0` means no socket -// timeout - hashing a big prefix on the other side takes a while. +// One-shot JSON request; timeout 0 = none (remote hashing is slow). function httpJson(device, method, route, { json, timeout = 15000 } = {}) { return new Promise((resolve, reject) => { const req = http.request( @@ -216,10 +183,9 @@ function targetOf(target) { return null } -// Send our sync code to the other device (the receiver checks it against its -// own code and hands back a token). A rejected code never gets stored. +// Trade the sync code for a token; a rejected code is never stored. async function verifyPeer(target, code) { - // one object with { host, port, code } or a target plus a separate code + // { host, port, code } in one object, or a target plus a separate code if (target && typeof target === 'object' && code === undefined && target.code !== undefined) code = target.code const device = targetOf(target) if (!device || !device.port) return { ok: false, error: 'bad-target' } @@ -312,9 +278,7 @@ async function entrySame(entry) { } } if (!entry.hash) return false // forced sync (or unreadable file) -> replace it - // A size mismatch alone settles it - reading the local file just to hash it - // would only confirm what the sizes already say. lstatSync because the - // answer (is it the regular file the manifest describes) needs no thread. + // A size mismatch alone settles it - no need to hash. let st try { st = fs.lstatSync(abs) @@ -329,9 +293,7 @@ async function entrySame(entry) { 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. + // Same bytes, wrong mode: repair locally instead of re-downloading. if (typeof entry.mode === 'number' && (st.mode & 0o777) !== entry.mode) applyMode(abs, entry.mode) return true } @@ -348,20 +310,14 @@ 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. +// After a unit lands, rewire links and restore modes, or it cannot start. 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 { - // a unit root that is a symlink is the whole unit: its target is not - // ours to walk (it may live outside the launcher), and the real folder - // behind it is healed through its own unit + // a symlinked unit root is not ours to walk; its own unit heals it if (!fs.lstatSync(abs).isDirectory()) continue healed.links += await rewireSymlinks(abs, [DIRS.FILES]) if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs) @@ -384,8 +340,7 @@ function ensureLink(abs, target) { } catch {} } -// Reports whether something was ACTUALLY removed: rmSync with force also -// "succeeds" on a missing path, which would inflate the delete counters. +// rmSync(force) "succeeds" on missing paths; only real removals count. function removeAbs(abs) { try { if (!fs.lstatSync(abs)) return false @@ -426,10 +381,7 @@ function removeOrphans(dir, keep) { return removed } -// Folder names in a registry directory. A symlinked folder counts too: a -// wine build or prefix kept on another disk is still part of the tree, and the -// sync carries it as the link it is (following it would copy the target and, -// for the pfx self link, loop). +// Registry folder names; symlinked folders count as the link they are. function listDirs(dir) { const out = [] let entries @@ -453,9 +405,7 @@ function listDirs(dir) { // Delete everything under a unit root that is not in the sender's manifest. async function pruneTree(abs, rel, keep) { - // A unit root can BE a symlink (a build or prefix kept on another disk). - // readdir would follow it and delete the real files behind it, which belong - // to the target, not to this unit. + // A symlinked unit root: readdir would delete the target's real files. let rootStat = null try { rootStat = await fsp.lstat(abs) @@ -482,8 +432,7 @@ async function pruneTree(abs, rel, keep) { return removed } -// Modes are applied after the bytes are in place: createWriteStream cannot set -// one, and a wine build without its executable bit is unusable. +// Modes go on after the bytes; a build without the exec bit is unusable. function applyMode(abs, mode) { if (typeof mode !== 'number' || !mode) return try { @@ -514,15 +463,13 @@ function writeStream(abs, stream, onBytes) { fs.renameSync(tmp, abs) resolve(out.bytesWritten || 0) } catch (err) { - // a sync race (the path changed between open and rename) must not - // undo the transfer itself - the next sync would just re-upload it + // a rename race must not undo the transfer removeAbs(tmp) resolve(0) } }) if (onBytes) { - // the pipe in flowing mode delivers every chunk to each 'data' listener, - // so counting here does not steal the bytes from the file being written + // flowing mode delivers every chunk to each listener stream.on('data', chunk => onBytes(chunk.length)) } stream.pipe(out) @@ -546,11 +493,7 @@ function applyApps(payload, cats) { } kept.push(game) } - // 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. + // App settings travel as stored; playtime only grows (higher wins). const merged = [...kept] for (const g of incoming) { const prev = existing.find(x => x.id === g.id) @@ -558,8 +501,7 @@ function applyApps(payload, cats) { 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 + // the per-app playtime file wins over the registry; write it too if (hasPlay) library.writePlaySeconds(g.id, playSeconds) } library.saveGames(merged) @@ -567,8 +509,7 @@ function applyApps(payload, cats) { for (const id of dropped) removed += removeTree(`data/icons/${id}`) const iconsRemoved = removed if (cats.has('apps-portable')) { - // files/apps only ever holds portable (copy/move) imports, so anything - // the sender does not own is a leftover of the mirror + // files/apps holds only portable imports; the rest is mirror leftover removed += removeOrphans(DIRS.APPS, incomingIds) } return removed @@ -577,8 +518,7 @@ function applyApps(payload, cats) { async function applyWines(payload) { const names = new Set((Array.isArray(payload.wines) ? payload.wines : []).map(w => w && w.name).filter(Boolean)) const removed = removeOrphans(DIRS.WINES, names) - // the registry is derived from the builds on disk, so a rescan is all that - // is needed to drop removed builds and pick up the synced ones + // the registry derives from disk; a rescan picks up the synced builds await wines.refreshWines() return removed } @@ -589,27 +529,20 @@ function applyPrefixes(payload, useSenderDefault) { const existing = prefixes.listPrefixes() const byName = new Map(existing.prefixes.map(p => [p.name, p])) const keep = new Map(incoming.map(p => [p.name, p])) - // every launcher seeds its own Main prefix - never delete the receiver's - // copy just because the sender does not know it + // never delete the receiver's own Main prefix if (!keep.has(prefixes.MAIN)) { keep.set(prefixes.MAIN, byName.get(prefixes.MAIN) || { name: prefixes.MAIN, created: Date.now() }) } const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys())) const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked })) - // 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). + // the default prefix moves only with synced default settings const wanted = useSenderDefault ? sender.default : existing.default const def = keep.has(wanted) ? wanted : prefixes.MAIN prefixes.savePrefixes({ default: def, prefixes: list }) return removed } -// Wine display names and disabled flags ("Sync settings"): registry-only -// values, matched by build id. Only builds registered HERE are touched, and an -// empty name CLEARS a custom label - syncing from a machine where the build -// was never renamed restores the folder-derived label there, exactly like the -// sender shows. System Wine has no registry entry and is never renamed. +// Wine display names/disabled flags by build id; empty name clears the label. function applyWineSettings(payload) { const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : [] let applied = 0 @@ -635,11 +568,7 @@ function applyWineSettings(payload) { return applied } -// Prefix REGISTRY names ("Sync settings"): a prefix's name IS its folder, so -// this never renames or creates folders - folders and their removal belong to -// the "Wine prefixes" category. It registers the sender's entries so a prefix -// whose folder already arrived (or is about to be used as a default) is known -// here, and it leaves every local prefix alone. +// Prefix registry names only; registers sender entries as known here. function applyPrefixSettings(payload) { const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null if (!sender) return 0 @@ -659,12 +588,7 @@ function applyPrefixSettings(payload) { return added } -// Per-app configuration ("Sync settings"): apps are matched by id and only -// apps that already exist here are patched - a settings sync cannot create an -// app whose files it does not carry. A wine id or prefix this device does not -// know is skipped instead of silently breaking the app (it would fall back to -// System Wine / the default prefix), and prefix-import entries keep their own -// prefix no matter what the sender has (updateGame pins it). +// Per-app config: patch existing apps only; unknown ids are skipped. function applyAppSettings(payload) { const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : [] if (!incoming.length) return 0 @@ -696,9 +620,7 @@ function applyAppSettings(payload) { return patched } -// The library ORDER ("Sync settings"): reorderGames keeps entries this device -// has that the sender does not know (they stay at the end), so a settings sync -// can reorder a library but never drop an app from it. +// Library order: entries unknown to the sender stay at the end. function applyAppOrder(payload) { const order = Array.isArray(payload.appOrder) ? payload.appOrder.filter(id => typeof id === 'string' && id) : [] if (!order.length) return 0 @@ -708,10 +630,8 @@ function applyAppOrder(payload) { return order.length } -// Everything the "Sync settings" category carries. Order matters: prefix names -// are registered before the default prefix is resolved and before app configs -// are checked against them; the app order is applied last so the patched -// library is written in the sender's order. +// "Sync settings" payload; order matters (names -> defaults -> configs -> +// order). function applySettings(payload, cats) { if (!cats.has('settings')) return null return { @@ -723,10 +643,7 @@ function applySettings(payload, cats) { } } -// The launcher-wide defaults ("Sync settings"): 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. +// Launcher defaults; only values that exist here are taken. function applyDefaults(payload) { const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {} const applied = {} @@ -752,9 +669,7 @@ function createServerState() { return { tokens: new Map(), verifying: new Set(), pending: new Map(), session: null } } -// The user's answer to an incoming-sync popup. Resolves the verification that -// is waiting for it; unknown/expired ids are a no-op (the request already -// timed out server-side). +// The user's popup answer; unknown/expired ids are a no-op. let serverState = null function respondIncoming(requestId, accept) { @@ -766,14 +681,10 @@ function respondIncoming(requestId, accept) { return true } -// Which counter a sync unit feeds on the receiving side (the unit key looks -// like "app:", "wine:" or "prefix:"). +// The receiving-side counter a unit feeds on ("app:" etc.). const UNIT_KINDS = { app: 'apps', wine: 'wines', prefix: 'prefixes' } -// Progress of a sync THIS device receives. The pushing device drives the -// transfer, but the user here also needs to see what is being written to -// their library: the launcher forwards this to its window, which shows a -// "Receiving from X…" popup with a bar. +// Progress of a sync this device receives, for the receiver's popup. function emitReceive(hooks, session, stage, extra = {}) { if (!hooks || !hooks.onReceive) return try { @@ -788,13 +699,11 @@ function emitReceive(hooks, session, stage, extra = {}) { 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: announced units, itemsDone: written units, totals: sender's plan 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 + // the bar shows the current phase, so hashing starts empty barBytes: session.barBytes || 0, barTotalBytes: session.barTotal || 0, deleted: extra.deleted || 0 @@ -819,12 +728,7 @@ function authorize(state, token) { return null } session.lastAt = Date.now() - // The expiry SLIDES with every request: hashing a big unit takes longer than - // any fixed TTL, and the request that finally moves files would arrive on a - // token that died while the sender was still hashing - the sync then ends - // right when it is ready to send. The heartbeat keeps the token alive through - // such a phase; a sender that really vanished still ages out this long after - // its last request. + // The expiry slides with every request: hashing can outlive any fixed TTL. session.expires = Date.now() + TOKEN_TTL_MS if (state.session && state.session.token === token) state.session.lastAt = session.lastAt return session @@ -869,27 +773,20 @@ async function handleVerify(state, req, res, hooks) { if (!payload || !payload.deviceId) return sendJson(res, 400, { ok: false, error: 'bad-request' }) const me = identity() const from = String(payload.deviceId) - // one verification per device at a time; other devices may not slip their - // verification into an ongoing sync either + // one verification per device at a time if (state.verifying.has(from)) return sendJson(res, 429, { ok: false, error: 'busy' }) const running = activeSession(state) if (running && running.deviceId !== from) return sendJson(res, 429, { ok: false, error: 'busy' }) - // one UNANSWERED consent popup at a time - a second sender would either - // overwrite the first popup or silently wait out its 60 seconds + // one unanswered consent popup at a time if (state.pending.size) return sendJson(res, 429, { ok: false, error: 'busy' }) if (hooks.isBusy && hooks.isBusy(from)) return sendJson(res, 429, { ok: false, error: 'busy' }) state.verifying.add(from) try { await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS)) if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' }) - // the sender may announce its totals with the verification, which is how - // the popup here can show "Apps: 0/5" before the first file list arrives + // the sender may announce totals up front ("0/5" in the popup) const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {} - // Nothing moves without the user behind this window accepting the push: - // a correct code only proves the sender read the code, it is not consent - // to overwrite this library. Without a UI to ask (tests, headless use) - // the gate cannot be answered and stays open - the launcher registers - // onIncoming whenever it shows a window. + // a correct code is not consent; without a UI to ask, the gate stays open if (typeof hooks.onIncoming === 'function') { const requestId = crypto.randomBytes(12).toString('hex') const decision = await new Promise(resolve => { @@ -920,8 +817,7 @@ async function handleVerify(state, req, res, hooks) { expires: Date.now() + TOKEN_TTL_MS, lastAt: Date.now(), units: new Map(), - // counters behind the "Receiving from X…" popup: files written, bytes - // written, and the same totals the sender planned (learned per unit) + // popup counters: files, bytes, sender's totals learned per unit counted: new Set(), received: 0, planned: 0, @@ -970,8 +866,7 @@ async function handlePlan(state, session, url, req, res, hooks) { 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 + // the sender's totals, so the popup can say "2/5" from the start try { const totals = JSON.parse(url.searchParams.get('totals') || 'null') if (totals && typeof totals === 'object') { @@ -983,8 +878,7 @@ async function handlePlan(state, session, url, req, res, hooks) { } } 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. + // comparing is its own phase: the bar restarts for the copy phase const unitTotalBytes = allowed.reduce((sum, entry) => sum + (entry.size || 0), 0) const announce = progressGate((data, force) => emitReceive(hooks, session, 'plan', data)) let compared = 0 @@ -996,10 +890,7 @@ async function handlePlan(state, session, url, req, res, hooks) { compared++ comparedBytes += entry.size || 0 session.barBytes = comparedBytes - // the file counter is not touched here: compared items include dirs and - // links, and until the upload starts nobody knows how many files will - // actually be written - the popup's counter freezes instead of jumping - // backwards when the copy phase starts its own count + // no file counting yet: dirs/links are compared too announce({ unit: label }) return same === true }) @@ -1024,12 +915,10 @@ async function handlePlan(state, session, url, req, res, hooks) { } }) 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 + // this unit's files/bytes, reported 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 + // the bar restarts for the copy phase session.unitWritten = 0 session.barTotal = uploadBytes session.barBytes = 0 @@ -1038,16 +927,14 @@ async function handlePlan(state, session, url, req, res, hooks) { session.counted.add(key) session.items[kind] = (session.items[kind] || 0) + 1 } - // the unit's human label (game / prefix / wine name) is announced while it - // is planned, so the later file events can name it in the popup + // the unit's label is announced up front, so file events can name it session.unitLabel = label sendJson(res, 200, { ok: true, upload, same, entries: paths.size }) announce({ unit: label }, true) } async function handlePut(session, url, req, res, hooks) { - // unit and path come as headers: they are constant per request, and routing - // them through the query made the receiver parse a fresh URL per file + // unit and path travel as headers: constant per request const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || '' const rel = req.headers['x-plwl-path'] || url.searchParams.get('path') || '' const unit = session.units.get(key) @@ -1056,10 +943,7 @@ 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). + // report bytes while the file is still arriving const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data)) try { await writeStream(abs, req, delta => { @@ -1068,8 +952,7 @@ async function handlePut(session, url, req, res, hooks) { 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 + // modes do not travel with the bytes; restore from the manifest applyMode(abs, unit.modes.get(rel)) session.received = (session.received || 0) + 1 sendJson(res, 200, { ok: true }) @@ -1088,18 +971,16 @@ 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) + // rewire links only once the whole tree is there 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 + // a finished unit completes its "2/5" slice 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) + // a finished unit leaves a finished bar behind session.barTotal = session.barTotal || 1 session.barBytes = session.barTotal sendJson(res, 200, { ok: true, deleted: removed }) @@ -1109,14 +990,12 @@ async function handleCommit(session, url, res, hooks) { async function handleApply(session, req, res, hooks) { const payload = await readJsonBody(req) if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' }) - // the categories come from another machine (and an older PLWL names the - // settings category "defaults"), so they go through the aliases here too + // sender categories go through the aliases (older PLWL says "defaults") const cats = new Set(normalizeCategories(payload.categories)) const appsDeleted = applyApps(payload, cats) const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0 const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('settings')) : 0 - // after the registries are in place, so a synced build/prefix can actually - // be chosen as the default and app configs can be checked against them + // after the registries, so synced builds/prefixes are usable as defaults const settings = applySettings(payload, cats) const removed = appsDeleted + winesDeleted + prefixesDeleted emitReceive(hooks, session, 'registry', { deleted: removed }) @@ -1151,12 +1030,7 @@ function handleEnd(state, session, res, hooks) { emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 }) } -// Liveness ping from the pushing device. It hashes whole units before it -// sends them and that can take many minutes, during which no other request -// arrives: without this the receiving popup would go quiet and its watchdog -// would eventually close it while the sync is still very much alive. The -// event only re-arms that watchdog (the renderer ignores its stage), so a -// transfer in progress keeps showing what it is doing. +// Liveness ping: hashing can take minutes; re-arms the popup watchdog. function handlePing(session, res, hooks) { session.lastAt = Date.now() emitReceive(hooks, session, 'wait') @@ -1185,8 +1059,7 @@ let httpServer = null let serverHooks = {} let listening = null // promise resolved once the receiving server is up -// Bind the receiving HTTP server. Resolves with the port it got and a close() -// for tests; the app itself only needs start()/stop(). +// Bind the receiving server; resolves with { port, close() }. function startServer(options = {}) { if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options } if (listening) return listening @@ -1212,8 +1085,7 @@ function startServer(options = {}) { } function stopServer() { - // new digests must not die with the process: the throttled mid-sync saves - // may have skipped the last few units + // throttled mid-sync saves may have skipped the last digests hashStore.save(true) if (httpServer) { try { @@ -1253,9 +1125,7 @@ function getState() { return { deviceId: me.deviceId, syncCode: me.syncCode, hash: hashStore.HASH_ALGO, port: httpPort, devices: listDevices() } } -// The pusher lives in its own module and is built with THIS module's helpers -// (identity, paths, registries), so one copy of it can serve several launcher -// states without mixing them up. +// The pusher is built with this module's helpers, so launcher states never mix. let pusher = null function getPusher() { if (!pusher) { @@ -1289,8 +1159,7 @@ function syncTo(payload) { return getPusher().syncToDevice(payload) } -// Push to one peer in a single call. `options` may carry the peer's record -// (host/port/id/name), the sync code, the categories to push and force. +// Push to one peer; options: peer record, code, categories, force. function runSync(options = {}, onProgress) { const target = options.port || options.host || options.address ? targetOf(options) : null const deviceId = options.deviceId || options.id diff --git a/src/lansyncpush.js b/src/lansyncpush.js index 4a272c5..8b6ed16 100644 --- a/src/lansyncpush.js +++ b/src/lansyncpush.js @@ -1,26 +1,19 @@ 'use strict' -// The pushing side of a LAN sync: this device is the source of truth and -// drives the whole transfer. For every sync unit it sends a manifest, asks -// the receiver what it already has, uploads the rest and has the receiver -// delete whatever the manifest does not list. -// -// Exported as a factory: src/lansync.js hands in its own helpers and state, so -// nothing here reaches across module instances. +// The pushing side of a LAN sync: per unit, send a manifest, learn what the +// receiver has, upload the rest, delete the surplus. Exported as a factory; +// src/lansync.js hands in its own helpers and state. const http = require('http') 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. +// Files sent at once: one at a time wastes LAN round trips on small files. const UPLOAD_CONCURRENCY = 4 -// Keeps the receiving side's session and popup alive while this device hashes -// a unit (minutes of complete silence from the network) - well under the -// receiving popup's watchdog, and cheap enough to run through the transfer too. -// Env override: tests cannot wait 20 s for a ping. +// Keeps the receiver alive while this side hashes (minutes of silence); env +// override for tests. const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000 function createPusher(api) { @@ -42,11 +35,9 @@ function createPusher(api) { prefixes } = 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. + // Manifest in, upload list out; NDJSON avoids one giant string. 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" + // announced up front, so the receiver's popup can 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' : ''}${totals}` @@ -88,8 +79,7 @@ function createPusher(api) { function putFile(device, token, unitKey, rel, abs) { return new Promise((resolve, reject) => { - // unit and path travel as headers: the receiver used to parse a fresh - // query string per file for values that are constant per request + // unit and path travel as headers: constant per request const req = http.request( { host: device.address, @@ -139,12 +129,7 @@ function createPusher(api) { for (const wine of wines.getWines()) { const rel = String(toPortablePath(wine.path) || '') const parts = rel.split('/') - // a build folder can sit one level down (zip-extracted layouts are - // detected at any depth): the unit is always the TOP folder under - // wines/ - that is the folder mirrored as a whole, and listDirs() - // names it as well. Using the basename here would point the unit at a - // folder that does not exist and inflate the "Wine versions" totals - // with a phantom unit. + // the unit is the TOP folder under wines/, not the detected build dir add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label) } for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`) @@ -167,9 +152,7 @@ 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. + // The whole app config travels (paths rewritten to relative form). function portableApp(game) { return library.portableGame(game) } @@ -191,13 +174,8 @@ function createPusher(api) { payload.wines = wineUnits().map(unit => ({ name: unit.id })) } if (cats.has('settings')) { - // "Sync settings": every registry-level value that describes how the - // launcher is set up here - the defaults, the names/flags the user gave - // wine builds, the prefix registry, and each app's configuration plus - // its position in the library. The receiver only patches things it - // already has; this category creates and deletes no files. Opt-in on - // purpose - a sync of apps must not silently rewrite the other - // machine's settings. + // "Sync settings": registry-level values only; opt-in - apps must not + // silently rewrite the other machine's settings. const defs = prefixes.listPrefixes() const games = library.loadGames() payload.defaults = { @@ -243,9 +221,7 @@ function createPusher(api) { skipped: 0, deleted: 0, bytes: 0, - // how many apps / wine builds / prefixes this sync walked over: the - // progress popup reports "3 games, 1 prefix" instead of file counts, - // which mean nothing to the user + // the popup reports "3 games, 1 prefix" instead of file counts items: { apps: 0, wines: 0, prefixes: 0 }, apps: { copied: 0, skipped: 0, deleted: 0 }, wines: { copied: 0, skipped: 0, deleted: 0 }, @@ -253,8 +229,7 @@ function createPusher(api) { } } - // Every sync resolves to the same shape, successes and failures alike, so - // callers can read counters/events without checking first. + // Every sync resolves to the same shape, success or failure. function syncResult(ok, error, summary, events) { const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines } summary.progress = events.length @@ -280,23 +255,19 @@ function createPusher(api) { } } - // Push the selected categories onto the peer. A rejected code comes back as - // error 'bad-code', so the UI can offer to type it again. + // Push to the peer; a rejected code comes back as 'bad-code'. async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) { const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || []) const device = target || api.listDevices().find(d => d.id === deviceId) 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. + // units are known before anything is read: announce them with the verify 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 + // every progress event is kept, so callers can report without subscribing const events = [] const progress = data => { events.push(data) @@ -305,8 +276,7 @@ function createPusher(api) { let verified try { - // longer than the receiver's incoming-request timeout: the user over - // there gets a popup and this side waits for the click + // longer than the receiver's incoming-request timeout (popup wait) verified = await httpJson(device, 'POST', '/plwl/verify', { json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals }, timeout: 90000 @@ -316,21 +286,15 @@ function createPusher(api) { } if (!verified.data || !verified.data.ok) { const error = (verified.data && verified.data.error) || `http-${verified.status}` - // the peer rotated its code: drop the stored pairing, so the next - // attempt asks for the new one instead of failing silently. A plain - // 'rejected' (the user over there declined the popup) keeps the pairing - // - the code was proven correct, the popup is the consent gate. + // a rotated code drops the pairing; a plain 'rejected' keeps it if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId) return fail(error) } const token = verified.data.token - // the code worked, so this device is paired now: the next sync needs no - // typing (a rejected code is never stored) + // paired now: the next sync needs no typing rememberDevice(device.id || deviceId, code, device.name) - // Hashing a big unit happens here, before a single byte is sent: without a - // sign of life the other side would see minutes of silence and its - // "receiving" popup could close while this device is still working. + // hashing precedes any byte: keep the receiver's popup alive meanwhile const heartbeat = setInterval(() => { httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {}) }, HEARTBEAT_MS) @@ -338,17 +302,13 @@ function createPusher(api) { 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 + // planned bytes so far, so the popup can show "1.2 GB / 3.4 GB" let plannedBytes = 0 let itemsSeen = 0 - // files uploaded/announced in PREVIOUS units: the popup's file counter is - // cumulative across the whole sync (a per-unit counter resets with every - // next app, which reads like progress going backwards) + // files from previous units: the popup counter is cumulative let allDone = 0 let allTotal = 0 - // the bar tracks the PHASE being worked on (its own bytes), never a - // leftover from the phase before: hashing starts from an empty bar + // the bar tracks the current phase only: hashing starts empty let barBytes = 0 let barTotal = 0 const snapshot = (phase, unit, done, total) => { @@ -371,19 +331,14 @@ function createPusher(api) { barBytes, barTotalBytes: barTotal } - // only phases that actually move the file counter carry one (the copy - // phase passes absolute, cumulative values); the rest omit it, so the - // popup freezes the last count instead of jumping backwards while this - // unit is still being scanned or compared + // only the copy phase carries a file count, so the popup never jumps back if (Number.isFinite(done) && Number.isFinite(total)) { data.done = done data.total = total } return data } - // hashing and copying walk thousands of files: the state is snapshotted on - // every file but forwarded a few times per second (phase changes always - // go through, so nothing on screen ever jumps backwards) + // snapshot per file, forward a few times per second (phase changes always) 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 () => { @@ -405,8 +360,7 @@ function createPusher(api) { const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => { barBytes = hashedBytes barTotal = totalBytes - // the file counter is not touched here: until the plan comes back - // nobody knows how many of this unit's files will actually move + // no file count until the plan returns emit('hash', unit, null, null) }) emit('plan', unit, null, null, true) @@ -414,9 +368,7 @@ function createPusher(api) { 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 || []) - // per category the FILES count: created/overwritten vs already - // identical (the plan reports both; dirs and links are re-ensured - // for free and are not files that moved) + // per category: files created/overwritten vs already identical const fileEntries = entries.filter(e => e.type === 'file') const filePaths = new Set(fileEntries.map(e => e.path)) let sameFiles = 0 @@ -431,10 +383,8 @@ function createPusher(api) { 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 + // a few files in flight at once: small files cost round trips + // the bar counts only bytes that move, and starts empty for the copy if (pending.length) { barBytes = 0 barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 0) @@ -459,9 +409,7 @@ function createPusher(api) { else for (const entry of pending) await send(entry) if (failed) throw failed emit('copy', unit, allDone + done, allTotal + pending.length, true) - // this unit's files count towards the running totals only once they - // are really on the wire - the counter holds there while the receiver - // prunes and the next unit is scanned/compared + // the totals count a unit's files only once they are on the wire allDone += pending.length allTotal += pending.length const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} }) diff --git a/src/library.js b/src/library.js index ccb8f85..3e53933 100644 --- a/src/library.js +++ b/src/library.js @@ -8,9 +8,8 @@ const { prefixPath, isPrefixRel } = require('./prefixes') const REGISTRY = path.join(DIRS.DATA, 'apps.json') const SETTINGS = path.join(DIRS.DATA, 'settings.json') -// Playtime lives in its own tiny file holding ONLY a number of seconds -// (files/data/appdata//playtime.txt) so the running game can update it -// every ~30s without rewriting the whole apps.json registry on each tick. +// Playtime lives in its own tiny file (files/data/appdata//playtime.txt), +// updated every ~30s without touching apps.json. function playtimeFile(id) { return path.join(DIRS.DATA, 'appdata', id, 'playtime.txt') } @@ -32,9 +31,7 @@ function writePlaySeconds(id, seconds) { function loadGames() { const games = readJson(REGISTRY, []) for (const g of games) { - // stored relative to FILES (portable) -> resolve to absolute for use. - // Prefix imports resolve against their own prefix instead (never the - // launcher-wide default). + // portable paths resolve against FILES, prefix imports against their prefix const usePrefix = g.importSource === 'prefix' && g.prefix ? prefixPath(g.prefix) : null for (const k of ['path', 'exePath', 'workdir']) { if (!g[k]) continue @@ -47,9 +44,7 @@ function loadGames() { return games } -// Paths of anything living inside FILES/ are stored RELATIVE to FILES so the -// whole launcher folder can be moved (portable mode). Absolute paths outside -// FILES (apps left in place) are kept as-is. +// Paths inside FILES/ are stored FILES-relative (portable); others as-is. function portableGame(g) { const out = { ...g } const pfx = out.importSource === 'prefix' && out.prefix ? prefixPath(out.prefix) : null @@ -57,8 +52,7 @@ function portableGame(g) { const v = out[k] if (!v) continue if (pfx && k !== 'iconSource' && !isPrefixRel(v)) { - // prefix game: store relative to the PREFIX ROOT (keeps the drive_c - // segment, e.g. "drive_c/Games/..." or "pfx/drive_c/Games/...") + // prefix game: store relative to the prefix root ("drive_c/...") const rel = path.relative(pfx, resolvePortablePath(v)) if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) out[k] = rel } else if (!(pfx && isPrefixRel(v))) { @@ -76,28 +70,21 @@ function getGame(id) { return loadGames().find(g => g.id === id) || null } -// Which prefix a game's runs land in: an app without one follows the launcher -// default. The single place for that fallback (runner.buildEnv and the in-use -// guards both use it, so a rename/delete can never blame the wrong folder). +// Which prefix a game's runs land in (launcher default when unset). function prefixNameForGame(game) { if (game && game.prefix) return game.prefix const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] }) return defs.default || 'Main' } -// Apps whose runs land in this prefix - used to keep a prefix that any app -// still references from being deleted out from under it. +// Apps whose runs land in this prefix (in-use guard). function gamesUsingPrefix(name) { if (!name) return [] return loadGames().filter(g => prefixNameForGame(g) === name) } -// In-use guards for registry edits: a prefix folder being renamed or deleted -// (or a wine build deleted) while a game runs in it would leave that process -// without its registry/files - the folder move/removal has to wait until the -// game stops. `running` is the games service's list of live runs -// ([{ name, prefixes, wine }]); prefixes lists every folder a run holds (its -// snapshot plus a new one it was pointed at mid-run). +// In-use guards: rename/delete of a prefix or build waits until no live run +// uses it. `running` is the games service's list of live runs. function appsUsingPrefix(name, running) { if (!name) return [] return (running || []).filter(r => (r.prefixes || []).includes(name)) @@ -107,15 +94,13 @@ function isPrefixInUse(name, running) { return appsUsingPrefix(name, running).length > 0 } -// A run with no explicit wine falls back to System Wine, which cannot be -// deleted anyway - only an exact match on a registered build counts. +// Only an exact match on a registered build counts. function isWineInUse(id, running) { if (!id || id === 'system') return false return (running || []).some(r => r.wine === id) } -// Switching the default prefix would silently move the next launch of every -// app that has no prefix of its own - so it waits while any of them runs. +// Switching the default prefix waits while any default-running app is live. function gamesFollowingDefault(running) { return (running || []).filter(r => { const game = getGame(r.id) @@ -134,16 +119,13 @@ function updateGame(id, patch) { const games = loadGames() const g = games.find(x => x.id === id) if (!g) return null - // prefix imports are pinned: their paths are stored relative to their - // prefix, so NOTHING may move them to another prefix or flip their - // import source (the patch fields are simply ignored) + // prefix imports are pinned; their patch fields are ignored if (g.importSource === 'prefix' && patch) { patch = { ...patch } delete patch.prefix delete patch.importSource } - // playSeconds goes to its own tiny file; the registry just mirrors it for - // anything reading apps.json directly (loadGames prefers the file anyway). + // playSeconds lives in its own file; the registry mirrors it if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds) Object.assign(g, patch) saveGames(games) @@ -154,9 +136,7 @@ function clearPlaytime(id) { return updateGame(id, { playSeconds: 0, lastPlayed: 0 }) } -// A portable app's folder is removed along with its registry entry - measured -// first (staging) so the renderer can show a bar while the tree is walked, -// instead of freezing on one rmSync of a multi-GB game. +// Delete a portable app's folder with measured progress. async function removeGame(id, onProgress) { const games = loadGames() const idx = games.findIndex(g => g.id === id) @@ -171,9 +151,7 @@ async function removeGame(id, onProgress) { const total = await treeSize(g.path) await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes })) } - // prefix imports live inside the prefix: deleting launcher files would - // nuke the user's prefix (installed DLLs, saves of every other app), so - // only the registry entry is dropped + // prefix imports: drop only the registry entry, never prefix files games.splice(idx, 1) saveGames(games) return true @@ -189,9 +167,7 @@ function saveSettings(patch) { return s } -// ID is the current timestamp (ms); while a game with that id or a folder -// with that name exists, keep incrementing - so ids and import folder names -// can never collide. +// Timestamp id, incremented while it collides. function newGameId() { let id = Date.now().toString() const taken = () => getGame(id) || fs.existsSync(path.join(DIRS.APPS, id)) @@ -211,8 +187,7 @@ function newGame({ id, name, exePath, workdir, mode }) { prefix: '', args: '', dllOverrides: [], - // Proton-only switch: keep UMU (the default path) or fall back to the - // build's steam.exe launcher for this app (see runner.buildEnv) + // Proton-only: UMU by default, or the build's steam.exe launcher disableUmu: false, env: {}, iconSource: '', @@ -222,9 +197,7 @@ function newGame({ id, name, exePath, workdir, mode }) { } } -// The registry array IS the library order, so a drag & drop reorder rewrites -// it whole. Ids the caller did not send keep their relative order at the end: -// a renderer that raced an import must never drop an entry. +// Reorder the registry array; unsent ids keep their order at the end. function reorderGames(ids) { const games = loadGames() const rest = new Map(games.map(g => [g.id, g])) @@ -240,10 +213,8 @@ function reorderGames(ids) { return out } -// Follows a prefix rename through the app registry. Raw JSON on purpose: -// loadGames()/saveGames() would resolve and re-relativize every path against -// a prefix folder that was just renamed. The stored paths of prefix imports -// are relative to the prefix root, so only the name reference changes. +// Follows a prefix rename through the registry. Raw JSON: the normal +// load/save would re-relativize paths against the renamed folder. function renamePrefixRefs(oldName, newName) { const games = readJson(REGISTRY, []) let updated = 0 diff --git a/src/main.js b/src/main.js index bb8fbe0..9bddea1 100644 --- a/src/main.js +++ b/src/main.js @@ -32,9 +32,7 @@ let autoQuitWhenDone = false // headless boot: quit after that game exits const getWindow = () => win // --- single instance --------------------------------------------------------- -// Diagnostics: the shortcut handoff path is invisible otherwise (a failing -// handoff just "does nothing" for the user). Everything lands in a small -// trace file next to the launcher data so problems are reproducible. +// Diagnostics trace for the otherwise invisible shortcut handoff. function trace(msg) { try { const file = path.join(DIRS.DATA, 'instance-trace.log') @@ -63,15 +61,11 @@ if (!gotLock) { }) } -// set on every second-instance handoff: the auto-quit check skips itself for -// a moment afterwards, so "one game exits" racing "a shortcut just started -// another game" can never kill the launcher mid-handoff +// auto-quit skips itself briefly after a handoff (start/exit race guard) let lastHandoffAt = 0 // --- "Open with PLWL" (Exec %f from the file manager) -------------------------- -// Hands the file to the window (renderer shows the wine/prefix picker popup); -// headless boots open the window for it - a file cannot be launched without -// the user choosing how. +// Hands the file to the window (picker popup); headless boots open the window. async function openWith(filePath) { trace(`openWith ${filePath} serviceReady=${!!gamesService}`) showMainWindow() @@ -99,9 +93,7 @@ function createTray() { try { tray = new Tray(icon) } catch (err) { - // never fatal (the window keeps working), but say why the icon is missing - // instead of failing silently - a missing tray looks like a hung app when - // the launcher runs in the background + // say why the tray icon is missing instead of failing silently console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`) tray = null return @@ -177,8 +169,7 @@ function showMainWindow() { } function wireWindow(w) { - // closing the window hides it to the tray (playtime keeps counting in the - // background) unless the user disabled that in settings - then it quits + // closing hides to the tray (unless disabled in settings, then it quits) w.on('close', e => { if (quitting) return let closeToTray = true @@ -208,13 +199,10 @@ async function launchGame(id, openFile) { trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`) if (res.ok) { rebuildTrayMenu() - // headless boot: when the auto-started game finishes and nothing else is - // running, quit the launcher + // headless boot: quit when the auto-started game finishes if (autoQuitWhenDone) { gamesService.setOnGameExit(() => { - // fires for EVERY game exit; quit only when the last one just ended, - // the user never opened the window and no shortcut handed a new game - // over in the last few seconds (start/exit race guard) + // quit only when the last game ended and nothing new came in if (Date.now() - lastHandoffAt < 5000) return if (!gamesService.listRunning().length) { quitting = true @@ -241,12 +229,10 @@ app.whenReady().then(async () => { const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu }) gamesService = games - // the tray comes first: nothing (least of all the network) may keep it from - // coming up - a launcher without a tray icon looks simply broken + // the tray comes first: nothing may keep it from coming up createTray() - // LAN sync runs whenever the launcher runs - that is what makes this - // device show up on the other machines' client lists + // LAN sync runs whenever the launcher runs, so this device is discoverable const lanHooks = { onDevices: devices => sendToWindow(win, 'lansync:devices', devices), onApplied: info => sendToWindow(win, 'lansync:applied', info), @@ -268,8 +254,7 @@ app.whenReady().then(async () => { } else { win = createMainWindow() wireWindow(win) - // file open handed over while the service was still booting (or queued - // by a second instance racing the first boot) + // file open queued while the service was still booting if (autoOpenPath || openwith.hasPending()) { openWith(autoOpenPath || openwith.getPending()) } diff --git a/src/names.js b/src/names.js index d4b2467..dc74a01 100644 --- a/src/names.js +++ b/src/names.js @@ -9,10 +9,8 @@ function cleanName(name) { .replace(/\s+/g, ' ') } -// A name that becomes a folder inside the launcher (a prefix): letters and -// digits of any language (Polish names keep their diacritics) plus the -// punctuation that is safe in a path segment. "." and ".." are path segments, -// not names. +// A folder-safe name: letters/digits of any language plus safe punctuation; +// "." and ".." are not names. function normalizePathName(name) { const out = cleanName(name).replace(/[^\p{L}\p{N} ._-]/gu, '') return /^\.+$/.test(out) ? '' : out diff --git a/src/openwith.js b/src/openwith.js index 520c4de..93231c2 100644 --- a/src/openwith.js +++ b/src/openwith.js @@ -1,9 +1,7 @@ 'use strict' -// "Open with PLWL" (file manager -> Open With -> PLWL): a file handed in via -// `--open ` (or the Exec %f plumbing) is parked here until the renderer -// picks it up, then launched ad hoc with the wine version + prefix chosen in -// the picker popup. Nothing is imported into the library. +// "Open with PLWL": a file handed in via --open is parked here until the +// renderer picks it up, then launched ad hoc. Nothing is imported. const fs = require('fs') const path = require('path') @@ -26,8 +24,7 @@ function clearPending() { pending = null } -// The launcher's own translations may not cover openwith texts in older -// installs - fall back to English keys so the UI never shows raw key names. +// Fall back to English keys on older installs. function textsFor(code) { let texts = {} try { @@ -58,8 +55,7 @@ function resolvePrefix(name) { return known || defs.default || 'Main' } -// Ad-hoc run target: shaped exactly like a library game (runner.buildEnv and -// the playtime/log paths expect those fields) but nothing is persisted. +// Ad-hoc run target shaped like a library game; nothing persisted. function makeTarget(openPath, wineId, prefixName) { const dir = path.dirname(openPath) return { @@ -82,9 +78,7 @@ function makeTarget(openPath, wineId, prefixName) { } } -// Launch without importing: resolves the chosen wine/prefix, builds the ad -// hoc target and runs it exactly like a library app (same env, same logging, -// same detached process group). +// Launch without importing, exactly like a library app. async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarted }) { if (!openPath || !fs.existsSync(openPath)) return { ok: false, error: 'file-missing' } if (!/\.(exe|msi|bat|cmd|lnk)$/i.test(openPath)) return { ok: false, error: 'not-windows' } @@ -92,9 +86,7 @@ async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarte const prefixName = resolvePrefix(prefixName0) const runner = require('./runner') try { - // runAdHoc builds the synthetic target internally (shared `openwith` id, - // so logs/playtime never collide with a library app id); the resolved - // prefix is passed through so buildEnv uses the chosen one + // shared `openwith` id keeps logs/playtime away from library ids const res = await runner.runAdHoc({ openPath, wine, systemWine: wine.type === 'system', prefix: prefixName }) if (onStarted) onStarted({ name: path.basename(openPath), pid: res.pid }) return { ok: true, pid: res.pid || null, logFile: res.logFile || null } diff --git a/src/paths.js b/src/paths.js index f99e020..a37f943 100644 --- a/src/paths.js +++ b/src/paths.js @@ -3,9 +3,7 @@ const path = require('path') const fs = require('fs') -// Packaged builds run from /olcxja.plwl/resources/app.asar: -// the portable layout (/files) is two levels above the resources dir -// and files bundled with the release are unpacked next to the asar. +// Packaged: resources/app.asar two levels below the portable root. function computeRoots() { const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/) if (m) { @@ -16,8 +14,7 @@ function computeRoots() { APP_ROOT, // FILES = BUILD/files, so BUILD is the portable root itself. BUILD: process.env.PLWL_BUILD_DIR || APP_ROOT, - // Bundled release files live inside the asar (Electron's patched fs - // can read them directly from the archive). + // bundled release files live inside the asar BUNDLED: fs.existsSync(path.join(RESOURCES, 'app.asar', 'files', 'translations')) ? path.join(RESOURCES, 'app.asar', 'files') : path.join(APP_ROOT, 'files') @@ -50,16 +47,11 @@ const DIRS = { FAKE_STEAM: path.join(DATA, 'fake-steam') } -// Bundled translation files are launcher-owned: on every start they are -// refreshed in the portable files/translations folder WHENEVER THEIR CONTENT -// CHANGED, so an update shipping new or changed keys reaches existing -// installs too (the old copy-only-if-missing logic froze translations at the -// version that happened to run first). Extra files in the target folder that -// the bundle does not ship (user-added languages) are left untouched. +// Bundled translations are refreshed on start whenever their content changed, +// so updates reach existing installs; user-added languages are untouched. function filesDiffer(a, b) { try { - // compare as text on purpose: Buffer.equals() throws on a string, and that - // throw was read as "differs", so every boot recopied every translation + // compare as text: Buffer.equals() throws on a string return fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8') } catch { return true // target missing/unreadable -> needs a (re)copy @@ -100,10 +92,8 @@ function ensureDirs() { } } -// Portable paths: anything stored under FILES/ goes into the registry as a -// path RELATIVE to FILES (e.g. apps//game.exe), so moving the whole -// launcher folder keeps the library working. Absolute paths outside FILES -// (games left in place) are stored as-is. Resolve on read, normalize on write. +// Anything under FILES/ is stored FILES-relative; absolute paths outside are +// kept as-is. Resolve on read, normalize on write. function toPortablePath(p) { if (typeof p !== 'string' || !p || !path.isAbsolute(p)) return p const rel = path.relative(FILES, p) @@ -116,15 +106,8 @@ function resolvePortablePath(p) { return path.join(FILES, p) } -// The launcher's own icon (renderer/plwl.png), used by the tray, the app -// icon and as the .desktop shortcut fallback. -// -// The module-relative copy comes FIRST: it is the only candidate that exists -// in every layout, because a packaged build keeps renderer/ inside -// app.asar and Electron's patched fs reads straight out of the archive (a -// release only ships olcxja.plwl.png next to the portable root, so looking at -// APP_ROOT alone made the tray icon vanish in installed builds). Cached: it -// never moves at runtime. +// The launcher's own icon (renderer/plwl.png) for tray, app icon, shortcut +// fallback. The module-relative copy is the only candidate in every layout. function launcherIconCandidates() { const bundled = path.join(__dirname, '..', 'renderer') return [ @@ -151,8 +134,7 @@ function launcherIconPath() { return launcherIcon || null } -// The same icon, but only as a real file on disk: a .desktop entry's Icon= is -// resolved by the desktop environment, which cannot look inside app.asar. +// The icon as a real file: a .desktop Icon= cannot live inside app.asar. function launcherIconDiskPath() { const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`)) return firstExisting(onDisk) diff --git a/src/prefixes.js b/src/prefixes.js index 69c6da7..6c907f5 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -7,9 +7,8 @@ const { rewireSymlinks } = require('./symlinks') const { removeTree, treeSize } = require('./fsutil') const { normalizePathName } = require('./names') -// An app path stored relative to its PREFIX ROOT ("drive_c/..." for plain -// wine, "pfx/drive_c/..." for Proton-managed prefixes): wine resolves it -// under C:\, everything else is FILES-relative or absolute. +// An app path stored relative to its prefix root ("drive_c/..." or +// "pfx/drive_c/..."); everything else is FILES-relative or absolute. const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i const isPrefixRel = v => typeof v === 'string' && PREFIX_REL_RE.test(v) @@ -48,12 +47,8 @@ function prefixExists(name) { } } -// Every Windows C: drive a prefix has: plain wine keeps one at the top -// (WINEPREFIX=), legacy Proton-managed prefixes nest one under pfx/ -// (WINEPREFIX=/pfx). A prefix used with BOTH wine kinds has both, and -// an import may come from either one - so callers that can accept several get -// them all, top level first. A unified prefix (pfx -> .) reaches the same -// drive twice; it is reported once. +// Every Windows C: drive a prefix has (top-level for plain wine, under pfx/ +// for Proton); a unified prefix reports its single drive once. function prefixDriveRoots(name) { const root = prefixPath(name) const found = [] @@ -79,22 +74,9 @@ function lstatOrNull(p) { } } -// Wine keeps its C: drive at /drive_c, Proton always runs its prefix -// from /pfx - and PLWL points compatdata at the launcher's prefix -// folder, so without a link a prefix used with both wine kinds grows TWO C: -// drives with separate registries, saves and installed DLLs. Making `pfx` a -// relative symlink back to the prefix itself collapses them: Proton's -// /pfx resolves to the prefix root, the exact folder wine uses. -// Every walker PLWL owns (fsutil, symlinks, LAN sync) treats symlinks as -// links and never follows them, so the self-reference is safe. -// -// Only a prefix that has no pfx AT ALL gets the link - that covers a fresh -// prefix and a plain-wine prefix first used through Proton. An existing -// layout is left exactly as it is: a prefix from an older PLWL keeps its real -// pfx/ folder (and here the two wine kinds keep two C: drives), and a prefix -// with both real drive_c trees keeps them - moving Windows installs around -// behind the user's back loses data and breaks the absolute/relative links -// inside them. +// Without a link, a prefix used with both wine kinds grows two C: drives; +// `pfx` -> prefix root collapses them. Only a prefix with no pfx at all gets +// the link - an existing layout is left exactly as it is. function ensureUnifiedPrefix(name) { if (!name) return false const pfx = path.join(prefixPath(name), 'pfx') @@ -108,12 +90,8 @@ function ensureUnifiedPrefix(name) { } } -// True when a path IS one of a prefix's own roots: the prefix folder, its -// pfx/ or either drive_c. A copy/move import of such a folder would move the -// prefix's C: drive (or the whole prefix) into files/apps, gutting it - the -// "import from a prefix" flow is the one that works in place. Both drive -// layouts are checked literally, not through prefixDriveRoots(): a prefix used -// with wine AND Proton has a drive_c each and BOTH are off limits. +// True when a path IS one of a prefix's own roots (folder, pfx/, drive_c): +// importing one would gut the prefix. function isPrefixRoot(p) { if (!p) return false const abs = path.resolve(String(p)) @@ -140,9 +118,7 @@ function createPrefix(name) { return { ok: true, name: clean } } -// A prefix is a whole windows drive - removal is measured before it starts -// (staging) so the UI can show a bar that actually moves while the tree is -// walked file by file (removeTree), instead of one blocking rmSync. +// Removal is measured (staging) so the UI shows a bar that moves. async function deletePrefix(name, onProgress) { if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' } const defs = listPrefixes() @@ -158,19 +134,13 @@ async function deletePrefix(name, onProgress) { 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). +// Rewire absolute links to relative so a moved prefix keeps working. async function healPrefix(name) { if (!name) return 0 return rewireSymlinks(prefixPath(name), [DIRS.FILES]) } -// Renaming a prefix moves its folder on disk and follows the name into the -// registry (default included) and every app that runs in it. Prefix imports -// keep their stored paths (they are relative to the prefix root), so only the -// reference changes - the files move with the folder. +// Rename moves the folder and follows the name through the registry. function renamePrefix(oldName, newName) { const clean = normalizePathName(newName) if (!clean) return { ok: false, error: 'invalidName' } @@ -180,8 +150,7 @@ function renamePrefix(oldName, newName) { if (entry.locked || String(oldName).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' } if (clean === oldName) return { ok: true, name: clean, games: 0 } if (defs.prefixes.some(p => p.name === clean) || fs.existsSync(prefixPath(clean))) return { ok: false, error: 'alreadyExists' } - // a registered prefix whose folder wine has not created yet can still be - // renamed - only the registry entry exists + // a registered prefix without a folder yet can still be renamed if (fs.existsSync(prefixPath(oldName))) { try { fs.renameSync(prefixPath(oldName), prefixPath(clean)) diff --git a/src/preload.js b/src/preload.js index cc608ad..d6364a7 100644 --- a/src/preload.js +++ b/src/preload.js @@ -3,9 +3,7 @@ const { contextBridge, ipcRenderer } = require('electron') const { DIRS } = require('./paths') -// Single-slot event callbacks: every import modal used to register ANOTHER -// listener and old ones never unregistered, so the file counter sped up per -// import. Only the latest subscriber receives an event. +// Single-slot event callbacks: only the latest subscriber receives an event. function singleSlot(channel) { let slot = null ipcRenderer.on(channel, (e, data) => { @@ -17,8 +15,7 @@ function singleSlot(channel) { } const onImportProgress = singleSlot('import:progress') -// "Open with PLWL": files handed over via --open (Exec %f) - the renderer -// shows the wine/prefix picker popup for them. +// "Open with PLWL": files handed over via --open (Exec %f). const onOpenWith = singleSlot('openwith:file') // one sync (and one pending consent request, enforced server-side) at a time const onLanSyncProgress = singleSlot('lansync:progress') diff --git a/src/runner.js b/src/runner.js index 867aa38..a527a93 100644 --- a/src/runner.js +++ b/src/runner.js @@ -8,11 +8,8 @@ const { DIRS } = require('./paths') const { prefixPath, ensureUnifiedPrefix } = require('./prefixes') const { prefixNameForGame } = require('./library') -// The fake Steam install PLWL falls back to when no real Steam client is -// found (see findSteamClientDir). 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. +// The fake Steam install used when no real Steam client is found. Both folders +// Proton links into its prefix have to exist, or the links dangle. function ensureFakeSteam() { const fake = DIRS.FAKE_STEAM fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true }) @@ -20,14 +17,9 @@ function ensureFakeSteam() { return fake } -// Proton expects the Steam client it is normally started by at -// STEAM_COMPAT_CLIENT_INSTALL_PATH: it copies the client's legacy DLLs from -// /legacycompat into the prefix, its built-in steam.exe loads the -// client's own steamclient.so, and media libs come from /ubuntu12_*. -// A client installed on this machine is worth using (read-only) - without one -// a Valve build's launch dies in a "steamclient_main.c" assert dialog - and -// only when there is none does PLWL fall back to the empty fake folder. GE -// builds don't need any of it: their UMU path never starts steam.exe. +// Proton needs the Steam client at STEAM_COMPAT_CLIENT_INSTALL_PATH or a Valve +// build dies in a "steamclient_main.c" assert; use a real client read-only, +// the empty fake otherwise (GE builds skip steam.exe entirely). const STEAM_CLIENT_MARKERS = [ ['legacycompat', 'steamclient.dll'], ['ubuntu12_32', 'steamclient.so'] @@ -57,21 +49,15 @@ function steamClientDir() { return findSteamClientDir() || ensureFakeSteam() } -// Module names travel in a semicolon-separated WINEDLLOVERRIDES list where -// '=' splits name from setting and ',' separates names - anything else in a -// name would corrupt the entries around it. A trailing .dll is what guides -// write; Wine strips it itself, so PLWL stores the bare name. +// WINEDLLOVERRIDES: ';'-separated, '=' splits name/setting, ',' separates +// names. Guides write a trailing .dll; Wine strips it, so store the bare name. function sanitizeDllName(name) { const clean = String(name || '').trim().replace(/\.dll$/i, '') return /^[^;=\s]+$/.test(clean) ? clean : '' } -// Wine replaces an earlier entry for the same module with a later one, so an -// inherited WINEDLLOVERRIDES goes FIRST and the app's own overrides are -// appended after it. An empty setting is Wine's "disabled" and has to survive -// as `name=`, not be dropped. Proton appends its own dxvk/nvapi/steam.exe -// defaults after everything - an app override for one of those modules only -// takes effect where Proton itself has no default. +// Wine keeps the LAST entry per module: inherited overrides go first, the +// app's own after. An empty setting is Wine's "disabled" and stays as `name=`. function dllOverridesFor(game, env) { const parts = [] const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '') @@ -87,11 +73,8 @@ function dllOverridesFor(game, env) { return parts.join(';') } -// 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. +// Repair only broken links (a resolvable absolute link would be synced out +// again). Once per prefix per launch. const healedPrefixes = new Set() async function healPrefixOnce(game) { const name = prefixNameForGame(game) @@ -105,13 +88,9 @@ async function healPrefixOnce(game) { } } -// 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. +// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix. +// A leftover EMPTY compat dir (an older PLWL bug) is replaced by the link; a +// populated one is left alone. function linkPrefix(compatData, prefixDir) { try { fs.mkdirSync(path.dirname(compatData), { recursive: true }) @@ -140,12 +119,9 @@ function linkPrefix(compatData, prefixDir) { function buildEnv({ game, wine }) { const env = { ...process.env } const isProton = wine && wine.type === 'proton' - // same fallback the games service snapshots into its running entries - // (library.prefixNameForGame), so in-use checks always name this folder + // same fallback the games service uses, so in-use checks name this folder const prefixName = prefixNameForGame(game) - // a prefix with no pfx yet gets the shared-link layout, so every launch - - // wine, Proton, tools - lands on that prefix's ONE C: drive; an existing - // nested layout is left as it is (see ensureUnifiedPrefix) + // no pfx yet -> shared-link layout; an existing nested layout is left as is try { ensureUnifiedPrefix(prefixName) } catch {} @@ -156,36 +132,23 @@ function buildEnv({ game, wine }) { env.STEAM_COMPAT_APP_ID = '0' env.SteamAppId = '0' env.SteamGameId = '0' - // Proton's session init reads STEAM_COMPAT_CLIENT_INSTALL_PATH on EVERY - // verb: its appcache sync (g_compatdata.sync_appcache) runs even for - // runinprefix and does os.environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] as - // soon as SteamGameId is set, so the variable must never be dropped - a - // missing one aborts the launch with a KeyError before wine even starts. - // Only what it POINTS AT depends on the mode: a UMU run may use a real - // client found on this machine, a disableUmu run always gets the empty - // fake folder (runinprefix copies nothing out of it; no steam.exe and no - // umu.exe is started there). + // every Proton verb reads STEAM_COMPAT_CLIENT_INSTALL_PATH (a missing one + // aborts with a KeyError). UMU run: real client; disableUmu: the fake. if (game.disableUmu) { delete env.UMU_ID env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam() } else { env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir() - // Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID - // set, newer Protons (incl. GE-Proton 9+) run the game through their - // built-in umu.exe instead of the steam.exe helper, which needs the - // Steam client's DLLs and dies in a "steamclient_main.c" assert dialog - // without them (see steamClientDir). + // UMU_ID makes newer Protons run through umu.exe instead of steam.exe, + // which needs the Steam client's DLLs (see steamClientDir). if (!env.UMU_ID) env.UMU_ID = 'umu-0' } env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx') env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs') fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true }) linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir) - // No silent fallback to a real directory: Proton would then set its prefix - // up under files/data/compat//pfx, outside files/wineprefixes/ where - // PLWL tracks it - rename, delete, the in-use guards and the LAN sync all - // look at the launcher prefix, so its C: drive would be orphaned. Only a - // missing path is refused; a populated one is linkPrefix's deliberate case. + // no silent fallback: a real dir would orphan the prefix outside where + // PLWL tracks it; only a missing path is refused if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) { throw new Error('prefix-link-failed') } @@ -198,17 +161,13 @@ function buildEnv({ game, wine }) { if (overrides) env.WINEDLLOVERRIDES = overrides else delete env.WINEDLLOVERRIDES - // the app's own environment comes last: it overrides everything above, - // including a WINEDLLOVERRIDES configured here + // the app's own environment comes last: it overrides everything above for (const [k, v] of Object.entries(game.env || {})) env[k] = v return { env, prefixDir } } -// Proton verbs: 'run' goes through the build's umu.exe/steam.exe launcher, -// while 'runinprefix' runs the build's wine directly (same session env, no -// umu/steam launcher ever starts - see game.disableUmu). runinprefix also skips -// Proton's prefix setup, so a prefix that never saw a normal Proton run stays -// a plain wine prefix instead of gaining Proton's default DLLs. +// Proton verbs: 'run' goes through umu.exe/steam.exe, 'runinprefix' runs the +// build's wine directly and skips Proton's prefix setup (see game.disableUmu). function protonVerb(game) { return game.disableUmu ? 'runinprefix' : 'run' } @@ -221,10 +180,8 @@ function parseArgs(args) { return out } -// A file handed over from the Linux file manager ("Open with" on an app -// shortcut) has to reach the Windows app as a WINDOWS path, the way it would -// on Windows: C:\ for anything inside the prefix's C: drive, and wine's Z:\ -// mapping (the filesystem root) for everything else. +// "Open with" handoff: the file reaches the app as a WINDOWS path (C:\ inside +// the prefix's drive, Z:\ for the rest). function windowsPathFor(file, prefixDir) { let abs = String(file || '') try { @@ -242,13 +199,9 @@ function windowsPathFor(file, prefixDir) { const GRACE_MS = 60000 -// Live playtime: while the game runs, update ONLY the tiny playtime file -// (a number of seconds) every 30s - apps.json is left untouched to spare the -// disk. lastPlayed is stamped once at start, final totals are mirrored into -// the registry once on exit. -// `base` is the seconds total this run adds up to. It is resolved by the -// CALLER from the live playtime file (not the registry) so ad-hoc runs that -// share one id (see runAdHoc) never mix two apps' playtime. +// Live playtime: tiny file updated every 30s, totals mirrored to the registry +// on exit. `base` comes from the caller (live file, not the registry) so +// shared ids never mix playtime. function startPlaytimeTicker({ game, startedAt, base }) { const library = require('./library') const timer = setInterval(() => { @@ -296,38 +249,29 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal child = spawn(cmd, base, { cwd: workdir, env, - // a handoff (a file given to an already running app) is not tracked: - // its pipes are left unread, so the output goes nowhere instead of - // filling up and blocking the app + // a handoff is untracked: its pipes drain so the app never blocks stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'], detached: true }) } 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'). + // spawn() resolves before the process exists; a missing binary only + // surfaces as an 'error' event, so wait for the real 'spawn'. let started = false child.once('error', err => { if (!started) reject(err) }) child.once('spawn', () => { started = true - // a handoff never writes the app's log or playtime: the tracked run owns - // both, and truncating the live log would destroy what it is writing + // a handoff writes no log or playtime: the tracked run owns both if (handoff) { child.unref() return resolve({ pid: child.pid, logFile: null }) } const logFile = latestLogPath(game.id) const ws = openLatestLog(game.id) - // a launch line of our own before the raw wine output: when a game - // refuses to start, "which prefix, which overrides, umu or steam.exe" - // is exactly what has to be answerable from the log + // a launch line first, so the log answers "which prefix/overrides/run" ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`) ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`) if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\n`) @@ -336,11 +280,10 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal 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 + // read the LIVE playtime file, not the registry (shared ids) 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 + // stamp "last played" once at start, even if we crash later require('./library').updateGame(game.id, { lastPlayed: startedAt }) child.on('exit', (code, signal) => { stopTicker() @@ -385,11 +328,9 @@ async function runTool({ game, wine, tool, args, systemWine }) { }) } -// Ad-hoc launch ("Open with PLWL"): the exact runGame pipeline, but with a -// synthetic target under the shared `openwith` id - log and playtime files -// live in files/data/appdata/openwith/ and no library entry is created. -// `prefix` carries the picker's choice ('' = launcher default, resolved by -// buildEnv); wine/systemWine are the already-resolved build objects. +// Ad-hoc launch ("Open with PLWL"): the runGame pipeline under the shared +// `openwith` id; no library entry is created. `prefix` is the picker's +// choice ('' = launcher default). function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) { const game = require('./openwith').makeTarget(openPath, 'default', prefix || '') return runGame({ game, wine, systemWine, onExit }) diff --git a/src/shortcuts.js b/src/shortcuts.js index 33899c0..e4f8e7a 100644 --- a/src/shortcuts.js +++ b/src/shortcuts.js @@ -1,9 +1,7 @@ 'use strict' -// .desktop shortcut generation. A shortcut launches the launcher itself with -// `--launch `: the main process either starts the game in the running -// instance or boots headless (no window, tray only), starts the game and lets -// playtime tracking continue normally. +// .desktop shortcut generation: a shortcut runs the launcher with +// `--launch ` (headless boot when nothing is running yet). const fs = require('fs') const path = require('path') @@ -11,8 +9,7 @@ const { DIRS, resolvePortablePath, launcherIconDiskPath } = require('./paths') const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA -// Where user-visible shortcuts live (XDG). Override with PLWL_SHORTCUTS_DIR -// so tests (and portable exotic setups) can redirect it. +// XDG shortcuts dir; PLWL_SHORTCUTS_DIR overrides it (tests). function desktopDirs() { const home = process.env.HOME || '/' const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share') @@ -22,8 +19,7 @@ function desktopDirs() { } } -// Launcher executable as the shortcut will see it, as an argv list: the -// packaged olcxja.plwl/olcxja.plwl binary, or `electron ` in dev. +// The launcher executable as an argv list (packaged binary or electron dev). function launcherArgv() { if (process.env.PLWL_LAUNCHER_CMD) return process.env.PLWL_LAUNCHER_CMD.split(' ').filter(Boolean) const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/) @@ -36,19 +32,13 @@ function launcherArgv() { return ['electron', path.resolve(__dirname, '..')] } -// Desktop Entry Exec field escaping (per the spec): % must be doubled, -// quotes and backslashes escaped, and tokens containing spaces wrapped in -// double quotes. +// Desktop Entry Exec escaping per the spec (% doubled, quotes, spaces). function execToken(token) { const escaped = token.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"') return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped } -// The `%f` file field is appended AFTER escaping (a literal % would have been -// doubled into a literal). It makes the shortcut a file handler: the file -// manager substitutes the opened file there - and removes the field when the -// shortcut is launched from the menu - so "Open with" on the shortcut runs the -// app with that file, like it does on Windows. +// %f appended after escaping makes the shortcut a file handler. function execLine(extraArgs) { const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)] return [...tokens.map(execToken), '%f'].join(' ') @@ -68,8 +58,7 @@ function buildDesktopEntry(game) { if (found) icon = path.join(iconDir, found) } catch {} if (!icon) { - // fall back to the launcher icon so the shortcut is never naked (the - // on-disk copy: the desktop environment cannot read inside app.asar) + // on-disk launcher icon fallback (Icon= cannot read the asar) icon = launcherIconDiskPath() || '' } const lines = [ diff --git a/src/symlinks.js b/src/symlinks.js index f11b724..a7c7ef5 100644 --- a/src/symlinks.js +++ b/src/symlinks.js @@ -1,36 +1,15 @@ '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. An absolute -// link is always rewritten; a relative one is only touched when it no longer -// resolves, because a tree that changed layout (a prefix whose contents moved -// one level up, see prefixes.ensureUnifiedPrefix) keeps relative links written -// against the old depth and every one of them breaks at once. +// imports and LAN sync. Rewires links to relative paths (absolute always; +// relative only when broken) and restores exec bits on build binaries. const fsp = require('fs').promises const fs = require('fs') const path = require('path') const { pathExists: exists } = require('./fsutil') -// 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. +// dosdevices/ mappings are supposed to point outside the prefix; never touched. const SKIP_DIRS = new Set(['dosdevices']) async function collectSymlinks(dir, out) { @@ -47,18 +26,12 @@ async function collectSymlinks(dir, 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. +// A missing target can only be something PLWL creates later (fake-steam, +// files/wines/...); the restriction keeps /usr/lib/... links alone. 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. +// True when `candidate` sits under `root` and its deepest existing parent is +// at least two components inside it. function anchoredInside(root, candidate) { let p = candidate while (p.length > root.length + 1) { @@ -69,12 +42,8 @@ function anchoredInside(root, candidate) { 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. +// The new target for an absolute link, relative to the link's own directory; +// falls back to the longest suffix that exists locally. function relativeTarget(link, target, roots) { const dir = path.dirname(link) for (const root of roots) { @@ -84,9 +53,7 @@ function relativeTarget(link, target, roots) { 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. + // second chance for links whose file is not here yet for (const requireLeaf of [true, false]) { for (const root of roots) { if (!root) continue @@ -112,11 +79,8 @@ function relativeTarget(link, target, roots) { return null } -// Rewire the symlinks under `root` that can be expressed relative to `roots` -// (usually [root, DIRS.FILES]): absolute ones always, relative ones only when -// they no longer resolve. 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. +// Rewire rewritable symlinks under `root`; returns how many changed. Repeats +// until nothing changes (links can chain). async function rewireSymlinks(root, roots = []) { if (!root) return 0 const candidates = [root, ...roots.filter(r => r && r !== root)] @@ -133,9 +97,7 @@ async function rewireSymlinks(root, roots = []) { continue } const absolute = path.isAbsolute(target) - // a relative link that still resolves is already relocatable - only a - // broken one is worth touching, and the suffix search in relativeTarget - // finds the file it was pointing at before the layout changed + // a relative link that still resolves is already relocatable if (!absolute && exists(path.resolve(path.dirname(link), target))) continue const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target) const next = relativeTarget(link, from, candidates) @@ -152,15 +114,11 @@ async function rewireSymlinks(root, roots = []) { 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. +// Binaries a wine/proton build must be able to execute; content copies drop +// the bit and spawn fails with EACCES. Windows .exe files 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. +// Give the exec bit back to a build's binaries and bin/ dirs. async function restoreExecutableBits(root) { if (!root) return 0 let fixed = 0 diff --git a/src/wines.js b/src/wines.js index 69ab83e..b1a7e0f 100644 --- a/src/wines.js +++ b/src/wines.js @@ -103,12 +103,8 @@ 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. 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. + // heal moved builds and builds missing their exec bit; only a broken build + // pays for the walk for (const w of found) { const old = prevById.get(w.id) const moved = !old || old.path !== w.path @@ -119,8 +115,7 @@ async function refreshWines() { } } const disabled = new Set(prev.filter(w => w.disabled).map(w => w.id)) - // a display name the user gave the build has to survive every rescan, just - // like the disabled flag (both live in the registry, not in the folder) + // display name and disabled flag live in the registry and survive rescans const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName])) for (const w of found) { if (disabled.has(w.id)) w.disabled = true @@ -131,8 +126,7 @@ async function refreshWines() { return found } -// Stored relative to FILES (portable), resolved to absolute for use - same -// scheme as the game registry. `id`, `name`, `label`, `version`, `type` pass through. +// Stored FILES-relative (portable); `id`/`name`/`label`/`version`/`type` pass through. function wineAbs(w) { return applyDisplayName({ ...w, path: resolvePortablePath(w.path), wineBin: resolvePortablePath(w.wineBin) }) } @@ -149,25 +143,20 @@ function getWine(id) { return getWines().find(w => w.id === id) || null } -// What the user typed wins over the folder-derived label everywhere the -// build is shown (library config, settings, Open with popup). Every registry -// read goes through here, so a stale stored label can never leak out. +// The user's label wins over the folder-derived one, everywhere. function applyDisplayName(w) { if (w && w.displayName) w.label = w.displayName return w } -// A build keeps its folder and id (apps point at the id, and prefix symlinks -// point inside the folder), so "renaming" only changes the shown label. +// "Renaming" only changes the shown label; folder and id stay. function setWineDisplayName(id, name) { const clean = normalizeLabel(name) if (!clean) return { ok: false, error: 'invalidName' } return writeWineLabel(id, clean) } -// Clears a custom label back to the folder-derived one: what the LAN sync -// calls when the sender never renamed the build. The rename dialog never -// clears - an empty name there is an error (setWineDisplayName above). +// Clears a custom label back to the folder-derived one (used by LAN sync). function clearWineDisplayName(id) { return writeWineLabel(id, '') } @@ -203,9 +192,7 @@ function getDefaultWine() { return require('./library').loadSettings().defaultWine || 'system' } -// Resolve the wine build a run should use: 'default'/'system'/unknown all -// fall back to System Wine. Shared by the games service and the ad-hoc -// "open with" runner. +// 'default'/'system'/unknown all fall back to System Wine. function resolveWine(id) { const system = { id: 'system', type: 'system', label: 'System Wine' } const wanted = id === 'default' ? getDefaultWine() : id @@ -238,22 +225,15 @@ async function deleteWine(id, onProgress) { } // --- symlink healing ------------------------------------------------------- -// Wine/Proton builds contain symlinks (e.g. inside the bundled default prefix) -// 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). The rewiring itself lives in symlinks.js -// because prefixes, synced apps and game imports need exactly the same thing. +// Builds contain absolute symlinks that dangle after a move and crash Proton +// during prefix setup; the rewiring lives in symlinks.js. async function fixBuildSymlinks(root) { 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. +// Make a freshly arrived build runnable: relative links, exec bits. async function healBuild(root) { - // a build folder that IS a symlink (kept on another disk) is left alone: - // the walk would follow the link and edit its target, which is not - // necessarily part of this launcher's trees + // a symlinked build folder is left alone: its target may be outside let st = null try { st = fs.lstatSync(root) @@ -275,16 +255,11 @@ function isExec(p) { } } -// Where a wine/proton build keeps the programs wine itself spawns (wine, -// wineserver, wineboot, ...) and the loaders next to the shared libraries. +// Where a build keeps the programs wine spawns (wine, wineserver, ...). 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. +// Cheap audit for a build missing its exec bits (invisible until Play fails +// with EACCES); only a failing build pays for the full walk. function buildAuditFailed(root, wineBin) { if (!root) return false if (wineBin && !isExec(wineBin)) return true @@ -319,8 +294,7 @@ async function importWineFolder(srcDir, onProgress) { if (build) copySrc = path.join(srcDir, build.name) } const dst = dedupeDir(WINES_DIR, path.basename(copySrc)) - // a wine/proton build is big enough that the copy needs a real bar: count it - // first (staging), then report bytes as they land while it copies + // count first (staging), then report bytes as they land const emit = (phase, detail) => { if (onProgress) onProgress({ phase, label: path.basename(dst), ...detail }) } @@ -336,8 +310,7 @@ async function importWineFolder(srcDir, onProgress) { return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables } } -// The pseudo entry for the distro's wine, offered next to the imported -// builds; version probe is best-effort (a PATH without wine still lists it). +// Pseudo entry for the distro's wine; version probe is best-effort. function systemWine() { let version = '' try {