cleanup comments, update translations, bump to 0.4.1

This commit is contained in:
olcxja 2026-10-05 15:42:44 +02:00
commit 7d8ab87eee
28 changed files with 412 additions and 1114 deletions

View file

@ -54,7 +54,9 @@ Renderer never touches Node/Electron directly — everything goes through the `w
## Rules ## 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. - 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. - 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/. - Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.

View file

@ -81,13 +81,13 @@
"config.prefixLocked": "This app was imported from a prefix, its prefix cannot be changed", "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.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.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.dllOverrides": "DLL overrides",
"config.overrides.add": "Add override", "config.overrides.add": "Add override",
"config.overrides.dll": "DLL name", "config.overrides.dll": "DLL name",
"config.overrides.setting": "Setting", "config.overrides.setting": "Setting",
"config.overrides.remove": "Remove", "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.save": "Save changes",
"config.shortcut": "Create shortcut", "config.shortcut": "Create shortcut",
"config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)",

View file

@ -81,13 +81,13 @@
"config.prefixLocked": "Ta aplikacja została zaimportowana z prefiksu, nie można zmienić jej prefiksu", "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.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.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.dllOverrides": "Nadpisania DLL",
"config.overrides.add": "Dodaj nadpisanie", "config.overrides.add": "Dodaj nadpisanie",
"config.overrides.dll": "Nazwa DLL", "config.overrides.dll": "Nazwa DLL",
"config.overrides.setting": "Ustawienie", "config.overrides.setting": "Ustawienie",
"config.overrides.remove": "Usuń", "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.save": "Zapisz zmiany",
"config.shortcut": "Utwórz skrót", "config.shortcut": "Utwórz skrót",
"config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)",

View file

@ -1,7 +1,7 @@
{ {
"name": "PLWL", "name": "PLWL",
"productName": "PLWL", "productName": "PLWL",
"version": "0.4.0", "version": "0.4.1",
"description": "Portable Linux Wine Launcher", "description": "Portable Linux Wine Launcher",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {

View file

@ -15,9 +15,7 @@ const state = {
games: [], games: [],
currentGame: null, currentGame: null,
lansync: null, lansync: null,
// true while this device pushes a sync (and while it shows the progress // true while this device pushes a sync: it owns the single popup
// popup): only one such popup exists, the pushing side wins over an
// incoming sync
lanPushing: false, lanPushing: false,
currentView: 'library' currentView: 'library'
} }
@ -78,8 +76,7 @@ function setButtonIcons() {
} }
} }
// Smoothly animate elements of `container` between their previous and new // FLIP: animate elements between their previous and new layout positions.
// positions after `mutate()` changes the layout (FLIP technique).
function flipShift(container, mutate) { function flipShift(container, mutate) {
const entries = [...container.children].map(el => ({ el, top: el.getBoundingClientRect().top })) const entries = [...container.children].map(el => ({ el, top: el.getBoundingClientRect().top }))
mutate() mutate()
@ -111,20 +108,13 @@ function toast(message, kind) {
}) })
} }
// The teardown of a closing modal is delayed (for the fade-out) - an // A delayed teardown must never kill a modal opened in the meantime.
// 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.
let modalTeardownTimer = null let modalTeardownTimer = null
let modalSwapTimer = null let modalSwapTimer = null
// A running sync must not be thrown away by a stray click: a locked modal // A locked modal ignores backdrop/Escape; only its own buttons close it.
// ignores the backdrop and Escape, only its own buttons (or the end of the
// operation) close it.
let modalLocked = false let modalLocked = false
// Resolver of the current promise-based modal (confirm/prompt). Escape, the // Settles the promise-based modal (confirm/prompt) so callers never hang.
// backdrop or another modal opening must settle it, or the awaiting caller
// would never continue.
let modalSettle = null let modalSettle = null
function settleModal(value) { function settleModal(value) {
@ -143,9 +133,7 @@ function showModal(html, opts) {
const overlay = qs('#modal-overlay') const overlay = qs('#modal-overlay')
const modal = qs('#modal') const modal = qs('#modal')
modal.classList.remove('closing') modal.classList.remove('closing')
// Opening over an already-visible modal (import mode -> prefix picker): // morph instead of close-then-open when a modal opens over a visible one
// morph instead of close-then-open - the box glides to the new height
// while old content dissolves into the new one.
const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim() const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim()
if (swapping) swapModalContent(modal, html) if (swapping) swapModalContent(modal, html)
else modal.innerHTML = html else modal.innerHTML = html
@ -154,10 +142,8 @@ function showModal(html, opts) {
return qs('#modal') return qs('#modal')
} }
// Animated content swap while the modal stays open. The new HTML is put in // Animated content swap: new HTML goes in synchronously, the old one fades
// SYNCHRONOUSLY (callers wire their buttons right after showModal returns); // out on top while the box glides to the new height.
// 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.
function swapModalContent(modal, html) { function swapModalContent(modal, html) {
if (modalSwapTimer) { if (modalSwapTimer) {
clearTimeout(modalSwapTimer) clearTimeout(modalSwapTimer)
@ -234,8 +220,7 @@ function confirmModal(message, okLabel) {
}) })
} }
// Shared "type a name" modal (prefix rename, wine display name): Enter // Shared "type a name" modal; Cancel/Escape/backdrop resolve null.
// submits, Cancel/Escape/backdrop resolve null.
function promptModal({ title, label, value, confirm, hint }) { function promptModal({ title, label, value, confirm, hint }) {
return new Promise(resolve => { return new Promise(resolve => {
const modal = showModal(` const modal = showModal(`
@ -419,10 +404,7 @@ function iconUrl(id) {
return `plwl-icon://${encodeURIComponent(id)}/icon` return `plwl-icon://${encodeURIComponent(id)}/icon`
} }
// Paths of imported (copy/move) apps live inside FILES/ and are stored // Portable apps show files/-relative paths; external apps keep absolute ones.
// 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.
function displayPath(p) { function displayPath(p) {
if (!p) return '' if (!p) return ''
const files = window.plwl.FILES_DIR const files = window.plwl.FILES_DIR
@ -430,8 +412,7 @@ function displayPath(p) {
return p return p
} }
// Inverse of displayPath for input fields: accept a files/-prefixed (or bare, // Inverse of displayPath: files/-prefixed or bare path -> absolute.
// FILES-relative) path and resolve it to an absolute one.
function absPathInput(v) { function absPathInput(v) {
if (!v) return v if (!v) return v
const files = window.plwl.FILES_DIR const files = window.plwl.FILES_DIR
@ -441,9 +422,7 @@ function absPathInput(v) {
return v return v
} }
// Prefix-imported apps: show paths the way wine sees them - relative to the // Prefix-imported apps: show paths as wine sees them (prefix-relative).
// prefix ("pfx/drive_c/..." or "drive_c/...") instead of the long
// files/wineprefixes/<prefix>/... form.
function displayGamePath(p, game) { function displayGamePath(p, game) {
if (game && game.importSource === 'prefix' && game.prefix) { if (game && game.importSource === 'prefix' && game.prefix) {
const files = window.plwl.FILES_DIR const files = window.plwl.FILES_DIR
@ -453,9 +432,7 @@ function displayGamePath(p, game) {
return displayPath(p) return displayPath(p)
} }
// Inverse of displayGamePath: accept a prefix-relative (drive_c/... or // Inverse of displayGamePath: prefix-relative path -> absolute under the prefix.
// pfx/drive_c/...) path and resolve it under the game's prefix; anything
// else falls through to the normal FILES/absolute handling.
function absGamePathInput(v, game) { function absGamePathInput(v, game) {
if (game && game.importSource === 'prefix' && game.prefix && /^(pfx\/)?drive_c([\/]|$)/i.test(v || '')) { if (game && game.importSource === 'prefix' && game.prefix && /^(pfx\/)?drive_c([\/]|$)/i.test(v || '')) {
const files = window.plwl.FILES_DIR const files = window.plwl.FILES_DIR
@ -474,11 +451,8 @@ function fmtPlaytime(seconds) {
} }
// --- reordering the library (drag & drop) ---------------------------------- // --- reordering the library (drag & drop) ----------------------------------
// The registry array IS the display order, so a drop reorders state.games and // The registry array is the display order; a drop reorders state.games and
// persists the whole id list; the main process appends any id the renderer did // persists the whole id list.
// 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.
let dragId = null let dragId = null
function clearDropMarks(except) { function clearDropMarks(except) {
@ -550,8 +524,7 @@ function wireCardDrag(card, game) {
} }
async function attachCardIcon(card, game) { async function attachCardIcon(card, game) {
// placeholder first so every card has a stable icon slot; swapped for the // placeholder first, swapped for the real icon once it loads
// real icon once it loads - games without one just keep the placeholder
const ph = document.createElement('div') const ph = document.createElement('div')
ph.className = 'game-icon placeholder' ph.className = 'game-icon placeholder'
card.prepend(ph) card.prepend(ph)
@ -708,9 +681,7 @@ async function startImportFlow() {
}) })
} }
// Import from a wine prefix, step 1: pick WHICH prefix - the app folder must // Import step 1: pick the prefix the app folder lives in.
// live inside one so its paths can stay prefix-relative (wine then sees it as
// a regular C:\ install).
async function startPrefixImportFlow() { async function startPrefixImportFlow() {
try { try {
state.prefixes = await window.plwl.prefixesList() state.prefixes = await window.plwl.prefixesList()
@ -763,8 +734,7 @@ async function doImport(srcDir, mode) {
const bar = modal.querySelector('#import-progress') const bar = modal.querySelector('#import-progress')
const pct = modal.querySelector('.progress-pct') const pct = modal.querySelector('.progress-pct')
const status = modal.querySelector('#import-status') const status = modal.querySelector('#import-status')
// byte totals drive the bar when the copy knows them; a rename or a // byte totals drive the bar when the copy knows them
// symlink-only run sends plain path events and only bumps the count
if (bar && data && data.totalBytes) { if (bar && data && data.totalBytes) {
renderProgressBar(bar, data.bytes, data.totalBytes) renderProgressBar(bar, data.bytes, data.totalBytes)
if (pct) pct.textContent = Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' 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 || '') return /\.(exe|msi|bat|cmd|lnk)$/i.test(p || '')
} }
// The popup: wine version + prefix selects defaulting to the LAUNCHER's // "Open with" popup: wine + prefix selects defaulting to the launcher defaults.
// defaults (settings.defaultWine / prefixes.default), like the per-app
// config's "Default (...)" options - just without importing anything.
async function showOpenWithModal(openPath) { async function showOpenWithModal(openPath) {
// always fresh: wines/prefixes/default can change while the launcher sits // always fresh: defaults can change while the launcher sits in the tray
// in the tray
try { try {
const [wines, prefixes, settings] = await Promise.all([ const [wines, prefixes, settings] = await Promise.all([
window.plwl.winesList(), window.plwl.winesList(),
@ -892,21 +859,16 @@ async function handleOpenWith(openPath) {
} }
function wireOpenWith() { function wireOpenWith() {
// Both delivery paths (live handoff and the boot queue) converge on // both delivery paths converge on openwithTake(); newest file wins
// 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).
window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith)) window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith))
// file queued before the window existed (boot handoff): pick it up once // file queued before the window existed (boot handoff): pick it up once
window.plwl.openwithTake().then(handleOpenWith) window.plwl.openwithTake().then(handleOpenWith)
} }
// Import from a prefix, step 2: register the app in place - nothing is // Import step 2: register the app in place, nothing is copied.
// copied, paths stay prefix-relative.
async function doPrefixImport(prefix, srcDir) { async function doPrefixImport(prefix, srcDir) {
const name = srcDir.split('/').filter(Boolean).pop() const name = srcDir.split('/').filter(Boolean).pop()
// nothing is copied here - the app is just registered in place, so there is // nothing copied: no progress to show
// no progress to show (no bar that would sit at zero)
showModal(` showModal(`
<h2>${t('import.adding', name)}</h2> <h2>${t('import.adding', name)}</h2>
<p class="subtitle">${t('import.prefix.registering')}</p> <p class="subtitle">${t('import.prefix.registering')}</p>
@ -934,9 +896,7 @@ async function doPrefixImport(prefix, srcDir) {
} }
} }
// Apps the launcher would name when it refuses a prefix rename/delete: a // Apps blocking a prefix rename/delete, fetched fresh for the warning.
// running app (blocking immediately) or a registered one (orphaned by a
// delete). Fetched fresh - the Prefixes view does not keep the library hot.
async function prefixUsers(name) { async function prefixUsers(name) {
const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()]) const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()])
const runningSet = new Set(running || []) const runningSet = new Set(running || [])
@ -1015,8 +975,7 @@ function renderPrefixes() {
const delBtn = row.querySelector('[data-act=delete]') const delBtn = row.querySelector('[data-act=delete]')
delBtn.innerHTML = `${ICONS.trash()}<span>${t('prefixes.delete')}</span>` delBtn.innerHTML = `${ICONS.trash()}<span>${t('prefixes.delete')}</span>`
delBtn.onclick = async () => { delBtn.onclick = async () => {
// refused before the confirm prompt, with the reason: the folder is // refused up front: a running app holds the folder, or apps reference it
// either held by a running app or still referenced by registered ones
const users = await prefixUsers(p.name) 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') 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')) 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) }, { value: '', label: t('config.prefixDefault', defs.default) },
...defs.prefixes.filter(p => p.name !== defs.default).map(p => ({ value: p.name, label: p.name })) ...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 // prefix imports pin their prefix even when it is the default
// list above omits) - keep it visible instead of a blank select
if (selected && !opts.some(o => o.value === selected)) opts.push({ value: selected, label: selected }) 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 || '') if (!selects[selectId]) selects[selectId] = new CustomSelect(qs('#' + selectId), opts, selected || '')
else selects[selectId].update(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') qs('#gc-env').value = Object.entries(game.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')
renderWineOptions('gc-wine', game.wine) renderWineOptions('gc-wine', game.wine)
renderPrefixOptions('gc-prefix', game.prefix) renderPrefixOptions('gc-prefix', game.prefix)
// apps imported from a prefix are pinned to it (their paths are prefix- // prefix-imported apps are pinned: the select is locked, not corrected on save
// relative) - the select is locked, not just corrected on save
const prefixLocked = game.importSource === 'prefix' const prefixLocked = game.importSource === 'prefix'
selects['gc-prefix'].setDisabled(prefixLocked) selects['gc-prefix'].setDisabled(prefixLocked)
const lockHint = qs('#gc-prefix-lock-hint') 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 // Shows the newest run log in the file manager.
// configuration, where the user is when something needs debugging.
async function showAppLogs() { async function showAppLogs() {
const game = state.currentGame const game = state.currentGame
if (!game) return if (!game) return
@ -1261,8 +1217,7 @@ function renderOverrides(overrides) {
} }
} }
// native,builtin first: that is the default a new override row starts on // native,builtin is the default a new override row starts on
// (load a native DLL when the game has one, fall back to Wine's builtin)
const OVERRIDE_OPTIONS = [ const OVERRIDE_OPTIONS = [
{ value: 'native,builtin', label: 'native,builtin' }, { value: 'native,builtin', label: 'native,builtin' },
{ value: 'native', label: 'native' }, { value: 'native', label: 'native' },
@ -1280,8 +1235,7 @@ function buildOverrideRow(o) {
` `
row.querySelector('.dll').value = o.dll || '' row.querySelector('.dll').value = o.dll || ''
const selectHost = document.createElement('span') const selectHost = document.createElement('span')
// a stored empty setting IS the "disabled" choice - a `||` fallback would // an empty setting IS the "disabled" choice - no || fallback here
// turn every disabled override back into the default on reopen
new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting == null ? 'native,builtin' : o.setting) new CustomSelect(selectHost, OVERRIDE_OPTIONS, o.setting == null ? 'native,builtin' : o.setting)
row.insertBefore(selectHost, row.querySelector('[data-act=del]')) row.insertBefore(selectHost, row.querySelector('[data-act=del]'))
row.querySelector('[data-act=del]').onclick = () => row.remove() row.querySelector('[data-act=del]').onclick = () => row.remove()
@ -1294,9 +1248,7 @@ function addOverrideRow() {
rows[rows.length - 1].querySelector('.dll').focus() rows[rows.length - 1].querySelector('.dll').focus()
} }
// The setting a guide or a launch option uses ("n", "native,builtin", "d"...) // Guide-style settings ("n", "native,builtin", "d"...) mapped to select values.
// mapped onto the values this select offers. Unknown text keeps the select's
// choice instead of inventing one.
function normalizeOverrideSetting(raw) { function normalizeOverrideSetting(raw) {
const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '') const v = String(raw || '').trim().toLowerCase().replace(/\s+/g, '')
if (!v || v === 'd' || v === 'disabled') return '' if (!v || v === 'd' || v === 'disabled') return ''
@ -1311,8 +1263,7 @@ function readOverrides() {
return qsa('#gc-overrides .override-row').map(row => { return qsa('#gc-overrides .override-row').map(row => {
let dll = row.querySelector('.dll').value.trim() let dll = row.querySelector('.dll').value.trim()
let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin' let setting = row.querySelector('.custom-select').dataset.value ?? 'native,builtin'
// "dinput8=native" pasted into the name box is the form every guide uses - // split pasted "dinput8=native" into name + setting
// split it here instead of storing a name with '=' that Wine cannot parse
const eq = dll.indexOf('=') const eq = dll.indexOf('=')
if (eq > 0) { if (eq > 0) {
const pasted = normalizeOverrideSetting(dll.slice(eq + 1)) const pasted = normalizeOverrideSetting(dll.slice(eq + 1))
@ -1332,10 +1283,7 @@ async function saveGameConfig() {
const idx = line.indexOf('=') const idx = line.indexOf('=')
if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim() 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 // prefix imports keep their prefix; only the prefix snaps back (with a hint)
// 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).
let prefixLockedHint = false let prefixLockedHint = false
let prefix = selects['gc-prefix'].value let prefix = selects['gc-prefix'].value
if (game.importSource === 'prefix') { if (game.importSource === 'prefix') {
@ -1358,8 +1306,7 @@ async function saveGameConfig() {
} }
await window.plwl.gamesUpdate(game.id, patch) await window.plwl.gamesUpdate(game.id, patch)
state.games = await window.plwl.gamesList() state.games = await window.plwl.gamesList()
// show what was actually stored: a pasted "name=setting" was split and a // show what was actually stored (split/stripped)
// .dll suffix stripped, so the rows would otherwise disagree with the file
renderOverrides(patch.dllOverrides) renderOverrides(patch.dllOverrides)
toast(t('config.saved'), 'success') toast(t('config.saved'), 'success')
if (prefixLockedHint) toast(t('config.prefixLocked'), 'error') if (prefixLockedHint) toast(t('config.prefixLocked'), 'error')
@ -1404,15 +1351,11 @@ const LAN_CATEGORIES = [
{ id: 'apps-prefix', label: 'lansync.appsPrefix' }, { id: 'apps-prefix', label: 'lansync.appsPrefix' },
{ id: 'wines', label: 'lansync.wines' }, { id: 'wines', label: 'lansync.wines' },
{ id: 'prefixes', label: 'lansync.prefixes' }, { id: 'prefixes', label: 'lansync.prefixes' },
// registry-level settings: default wine/prefix, custom names of wine builds // registry-level settings only; no files move with this category
// and prefixes, and each app's configuration plus its library order. No
// files move with this category - it only patches what already exists.
{ id: 'settings', label: 'lansync.settings' } { id: 'settings', label: 'lansync.settings' }
] ]
// Byte sizes for the progress popups. Decimal units, the way a file manager // Byte sizes in decimal units, as a file manager shows them.
// or a download shows them, because that is what a "1.2 GB" on screen means
// to the user.
function formatBytes(bytes) { function formatBytes(bytes) {
const value = Math.max(0, Number(bytes) || 0) const value = Math.max(0, Number(bytes) || 0)
if (value < 1000) return `${Math.round(value)} B` if (value < 1000) return `${Math.round(value)} B`
@ -1421,10 +1364,7 @@ function formatBytes(bytes) {
return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB` return `${(value / 1000 / 1000 / 1000).toFixed(2)} GB`
} }
// "Apps: 2/5 · Prefixes: 1/2 · Wine versions: 0/1" - how much of each category // "Apps: 2/5 · ..." per-category progress; no totals -> plain count.
// is done out of how much the sync carries, so the line says what is still
// coming instead of only listing what has already been walked over. A peer
// that does not announce its totals falls back to the plain count.
function lanStatsLine(items, itemsDone, totals) { function lanStatsLine(items, itemsDone, totals) {
const rows = [ const rows = [
['apps', 'lansync.statsApps'], ['apps', 'lansync.statsApps'],
@ -1443,11 +1383,7 @@ function lanStatsLine(items, itemsDone, totals) {
return out.join(' · ') return out.join(' · ')
} }
// The bar follows BYTES, not file counts: a sync spends its time on the big // The bar follows bytes and the current phase (a phase starts empty).
// files, and a file-count bar sits at 99% while the last gigabyte crawls in.
// It also follows the PHASE it is in (barBytes/barTotalBytes): a phase starts
// from an empty bar instead of inheriting a full one from the phase before, so
// hashing never looks finished while it has just begun.
function renderProgressBar(bar, bytes, totalBytes) { function renderProgressBar(bar, bytes, totalBytes) {
const sent = Math.max(0, Number(bytes) || 0) const sent = Math.max(0, Number(bytes) || 0)
const total = Math.max(0, Number(totalBytes) || 0) const total = Math.max(0, Number(totalBytes) || 0)
@ -1462,12 +1398,8 @@ function barValues(info) {
return [bytes, total] return [bytes, total]
} }
// One modal for operations with a measurable size (import, delete, long // Progress modal for byte-measured work; `action` is the ipc call, the modal
// copies): the bar follows BYTES for the same reason the sync bar does, and a // closes itself when it settles.
// 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.
async function runWithProgress({ title, action }) { async function runWithProgress({ title, action }) {
const modal = showModal(` const modal = showModal(`
<h2>${title}</h2> <h2>${title}</h2>
@ -1479,8 +1411,7 @@ async function runWithProgress({ title, action }) {
const status = modal.querySelector('#op-status') const status = modal.querySelector('#op-status')
window.plwl.onImportProgress(data => { window.plwl.onImportProgress(data => {
if (!data || !data.phase) return if (!data || !data.phase) return
// the counting pass reports its own phase with the totals; the bar starts // the counting pass reports its own phase with the totals
// moving once the real units arrive
if (data.phase === 'count') return if (data.phase === 'count') return
renderProgressBar(bar, data.bytes, data.totalBytes) renderProgressBar(bar, data.bytes, data.totalBytes)
pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : '' 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 // "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes.
// 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.
function lanBytesLine(bytes, totalBytes) { function lanBytesLine(bytes, totalBytes) {
const sent = formatBytes(bytes) const sent = formatBytes(bytes)
if (!totalBytes) return bytes > 0 ? t('lansync.progressSent', sent) : '' 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 // Collects the other device's sync code plus what to push.
// again with a cleared code after a rejected one, so the user can retype it.
async function openLanSyncModal(device, errorKey) { async function openLanSyncModal(device, errorKey) {
const modal = showModal(` const modal = showModal(`
<h2 id="ls-modal-title"></h2> <h2 id="ls-modal-title"></h2>
@ -1629,10 +1557,7 @@ async function runLanSync(device, code, categories, force) {
state.lanPushing = true state.lanPushing = true
window.plwl.onLanSyncProgress(data => { window.plwl.onLanSyncProgress(data => {
if (!active) return if (!active) return
// Every line keeps its value while the sync moves between phases: the // the file counter is cumulative across units; only the bar follows phases
// 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
phase.textContent = t('lansync.phase.' + data.phase, data.label || '').trim() phase.textContent = t('lansync.phase.' + data.phase, data.label || '').trim()
renderProgressBar(bar, ...barValues(data)) renderProgressBar(bar, ...barValues(data))
if (data.total > 0) count.textContent = t('lansync.progressFiles', data.done, data.total) 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) ------------- // --- receiving a sync (the other device is the source of truth) -------------
// A sync pushed onto THIS device must also be visible here - otherwise the // A sync pushed onto this device shows the same popup, driven by the
// library changes under the user's hands with no explanation. Same popup, but // receiving server's events. Hashing can be minutes of silence: the popup
// driven by the events the receiving server emits, and equally not closable // switches to "waiting" and events reopen it if the watchdog closed it.
// 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).
const RECEIVE_WATCHDOG_MS = 15 * 60 * 1000 const RECEIVE_WATCHDOG_MS = 15 * 60 * 1000
const RECEIVE_STALL_MS = 6 * 1000 const RECEIVE_STALL_MS = 6 * 1000
// phases whose wording differs on this side (everything else is neutral: // phase wordings that differ on this side; the rest are neutral
// "Comparing X", "Updating the library", "Finishing up")
const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' } const RECV_PHASE_KEYS = { copy: 'lansync.recvPhase.copy' }
// A device wants to push to us: the popup asks for consent before anything // Incoming push: consent popup before anything moves.
// moves. One pending request at a time (the server enforces that too).
let incomingRequest = null let incomingRequest = null
let incomingTimer = null let incomingTimer = null
function onLanIncoming(info) { function onLanIncoming(info) {
if (!info || !info.requestId) return if (!info || !info.requestId) return
if (state.lanPushing || receiving) { if (state.lanPushing || receiving) {
// our own transfer owns the modal slot - answer so the sender fails // our own transfer owns the modal slot: decline so the sender fails fast
// fast instead of waiting out its timeout
window.plwl.lansyncRespondIncoming(info.requestId, false) window.plwl.lansyncRespondIncoming(info.requestId, false)
return return
} }
@ -1719,8 +1636,7 @@ function onLanIncoming(info) {
modal.querySelector('#ls-incoming-title').textContent = t('lansync.incomingTitle', name) modal.querySelector('#ls-incoming-title').textContent = t('lansync.incomingTitle', name)
modal.querySelector('#ls-incoming-text').textContent = t('lansync.incomingText', name) modal.querySelector('#ls-incoming-text').textContent = t('lansync.incomingText', name)
modal.querySelector('#ls-incoming-what').textContent = parts.length ? parts.join(' · ') : t('lansync.incomingNothing') 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 // phrased from the receiving side's point of view
// (receiving) side's point of view, not the send modal's mirrorWarning
modal.querySelector('#ls-incoming-warning').textContent = t('lansync.incomingMirror') modal.querySelector('#ls-incoming-warning').textContent = t('lansync.incomingMirror')
modal.querySelector('#ls-incoming-decline').textContent = t('lansync.decline') modal.querySelector('#ls-incoming-decline').textContent = t('lansync.decline')
modal.querySelector('#ls-incoming-accept').textContent = t('lansync.accept') modal.querySelector('#ls-incoming-accept').textContent = t('lansync.accept')
@ -1747,10 +1663,7 @@ function onLanIncoming(info) {
} }
let receiving = false let receiving = false
// true only once the sender's 'done' event ended the transfer. A popup the // Only the sender's 'done' ends this; a watchdog-closed popup may reopen.
// 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.
let receiveEnded = false let receiveEnded = false
let receiveWatchdog = null let receiveWatchdog = null
let receiveStall = null let receiveStall = null
@ -1791,10 +1704,7 @@ function endReceiving(message) {
closeModal() closeModal()
} }
// A sender that crashed halfway through must not leave this window locked: the // A crashed sender must not leave the window locked: close after a long silence.
// popup closes itself if nothing at all arrives for a long time (the server
// drops the session after the same idle time). While it waits, the phase line
// says so rather than the popup disappearing mid-transfer.
function armReceiveWatchdog() { function armReceiveWatchdog() {
if (receiveWatchdog) clearTimeout(receiveWatchdog) if (receiveWatchdog) clearTimeout(receiveWatchdog)
receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS) receiveWatchdog = setTimeout(() => endReceiving(t('lansync.recvIdle')), RECEIVE_WATCHDOG_MS)
@ -1812,10 +1722,7 @@ function onLanReceiving(info) {
if (info.stage === 'start') receiveEnded = false if (info.stage === 'start') receiveEnded = false
if (info.stage === 'done') receiveEnded = true if (info.stage === 'done') receiveEnded = true
if (!receiving) { if (!receiving) {
// The popup also opens mid-sync: the sender may have been hashing for // reopen a watchdog-closed popup unless the sync already ended
// 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.
if (receiveEnded) return if (receiveEnded) return
if (qs('#modal-overlay').style.display !== 'none') return if (qs('#modal-overlay').style.display !== 'none') return
receiving = true receiving = true
@ -1840,17 +1747,12 @@ function onLanReceiving(info) {
function renderReceiving(info) { function renderReceiving(info) {
const parts = receiveModalParts() const parts = receiveModalParts()
if (!parts) return if (!parts) return
// A liveness ping from the sender (it is busy hashing a unit): the watchdog // a ping keeps the screen as it is; only a reopened popup needs a phase line
// 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.
if (info.stage === 'wait') { if (info.stage === 'wait') {
if (!parts.phase.textContent) parts.phase.textContent = t('lansync.recvWaiting') if (!parts.phase.textContent) parts.phase.textContent = t('lansync.recvWaiting')
return return
} }
// The same phases read from this side's point of view: a file arriving here // the same phases, worded for receiving
// is being received, not sent.
const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage
parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim() parts.phase.textContent = (info.stage === 'start' ? t('lansync.recvWaiting') : t(phaseKey, info.label || '')).trim()
renderProgressBar(parts.bar, ...barValues(info)) renderProgressBar(parts.bar, ...barValues(info))
@ -1859,8 +1761,7 @@ function renderReceiving(info) {
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals) parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes) parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
if (info.stage === 'done') { if (info.stage === 'done') {
// let the totals be read, then get out of the way (the library reloads // let the totals be read, then close (the library reloads itself)
// itself through lansync:applied)
if (receiveCloseTimer) clearTimeout(receiveCloseTimer) if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
receiveCloseTimer = setTimeout(() => { receiveCloseTimer = setTimeout(() => {
receiveCloseTimer = null receiveCloseTimer = null
@ -1869,10 +1770,7 @@ function renderReceiving(info) {
} }
} }
// Data received from another device changed the local files: reload every // A received sync changed local files: reload every list (settings included).
// 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.
async function reloadLocalData() { async function reloadLocalData() {
const [wines, prefixes, games, settings] = await Promise.all([ const [wines, prefixes, games, settings] = await Promise.all([
window.plwl.winesList(), window.plwl.winesList(),
@ -1921,8 +1819,7 @@ function wireEvents() {
}) })
qs('#btn-import-top').addEventListener('click', startImportFlow) qs('#btn-import-top').addEventListener('click', startImportFlow)
qs('#btn-import-empty').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 // a drop on the empty grid area moves the card to the end
// card are handled by the card itself (and stop there)
const gameList = qs('#game-list') const gameList = qs('#game-list')
gameList.addEventListener('dragover', e => { gameList.addEventListener('dragover', e => {
if (dragId) e.preventDefault() if (dragId) e.preventDefault()
@ -1982,10 +1879,7 @@ function wireEvents() {
qs('#btn-gc-logs').addEventListener('click', showAppLogs) qs('#btn-gc-logs').addEventListener('click', showAppLogs)
qs('#gc-override-add').addEventListener('click', addOverrideRow) qs('#gc-override-add').addEventListener('click', addOverrideRow)
qs('#gc-exe-browse').addEventListener('click', async () => { qs('#gc-exe-browse').addEventListener('click', async () => {
// start from the field being edited (exe picker from the exe field) and // resolve the SHOWN path to an absolute one, or the picker opens at cwd
// 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 ('/')
const cur = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame) const cur = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR }) const p = await window.plwl.dialogPickFile({ properties: ['openFile'], defaultPath: cur || window.plwl.FILES_DIR })
if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame) if (p) qs('#gc-exe').value = displayGamePath(p, state.currentGame)
@ -2001,14 +1895,12 @@ function wireEvents() {
if (res.ok) refreshConfigIcon() if (res.ok) refreshConfigIcon()
} }
qs('#gc-icon-auto').addEventListener('click', async () => { qs('#gc-icon-auto').addEventListener('click', async () => {
// extract from the exe the field shows, not the saved one - the user may // use the exe the field shows, not the saved one
// have just pointed the app at another exe and expects its icon
const exe = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame) const exe = absGamePathInput(qs('#gc-exe').value.trim(), state.currentGame)
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id, exe)) applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id, exe))
}) })
qs('#gc-icon-browse').addEventListener('click', async () => { qs('#gc-icon-browse').addEventListener('click', async () => {
// start in the app's own folder (prefix apps included) - that is where // start in the app's own folder, where the icon exe most likely lives
// the exe with the embedded icon most likely lives
const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame) const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({ const p = await window.plwl.dialogPickFile({
properties: ['openFile'], properties: ['openFile'],

View file

@ -3,35 +3,15 @@
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
// Shared command-line parsing for the launcher process itself. Both boot // Command-line parsing shared by boot and the single-instance handoff.
// (process.argv) and the single-instance handoff (second-instance argv) use // Supported: --launch <id>, --file <path>, --open <path>, or a bare file path.
// it, so a shortcut, a "Open with PLWL" file-open and Chromium's own injected // Electron re-serializes handoff argv, so options/positionals match in order.
// flags are handled the same way everywhere.
//
// Supported forms:
// --launch <id> | --launch=<id> auto-start that library app
// --file <path> | --file=<path> hand this file to the --launch app
// --open <path> | --open=<path> 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 <id> --file <path>` arrives as
// `--launch --file --allow-file-access-from-files <id> <path>`. 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.
const VALUE_OPTIONS = ['--launch', '--file', '--open'] const VALUE_OPTIONS = ['--launch', '--file', '--open']
// The options that carry a value, the positional arguments that follow the // Value options, the positionals that follow the switches, and inline
// switches, and the values written inline (`--name=value`, which keeps its // `--name=value` pairs. Tokens before the first switch are the executable
// value no matter how the rest is ordered). // (matched against the running app path in dev mode and skipped).
//
// Tokens before the first switch are the executable - never values. In dev
// mode (`electron <app> ...`) 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.
function splitArgv(argv) { function splitArgv(argv) {
const options = [] const options = []
const positional = [] const positional = []
@ -56,10 +36,8 @@ function splitArgv(argv) {
return { options, positional, inline } return { options, positional, inline }
} }
// The value of an option: inline when it was written as `--name=value`, // An option's value: inline `--name=value`, or the positional at the same
// otherwise the positional argument with the SAME position as the option // in-order position among the value options.
// among our value options (Chromium pushes all switches before all values, so
// any other pairing would read another option's value).
function optionValue(parsed, name) { function optionValue(parsed, name) {
if (parsed.inline.has(name)) return parsed.inline.get(name) if (parsed.inline.has(name)) return parsed.inline.get(name)
const index = parsed.options.indexOf(name) const index = parsed.options.indexOf(name)
@ -68,16 +46,11 @@ function optionValue(parsed, name) {
return value === undefined ? null : value return value === undefined ? null : value
} }
// The file an app shortcut was asked to open: `--file <path>` when the // The file an app shortcut was asked to open; only an absolute path counts.
// 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.
const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/ const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/
function launchFileOf(parsed, launchId) { function launchFileOf(parsed, launchId) {
const explicit = optionValue(parsed, '--file') const explicit = optionValue(parsed, '--file')
// a desktop environment that does not expand field codes would hand over // a literal %f token is never a file path
// the literal token - never treat that as a file path
if (explicit && !FIELD_CODE_RE.test(explicit) && path.isAbsolute(explicit)) return explicit if (explicit && !FIELD_CODE_RE.test(explicit) && path.isAbsolute(explicit)) return explicit
if (!launchId) return null if (!launchId) return null
for (let i = parsed.positional.length - 1; i >= 0; i--) { for (let i = parsed.positional.length - 1; i >= 0; i--) {
@ -97,10 +70,7 @@ function parseArgv(argv) {
const launchFile = launchFileOf(parsed, launchId) const launchFile = launchFileOf(parsed, launchId)
let openPath = optionValue(parsed, '--open') let openPath = optionValue(parsed, '--open')
if (!openPath && !launchId) { if (!openPath && !launchId) {
// "Open with" plumbing (Exec %f): the file path arrives as the only // Exec %f: the file path arrives as the only non-flag argument
// 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).
const bare = args.find( const bare = args.find(
a => a =>
!a.startsWith('-') && !a.startsWith('-') &&

View file

@ -23,11 +23,7 @@ function dedupeDir(base, name) {
return path.join(base, `${name} (${n})`) return path.join(base, `${name} (${n})`)
} }
// Progress is cheap to produce and expensive to render: hashing or copying a // Events are rate limited to ~10/s; `force` pushes the final value through.
// 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.
const PROGRESS_MIN_MS = 100 const PROGRESS_MIN_MS = 100
function progressGate(emit) { function progressGate(emit) {
@ -60,9 +56,7 @@ function progressGate(emit) {
return push return push
} }
// Run `fn` over `items` with at most `limit` calls in flight, results in // Run `fn` over `items` with at most `limit` in flight, results in order.
// 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.
async function mapLimit(items, limit, fn) { async function mapLimit(items, limit, fn) {
const out = new Array(items.length) const out = new Array(items.length)
let next = 0 let next = 0
@ -76,15 +70,12 @@ async function mapLimit(items, limit, fn) {
return out return out
} }
// What a tree copy will have to move. Symlinks have no size of their own // What a tree copy will move. Symlinks are recreated, not read. `skipDir`
// (they are recreated, not read), so they only count for the file total. // leaves out one directory name (redistributables).
// `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.
async function treeSize(root) { async function treeSize(root) {
let files = 0 let files = 0
let bytes = 0 let bytes = 0
// a symlink (or a plain file) as the root is the whole tree: walking it // a symlinked root is the whole tree; never measure through it
// would measure whatever it points at, not what is about to be removed
let rootStat = null let rootStat = null
try { try {
rootStat = await fsp.lstat(root) rootStat = await fsp.lstat(root)
@ -117,11 +108,8 @@ async function treeSize(root) {
return { files, bytes } return { files, bytes }
} }
// Copy a directory tree the way an imported game/wine build has to arrive: // Copy a tree with symlinks recreated and permissions carried (copyFile
// symlinks recreated as they are (copyFile would silently drop them) and file // drops both); reports cumulative { done, bytes } through onProgress.
// 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.
async function copyTree(src, dst, onProgress) { async function copyTree(src, dst, onProgress) {
let done = 0 let done = 0
let bytes = 0 let bytes = 0
@ -173,10 +161,8 @@ async function copyTree(src, dst, onProgress) {
return { done, bytes } return { done, bytes }
} }
// Delete a tree deepest-first so a long removal (a multi-GB prefix) can drive // Delete deepest-first with progress through onProgress; symlinks are
// a progress bar instead of freezing the window on one rmSync. Symlinks are // unlinked, never followed.
// unlinked, never followed. Reports cumulative { files, bytes, dirs } through
// `onProgress`; a missing tree is simply done.
async function removeTree(root, onProgress) { async function removeTree(root, onProgress) {
let files = 0 let files = 0
let dirs = 0 let dirs = 0
@ -185,9 +171,7 @@ async function removeTree(root, onProgress) {
const tick = () => { const tick = () => {
if (gate) gate({ files, dirs, bytes }, false) if (gate) gate({ files, dirs, bytes }, false)
} }
// A symlink (or a file) as the root is removed as itself, never walked // a symlinked root is removed as itself, never walked through
// 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.
let rootStat = null let rootStat = null
try { try {
rootStat = await fsp.lstat(root) rootStat = await fsp.lstat(root)

View file

@ -1,19 +1,14 @@
'use strict' 'use strict'
// Content hashing for the LAN sync, plus the per-file digest cache. // Content hashing for the LAN sync, plus the per-file digest cache
// // (files/data/lansync-hashes.json, keyed by path with size + mtime). The
// 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
// cache is machine-local and never travels in a sync. // cache is machine-local and never travels in a sync.
const crypto = require('crypto') const crypto = require('crypto')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
// BLAKE2b resists collisions like SHA-2 but is noticeably faster. Older // BLAKE2b is faster than SHA-2; older runtimes fall back to SHA-2.
// runtimes fall back to SHA-2.
const HASH_ALGO = (() => { const HASH_ALGO = (() => {
for (const algo of ['blake2b512', 'sha512', 'sha256']) { for (const algo of ['blake2b512', 'sha512', 'sha256']) {
try { try {
@ -24,9 +19,7 @@ const HASH_ALGO = (() => {
return 'sha256' return 'sha256'
})() })()
// Streaming through one callback instead of accumulating 'data' chunks keeps // Streamed hashing keeps memory flat on huge files.
// memory flat for huge files, and a small highWaterMark gives the read pump
// real backpressure. Digest speed does not care about the chunk size.
function hashFile(file, onBytes) { function hashFile(file, onBytes) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const hash = crypto.createHash(HASH_ALGO) 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 // Beyond a quarter million entries, the least recently touched are dropped.
// longest time untouched are dropped first.
const HASH_CACHE_MAX = 250000 const HASH_CACHE_MAX = 250000
// Mid-sync saves are throttled to this (the file can hold tens of megabytes); // Mid-sync saves are throttled; callers that must not lose entries pass force=true.
// callers that must not lose entries (sync end, shutdown) pass force=true.
const SAVE_MIN_MS = 60000 const SAVE_MIN_MS = 60000
function createHashCache(dataDir) { function createHashCache(dataDir) {
@ -64,8 +55,7 @@ function createHashCache(dataDir) {
return cache return cache
} }
// Compact JSON (the entries are arrays) keeps a full cache at tens of // Compact JSON: a full cache writes in tens of ms.
// milliseconds instead of the seconds pretty-printed JSON needs.
function save(force) { function save(force) {
if (!dirty || !cache) return if (!dirty || !cache) return
if (!force && Date.now() - savedAt < SAVE_MIN_MS) return if (!force && Date.now() - savedAt < SAVE_MIN_MS) return
@ -86,10 +76,8 @@ function createHashCache(dataDir) {
} catch {} } catch {}
} }
// The remembered digest of a file, or null when it has to be read again. // The remembered digest, or null when it has to be read again. A hit marks
// A hit only marks the cache dirty when its timestamp is actually stale: // the cache dirty only when the timestamp is 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.
function hit(rel, size, mtimeMs) { function hit(rel, size, mtimeMs) {
const known = load()[rel] const known = load()[rel]
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') { if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
@ -109,9 +97,7 @@ function createHashCache(dataDir) {
dirty = true dirty = true
} }
// Digest of a file we already stat'ed: size and mtime unchanged means the // Unchanged size + mtime -> cached digest; otherwise read and store.
// bytes are the same, and the digest from an earlier sync still describes
// them. Otherwise the file is read (progress through onBytes) and stored.
async function cachedHash(rel, abs, size, mtimeMs, onBytes) { async function cachedHash(rel, abs, size, mtimeMs, onBytes) {
const known = hit(rel, size, mtimeMs) const known = hit(rel, size, mtimeMs)
if (known) return known if (known) return known
@ -120,8 +106,7 @@ function createHashCache(dataDir) {
return hash return hash
} }
// The user can drop the cache from the settings view; a corrupted or stale // Settings view "clear cache" and stale files go through here.
// file is also replaced wholesale this way.
function clear() { function clear() {
cache = null cache = null
dirty = false dirty = false
@ -134,8 +119,7 @@ function createHashCache(dataDir) {
} catch {} } catch {}
} }
// Dropping the in-memory copy makes the next lookup re-read the file from // Dropping the memory copy makes the next lookup re-read from disk (tests).
// disk - what tests use to prove a file was hashed instead of remembered.
function forget() { function forget() {
cache = null cache = null
dirty = false dirty = false

View file

@ -120,17 +120,13 @@ function pngSize(img) {
return null return null
} }
// Raw RT_ICON payloads are BITMAPINFOHEADER-based bitmaps: biSize, biWidth, // RT_ICON payloads are BITMAPINFOHEADER bitmaps; read real dimensions from
// biHeight (2x the visible height - XOR + AND mask), biPlanes, biBitCount, // there (ICONDIRENTRY-style first bytes yield garbage).
// ..., 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).
function bmpDims(img) { function bmpDims(img) {
if (img.length < 12) return null if (img.length < 12) return null
const biSize = img.readUInt32LE(0) const biSize = img.readUInt32LE(0)
if (biSize === 12) { if (biSize === 12) {
// BITMAPCOREHEADER (Win 2.x/3.x era icons): DWORD size, then WORD width, // BITMAPCOREHEADER (old icons): WORD width/height/planes/bitcount
// height, planes, bitcount
const w = img.readUInt16LE(4) const w = img.readUInt16LE(4)
const h = Math.floor(img.readUInt16LE(6) / 2) const h = Math.floor(img.readUInt16LE(6) / 2)
const planes = img.readUInt16LE(8) || 1 const planes = img.readUInt16LE(8) || 1

View file

@ -13,9 +13,8 @@ const { copyTree, treeSize } = require('./fsutil')
// Directories that typically hold installers/redistributables, not the game. // Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc']) const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
// The copy itself lives in fsutil (copyTree): symlinks recreated, permissions // The copy itself lives in fsutil.copyTree; moveFolder falls back to it
// carried over, byte-level progress. moveFolder only falls back to it across // across filesystems only.
// filesystems; a same-device rename reports nothing (nothing is copied).
function moveFolder(src, dst, onProgress) { function moveFolder(src, dst, onProgress) {
const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true })) 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() 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) { async function importGame(srcDir, mode, onProgress) {
// a prefix (or its drive_c / pfx) is not an app folder: copy/move would take // a prefix (or its drive_c/pfx) is not an app folder
// the prefix's C: drive with it and leave the prefix gutted
if (isPrefixRoot(srcDir)) return { ok: false, error: 'prefix-root' } if (isPrefixRoot(srcDir)) return { ok: false, error: 'prefix-root' }
const name = path.basename(srcDir) const name = path.basename(srcDir)
// Validate before touching the user's files: refuse an import without any // validate before touching anything: no .exe -> refuse
// .exe (and don't half-move a folder we'd then have to roll back).
const preExe = await findDefaultExe(srcDir) const preExe = await findDefaultExe(srcDir)
if (!preExe) return { ok: false, error: 'no-exe-found' } if (!preExe) return { ok: false, error: 'no-exe-found' }
// The id doubles as the imported folder name, so generate it first and // the id doubles as the imported folder name
// create files/apps/<id> - no dedupe suffixes, ids can never collide.
const id = newGameId() const id = newGameId()
let workdir = srcDir let workdir = srcDir
if (mode === 'copy' || mode === 'move') { 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 })) 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) } else await moveFolder(srcDir, dst, onProgress)
workdir = dst workdir = dst
// anything the game linked to INSIDE the launcher data (its own folder, an // in-data link targets become relative, so the import survives a move
// icon, a shared prefix file) becomes a relative link, so the imported app
// survives the launcher being moved - the same healing wine builds get
try { try {
await rewireSymlinks(dst, [DIRS.FILES]) await rewireSymlinks(dst, [DIRS.FILES])
} catch {} } catch {}
@ -60,8 +54,7 @@ async function importGame(srcDir, mode, onProgress) {
} }
const game = newGame({ id, name, exePath, workdir, mode }) const game = newGame({ id, name, exePath, workdir, mode })
game.imported = mode === 'copy' || mode === 'move' game.imported = mode === 'copy' || mode === 'move'
// the exe was picked automatically -> grab its icon right away; a missing // auto-picked exe -> grab its icon; a missing icon is not a failure
// icon is not an import failure (game can still get one from config)
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico') const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
if (extractIconFromExe(resolvePortablePath(exePath), icon).ok) { if (extractIconFromExe(resolvePortablePath(exePath), icon).ok) {
game.iconSource = icon game.iconSource = icon
@ -69,22 +62,14 @@ async function importGame(srcDir, mode, onProgress) {
return addGame(game) return addGame(game)
} }
// Import from a wine prefix: the folder is picked under the prefix's // Import from a prefix: the folder stays in place, paths stored prefix-relative.
// 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:\...
async function importGameFromPrefix(prefix, srcDir, onProgress) { async function importGameFromPrefix(prefix, srcDir, onProgress) {
if (!listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' } 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:\ // the folder must live inside one of the prefix's C: drives
// 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.
const driveRoots = prefixDriveRoots(prefix) const driveRoots = prefixDriveRoots(prefix)
if (!driveRoots.length) return { ok: false, error: 'no-drive-c' } if (!driveRoots.length) return { ok: false, error: 'no-drive-c' }
const src = path.resolve(String(srcDir)) const src = path.resolve(String(srcDir))
// realpath on both sides: a unified prefix reaches drive_c through the pfx // realpath both sides: a unified prefix reaches drive_c through pfx too
// symlink too, and that path is the same folder (see ensureUnifiedPrefix)
let realSrc let realSrc
try { try {
realSrc = fs.realpathSync(src) 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' }) const game = newGame({ id, name: path.basename(src), exePath: preExe, workdir: src, mode: 'prefix' })
game.importSource = 'prefix' game.importSource = 'prefix'
game.prefix = prefix game.prefix = prefix
// exe picked automatically -> grab its icon right away; a missing icon is // auto-picked exe -> grab its icon; a missing icon is not a failure
// not an import failure (game can still get one from config)
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico') const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
if (extractIconFromExe(resolvePortablePath(preExe), icon).ok) { if (extractIconFromExe(resolvePortablePath(preExe), icon).ok) {
game.iconSource = icon game.iconSource = icon
@ -117,9 +101,7 @@ async function importGameFromPrefix(prefix, srcDir, onProgress) {
return addGame(game) return addGame(game)
} }
// Largest .exe in the tree, skipping redistributable folders. Files are // Largest .exe in the tree, skipping redistributable folders.
// stat'ed in parallel and folders walked breadth-first so big games don't
// turn the picker into a sequential crawl.
async function findDefaultExe(dir) { async function findDefaultExe(dir) {
let best = '' let best = ''
let bestSize = -1 let bestSize = -1

View file

@ -17,15 +17,9 @@ function sendToWindow(win, channel, data) {
if (win && !win.isDestroyed()) win.webContents.send(channel, data) if (win && !win.isDestroyed()) win.webContents.send(channel, data)
} }
// Game-process state: id -> { pid (null while the process is starting), the // Game-process state: id -> { pid, resolved prefix name and wine id }, frozen
// prefix name and wine id the run was resolved to }. Both are frozen here: // at launch (the in-use guards must name what the process really locked).
// the default prefix/wine can be switched and the app's own config edited // onGamesChanged fires in the main process when the running set changes.
// 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).
const STARTUP_GRACE_MS = 60000 const STARTUP_GRACE_MS = 60000
function createGamesService({ getWindow, onGamesChanged }) { 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 // a stale entry (no pid yet / dead pid) must not answer "already running"
// by a launch that never got a pid (the binary could not be executed, so the
// spawn failed) or whose pid is gone (the game crashed, the launcher was
// restarted) would otherwise answer every later Play with "already
// running".
function prune() { function prune() {
for (const [id, entry] of [...running]) { for (const [id, entry] of [...running]) {
if (alive(entry.pid)) continue if (alive(entry.pid)) continue
@ -70,11 +60,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
prune() prune()
return [...running.keys()] return [...running.keys()]
} }
// What the still-running games actually use, for the "is this prefix/wine in // snapshot + live name together name every folder a run touches
// 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.
const runningInfo = () => { const runningInfo = () => {
prune() prune()
const out = [] const out = []
@ -87,9 +73,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
return out return out
} }
// "Open with PLWL" handoff: queue the file for the renderer (picker popup // "Open with" handoff: queue the file for the renderer.
// 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.
function openExternally(openPath) { function openExternally(openPath) {
require('../openwith').setPending(openPath) require('../openwith').setPending(openPath)
sendToWindow(getWindow(), 'openwith:file', openPath) sendToWindow(getWindow(), 'openwith:file', openPath)
@ -118,10 +102,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
return { ok: false, error: 'wine-not-found' } return { ok: false, error: 'wine-not-found' }
} }
if (running.has(id)) { if (running.has(id)) {
// Opening a document in an app that is already running is a HANDOFF, // a document into a running app is a handoff, not a tracked run
// 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.
if (!openFile) return { ok: false, error: 'already-running' } if (!openFile) return { ok: false, error: 'already-running' }
try { try {
await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true }) await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true })
@ -134,8 +115,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
startedAt.set(id, Date.now()) startedAt.set(id, Date.now())
try { try {
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id), openFile }) 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 // stop requested while still starting: kill and clear
// leave nothing in the running set
if (killRequests.has(id)) { if (killRequests.has(id)) {
killRequests.delete(id) killRequests.delete(id)
if (res.pid) killProcessGroup(res.pid) if (res.pid) killProcessGroup(res.pid)
@ -145,8 +125,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
notifyChanged() notifyChanged()
return { ok: true, pid: null, logFile: res.logFile || null } return { ok: true, pid: null, logFile: res.logFile || null }
} }
// no pid means the process was not started after all - never keep such // no pid: never keep the entry, it would block the next launch
// an entry, it would block the next launch with "already running"
if (!res.pid) { if (!res.pid) {
running.delete(id) running.delete(id)
startedAt.delete(id) startedAt.delete(id)
@ -164,8 +143,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
} }
} }
// Stop always clears the state: whether the process was running, still // Stop always clears the state.
// starting (kill request for as soon as it has a pid) or already gone.
async function kill(id) { async function kill(id) {
const entry = running.get(id) const entry = running.get(id)
if (entry) killProcessGroup(entry.pid) 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 // subscribe to game exits (tray/headless auto-quit)
// the game that auto-started the launcher has finished)
function setOnGameExit(fn) { function setOnGameExit(fn) {
onGameExit = typeof fn === 'function' ? fn : null onGameExit = typeof fn === 'function' ? fn : null
} }
@ -204,8 +181,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data)) return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data))
} }
// prefix import: nothing is copied, the game just gets registered with // prefix import: register in place, nothing copied
// paths relative to the prefix (wine sees it as a normal C:\ install)
async function importFromPrefix(win, prefix, srcDir) { async function importFromPrefix(win, prefix, srcDir) {
return importGameFromPrefix(prefix, srcDir) return importGameFromPrefix(prefix, srcDir)
} }

View file

@ -25,9 +25,7 @@ function errorResult(err) {
const iconDir = id => path.join(DIRS.DATA, 'icons', id) const iconDir = id => path.join(DIRS.DATA, 'icons', id)
// `keep` spares one file (the icon just written): the icon protocol picks the // `keep` spares the icon just written: the protocol picks the LAST icon.*.
// LAST icon.* alphabetically, so a leftover icon.png from an earlier pick
// would otherwise keep showing the old icon.
function clearIconDir(id, keep) { function clearIconDir(id, keep) {
try { try {
for (const f of fs.readdirSync(iconDir(id))) { 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:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name))
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id)) ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id))
ipcMain.handle('wines:delete', async (e, id) => { ipcMain.handle('wines:delete', async (e, id) => {
// the build's files back the running prefix (dlls, stub exes) - deleting // the build's files back a running prefix; deleting it breaks that game
// it under a running game breaks that game
if (library.isWineInUse(id, games.runningInfo())) { if (library.isWineInUse(id, games.runningInfo())) {
const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name) const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name)
return { ok: false, error: 'inUse', apps } return { ok: false, error: 'inUse', apps }
@ -94,10 +91,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
}) })
// prefixes --------------------------------------------------------------------- // prefixes ---------------------------------------------------------------------
// A prefix folder must not move or disappear while a game runs in it: wine // A prefix must not move/disappear while a game runs in it.
// 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.
const prefixInUse = name => { const prefixInUse = name => {
const apps = library.appsUsingPrefix(name, games.runningInfo()) const apps = library.appsUsingPrefix(name, games.runningInfo())
return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null 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) => { ipcMain.handle('prefixes:delete', async (e, name) => {
const busy = prefixInUse(name) const busy = prefixInUse(name)
if (busy) return busy if (busy) return busy
// deleting a prefix that still has apps registered in it would orphan // deleting a prefix with registered apps would orphan them
// them (they fall back to the default prefix and lose their files)
const users = library.gamesUsingPrefix(name).map(g => g.name) const users = library.gamesUsingPrefix(name).map(g => g.name)
if (users.length) return { ok: false, error: 'hasApps', apps: users } if (users.length) return { ok: false, error: 'hasApps', apps: users }
return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data)) 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:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName))
ipcMain.handle('prefixes:setDefault', (e, name) => { ipcMain.handle('prefixes:setDefault', (e, name) => {
// apps without a prefix field of their own resolve the default at launch; // switching the default would silently move apps that follow it
// switching it now would silently move their next run (a prefix import is
// pinned to its own name)
const defs = prefixes.listPrefixes() const defs = prefixes.listPrefixes()
if (name !== defs.default) { if (name !== defs.default) {
const affected = library.gamesFollowingDefault(games.runningInfo()) const affected = library.gamesFollowingDefault(games.runningInfo())
@ -129,8 +120,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
// games: library ----------------------------------------------------------------- // games: library -----------------------------------------------------------------
ipcMain.handle('games:list', () => library.loadGames()) ipcMain.handle('games:list', () => library.loadGames())
ipcMain.handle('games:get', (e, id) => library.getGame(id)) ipcMain.handle('games:get', (e, id) => library.getGame(id))
// prefix pin is enforced inside library.updateGame (prefix/importSource // the prefix pin is enforced inside library.updateGame
// fields are ignored for prefix imports), so nothing extra needed here
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch)) ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids)) ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids))
ipcMain.handle('games:remove', (e, id) => { ipcMain.handle('games:remove', (e, id) => {
@ -187,8 +177,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
if (!game || !game.exePath) return { ok: false, error: 'bad-request' } if (!game || !game.exePath) return { ok: false, error: 'bad-request' }
return extractGameIcon(id, game.exePath) return extractGameIcon(id, game.exePath)
}) })
// exePath comes from the config field, which may hold a path the user just // use the exe the field holds (maybe picked but not saved yet)
// picked but has not saved yet - the icon has to come from THAT exe
ipcMain.handle('games:autoIcon', (e, id, exePath) => { ipcMain.handle('games:autoIcon', (e, id, exePath) => {
const game = library.getGame(id) const game = library.getGame(id)
if (!game) return { ok: false, error: 'bad-request' } if (!game) return { ok: false, error: 'bad-request' }
@ -198,9 +187,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
}) })
// "Open with PLWL" ------------------------------------------------------------------ // "Open with PLWL" ------------------------------------------------------------------
// The renderer asks for the file handed in via `--open` (Exec %f), then // `--open` (Exec %f) hands the file over; nothing is imported.
// launches it with the wine/prefix picked in the popup. Nothing is imported
// into the library.
ipcMain.handle('openwith:take', async () => { ipcMain.handle('openwith:take', async () => {
const p = openwith.getPending() const p = openwith.getPending()
if (!p) return null if (!p) return null
@ -246,14 +233,9 @@ function registerIpc({ getWindow, onGamesChanged }) {
return errorResult(err) return errorResult(err)
} }
}) })
// import from an existing wine prefix: pick the prefix first, then the app // import from an existing prefix: paths are stored prefix-relative
// folder inside it (drive_c) - paths are stored prefix-relative so the app
// shows up for wine as a regular C:\ install
ipcMain.handle('games:pickPrefixFolder', async (e, prefix) => { ipcMain.handle('games:pickPrefixFolder', async (e, prefix) => {
// open the picker straight in the prefix's C: drive (plain wine: top // open the picker in the prefix's C: drive (mixed layouts: prefix folder)
// 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
const driveRoots = prefixes.prefixDriveRoots(prefix) const driveRoots = prefixes.prefixDriveRoots(prefix)
const start = driveRoots.length === 1 ? driveRoots[0] : prefixes.prefixPath(prefix) const start = driveRoots.length === 1 ? driveRoots[0] : prefixes.prefixPath(prefix)
const res = await dialog.showOpenDialog(getWindow(), { const res = await dialog.showOpenDialog(getWindow(), {
@ -277,10 +259,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
// dialogs & shell --------------------------------------------------------------------------- // dialogs & shell ---------------------------------------------------------------------------
ipcMain.handle('dialog:pickFile', async (e, opts) => { ipcMain.handle('dialog:pickFile', async (e, opts) => {
const o = { ...(opts || {}) } const o = { ...(opts || {}) }
// Electron resolves a RELATIVE defaultPath against the process cwd (the // anchor FILES-relative paths, or Electron resolves them against cwd
// install root in packaged mode), which lands the picker somewhere
// random like '/'. Renderer paths are FILES-relative by convention, so
// anchor them there instead.
if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) { if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) {
o.defaultPath = path.join(DIRS.FILES, 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) if (pick) file = path.join(iconDir(id), pick)
} catch {} } catch {}
if (!file) return new Response('', { status: 404 }) if (!file) return new Response('', { status: 404 })
// a new icon lands at the SAME url, so the browser would keep its cached // the icon url never changes: send no-cache headers
// copy and the library card would only catch up after a restart - never
// let the icon request be cached
const res = await net.fetch('file://' + file) const res = await net.fetch('file://' + file)
return new Response(await res.arrayBuffer(), { return new Response(await res.arrayBuffer(), {
headers: { headers: {

View file

@ -1,9 +1,7 @@
'use strict' 'use strict'
// UDP discovery for the LAN sync: every running PLWL beacons on the local // UDP discovery for the LAN sync: beacons fill the devices list and double
// network and listens for the same beacon, so the devices list fills itself. // as liveness (a device drops off a few seconds after close).
// The beacon doubles as a liveness signal - a device drops off the list a few
// seconds after its PLWL is closed.
const dgram = require('dgram') const dgram = require('dgram')
const os = require('os') const os = require('os')

View file

@ -1,15 +1,12 @@
'use strict' 'use strict'
// Device identity for the LAN sync: the id this install is known by, the code // Device identity for the LAN sync: id, pairing code, paired devices. Factory
// other devices must type to pair with it, and the list of paired devices. // over the config file, one per launcher state.
// Exported as a factory over the config file, so every launcher state (and
// every test device) gets its own identity.
const crypto = require('crypto') const crypto = require('crypto')
const { readJson, writeJson } = require('./paths') const { readJson, writeJson } = require('./paths')
// The sync code is read out on one machine and typed on the other, so the // The code is read out and typed; the alphabet skips 0/O, 1/I/L.
// alphabet skips the characters people mix up (0/O, 1/I/L).
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
@ -24,9 +21,7 @@ function randomChars(alphabet, count) {
return out return out
} }
// 24 characters: the current timestamp in base36 (unique per millisecond, // base36 timestamp + random padding: unique across machines.
// 8 chars until 2089) plus random padding, so two machines generating an id
// within the same millisecond still get different ids.
function makeDeviceId() { function makeDeviceId() {
const stamp = Date.now().toString(36) const stamp = Date.now().toString(36)
return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24) return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24)
@ -37,11 +32,7 @@ function makeSyncCode() {
} }
function createIdentity(configFile) { function createIdentity(configFile) {
// The identity file is read once and then kept in memory: the beacon calls // Read once, kept in memory: the beacon calls identity() constantly.
// 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.
let cached = null let cached = null
function readConfig() { function readConfig() {
@ -51,9 +42,7 @@ function createIdentity(configFile) {
return cached return cached
} }
// The id and the code are generated on FIRST use and then kept forever: // Id and code are generated on first use and kept forever.
// other devices remember the id (they pair with it) and the code (so it
// only has to be typed once).
function identity() { function identity() {
const cfg = readConfig() const cfg = readConfig()
let changed = false let changed = false
@ -73,8 +62,7 @@ function createIdentity(configFile) {
return cfg return cfg
} }
// Both rotations return the whole identity ({ deviceId, syncCode, known }), // Rotations return the whole identity.
// so callers get a consistent view of this device after the change.
function rotateDeviceId() { function rotateDeviceId() {
const cfg = identity() const cfg = identity()
const previous = cfg.deviceId const previous = cfg.deviceId
@ -93,9 +81,7 @@ function createIdentity(configFile) {
return cfg return cfg
} }
// A device the user synced with successfully: its code is stored next to // A paired device remembers its code; a rejected code is forgotten.
// 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.
function knownDevice(id) { function knownDevice(id) {
return identity().known[id] || null return identity().known[id] || null
} }
@ -122,8 +108,7 @@ function createIdentity(configFile) {
return { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode } return { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode }
} }
// Compare two typed codes without leaking timing: the codes are short, but a // Constant-time code comparison.
// device may only verify once per two seconds anyway.
function sameCode(a, b) { function sameCode(a, b) {
const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase()) const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase())
const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase()) const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase())

View file

@ -1,23 +1,15 @@
'use strict' 'use strict'
// Sender-side hashing: walking sync units into a manifest of dirs, links and // Sender-side hashing: walk sync units into a manifest of dirs, links, files.
// files, with the digest of every file. Exported as a factory over the path // Factory over the path guards and hash store, one per launcher state.
// guards and the hash store, so every launcher state (and every test device)
// builds manifests for its own files.
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
const fsp = fs.promises const fsp = fs.promises
function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) { function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) {
// Symlink targets travel RELATIVE whenever they point inside the launcher // Targets inside the launcher data travel relative (the mirrored layout is
// data (a prefix linking into its wine build, an app linking to its own // the same on both machines); outside targets stay as they are.
// assets): the mirrored trees have the same internal layout on both
// machines, so a relative target is correct over there while an absolute one
// would point at the sender's own folders - which is exactly how a synced
// prefix ends up with hundreds of dangling links. Targets outside files/
// (a system font, a mounted drive, /) are machine-specific and stay as they
// are.
function portableLinkTarget(linkAbs, target) { function portableLinkTarget(linkAbs, target) {
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
const rel = path.relative(path.dirname(linkAbs), target) const rel = path.relative(path.dirname(linkAbs), target)
@ -28,16 +20,8 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
return rel return rel
} }
// Walk without following symlinks: builds and prefixes contain links that // Walk without following symlinks (dosdevices etc. are part of the sync).
// are part of what is being synced (dosdevices, build internals), and // Iterative, with one directory's stat/readlink calls in flight together.
// 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.
async function scanTree(abs, rel, out, mtimes) { async function scanTree(abs, rel, out, mtimes) {
const queue = [[abs, rel]] const queue = [[abs, rel]]
while (queue.length) { while (queue.length) {
@ -77,18 +61,16 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
return return
} }
if (!st.isFile()) return if (!st.isFile()) return
// mode travels with the file: a wine build's `proton`/`wine` binary is // mode travels with the file
// useless on the other side without its executable bit
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null }) 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) if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
}) })
queue.push(...dirs) queue.push(...dirs)
} }
} }
// Manifest of one sync unit. `force` skips hashing entirely - the receiver // Manifest of one unit; `force` skips hashing (receiver replaces everything).
// then treats every file as different ("force sync all files").
async function buildManifest(roots, force, onProgress) { async function buildManifest(roots, force, onProgress) {
const entries = [] const entries = []
const mtimes = new Map() const mtimes = new Map()
@ -116,8 +98,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
} }
} }
const files = entries.filter(e => e.type === 'file') const files = entries.filter(e => e.type === 'file')
// the unit's total size is known before a byte is hashed, so the bar can // total size is known before hashing, so the bar starts empty
// start empty and fill with the bytes actually read
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
if (!force && files.length) { if (!force && files.length) {
let done = 0 let done = 0
@ -125,8 +106,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
await mapLimit(files, concurrency, async entry => { await mapLimit(files, concurrency, async entry => {
const abs = filesPath(entry.path) const abs = filesPath(entry.path)
try { try {
// identical size and mtime means identical bytes, so the digest from // unchanged size + mtime: the cached digest still holds
// an earlier sync still describes this file
const mtimeMs = mtimes.get(entry.path) || 0 const mtimeMs = mtimes.get(entry.path) || 0
entry.hash = abs entry.hash = abs
? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => { ? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => {

View file

@ -1,10 +1,7 @@
'use strict' 'use strict'
// LAN syncing. Every running PLWL instance beacons on the local network and // LAN sync: instances beacon and push to each other over HTTP. The sender's
// runs a small HTTP server other instances can push to. Whoever starts a sync // tree wins; extra receiver files are deleted.
// 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.
const http = require('http') const http = require('http')
const crypto = require('crypto') const crypto = require('crypto')
@ -28,43 +25,30 @@ const { isPrefixRel } = prefixes
const CONFIG = path.join(DIRS.DATA, 'lansync.json') const CONFIG = path.join(DIRS.DATA, 'lansync.json')
// The beacon doubles as a liveness signal: a device drops off the list a few // The beacon doubles as liveness: a device drops off a few seconds after close.
// seconds after its PLWL is closed.
const DISCOVERY_PORT = Number(process.env.PLWL_LANSYNC_PORT) || 47821 const DISCOVERY_PORT = Number(process.env.PLWL_LANSYNC_PORT) || 47821
const BEACON_TAG = 'plwl-lansync' const BEACON_TAG = 'plwl-lansync'
const PROTOCOL = 1 const PROTOCOL = 1
// A device is only visible while it beacons; 2.5 s against a 6 s TTL leaves // 2.5 s beacon against 6 s TTL: one lost datagram still fits.
// 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).
const BEACON_MS = 2500 const BEACON_MS = 2500
const DEVICE_TTL_MS = 6000 const DEVICE_TTL_MS = 6000
// Every verification costs this much and a device may only have one in // Cost + one-in-flight limit make guessing the 8 character code pointless.
// flight - together they make guessing an 8 character code over the LAN
// pointless (and a failed guess cannot be retried in a tight loop).
const VERIFY_DELAY_MS = 2000 const VERIFY_DELAY_MS = 2000
// How long a session token stays valid without any request. Env override for // Session token TTL; env override for tests.
// tests, which cannot wait half an hour to prove expiry.
const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 1000 const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 1000
const SESSION_IDLE_MS = 5 * 60 * 1000 const SESSION_IDLE_MS = 5 * 60 * 1000
// How long the receiving user has to click Accept before the request expires: // Receiver consent window; the sender's verify timeout must exceed it.
// the sender waits for this decision, so its own verify timeout has to be
// longer than this.
const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) || 60 * 1000 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 // The only trees a sync may touch; the rest of files/ is local state.
// state (settings, translations, playtime, logs) that must never move
// between machines.
const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/'] const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
// What a sync can carry, plus lenient aliases so callers can name the // Sync categories plus lenient aliases for older senders.
// categories the way the UI/README does.
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings'] const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
const CATEGORY_ALIASES = { const CATEGORY_ALIASES = {
// plain "apps" means the whole app library: portable ones AND the ones // the whole app library: portable + prefix-import launcher entries
// imported from a prefix (only the launcher entries, their files stay in
// the prefix)
apps: ['apps-portable', 'apps-prefix'], apps: ['apps-portable', 'apps-prefix'],
app: ['apps-portable', 'apps-prefix'], app: ['apps-portable', 'apps-prefix'],
appsportable: ['apps-portable'], appsportable: ['apps-portable'],
@ -80,10 +64,7 @@ const CATEGORY_ALIASES = {
wineprefixes: ['prefixes'], wineprefixes: ['prefixes'],
prefixes: ['prefixes'], prefixes: ['prefixes'],
prefix: ['prefixes'], prefix: ['prefixes'],
// the launcher's own settings ("Sync settings"): the defaults, the names and // launcher settings: defaults, wine labels, prefix registry, app configs
// 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.
settings: ['settings'], settings: ['settings'],
syncsettings: ['settings'], syncsettings: ['settings'],
defaults: ['settings'], defaults: ['settings'],
@ -91,9 +72,7 @@ const CATEGORY_ALIASES = {
defaultsettings: ['settings'] defaultsettings: ['settings']
} }
// Which sync category an app entry belongs to. Portable copy/move imports own // An app entry's category: portable imports own their apps/ folder.
// their folder in files/apps; prefix imports only have a launcher entry (their
// files stay inside the prefix).
function appCategory(game) { function appCategory(game) {
if (!game || typeof game !== 'object') return null if (!game || typeof game !== 'object') return null
if (game.importSource === 'prefix' || game.mode === 'prefix') return 'apps-prefix' if (game.importSource === 'prefix' || game.mode === 'prefix') return 'apps-prefix'
@ -101,16 +80,11 @@ function appCategory(game) {
return null return null
} }
// Where an app entry's files live, read off its stored path: a portable // An app entry's files, read off its stored path; outside the data = not synced.
// 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.
function classifyApp(app) { function classifyApp(app) {
if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null
const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir
// nothing that points anywhere inside the launcher data: the app is left // external entry: nothing to sync
// where it is (an external entry), nothing of it can travel in a sync
if (!raw || typeof raw !== 'string') return 'external' if (!raw || typeof raw !== 'string') return 'external'
const value = raw.replace(/\\/g, '/') 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) 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 --------------------------------------- // --- files: hashing, paths, manifests ---------------------------------------
// Hashing a tree is dominated by opening the (often tiny) files, not by the // A few digests in flight keep the disk busy on many small files.
// digest itself: a few in flight at once keeps the disk and the cpu busy
// instead of waiting on one file at a time.
const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4)) 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 // Digests cached per file (size + mtime); one store per launcher state.
// change since the last sync is never read again - only stat'ed. One store per
// launcher state (tests re-require this module per device).
const hashStore = createHashCache(DIRS.DATA) const hashStore = createHashCache(DIRS.DATA)
const hashFile = hashStore.hashFile const hashFile = hashStore.hashFile
// Protocol paths are FILES-relative with '/' separators. Anything escaping // Protocol paths are FILES-relative; escapes are rejected (untrusted).
// files/ (a '..' segment, an absolute path) is rejected - manifests come from
// another machine.
function filesPath(rel) { function filesPath(rel) {
if (typeof rel !== 'string' || !rel || rel.includes('\0') || path.isAbsolute(rel)) return null if (typeof rel !== 'string' || !rel || rel.includes('\0') || path.isAbsolute(rel)) return null
const root = path.resolve(DIRS.FILES) const root = path.resolve(DIRS.FILES)
@ -171,8 +139,7 @@ const buildManifest = manifest.buildManifest
// --- HTTP helpers (shared with the pusher) ---------------------------------- // --- HTTP helpers (shared with the pusher) ----------------------------------
// One-shot JSON request against another instance. `timeout: 0` means no socket // One-shot JSON request; timeout 0 = none (remote hashing is slow).
// timeout - hashing a big prefix on the other side takes a while.
function httpJson(device, method, route, { json, timeout = 15000 } = {}) { function httpJson(device, method, route, { json, timeout = 15000 } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = http.request( const req = http.request(
@ -216,10 +183,9 @@ function targetOf(target) {
return null return null
} }
// Send our sync code to the other device (the receiver checks it against its // Trade the sync code for a token; a rejected code is never stored.
// own code and hands back a token). A rejected code never gets stored.
async function verifyPeer(target, code) { 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 if (target && typeof target === 'object' && code === undefined && target.code !== undefined) code = target.code
const device = targetOf(target) const device = targetOf(target)
if (!device || !device.port) return { ok: false, error: 'bad-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 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 // A size mismatch alone settles it - no need to hash.
// would only confirm what the sizes already say. lstatSync because the
// answer (is it the regular file the manifest describes) needs no thread.
let st let st
try { try {
st = fs.lstatSync(abs) st = fs.lstatSync(abs)
@ -329,9 +293,7 @@ async function entrySame(entry) {
return false return false
} }
if (!same) return false if (!same) return false
// Same bytes, wrong permissions (a build synced before modes travelled, or a // Same bytes, wrong mode: repair locally instead of re-downloading.
// copy that dropped the bit): repaired right here instead of pulling the
// whole file over the network again.
if (typeof entry.mode === 'number' && (st.mode & 0o777) !== entry.mode) applyMode(abs, entry.mode) if (typeof entry.mode === 'number' && (st.mode & 0o777) !== entry.mode) applyMode(abs, entry.mode)
return true return true
} }
@ -348,20 +310,14 @@ function ensureDir(abs) {
} catch {} } catch {}
} }
// After a unit has landed, its links and permissions have to be this machine's // After a unit lands, rewire links and restore modes, or it cannot start.
// (the sender stored whatever was true over there): absolute symlinks pointing
// into the launcher data become relative, and a build gets back the executable
// bit a content copy drops. Without this a synced prefix/proton build is dead
// weight - the launcher cannot start it.
async function healUnitRoots(roots) { async function healUnitRoots(roots) {
const healed = { links: 0, executables: 0 } const healed = { links: 0, executables: 0 }
for (const rel of Array.isArray(roots) ? roots : []) { for (const rel of Array.isArray(roots) ? roots : []) {
const abs = filesPath(ownedRoot(rel)) const abs = filesPath(ownedRoot(rel))
if (!abs) continue if (!abs) continue
try { try {
// a unit root that is a symlink is the whole unit: its target is not // a symlinked unit root is not ours to walk; its own unit heals it
// ours to walk (it may live outside the launcher), and the real folder
// behind it is healed through its own unit
if (!fs.lstatSync(abs).isDirectory()) continue if (!fs.lstatSync(abs).isDirectory()) continue
healed.links += await rewireSymlinks(abs, [DIRS.FILES]) healed.links += await rewireSymlinks(abs, [DIRS.FILES])
if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs) if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs)
@ -384,8 +340,7 @@ function ensureLink(abs, target) {
} catch {} } catch {}
} }
// Reports whether something was ACTUALLY removed: rmSync with force also // rmSync(force) "succeeds" on missing paths; only real removals count.
// "succeeds" on a missing path, which would inflate the delete counters.
function removeAbs(abs) { function removeAbs(abs) {
try { try {
if (!fs.lstatSync(abs)) return false if (!fs.lstatSync(abs)) return false
@ -426,10 +381,7 @@ function removeOrphans(dir, keep) {
return removed return removed
} }
// Folder names in a registry directory. A symlinked folder counts too: a // Registry folder names; symlinked folders count as the link they are.
// 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).
function listDirs(dir) { function listDirs(dir) {
const out = [] const out = []
let entries let entries
@ -453,9 +405,7 @@ function listDirs(dir) {
// Delete everything under a unit root that is not in the sender's manifest. // Delete everything under a unit root that is not in the sender's manifest.
async function pruneTree(abs, rel, keep) { async function pruneTree(abs, rel, keep) {
// A unit root can BE a symlink (a build or prefix kept on another disk). // A symlinked unit root: readdir would delete the target's real files.
// readdir would follow it and delete the real files behind it, which belong
// to the target, not to this unit.
let rootStat = null let rootStat = null
try { try {
rootStat = await fsp.lstat(abs) rootStat = await fsp.lstat(abs)
@ -482,8 +432,7 @@ async function pruneTree(abs, rel, keep) {
return removed return removed
} }
// Modes are applied after the bytes are in place: createWriteStream cannot set // Modes go on after the bytes; a build without the exec bit is unusable.
// one, and a wine build without its executable bit is unusable.
function applyMode(abs, mode) { function applyMode(abs, mode) {
if (typeof mode !== 'number' || !mode) return if (typeof mode !== 'number' || !mode) return
try { try {
@ -514,15 +463,13 @@ function writeStream(abs, stream, onBytes) {
fs.renameSync(tmp, abs) fs.renameSync(tmp, abs)
resolve(out.bytesWritten || 0) resolve(out.bytesWritten || 0)
} catch (err) { } catch (err) {
// a sync race (the path changed between open and rename) must not // a rename race must not undo the transfer
// undo the transfer itself - the next sync would just re-upload it
removeAbs(tmp) removeAbs(tmp)
resolve(0) resolve(0)
} }
}) })
if (onBytes) { if (onBytes) {
// the pipe in flowing mode delivers every chunk to each 'data' listener, // flowing mode delivers every chunk to each listener
// so counting here does not steal the bytes from the file being written
stream.on('data', chunk => onBytes(chunk.length)) stream.on('data', chunk => onBytes(chunk.length))
} }
stream.pipe(out) stream.pipe(out)
@ -546,11 +493,7 @@ function applyApps(payload, cats) {
} }
kept.push(game) kept.push(game)
} }
// Playtime and the app's own settings (wine build, prefix, args, env, // App settings travel as stored; playtime only grows (higher wins).
// dllOverrides) travel with the app: paths are left exactly as the sender
// stored them (portable/prefix-relative), so a synced entry keeps working.
// Playtime only ever GROWS - the higher counter wins, so pushing from a
// freshly installed machine cannot wipe a library's play history.
const merged = [...kept] const merged = [...kept]
for (const g of incoming) { for (const g of incoming) {
const prev = existing.find(x => x.id === g.id) 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 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) const lastPlayed = Math.max(Number(prev && prev.lastPlayed) || 0, Number(g.lastPlayed) || 0)
merged.push({ ...g, playSeconds, lastPlayed }) merged.push({ ...g, playSeconds, lastPlayed })
// the per-app playtime file wins over the registry, so a synced counter // the per-app playtime file wins over the registry; write it too
// has to be written there too or loadGames would keep reporting the old one
if (hasPlay) library.writePlaySeconds(g.id, playSeconds) if (hasPlay) library.writePlaySeconds(g.id, playSeconds)
} }
library.saveGames(merged) library.saveGames(merged)
@ -567,8 +509,7 @@ function applyApps(payload, cats) {
for (const id of dropped) removed += removeTree(`data/icons/${id}`) for (const id of dropped) removed += removeTree(`data/icons/${id}`)
const iconsRemoved = removed const iconsRemoved = removed
if (cats.has('apps-portable')) { if (cats.has('apps-portable')) {
// files/apps only ever holds portable (copy/move) imports, so anything // files/apps holds only portable imports; the rest is mirror leftover
// the sender does not own is a leftover of the mirror
removed += removeOrphans(DIRS.APPS, incomingIds) removed += removeOrphans(DIRS.APPS, incomingIds)
} }
return removed return removed
@ -577,8 +518,7 @@ function applyApps(payload, cats) {
async function applyWines(payload) { async function applyWines(payload) {
const names = new Set((Array.isArray(payload.wines) ? payload.wines : []).map(w => w && w.name).filter(Boolean)) const names = new Set((Array.isArray(payload.wines) ? payload.wines : []).map(w => w && w.name).filter(Boolean))
const removed = removeOrphans(DIRS.WINES, names) const removed = removeOrphans(DIRS.WINES, names)
// the registry is derived from the builds on disk, so a rescan is all that // the registry derives from disk; a rescan picks up the synced builds
// is needed to drop removed builds and pick up the synced ones
await wines.refreshWines() await wines.refreshWines()
return removed return removed
} }
@ -589,27 +529,20 @@ function applyPrefixes(payload, useSenderDefault) {
const existing = prefixes.listPrefixes() const existing = prefixes.listPrefixes()
const byName = new Map(existing.prefixes.map(p => [p.name, p])) const byName = new Map(existing.prefixes.map(p => [p.name, p]))
const keep = new Map(incoming.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 // never delete the receiver's own Main prefix
// copy just because the sender does not know it
if (!keep.has(prefixes.MAIN)) { if (!keep.has(prefixes.MAIN)) {
keep.set(prefixes.MAIN, byName.get(prefixes.MAIN) || { name: prefixes.MAIN, created: Date.now() }) keep.set(prefixes.MAIN, byName.get(prefixes.MAIN) || { name: prefixes.MAIN, created: Date.now() })
} }
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys())) const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked })) const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked }))
// Which prefix is the default only moves when the user asked for the default // the default prefix moves only with synced default settings
// settings to be synced; otherwise this device keeps its own (as long as the
// prefix still exists here).
const wanted = useSenderDefault ? sender.default : existing.default const wanted = useSenderDefault ? sender.default : existing.default
const def = keep.has(wanted) ? wanted : prefixes.MAIN const def = keep.has(wanted) ? wanted : prefixes.MAIN
prefixes.savePrefixes({ default: def, prefixes: list }) prefixes.savePrefixes({ default: def, prefixes: list })
return removed return removed
} }
// Wine display names and disabled flags ("Sync settings"): registry-only // Wine display names/disabled flags by build id; empty name clears the label.
// 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.
function applyWineSettings(payload) { function applyWineSettings(payload) {
const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : [] const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : []
let applied = 0 let applied = 0
@ -635,11 +568,7 @@ function applyWineSettings(payload) {
return applied return applied
} }
// Prefix REGISTRY names ("Sync settings"): a prefix's name IS its folder, so // Prefix registry names only; registers sender entries as known here.
// 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.
function applyPrefixSettings(payload) { function applyPrefixSettings(payload) {
const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null
if (!sender) return 0 if (!sender) return 0
@ -659,12 +588,7 @@ function applyPrefixSettings(payload) {
return added return added
} }
// Per-app configuration ("Sync settings"): apps are matched by id and only // Per-app config: patch existing apps only; unknown ids are skipped.
// 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).
function applyAppSettings(payload) { function applyAppSettings(payload) {
const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : [] const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : []
if (!incoming.length) return 0 if (!incoming.length) return 0
@ -696,9 +620,7 @@ function applyAppSettings(payload) {
return patched return patched
} }
// The library ORDER ("Sync settings"): reorderGames keeps entries this device // Library order: entries unknown to the sender stay at the end.
// 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.
function applyAppOrder(payload) { function applyAppOrder(payload) {
const order = Array.isArray(payload.appOrder) ? payload.appOrder.filter(id => typeof id === 'string' && id) : [] const order = Array.isArray(payload.appOrder) ? payload.appOrder.filter(id => typeof id === 'string' && id) : []
if (!order.length) return 0 if (!order.length) return 0
@ -708,10 +630,8 @@ function applyAppOrder(payload) {
return order.length return order.length
} }
// Everything the "Sync settings" category carries. Order matters: prefix names // "Sync settings" payload; order matters (names -> defaults -> configs ->
// are registered before the default prefix is resolved and before app configs // order).
// are checked against them; the app order is applied last so the patched
// library is written in the sender's order.
function applySettings(payload, cats) { function applySettings(payload, cats) {
if (!cats.has('settings')) return null if (!cats.has('settings')) return null
return { return {
@ -723,10 +643,7 @@ function applySettings(payload, cats) {
} }
} }
// The launcher-wide defaults ("Sync settings"): the wine every app falls back // Launcher defaults; only values that exist here are taken.
// to and the prefix new imports land in. Only values that exist HERE are taken
// - a default pointing at a build or prefix this device does not have would be
// worse than keeping the current one.
function applyDefaults(payload) { function applyDefaults(payload) {
const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {} const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {}
const applied = {} const applied = {}
@ -752,9 +669,7 @@ function createServerState() {
return { tokens: new Map(), verifying: new Set(), pending: new Map(), session: null } 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 // The user's popup answer; unknown/expired ids are a no-op.
// is waiting for it; unknown/expired ids are a no-op (the request already
// timed out server-side).
let serverState = null let serverState = null
function respondIncoming(requestId, accept) { function respondIncoming(requestId, accept) {
@ -766,14 +681,10 @@ function respondIncoming(requestId, accept) {
return true return true
} }
// Which counter a sync unit feeds on the receiving side (the unit key looks // The receiving-side counter a unit feeds on ("app:<id>" etc.).
// like "app:<id>", "wine:<name>" or "prefix:<name>").
const UNIT_KINDS = { app: 'apps', wine: 'wines', prefix: 'prefixes' } const UNIT_KINDS = { app: 'apps', wine: 'wines', prefix: 'prefixes' }
// Progress of a sync THIS device receives. The pushing device drives the // Progress of a sync this device receives, for the receiver's popup.
// 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.
function emitReceive(hooks, session, stage, extra = {}) { function emitReceive(hooks, session, stage, extra = {}) {
if (!hooks || !hooks.onReceive) return if (!hooks || !hooks.onReceive) return
try { try {
@ -788,13 +699,11 @@ function emitReceive(hooks, session, stage, extra = {}) {
total: extra.total == null ? session.planned || 0 : extra.total, total: extra.total == null ? session.planned || 0 : extra.total,
bytes: session.bytes || 0, bytes: session.bytes || 0,
totalBytes: session.plannedBytes || 0, totalBytes: session.plannedBytes || 0,
// items: units this sync announced, itemsDone: units fully written, // items: announced units, itemsDone: written units, totals: sender's plan
// totals: everything the sender picked (so the popup can say "2/5")
items: { ...(session.items || {}) }, items: { ...(session.items || {}) },
itemsDone: { ...(session.itemsDone || {}) }, itemsDone: { ...(session.itemsDone || {}) },
totals: { ...(session.totals || {}) }, totals: { ...(session.totals || {}) },
// what the BAR shows: the phase being worked on right now, so hashing // the bar shows the current phase, so hashing starts empty
// starts empty instead of continuing a full bar from earlier
barBytes: session.barBytes || 0, barBytes: session.barBytes || 0,
barTotalBytes: session.barTotal || 0, barTotalBytes: session.barTotal || 0,
deleted: extra.deleted || 0 deleted: extra.deleted || 0
@ -819,12 +728,7 @@ function authorize(state, token) {
return null return null
} }
session.lastAt = Date.now() session.lastAt = Date.now()
// The expiry SLIDES with every request: hashing a big unit takes longer than // The expiry slides with every request: hashing can outlive any fixed TTL.
// 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.
session.expires = Date.now() + TOKEN_TTL_MS session.expires = Date.now() + TOKEN_TTL_MS
if (state.session && state.session.token === token) state.session.lastAt = session.lastAt if (state.session && state.session.token === token) state.session.lastAt = session.lastAt
return session 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' }) if (!payload || !payload.deviceId) return sendJson(res, 400, { ok: false, error: 'bad-request' })
const me = identity() const me = identity()
const from = String(payload.deviceId) const from = String(payload.deviceId)
// one verification per device at a time; other devices may not slip their // one verification per device at a time
// verification into an ongoing sync either
if (state.verifying.has(from)) return sendJson(res, 429, { ok: false, error: 'busy' }) if (state.verifying.has(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
const running = activeSession(state) const running = activeSession(state)
if (running && running.deviceId !== from) return sendJson(res, 429, { ok: false, error: 'busy' }) 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 // one unanswered consent popup at a time
// overwrite the first popup or silently wait out its 60 seconds
if (state.pending.size) return sendJson(res, 429, { ok: false, error: 'busy' }) 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' }) if (hooks.isBusy && hooks.isBusy(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
state.verifying.add(from) state.verifying.add(from)
try { try {
await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS)) await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS))
if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' }) if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' })
// the sender may announce its totals with the verification, which is how // the sender may announce totals up front ("0/5" in the popup)
// the popup here can show "Apps: 0/5" before the first file list arrives
const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {} const announced = payload.totals && typeof payload.totals === 'object' ? payload.totals : {}
// Nothing moves without the user behind this window accepting the push: // a correct code is not consent; without a UI to ask, the gate stays open
// 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.
if (typeof hooks.onIncoming === 'function') { if (typeof hooks.onIncoming === 'function') {
const requestId = crypto.randomBytes(12).toString('hex') const requestId = crypto.randomBytes(12).toString('hex')
const decision = await new Promise(resolve => { const decision = await new Promise(resolve => {
@ -920,8 +817,7 @@ async function handleVerify(state, req, res, hooks) {
expires: Date.now() + TOKEN_TTL_MS, expires: Date.now() + TOKEN_TTL_MS,
lastAt: Date.now(), lastAt: Date.now(),
units: new Map(), units: new Map(),
// counters behind the "Receiving from X…" popup: files written, bytes // popup counters: files, bytes, sender's totals learned per unit
// written, and the same totals the sender planned (learned per unit)
counted: new Set(), counted: new Set(),
received: 0, received: 0,
planned: 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) for (const entry of entries) if (entry.type === 'file' && typeof entry.mode === 'number') modes.set(entry.path, entry.mode)
// a force sync compares nothing: every entry counts as different // a force sync compares nothing: every entry counts as different
const forced = url.searchParams.get('force') === '1' const forced = url.searchParams.get('force') === '1'
// how many apps/prefixes/wines the sender picked in total: without it the // the sender's totals, so the popup can say "2/5" from the start
// popup here could only ever say what has already arrived
try { try {
const totals = JSON.parse(url.searchParams.get('totals') || 'null') const totals = JSON.parse(url.searchParams.get('totals') || 'null')
if (totals && typeof totals === 'object') { if (totals && typeof totals === 'object') {
@ -983,8 +878,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
} }
} catch {} } catch {}
const allowed = entries.filter(entry => withinRoots(entry.path, owned)) const allowed = entries.filter(entry => withinRoots(entry.path, owned))
// Comparing this unit is a phase of its own: the bar fills with the bytes // comparing is its own phase: the bar restarts for the copy phase
// checked here, then starts over empty for the files that follow.
const unitTotalBytes = allowed.reduce((sum, entry) => sum + (entry.size || 0), 0) const unitTotalBytes = allowed.reduce((sum, entry) => sum + (entry.size || 0), 0)
const announce = progressGate((data, force) => emitReceive(hooks, session, 'plan', data)) const announce = progressGate((data, force) => emitReceive(hooks, session, 'plan', data))
let compared = 0 let compared = 0
@ -996,10 +890,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
compared++ compared++
comparedBytes += entry.size || 0 comparedBytes += entry.size || 0
session.barBytes = comparedBytes session.barBytes = comparedBytes
// the file counter is not touched here: compared items include dirs and // no file counting yet: dirs/links are compared too
// 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
announce({ unit: label }) announce({ unit: label })
return same === true 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 }) session.units.set(key, { roots: owned, paths, label, modes })
// what this unit adds to the transfer: the popup reports files and bytes // this unit's files/bytes, reported as they arrive
// against these as they arrive
session.planned += upload.length session.planned += upload.length
session.plannedBytes += uploadBytes session.plannedBytes += uploadBytes
// the files are next: the bar counts the bytes about to be written, from an // the bar restarts for the copy phase
// empty bar - a unit that needs nothing at all is simply finished
session.unitWritten = 0 session.unitWritten = 0
session.barTotal = uploadBytes session.barTotal = uploadBytes
session.barBytes = 0 session.barBytes = 0
@ -1038,16 +927,14 @@ async function handlePlan(state, session, url, req, res, hooks) {
session.counted.add(key) session.counted.add(key)
session.items[kind] = (session.items[kind] || 0) + 1 session.items[kind] = (session.items[kind] || 0) + 1
} }
// the unit's human label (game / prefix / wine name) is announced while it // the unit's label is announced up front, so file events can name it
// is planned, so the later file events can name it in the popup
session.unitLabel = label session.unitLabel = label
sendJson(res, 200, { ok: true, upload, same, entries: paths.size }) sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
announce({ unit: label }, true) announce({ unit: label }, true)
} }
async function handlePut(session, url, req, res, hooks) { async function handlePut(session, url, req, res, hooks) {
// unit and path come as headers: they are constant per request, and routing // unit and path travel as headers: constant per request
// them through the query made the receiver parse a fresh URL per file
const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || '' const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || ''
const rel = req.headers['x-plwl-path'] || url.searchParams.get('path') || '' const rel = req.headers['x-plwl-path'] || url.searchParams.get('path') || ''
const unit = session.units.get(key) const unit = session.units.get(key)
@ -1056,10 +943,7 @@ async function handlePut(session, url, req, res, hooks) {
req.resume() req.resume()
return sendJson(res, 400, { ok: false, error: 'bad-path' }) return sendJson(res, 400, { ok: false, error: 'bad-path' })
} }
// Bytes are reported while the file is still arriving: waiting for the whole // report bytes while the file is still arriving
// file to land would leave the popup frozen for the entire transfer of a
// single big asset (that is what made it look like it only moved once per
// received item).
const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data)) const announce = progressGate((data, force) => emitReceive(hooks, session, 'copy', data))
try { try {
await writeStream(abs, req, delta => { await writeStream(abs, req, delta => {
@ -1068,8 +952,7 @@ async function handlePut(session, url, req, res, hooks) {
session.barBytes = session.unitWritten session.barBytes = session.unitWritten
announce({ unit: session.unitLabel || '' }) announce({ unit: session.unitLabel || '' })
}) })
// permissions do not travel with the bytes: the executable bit of a wine // modes do not travel with the bytes; restore from the manifest
// build's binaries has to be restored from the sender's manifest
applyMode(abs, unit.modes.get(rel)) applyMode(abs, unit.modes.get(rel))
session.received = (session.received || 0) + 1 session.received = (session.received || 0) + 1
sendJson(res, 200, { ok: true }) sendJson(res, 200, { ok: true })
@ -1088,18 +971,16 @@ async function handleCommit(session, url, res, hooks) {
const abs = filesPath(root) const abs = filesPath(root)
if (abs) removed += await pruneTree(abs, root, unit.paths) if (abs) removed += await pruneTree(abs, root, unit.paths)
} }
// the unit is complete - rewiring its links is only worth doing once the // rewire links only once the whole tree is there
// whole tree is there (a prefix's links chain through its own files)
try { try {
await healUnitRoots(unit.roots) await healUnitRoots(unit.roots)
} catch {} } catch {}
session.units.delete(key) session.units.delete(key)
session.deleted = (session.deleted || 0) + removed session.deleted = (session.deleted || 0) + removed
// a finished unit: this is what turns "Apps: 2/5" from the sender's totals // a finished unit completes its "2/5" slice
// and the units written here
const kind = UNIT_KINDS[String(key).split(':')[0]] const kind = UNIT_KINDS[String(key).split(':')[0]]
if (kind) session.itemsDone[kind] = (session.itemsDone[kind] || 0) + 1 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.barTotal = session.barTotal || 1
session.barBytes = session.barTotal session.barBytes = session.barTotal
sendJson(res, 200, { ok: true, deleted: removed }) 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) { async function handleApply(session, req, res, hooks) {
const payload = await readJsonBody(req) const payload = await readJsonBody(req)
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' }) if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
// the categories come from another machine (and an older PLWL names the // sender categories go through the aliases (older PLWL says "defaults")
// settings category "defaults"), so they go through the aliases here too
const cats = new Set(normalizeCategories(payload.categories)) const cats = new Set(normalizeCategories(payload.categories))
const appsDeleted = applyApps(payload, cats) const appsDeleted = applyApps(payload, cats)
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0 const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('settings')) : 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 // after the registries, so synced builds/prefixes are usable as defaults
// be chosen as the default and app configs can be checked against them
const settings = applySettings(payload, cats) const settings = applySettings(payload, cats)
const removed = appsDeleted + winesDeleted + prefixesDeleted const removed = appsDeleted + winesDeleted + prefixesDeleted
emitReceive(hooks, session, 'registry', { deleted: removed }) emitReceive(hooks, session, 'registry', { deleted: removed })
@ -1151,12 +1030,7 @@ function handleEnd(state, session, res, hooks) {
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 }) emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
} }
// Liveness ping from the pushing device. It hashes whole units before it // Liveness ping: hashing can take minutes; re-arms the popup watchdog.
// 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.
function handlePing(session, res, hooks) { function handlePing(session, res, hooks) {
session.lastAt = Date.now() session.lastAt = Date.now()
emitReceive(hooks, session, 'wait') emitReceive(hooks, session, 'wait')
@ -1185,8 +1059,7 @@ let httpServer = null
let serverHooks = {} let serverHooks = {}
let listening = null // promise resolved once the receiving server is up 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() // Bind the receiving server; resolves with { port, close() }.
// for tests; the app itself only needs start()/stop().
function startServer(options = {}) { function startServer(options = {}) {
if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options } if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options }
if (listening) return listening if (listening) return listening
@ -1212,8 +1085,7 @@ function startServer(options = {}) {
} }
function stopServer() { function stopServer() {
// new digests must not die with the process: the throttled mid-sync saves // throttled mid-sync saves may have skipped the last digests
// may have skipped the last few units
hashStore.save(true) hashStore.save(true)
if (httpServer) { if (httpServer) {
try { try {
@ -1253,9 +1125,7 @@ function getState() {
return { deviceId: me.deviceId, syncCode: me.syncCode, hash: hashStore.HASH_ALGO, port: httpPort, devices: listDevices() } 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 // The pusher is built with this module's helpers, so launcher states never mix.
// (identity, paths, registries), so one copy of it can serve several launcher
// states without mixing them up.
let pusher = null let pusher = null
function getPusher() { function getPusher() {
if (!pusher) { if (!pusher) {
@ -1289,8 +1159,7 @@ function syncTo(payload) {
return getPusher().syncToDevice(payload) return getPusher().syncToDevice(payload)
} }
// Push to one peer in a single call. `options` may carry the peer's record // Push to one peer; options: peer record, code, categories, force.
// (host/port/id/name), the sync code, the categories to push and force.
function runSync(options = {}, onProgress) { function runSync(options = {}, onProgress) {
const target = options.port || options.host || options.address ? targetOf(options) : null const target = options.port || options.host || options.address ? targetOf(options) : null
const deviceId = options.deviceId || options.id const deviceId = options.deviceId || options.id

View file

@ -1,26 +1,19 @@
'use strict' 'use strict'
// The pushing side of a LAN sync: this device is the source of truth and // The pushing side of a LAN sync: per unit, send a manifest, learn what the
// drives the whole transfer. For every sync unit it sends a manifest, asks // receiver has, upload the rest, delete the surplus. Exported as a factory;
// the receiver what it already has, uploads the rest and has the receiver // src/lansync.js hands in its own helpers and state.
// 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.
const http = require('http') const http = require('http')
const os = require('os') const os = require('os')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
// Files sent at once: one at a time wastes most of a LAN round trip on the // Files sent at once: one at a time wastes LAN round trips on small files.
// small files a game or prefix is mostly made of.
const UPLOAD_CONCURRENCY = 4 const UPLOAD_CONCURRENCY = 4
// Keeps the receiving side's session and popup alive while this device hashes // Keeps the receiver alive while this side hashes (minutes of silence); env
// a unit (minutes of complete silence from the network) - well under the // override for tests.
// receiving popup's watchdog, and cheap enough to run through the transfer too.
// Env override: tests cannot wait 20 s for a ping.
const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000 const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
function createPusher(api) { function createPusher(api) {
@ -42,11 +35,9 @@ function createPusher(api) {
prefixes prefixes
} = api } = api
// Manifest in, list of files to upload out. NDJSON keeps a prefix with tens // Manifest in, upload list out; NDJSON avoids one giant string.
// of thousands of entries from being one giant string in memory either side.
function planUnit(device, token, unit, entries, force, meta) { function planUnit(device, token, unit, entries, force, meta) {
// the receiver is told how many apps/prefixes/wines the whole sync carries // announced up front, so the receiver's popup can say "Apps: 2/5"
// up front, which is what lets its popup say "Apps: 2/5"
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : '' const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}${totals}` 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) { function putFile(device, token, unitKey, rel, abs) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// unit and path travel as headers: the receiver used to parse a fresh // unit and path travel as headers: constant per request
// query string per file for values that are constant per request
const req = http.request( const req = http.request(
{ {
host: device.address, host: device.address,
@ -139,12 +129,7 @@ function createPusher(api) {
for (const wine of wines.getWines()) { for (const wine of wines.getWines()) {
const rel = String(toPortablePath(wine.path) || '') const rel = String(toPortablePath(wine.path) || '')
const parts = rel.split('/') const parts = rel.split('/')
// a build folder can sit one level down (zip-extracted layouts are // the unit is the TOP folder under wines/, not the detected build dir
// 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.
add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label) 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}`) for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`)
@ -167,9 +152,7 @@ function createPusher(api) {
// --- registry payload ----------------------------------------------------- // --- registry payload -----------------------------------------------------
// Everything the app config holds travels: paths (portableGame rewrites them // The whole app config travels (paths rewritten to relative form).
// to the FILES/prefix relative form both machines understand), the wine
// build and prefix it runs in, args/env/dllOverrides and the play counters.
function portableApp(game) { function portableApp(game) {
return library.portableGame(game) return library.portableGame(game)
} }
@ -191,13 +174,8 @@ function createPusher(api) {
payload.wines = wineUnits().map(unit => ({ name: unit.id })) payload.wines = wineUnits().map(unit => ({ name: unit.id }))
} }
if (cats.has('settings')) { if (cats.has('settings')) {
// "Sync settings": every registry-level value that describes how the // "Sync settings": registry-level values only; opt-in - apps must not
// launcher is set up here - the defaults, the names/flags the user gave // silently rewrite the other machine's settings.
// 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.
const defs = prefixes.listPrefixes() const defs = prefixes.listPrefixes()
const games = library.loadGames() const games = library.loadGames()
payload.defaults = { payload.defaults = {
@ -243,9 +221,7 @@ function createPusher(api) {
skipped: 0, skipped: 0,
deleted: 0, deleted: 0,
bytes: 0, bytes: 0,
// how many apps / wine builds / prefixes this sync walked over: the // the popup reports "3 games, 1 prefix" instead of file counts
// progress popup reports "3 games, 1 prefix" instead of file counts,
// which mean nothing to the user
items: { apps: 0, wines: 0, prefixes: 0 }, items: { apps: 0, wines: 0, prefixes: 0 },
apps: { copied: 0, skipped: 0, deleted: 0 }, apps: { copied: 0, skipped: 0, deleted: 0 },
wines: { 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 // Every sync resolves to the same shape, success or failure.
// callers can read counters/events without checking first.
function syncResult(ok, error, summary, events) { function syncResult(ok, error, summary, events) {
const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines } const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines }
summary.progress = events.length summary.progress = events.length
@ -280,23 +255,19 @@ function createPusher(api) {
} }
} }
// Push the selected categories onto the peer. A rejected code comes back as // Push to the peer; a rejected code comes back as 'bad-code'.
// error 'bad-code', so the UI can offer to type it again.
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) { async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || []) const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || [])
const device = target || api.listDevices().find(d => d.id === deviceId) const device = target || api.listDevices().find(d => d.id === deviceId)
if (!device || !device.address || !device.port) return fail('offline') if (!device || !device.address || !device.port) return fail('offline')
const cats = new Set(normalizeCategories(categories)) const cats = new Set(normalizeCategories(categories))
if (!cats.size) return fail('nothing-selected') if (!cats.size) return fail('nothing-selected')
// The units are known before anything is read, so how many apps / wine // units are known before anything is read: announce them with the verify
// builds / prefixes this sync carries is announced with the verification:
// the other side can show "Apps: 0/5" from its first event.
const units = buildUnits(cats) const units = buildUnits(cats)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps') const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const totals = { apps: 0, wines: 0, prefixes: 0 } const totals = { apps: 0, wines: 0, prefixes: 0 }
for (const unit of units) totals[bucketOf(unit.kind)] += 1 for (const unit of units) totals[bucketOf(unit.kind)] += 1
// every progress event is kept (and handed to onProgress when given) so // every progress event is kept, so callers can report without subscribing
// callers can report on the transfer without subscribing first
const events = [] const events = []
const progress = data => { const progress = data => {
events.push(data) events.push(data)
@ -305,8 +276,7 @@ function createPusher(api) {
let verified let verified
try { try {
// longer than the receiver's incoming-request timeout: the user over // longer than the receiver's incoming-request timeout (popup wait)
// there gets a popup and this side waits for the click
verified = await httpJson(device, 'POST', '/plwl/verify', { verified = await httpJson(device, 'POST', '/plwl/verify', {
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals }, json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase(), totals },
timeout: 90000 timeout: 90000
@ -316,21 +286,15 @@ function createPusher(api) {
} }
if (!verified.data || !verified.data.ok) { if (!verified.data || !verified.data.ok) {
const error = (verified.data && verified.data.error) || `http-${verified.status}` const error = (verified.data && verified.data.error) || `http-${verified.status}`
// the peer rotated its code: drop the stored pairing, so the next // a rotated code drops the pairing; a plain 'rejected' keeps it
// 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.
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId) if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
return fail(error) return fail(error)
} }
const token = verified.data.token const token = verified.data.token
// the code worked, so this device is paired now: the next sync needs no // paired now: the next sync needs no typing
// typing (a rejected code is never stored)
rememberDevice(device.id || deviceId, code, device.name) rememberDevice(device.id || deviceId, code, device.name)
// Hashing a big unit happens here, before a single byte is sent: without a // hashing precedes any byte: keep the receiver's popup alive meanwhile
// sign of life the other side would see minutes of silence and its
// "receiving" popup could close while this device is still working.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {}) httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {})
}, HEARTBEAT_MS) }, HEARTBEAT_MS)
@ -338,17 +302,13 @@ function createPusher(api) {
const summary = newSummary(units.length) const summary = newSummary(units.length)
const finished = { apps: 0, wines: 0, prefixes: 0 } const finished = { apps: 0, wines: 0, prefixes: 0 }
// bytes the units planned so far will put on the wire: the popup can then // planned bytes so far, so the popup can show "1.2 GB / 3.4 GB"
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
let plannedBytes = 0 let plannedBytes = 0
let itemsSeen = 0 let itemsSeen = 0
// files uploaded/announced in PREVIOUS units: the popup's file counter is // files from previous units: the popup counter is cumulative
// cumulative across the whole sync (a per-unit counter resets with every
// next app, which reads like progress going backwards)
let allDone = 0 let allDone = 0
let allTotal = 0 let allTotal = 0
// the bar tracks the PHASE being worked on (its own bytes), never a // the bar tracks the current phase only: hashing starts empty
// leftover from the phase before: hashing starts from an empty bar
let barBytes = 0 let barBytes = 0
let barTotal = 0 let barTotal = 0
const snapshot = (phase, unit, done, total) => { const snapshot = (phase, unit, done, total) => {
@ -371,19 +331,14 @@ function createPusher(api) {
barBytes, barBytes,
barTotalBytes: barTotal barTotalBytes: barTotal
} }
// only phases that actually move the file counter carry one (the copy // only the copy phase carries a file count, so the popup never jumps back
// 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
if (Number.isFinite(done) && Number.isFinite(total)) { if (Number.isFinite(done) && Number.isFinite(total)) {
data.done = done data.done = done
data.total = total data.total = total
} }
return data return data
} }
// hashing and copying walk thousands of files: the state is snapshotted on // snapshot per file, forward a few times per second (phase changes always)
// every file but forwarded a few times per second (phase changes always
// go through, so nothing on screen ever jumps backwards)
const gate = typeof api.progressGate === 'function' ? api.progressGate(progress) : data => progress(data) const 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 emit = (phase, unit, done, total, force) => gate(snapshot(phase, unit, done, total), !!force)
const end = async () => { const end = async () => {
@ -405,8 +360,7 @@ function createPusher(api) {
const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => { const entries = await buildManifest(roots, !!force, (done, total, hashedBytes, totalBytes) => {
barBytes = hashedBytes barBytes = hashedBytes
barTotal = totalBytes barTotal = totalBytes
// the file counter is not touched here: until the plan comes back // no file count until the plan returns
// nobody knows how many of this unit's files will actually move
emit('hash', unit, null, null) emit('hash', unit, null, null)
}) })
emit('plan', unit, null, null, true) 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}`) if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
const upload = new Set(plan.data.upload || []) const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || []) const same = new Set(plan.data.same || [])
// per category the FILES count: created/overwritten vs already // per category: files created/overwritten vs already identical
// identical (the plan reports both; dirs and links are re-ensured
// for free and are not files that moved)
const fileEntries = entries.filter(e => e.type === 'file') const fileEntries = entries.filter(e => e.type === 'file')
const filePaths = new Set(fileEntries.map(e => e.path)) const filePaths = new Set(fileEntries.map(e => e.path))
let sameFiles = 0 let sameFiles = 0
@ -431,10 +383,8 @@ function createPusher(api) {
pending.push(entry) pending.push(entry)
plannedBytes += entry.size || 0 plannedBytes += entry.size || 0
} }
// a few files in flight at once: with thousands of small ones the // a few files in flight at once: small files cost round trips
// round trip to the other machine costs more than the bytes do // the bar counts only bytes that move, and starts empty for the copy
// the files that were already identical never move, so the bar counts
// only the bytes that do - and starts empty for the transfer phase
if (pending.length) { if (pending.length) {
barBytes = 0 barBytes = 0
barTotal = pending.reduce((sum, entry) => sum + (entry.size || 0), 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) else for (const entry of pending) await send(entry)
if (failed) throw failed if (failed) throw failed
emit('copy', unit, allDone + done, allTotal + pending.length, true) emit('copy', unit, allDone + done, allTotal + pending.length, true)
// this unit's files count towards the running totals only once they // the totals count a unit's files only once they are on the wire
// are really on the wire - the counter holds there while the receiver
// prunes and the next unit is scanned/compared
allDone += pending.length allDone += pending.length
allTotal += pending.length allTotal += pending.length
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} }) const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })

View file

@ -8,9 +8,8 @@ const { prefixPath, isPrefixRel } = require('./prefixes')
const REGISTRY = path.join(DIRS.DATA, 'apps.json') const REGISTRY = path.join(DIRS.DATA, 'apps.json')
const SETTINGS = path.join(DIRS.DATA, 'settings.json') const SETTINGS = path.join(DIRS.DATA, 'settings.json')
// Playtime lives in its own tiny file holding ONLY a number of seconds // Playtime lives in its own tiny file (files/data/appdata/<id>/playtime.txt),
// (files/data/appdata/<id>/playtime.txt) so the running game can update it // updated every ~30s without touching apps.json.
// every ~30s without rewriting the whole apps.json registry on each tick.
function playtimeFile(id) { function playtimeFile(id) {
return path.join(DIRS.DATA, 'appdata', id, 'playtime.txt') return path.join(DIRS.DATA, 'appdata', id, 'playtime.txt')
} }
@ -32,9 +31,7 @@ function writePlaySeconds(id, seconds) {
function loadGames() { function loadGames() {
const games = readJson(REGISTRY, []) const games = readJson(REGISTRY, [])
for (const g of games) { for (const g of games) {
// stored relative to FILES (portable) -> resolve to absolute for use. // portable paths resolve against FILES, prefix imports against their prefix
// Prefix imports resolve against their own prefix instead (never the
// launcher-wide default).
const usePrefix = g.importSource === 'prefix' && g.prefix ? prefixPath(g.prefix) : null const usePrefix = g.importSource === 'prefix' && g.prefix ? prefixPath(g.prefix) : null
for (const k of ['path', 'exePath', 'workdir']) { for (const k of ['path', 'exePath', 'workdir']) {
if (!g[k]) continue if (!g[k]) continue
@ -47,9 +44,7 @@ function loadGames() {
return games return games
} }
// Paths of anything living inside FILES/ are stored RELATIVE to FILES so the // Paths inside FILES/ are stored FILES-relative (portable); others as-is.
// whole launcher folder can be moved (portable mode). Absolute paths outside
// FILES (apps left in place) are kept as-is.
function portableGame(g) { function portableGame(g) {
const out = { ...g } const out = { ...g }
const pfx = out.importSource === 'prefix' && out.prefix ? prefixPath(out.prefix) : null const pfx = out.importSource === 'prefix' && out.prefix ? prefixPath(out.prefix) : null
@ -57,8 +52,7 @@ function portableGame(g) {
const v = out[k] const v = out[k]
if (!v) continue if (!v) continue
if (pfx && k !== 'iconSource' && !isPrefixRel(v)) { if (pfx && k !== 'iconSource' && !isPrefixRel(v)) {
// prefix game: store relative to the PREFIX ROOT (keeps the drive_c // prefix game: store relative to the prefix root ("drive_c/...")
// segment, e.g. "drive_c/Games/..." or "pfx/drive_c/Games/...")
const rel = path.relative(pfx, resolvePortablePath(v)) const rel = path.relative(pfx, resolvePortablePath(v))
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) out[k] = rel if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) out[k] = rel
} else if (!(pfx && isPrefixRel(v))) { } else if (!(pfx && isPrefixRel(v))) {
@ -76,28 +70,21 @@ function getGame(id) {
return loadGames().find(g => g.id === id) || null return loadGames().find(g => g.id === id) || null
} }
// Which prefix a game's runs land in: an app without one follows the launcher // Which prefix a game's runs land in (launcher default when unset).
// 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).
function prefixNameForGame(game) { function prefixNameForGame(game) {
if (game && game.prefix) return game.prefix if (game && game.prefix) return game.prefix
const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] }) const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] })
return defs.default || 'Main' return defs.default || 'Main'
} }
// Apps whose runs land in this prefix - used to keep a prefix that any app // Apps whose runs land in this prefix (in-use guard).
// still references from being deleted out from under it.
function gamesUsingPrefix(name) { function gamesUsingPrefix(name) {
if (!name) return [] if (!name) return []
return loadGames().filter(g => prefixNameForGame(g) === name) return loadGames().filter(g => prefixNameForGame(g) === name)
} }
// In-use guards for registry edits: a prefix folder being renamed or deleted // In-use guards: rename/delete of a prefix or build waits until no live run
// (or a wine build deleted) while a game runs in it would leave that process // uses it. `running` is the games service's list of live runs.
// 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).
function appsUsingPrefix(name, running) { function appsUsingPrefix(name, running) {
if (!name) return [] if (!name) return []
return (running || []).filter(r => (r.prefixes || []).includes(name)) return (running || []).filter(r => (r.prefixes || []).includes(name))
@ -107,15 +94,13 @@ function isPrefixInUse(name, running) {
return appsUsingPrefix(name, running).length > 0 return appsUsingPrefix(name, running).length > 0
} }
// A run with no explicit wine falls back to System Wine, which cannot be // Only an exact match on a registered build counts.
// deleted anyway - only an exact match on a registered build counts.
function isWineInUse(id, running) { function isWineInUse(id, running) {
if (!id || id === 'system') return false if (!id || id === 'system') return false
return (running || []).some(r => r.wine === id) return (running || []).some(r => r.wine === id)
} }
// Switching the default prefix would silently move the next launch of every // Switching the default prefix waits while any default-running app is live.
// app that has no prefix of its own - so it waits while any of them runs.
function gamesFollowingDefault(running) { function gamesFollowingDefault(running) {
return (running || []).filter(r => { return (running || []).filter(r => {
const game = getGame(r.id) const game = getGame(r.id)
@ -134,16 +119,13 @@ function updateGame(id, patch) {
const games = loadGames() const games = loadGames()
const g = games.find(x => x.id === id) const g = games.find(x => x.id === id)
if (!g) return null if (!g) return null
// prefix imports are pinned: their paths are stored relative to their // prefix imports are pinned; their patch fields are ignored
// prefix, so NOTHING may move them to another prefix or flip their
// import source (the patch fields are simply ignored)
if (g.importSource === 'prefix' && patch) { if (g.importSource === 'prefix' && patch) {
patch = { ...patch } patch = { ...patch }
delete patch.prefix delete patch.prefix
delete patch.importSource delete patch.importSource
} }
// playSeconds goes to its own tiny file; the registry just mirrors it for // playSeconds lives in its own file; the registry mirrors it
// anything reading apps.json directly (loadGames prefers the file anyway).
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds) if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)
Object.assign(g, patch) Object.assign(g, patch)
saveGames(games) saveGames(games)
@ -154,9 +136,7 @@ function clearPlaytime(id) {
return updateGame(id, { playSeconds: 0, lastPlayed: 0 }) return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
} }
// A portable app's folder is removed along with its registry entry - measured // Delete a portable app's folder with measured progress.
// 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.
async function removeGame(id, onProgress) { async function removeGame(id, onProgress) {
const games = loadGames() const games = loadGames()
const idx = games.findIndex(g => g.id === id) const idx = games.findIndex(g => g.id === id)
@ -171,9 +151,7 @@ async function removeGame(id, onProgress) {
const total = await treeSize(g.path) 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 })) 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 // prefix imports: drop only the registry entry, never prefix files
// nuke the user's prefix (installed DLLs, saves of every other app), so
// only the registry entry is dropped
games.splice(idx, 1) games.splice(idx, 1)
saveGames(games) saveGames(games)
return true return true
@ -189,9 +167,7 @@ function saveSettings(patch) {
return s return s
} }
// ID is the current timestamp (ms); while a game with that id or a folder // Timestamp id, incremented while it collides.
// with that name exists, keep incrementing - so ids and import folder names
// can never collide.
function newGameId() { function newGameId() {
let id = Date.now().toString() let id = Date.now().toString()
const taken = () => getGame(id) || fs.existsSync(path.join(DIRS.APPS, id)) const taken = () => getGame(id) || fs.existsSync(path.join(DIRS.APPS, id))
@ -211,8 +187,7 @@ function newGame({ id, name, exePath, workdir, mode }) {
prefix: '', prefix: '',
args: '', args: '',
dllOverrides: [], dllOverrides: [],
// Proton-only switch: keep UMU (the default path) or fall back to the // Proton-only: UMU by default, or the build's steam.exe launcher
// build's steam.exe launcher for this app (see runner.buildEnv)
disableUmu: false, disableUmu: false,
env: {}, env: {},
iconSource: '', 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 // Reorder the registry array; unsent ids keep their order at the end.
// 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.
function reorderGames(ids) { function reorderGames(ids) {
const games = loadGames() const games = loadGames()
const rest = new Map(games.map(g => [g.id, g])) const rest = new Map(games.map(g => [g.id, g]))
@ -240,10 +213,8 @@ function reorderGames(ids) {
return out return out
} }
// Follows a prefix rename through the app registry. Raw JSON on purpose: // Follows a prefix rename through the registry. Raw JSON: the normal
// loadGames()/saveGames() would resolve and re-relativize every path against // load/save would re-relativize paths against the renamed folder.
// 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.
function renamePrefixRefs(oldName, newName) { function renamePrefixRefs(oldName, newName) {
const games = readJson(REGISTRY, []) const games = readJson(REGISTRY, [])
let updated = 0 let updated = 0

View file

@ -32,9 +32,7 @@ let autoQuitWhenDone = false // headless boot: quit after that game exits
const getWindow = () => win const getWindow = () => win
// --- single instance --------------------------------------------------------- // --- single instance ---------------------------------------------------------
// Diagnostics: the shortcut handoff path is invisible otherwise (a failing // Diagnostics trace for the otherwise invisible shortcut handoff.
// handoff just "does nothing" for the user). Everything lands in a small
// trace file next to the launcher data so problems are reproducible.
function trace(msg) { function trace(msg) {
try { try {
const file = path.join(DIRS.DATA, 'instance-trace.log') 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 // auto-quit skips itself briefly after a handoff (start/exit race guard)
// a moment afterwards, so "one game exits" racing "a shortcut just started
// another game" can never kill the launcher mid-handoff
let lastHandoffAt = 0 let lastHandoffAt = 0
// --- "Open with PLWL" (Exec %f from the file manager) -------------------------- // --- "Open with PLWL" (Exec %f from the file manager) --------------------------
// Hands the file to the window (renderer shows the wine/prefix picker popup); // Hands the file to the window (picker popup); headless boots open the window.
// headless boots open the window for it - a file cannot be launched without
// the user choosing how.
async function openWith(filePath) { async function openWith(filePath) {
trace(`openWith ${filePath} serviceReady=${!!gamesService}`) trace(`openWith ${filePath} serviceReady=${!!gamesService}`)
showMainWindow() showMainWindow()
@ -99,9 +93,7 @@ function createTray() {
try { try {
tray = new Tray(icon) tray = new Tray(icon)
} catch (err) { } catch (err) {
// never fatal (the window keeps working), but say why the icon is missing // say why the tray icon is missing instead of failing silently
// instead of failing silently - a missing tray looks like a hung app when
// the launcher runs in the background
console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`) console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`)
tray = null tray = null
return return
@ -177,8 +169,7 @@ function showMainWindow() {
} }
function wireWindow(w) { function wireWindow(w) {
// closing the window hides it to the tray (playtime keeps counting in the // closing hides to the tray (unless disabled in settings, then it quits)
// background) unless the user disabled that in settings - then it quits
w.on('close', e => { w.on('close', e => {
if (quitting) return if (quitting) return
let closeToTray = true let closeToTray = true
@ -208,13 +199,10 @@ async function launchGame(id, openFile) {
trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`) trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`)
if (res.ok) { if (res.ok) {
rebuildTrayMenu() rebuildTrayMenu()
// headless boot: when the auto-started game finishes and nothing else is // headless boot: quit when the auto-started game finishes
// running, quit the launcher
if (autoQuitWhenDone) { if (autoQuitWhenDone) {
gamesService.setOnGameExit(() => { gamesService.setOnGameExit(() => {
// fires for EVERY game exit; quit only when the last one just ended, // quit only when the last game ended and nothing new came in
// the user never opened the window and no shortcut handed a new game
// over in the last few seconds (start/exit race guard)
if (Date.now() - lastHandoffAt < 5000) return if (Date.now() - lastHandoffAt < 5000) return
if (!gamesService.listRunning().length) { if (!gamesService.listRunning().length) {
quitting = true quitting = true
@ -241,12 +229,10 @@ app.whenReady().then(async () => {
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu }) const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
gamesService = games gamesService = games
// the tray comes first: nothing (least of all the network) may keep it from // the tray comes first: nothing may keep it from coming up
// coming up - a launcher without a tray icon looks simply broken
createTray() createTray()
// LAN sync runs whenever the launcher runs - that is what makes this // LAN sync runs whenever the launcher runs, so this device is discoverable
// device show up on the other machines' client lists
const lanHooks = { const lanHooks = {
onDevices: devices => sendToWindow(win, 'lansync:devices', devices), onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
onApplied: info => sendToWindow(win, 'lansync:applied', info), onApplied: info => sendToWindow(win, 'lansync:applied', info),
@ -268,8 +254,7 @@ app.whenReady().then(async () => {
} else { } else {
win = createMainWindow() win = createMainWindow()
wireWindow(win) wireWindow(win)
// file open handed over while the service was still booting (or queued // file open queued while the service was still booting
// by a second instance racing the first boot)
if (autoOpenPath || openwith.hasPending()) { if (autoOpenPath || openwith.hasPending()) {
openWith(autoOpenPath || openwith.getPending()) openWith(autoOpenPath || openwith.getPending())
} }

View file

@ -9,10 +9,8 @@ function cleanName(name) {
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
} }
// A name that becomes a folder inside the launcher (a prefix): letters and // A folder-safe name: letters/digits of any language plus safe punctuation;
// digits of any language (Polish names keep their diacritics) plus the // "." and ".." are not names.
// punctuation that is safe in a path segment. "." and ".." are path segments,
// not names.
function normalizePathName(name) { function normalizePathName(name) {
const out = cleanName(name).replace(/[^\p{L}\p{N} ._-]/gu, '') const out = cleanName(name).replace(/[^\p{L}\p{N} ._-]/gu, '')
return /^\.+$/.test(out) ? '' : out return /^\.+$/.test(out) ? '' : out

View file

@ -1,9 +1,7 @@
'use strict' 'use strict'
// "Open with PLWL" (file manager -> Open With -> PLWL): a file handed in via // "Open with PLWL": a file handed in via --open is parked here until the
// `--open <path>` (or the Exec %f plumbing) is parked here until the renderer // renderer picks it up, then launched ad hoc. Nothing is imported.
// picks it up, then launched ad hoc with the wine version + prefix chosen in
// the picker popup. Nothing is imported into the library.
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
@ -26,8 +24,7 @@ function clearPending() {
pending = null pending = null
} }
// The launcher's own translations may not cover openwith texts in older // Fall back to English keys on older installs.
// installs - fall back to English keys so the UI never shows raw key names.
function textsFor(code) { function textsFor(code) {
let texts = {} let texts = {}
try { try {
@ -58,8 +55,7 @@ function resolvePrefix(name) {
return known || defs.default || 'Main' return known || defs.default || 'Main'
} }
// Ad-hoc run target: shaped exactly like a library game (runner.buildEnv and // Ad-hoc run target shaped like a library game; nothing persisted.
// the playtime/log paths expect those fields) but nothing is persisted.
function makeTarget(openPath, wineId, prefixName) { function makeTarget(openPath, wineId, prefixName) {
const dir = path.dirname(openPath) const dir = path.dirname(openPath)
return { return {
@ -82,9 +78,7 @@ function makeTarget(openPath, wineId, prefixName) {
} }
} }
// Launch without importing: resolves the chosen wine/prefix, builds the ad // Launch without importing, exactly like a library app.
// hoc target and runs it exactly like a library app (same env, same logging,
// same detached process group).
async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarted }) { async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarted }) {
if (!openPath || !fs.existsSync(openPath)) return { ok: false, error: 'file-missing' } 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' } 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 prefixName = resolvePrefix(prefixName0)
const runner = require('./runner') const runner = require('./runner')
try { try {
// runAdHoc builds the synthetic target internally (shared `openwith` id, // shared `openwith` id keeps logs/playtime away from library ids
// so logs/playtime never collide with a library app id); the resolved
// prefix is passed through so buildEnv uses the chosen one
const res = await runner.runAdHoc({ openPath, wine, systemWine: wine.type === 'system', prefix: prefixName }) const res = await runner.runAdHoc({ openPath, wine, systemWine: wine.type === 'system', prefix: prefixName })
if (onStarted) onStarted({ name: path.basename(openPath), pid: res.pid }) if (onStarted) onStarted({ name: path.basename(openPath), pid: res.pid })
return { ok: true, pid: res.pid || null, logFile: res.logFile || null } return { ok: true, pid: res.pid || null, logFile: res.logFile || null }

View file

@ -3,9 +3,7 @@
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
// Packaged builds run from <portable root>/olcxja.plwl/resources/app.asar: // Packaged: resources/app.asar two levels below the portable root.
// the portable layout (<root>/files) is two levels above the resources dir
// and files bundled with the release are unpacked next to the asar.
function computeRoots() { function computeRoots() {
const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/) const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/)
if (m) { if (m) {
@ -16,8 +14,7 @@ function computeRoots() {
APP_ROOT, APP_ROOT,
// FILES = BUILD/files, so BUILD is the portable root itself. // FILES = BUILD/files, so BUILD is the portable root itself.
BUILD: process.env.PLWL_BUILD_DIR || APP_ROOT, BUILD: process.env.PLWL_BUILD_DIR || APP_ROOT,
// Bundled release files live inside the asar (Electron's patched fs // bundled release files live inside the asar
// can read them directly from the archive).
BUNDLED: fs.existsSync(path.join(RESOURCES, 'app.asar', 'files', 'translations')) BUNDLED: fs.existsSync(path.join(RESOURCES, 'app.asar', 'files', 'translations'))
? path.join(RESOURCES, 'app.asar', 'files') ? path.join(RESOURCES, 'app.asar', 'files')
: path.join(APP_ROOT, 'files') : path.join(APP_ROOT, 'files')
@ -50,16 +47,11 @@ const DIRS = {
FAKE_STEAM: path.join(DATA, 'fake-steam') FAKE_STEAM: path.join(DATA, 'fake-steam')
} }
// Bundled translation files are launcher-owned: on every start they are // Bundled translations are refreshed on start whenever their content changed,
// refreshed in the portable files/translations folder WHENEVER THEIR CONTENT // so updates reach existing installs; user-added languages are untouched.
// 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.
function filesDiffer(a, b) { function filesDiffer(a, b) {
try { try {
// compare as text on purpose: Buffer.equals() throws on a string, and that // compare as text: Buffer.equals() throws on a string
// throw was read as "differs", so every boot recopied every translation
return fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8') return fs.readFileSync(a, 'utf8') !== fs.readFileSync(b, 'utf8')
} catch { } catch {
return true // target missing/unreadable -> needs a (re)copy 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 // Anything under FILES/ is stored FILES-relative; absolute paths outside are
// path RELATIVE to FILES (e.g. apps/<id>/game.exe), so moving the whole // kept as-is. Resolve on read, normalize on write.
// launcher folder keeps the library working. Absolute paths outside FILES
// (games left in place) are stored as-is. Resolve on read, normalize on write.
function toPortablePath(p) { function toPortablePath(p) {
if (typeof p !== 'string' || !p || !path.isAbsolute(p)) return p if (typeof p !== 'string' || !p || !path.isAbsolute(p)) return p
const rel = path.relative(FILES, p) const rel = path.relative(FILES, p)
@ -116,15 +106,8 @@ function resolvePortablePath(p) {
return path.join(FILES, p) return path.join(FILES, p)
} }
// The launcher's own icon (renderer/plwl.png), used by the tray, the app // The launcher's own icon (renderer/plwl.png) for tray, app icon, shortcut
// icon and as the .desktop shortcut fallback. // fallback. The module-relative copy is the only candidate in every layout.
//
// 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.
function launcherIconCandidates() { function launcherIconCandidates() {
const bundled = path.join(__dirname, '..', 'renderer') const bundled = path.join(__dirname, '..', 'renderer')
return [ return [
@ -151,8 +134,7 @@ function launcherIconPath() {
return launcherIcon || null return launcherIcon || null
} }
// The same icon, but only as a real file on disk: a .desktop entry's Icon= is // The icon as a real file: a .desktop Icon= cannot live inside app.asar.
// resolved by the desktop environment, which cannot look inside app.asar.
function launcherIconDiskPath() { function launcherIconDiskPath() {
const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`)) const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`))
return firstExisting(onDisk) return firstExisting(onDisk)

View file

@ -7,9 +7,8 @@ const { rewireSymlinks } = require('./symlinks')
const { removeTree, treeSize } = require('./fsutil') const { removeTree, treeSize } = require('./fsutil')
const { normalizePathName } = require('./names') const { normalizePathName } = require('./names')
// An app path stored relative to its PREFIX ROOT ("drive_c/..." for plain // An app path stored relative to its prefix root ("drive_c/..." or
// wine, "pfx/drive_c/..." for Proton-managed prefixes): wine resolves it // "pfx/drive_c/..."); everything else is FILES-relative or absolute.
// under C:\, everything else is FILES-relative or absolute.
const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i
const isPrefixRel = v => typeof v === 'string' && PREFIX_REL_RE.test(v) 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 // Every Windows C: drive a prefix has (top-level for plain wine, under pfx/
// (WINEPREFIX=<prefix>), legacy Proton-managed prefixes nest one under pfx/ // for Proton); a unified prefix reports its single drive once.
// (WINEPREFIX=<prefix>/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.
function prefixDriveRoots(name) { function prefixDriveRoots(name) {
const root = prefixPath(name) const root = prefixPath(name)
const found = [] const found = []
@ -79,22 +74,9 @@ function lstatOrNull(p) {
} }
} }
// Wine keeps its C: drive at <prefix>/drive_c, Proton always runs its prefix // Without a link, a prefix used with both wine kinds grows two C: drives;
// from <compatdata>/pfx - and PLWL points compatdata at the launcher's prefix // `pfx` -> prefix root collapses them. Only a prefix with no pfx at all gets
// folder, so without a link a prefix used with both wine kinds grows TWO C: // the link - an existing layout is left exactly as it is.
// drives with separate registries, saves and installed DLLs. Making `pfx` a
// relative symlink back to the prefix itself collapses them: Proton's
// <compatdata>/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.
function ensureUnifiedPrefix(name) { function ensureUnifiedPrefix(name) {
if (!name) return false if (!name) return false
const pfx = path.join(prefixPath(name), 'pfx') 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 // True when a path IS one of a prefix's own roots (folder, pfx/, drive_c):
// pfx/ or either drive_c. A copy/move import of such a folder would move the // importing one would gut the prefix.
// 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.
function isPrefixRoot(p) { function isPrefixRoot(p) {
if (!p) return false if (!p) return false
const abs = path.resolve(String(p)) const abs = path.resolve(String(p))
@ -140,9 +118,7 @@ function createPrefix(name) {
return { ok: true, name: clean } return { ok: true, name: clean }
} }
// A prefix is a whole windows drive - removal is measured before it starts // Removal is measured (staging) so the UI shows a bar that moves.
// (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.
async function deletePrefix(name, onProgress) { async function deletePrefix(name, onProgress) {
if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' } if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
const defs = listPrefixes() const defs = listPrefixes()
@ -158,19 +134,13 @@ async function deletePrefix(name, onProgress) {
return { ok: true } return { ok: true }
} }
// A prefix that arrived from another machine (or a launcher that was moved) is // Rewire absolute links to relative so a moved prefix keeps working.
// full of absolute symlinks into its wine/proton build - fonts, built-in
// program stubs, dlls. Rewire them to relative paths so the prefix keeps
// working here and stays relocatable (same healing wine builds get on import).
async function healPrefix(name) { async function healPrefix(name) {
if (!name) return 0 if (!name) return 0
return rewireSymlinks(prefixPath(name), [DIRS.FILES]) return rewireSymlinks(prefixPath(name), [DIRS.FILES])
} }
// Renaming a prefix moves its folder on disk and follows the name into the // Rename moves the folder and follows the name through the registry.
// 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.
function renamePrefix(oldName, newName) { function renamePrefix(oldName, newName) {
const clean = normalizePathName(newName) const clean = normalizePathName(newName)
if (!clean) return { ok: false, error: 'invalidName' } 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 (entry.locked || String(oldName).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
if (clean === oldName) return { ok: true, name: clean, games: 0 } 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' } 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 // a registered prefix without a folder yet can still be renamed
// renamed - only the registry entry exists
if (fs.existsSync(prefixPath(oldName))) { if (fs.existsSync(prefixPath(oldName))) {
try { try {
fs.renameSync(prefixPath(oldName), prefixPath(clean)) fs.renameSync(prefixPath(oldName), prefixPath(clean))

View file

@ -3,9 +3,7 @@
const { contextBridge, ipcRenderer } = require('electron') const { contextBridge, ipcRenderer } = require('electron')
const { DIRS } = require('./paths') const { DIRS } = require('./paths')
// Single-slot event callbacks: every import modal used to register ANOTHER // Single-slot event callbacks: only the latest subscriber receives an event.
// listener and old ones never unregistered, so the file counter sped up per
// import. Only the latest subscriber receives an event.
function singleSlot(channel) { function singleSlot(channel) {
let slot = null let slot = null
ipcRenderer.on(channel, (e, data) => { ipcRenderer.on(channel, (e, data) => {
@ -17,8 +15,7 @@ function singleSlot(channel) {
} }
const onImportProgress = singleSlot('import:progress') const onImportProgress = singleSlot('import:progress')
// "Open with PLWL": files handed over via --open (Exec %f) - the renderer // "Open with PLWL": files handed over via --open (Exec %f).
// shows the wine/prefix picker popup for them.
const onOpenWith = singleSlot('openwith:file') const onOpenWith = singleSlot('openwith:file')
// one sync (and one pending consent request, enforced server-side) at a time // one sync (and one pending consent request, enforced server-side) at a time
const onLanSyncProgress = singleSlot('lansync:progress') const onLanSyncProgress = singleSlot('lansync:progress')

View file

@ -8,11 +8,8 @@ const { DIRS } = require('./paths')
const { prefixPath, ensureUnifiedPrefix } = require('./prefixes') const { prefixPath, ensureUnifiedPrefix } = require('./prefixes')
const { prefixNameForGame } = require('./library') const { prefixNameForGame } = require('./library')
// The fake Steam install PLWL falls back to when no real Steam client is // The fake Steam install used when no real Steam client is found. Both folders
// found (see findSteamClientDir). Both folders Proton links into its prefix // Proton links into its prefix have to exist, or the links dangle.
// have to exist: it symlinks <prefix>/drive_c/Program Files (x86)/Steam/
// appcache -> <fake>/appcache while setting the prefix up, and a link into a
// missing folder is a dangling link in a tree that gets synced.
function ensureFakeSteam() { function ensureFakeSteam() {
const fake = DIRS.FAKE_STEAM const fake = DIRS.FAKE_STEAM
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true }) fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
@ -20,14 +17,9 @@ function ensureFakeSteam() {
return fake return fake
} }
// Proton expects the Steam client it is normally started by at // Proton needs the Steam client at STEAM_COMPAT_CLIENT_INSTALL_PATH or a Valve
// STEAM_COMPAT_CLIENT_INSTALL_PATH: it copies the client's legacy DLLs from // build dies in a "steamclient_main.c" assert; use a real client read-only,
// <client>/legacycompat into the prefix, its built-in steam.exe loads the // the empty fake otherwise (GE builds skip steam.exe entirely).
// client's own steamclient.so, and media libs come from <client>/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.
const STEAM_CLIENT_MARKERS = [ const STEAM_CLIENT_MARKERS = [
['legacycompat', 'steamclient.dll'], ['legacycompat', 'steamclient.dll'],
['ubuntu12_32', 'steamclient.so'] ['ubuntu12_32', 'steamclient.so']
@ -57,21 +49,15 @@ function steamClientDir() {
return findSteamClientDir() || ensureFakeSteam() return findSteamClientDir() || ensureFakeSteam()
} }
// Module names travel in a semicolon-separated WINEDLLOVERRIDES list where // WINEDLLOVERRIDES: ';'-separated, '=' splits name/setting, ',' separates
// '=' splits name from setting and ',' separates names - anything else in a // names. Guides write a trailing .dll; Wine strips it, so store the bare name.
// name would corrupt the entries around it. A trailing .dll is what guides
// write; Wine strips it itself, so PLWL stores the bare name.
function sanitizeDllName(name) { function sanitizeDllName(name) {
const clean = String(name || '').trim().replace(/\.dll$/i, '') const clean = String(name || '').trim().replace(/\.dll$/i, '')
return /^[^;=\s]+$/.test(clean) ? clean : '' return /^[^;=\s]+$/.test(clean) ? clean : ''
} }
// Wine replaces an earlier entry for the same module with a later one, so an // Wine keeps the LAST entry per module: inherited overrides go first, the
// inherited WINEDLLOVERRIDES goes FIRST and the app's own overrides are // app's own after. An empty setting is Wine's "disabled" and stays as `name=`.
// 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.
function dllOverridesFor(game, env) { function dllOverridesFor(game, env) {
const parts = [] const parts = []
const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '') const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '')
@ -87,11 +73,8 @@ function dllOverridesFor(game, env) {
return parts.join(';') return parts.join(';')
} }
// Wine only ever repairs links that are BROKEN: a prefix that came from // Repair only broken links (a resolvable absolute link would be synced out
// another machine (or from an older PLWL) can still hold absolute links into // again). Once per prefix per launch.
// its build that happen to resolve here, and those would be synced out again.
// Healing once per prefix per launch keeps the whole tree relocatable and
// costs a single walk in the common (already relative) case.
const healedPrefixes = new Set() const healedPrefixes = new Set()
async function healPrefixOnce(game) { async function healPrefixOnce(game) {
const name = prefixNameForGame(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 // Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix.
// symlink to the launcher's own prefix folder. The parent directory is created // A leftover EMPTY compat dir (an older PLWL bug) is replaced by the link; a
// FIRST: symlink() fails with ENOENT when compat/ does not exist yet, and the // populated one is left alone.
// old fallback then created a plain empty directory instead - Proton silently
// started with an empty prefix (the game never saw its own data). A leftover
// EMPTY directory from exactly that situation is safe to replace with the
// link; a populated one is left alone.
function linkPrefix(compatData, prefixDir) { function linkPrefix(compatData, prefixDir) {
try { try {
fs.mkdirSync(path.dirname(compatData), { recursive: true }) fs.mkdirSync(path.dirname(compatData), { recursive: true })
@ -140,12 +119,9 @@ function linkPrefix(compatData, prefixDir) {
function buildEnv({ game, wine }) { function buildEnv({ game, wine }) {
const env = { ...process.env } const env = { ...process.env }
const isProton = wine && wine.type === 'proton' const isProton = wine && wine.type === 'proton'
// same fallback the games service snapshots into its running entries // same fallback the games service uses, so in-use checks name this folder
// (library.prefixNameForGame), so in-use checks always name this folder
const prefixName = prefixNameForGame(game) const prefixName = prefixNameForGame(game)
// a prefix with no pfx yet gets the shared-link layout, so every launch - // no pfx yet -> shared-link layout; an existing nested layout is left as is
// wine, Proton, tools - lands on that prefix's ONE C: drive; an existing
// nested layout is left as it is (see ensureUnifiedPrefix)
try { try {
ensureUnifiedPrefix(prefixName) ensureUnifiedPrefix(prefixName)
} catch {} } catch {}
@ -156,36 +132,23 @@ function buildEnv({ game, wine }) {
env.STEAM_COMPAT_APP_ID = '0' env.STEAM_COMPAT_APP_ID = '0'
env.SteamAppId = '0' env.SteamAppId = '0'
env.SteamGameId = '0' env.SteamGameId = '0'
// Proton's session init reads STEAM_COMPAT_CLIENT_INSTALL_PATH on EVERY // every Proton verb reads STEAM_COMPAT_CLIENT_INSTALL_PATH (a missing one
// verb: its appcache sync (g_compatdata.sync_appcache) runs even for // aborts with a KeyError). UMU run: real client; disableUmu: the fake.
// 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).
if (game.disableUmu) { if (game.disableUmu) {
delete env.UMU_ID delete env.UMU_ID
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam() env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam()
} else { } else {
env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir() env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir()
// Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID // UMU_ID makes newer Protons run through umu.exe instead of steam.exe,
// set, newer Protons (incl. GE-Proton 9+) run the game through their // which needs the Steam client's DLLs (see steamClientDir).
// 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).
if (!env.UMU_ID) env.UMU_ID = 'umu-0' if (!env.UMU_ID) env.UMU_ID = 'umu-0'
} }
env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx') env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx')
env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs') env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs')
fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true }) fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true })
linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir) linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir)
// No silent fallback to a real directory: Proton would then set its prefix // no silent fallback: a real dir would orphan the prefix outside where
// up under files/data/compat/<id>/pfx, outside files/wineprefixes/ where // PLWL tracks it; only a missing path is refused
// 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.
if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) { if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) {
throw new Error('prefix-link-failed') throw new Error('prefix-link-failed')
} }
@ -198,17 +161,13 @@ function buildEnv({ game, wine }) {
if (overrides) env.WINEDLLOVERRIDES = overrides if (overrides) env.WINEDLLOVERRIDES = overrides
else delete env.WINEDLLOVERRIDES else delete env.WINEDLLOVERRIDES
// the app's own environment comes last: it overrides everything above, // the app's own environment comes last: it overrides everything above
// including a WINEDLLOVERRIDES configured here
for (const [k, v] of Object.entries(game.env || {})) env[k] = v for (const [k, v] of Object.entries(game.env || {})) env[k] = v
return { env, prefixDir } return { env, prefixDir }
} }
// Proton verbs: 'run' goes through the build's umu.exe/steam.exe launcher, // Proton verbs: 'run' goes through umu.exe/steam.exe, 'runinprefix' runs the
// while 'runinprefix' runs the build's wine directly (same session env, no // build's wine directly and skips Proton's prefix setup (see game.disableUmu).
// 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.
function protonVerb(game) { function protonVerb(game) {
return game.disableUmu ? 'runinprefix' : 'run' return game.disableUmu ? 'runinprefix' : 'run'
} }
@ -221,10 +180,8 @@ function parseArgs(args) {
return out return out
} }
// A file handed over from the Linux file manager ("Open with" on an app // "Open with" handoff: the file reaches the app as a WINDOWS path (C:\ inside
// shortcut) has to reach the Windows app as a WINDOWS path, the way it would // the prefix's drive, Z:\ for the rest).
// on Windows: C:\ for anything inside the prefix's C: drive, and wine's Z:\
// mapping (the filesystem root) for everything else.
function windowsPathFor(file, prefixDir) { function windowsPathFor(file, prefixDir) {
let abs = String(file || '') let abs = String(file || '')
try { try {
@ -242,13 +199,9 @@ function windowsPathFor(file, prefixDir) {
const GRACE_MS = 60000 const GRACE_MS = 60000
// Live playtime: while the game runs, update ONLY the tiny playtime file // Live playtime: tiny file updated every 30s, totals mirrored to the registry
// (a number of seconds) every 30s - apps.json is left untouched to spare the // on exit. `base` comes from the caller (live file, not the registry) so
// disk. lastPlayed is stamped once at start, final totals are mirrored into // shared ids never mix playtime.
// 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.
function startPlaytimeTicker({ game, startedAt, base }) { function startPlaytimeTicker({ game, startedAt, base }) {
const library = require('./library') const library = require('./library')
const timer = setInterval(() => { const timer = setInterval(() => {
@ -296,38 +249,29 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal
child = spawn(cmd, base, { child = spawn(cmd, base, {
cwd: workdir, cwd: workdir,
env, env,
// a handoff (a file given to an already running app) is not tracked: // a handoff is untracked: its pipes drain so the app never blocks
// its pipes are left unread, so the output goes nowhere instead of
// filling up and blocking the app
stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'], stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'],
detached: true detached: true
}) })
} catch (err) { } catch (err) {
return reject(err) return reject(err)
} }
// spawn() returns before the OS actually starts the program: when the // spawn() resolves before the process exists; a missing binary only
// binary is missing or has no executable bit (a wine build copied without // surfaces as an 'error' event, so wait for the real 'spawn'.
// its permissions, a wrong path) the failure only arrives as an 'error'
// event, AFTER this function would have resolved. Resolving there marked
// games that never started as running forever, so the promise only settles
// once the process really exists ('spawn').
let started = false let started = false
child.once('error', err => { child.once('error', err => {
if (!started) reject(err) if (!started) reject(err)
}) })
child.once('spawn', () => { child.once('spawn', () => {
started = true started = true
// a handoff never writes the app's log or playtime: the tracked run owns // a handoff writes no log or playtime: the tracked run owns both
// both, and truncating the live log would destroy what it is writing
if (handoff) { if (handoff) {
child.unref() child.unref()
return resolve({ pid: child.pid, logFile: null }) return resolve({ pid: child.pid, logFile: null })
} }
const logFile = latestLogPath(game.id) const logFile = latestLogPath(game.id)
const ws = openLatestLog(game.id) const ws = openLatestLog(game.id)
// a launch line of our own before the raw wine output: when a game // a launch line first, so the log answers "which prefix/overrides/run"
// refuses to start, "which prefix, which overrides, umu or steam.exe"
// is exactly what has to be answerable from the log
ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`) ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`)
ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`) ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`)
if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\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.stderr.pipe(ws)
child.unref() child.unref()
const startedAt = Date.now() const startedAt = Date.now()
// read the LIVE playtime file, not the (possibly shared/stale) registry // read the LIVE playtime file, not the registry (shared ids)
// value - ad-hoc "open with" runs share one id and must not mix playtime
const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0) const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0)
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds }) 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 }) require('./library').updateGame(game.id, { lastPlayed: startedAt })
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {
stopTicker() 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 // Ad-hoc launch ("Open with PLWL"): the runGame pipeline under the shared
// synthetic target under the shared `openwith` id - log and playtime files // `openwith` id; no library entry is created. `prefix` is the picker's
// live in files/data/appdata/openwith/ and no library entry is created. // choice ('' = launcher default).
// `prefix` carries the picker's choice ('' = launcher default, resolved by
// buildEnv); wine/systemWine are the already-resolved build objects.
function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) { function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) {
const game = require('./openwith').makeTarget(openPath, 'default', prefix || '') const game = require('./openwith').makeTarget(openPath, 'default', prefix || '')
return runGame({ game, wine, systemWine, onExit }) return runGame({ game, wine, systemWine, onExit })

View file

@ -1,9 +1,7 @@
'use strict' 'use strict'
// .desktop shortcut generation. A shortcut launches the launcher itself with // .desktop shortcut generation: a shortcut runs the launcher with
// `--launch <id>`: the main process either starts the game in the running // `--launch <id>` (headless boot when nothing is running yet).
// instance or boots headless (no window, tray only), starts the game and lets
// playtime tracking continue normally.
const fs = require('fs') const fs = require('fs')
const path = require('path') 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 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 // XDG shortcuts dir; PLWL_SHORTCUTS_DIR overrides it (tests).
// so tests (and portable exotic setups) can redirect it.
function desktopDirs() { function desktopDirs() {
const home = process.env.HOME || '/' const home = process.env.HOME || '/'
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share') 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 // The launcher executable as an argv list (packaged binary or electron dev).
// packaged olcxja.plwl/olcxja.plwl binary, or `electron <project>` in dev.
function launcherArgv() { function launcherArgv() {
if (process.env.PLWL_LAUNCHER_CMD) return process.env.PLWL_LAUNCHER_CMD.split(' ').filter(Boolean) if (process.env.PLWL_LAUNCHER_CMD) return process.env.PLWL_LAUNCHER_CMD.split(' ').filter(Boolean)
const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/) const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/)
@ -36,19 +32,13 @@ function launcherArgv() {
return ['electron', path.resolve(__dirname, '..')] return ['electron', path.resolve(__dirname, '..')]
} }
// Desktop Entry Exec field escaping (per the spec): % must be doubled, // Desktop Entry Exec escaping per the spec (% doubled, quotes, spaces).
// quotes and backslashes escaped, and tokens containing spaces wrapped in
// double quotes.
function execToken(token) { function execToken(token) {
const escaped = token.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"') const escaped = token.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped
} }
// The `%f` file field is appended AFTER escaping (a literal % would have been // %f appended after escaping makes the shortcut a file handler.
// 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.
function execLine(extraArgs) { function execLine(extraArgs) {
const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)] const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)]
return [...tokens.map(execToken), '%f'].join(' ') return [...tokens.map(execToken), '%f'].join(' ')
@ -68,8 +58,7 @@ function buildDesktopEntry(game) {
if (found) icon = path.join(iconDir, found) if (found) icon = path.join(iconDir, found)
} catch {} } catch {}
if (!icon) { if (!icon) {
// fall back to the launcher icon so the shortcut is never naked (the // on-disk launcher icon fallback (Icon= cannot read the asar)
// on-disk copy: the desktop environment cannot read inside app.asar)
icon = launcherIconDiskPath() || '' icon = launcherIconDiskPath() || ''
} }
const lines = [ const lines = [

View file

@ -1,36 +1,15 @@
'use strict' 'use strict'
// Symlink and permission healing, shared by wine builds, prefixes, game // Symlink and permission healing, shared by wine builds, prefixes, game
// imports and LAN sync. // imports and LAN sync. Rewires links to relative paths (absolute always;
// // relative only when broken) and restores exec bits on build binaries.
// 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.
const fsp = require('fs').promises const fsp = require('fs').promises
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const { pathExists: exists } = require('./fsutil') const { pathExists: exists } = require('./fsutil')
// dosdevices/ holds the prefix's drive mappings (c:, z:, d:, com1 ...). They // dosdevices/ mappings are supposed to point outside the prefix; never touched.
// are *supposed* to point outside the prefix - at the filesystem root, a
// mounted CD or a serial port - and wine rewrites them itself, so they are
// never touched.
const SKIP_DIRS = new Set(['dosdevices']) const SKIP_DIRS = new Set(['dosdevices'])
async function collectSymlinks(dir, out) { 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 // A missing target can only be something PLWL creates later (fake-steam,
// creates later: Proton links files/data/fake-steam/appcache into its prefix, // files/wines/...); the restriction keeps /usr/lib/... links alone.
// a prefix links into files/wines/<build>/... (see the fallback pass below).
// Restricting the fallback to these trees keeps an unrelated /usr/lib/... link
// from being mistaken for a wine build's own files/lib/ directory.
const ANCHOR_DIRS = new Set(['wines', 'wineprefixes', 'apps', 'data', 'translations']) const ANCHOR_DIRS = new Set(['wines', 'wineprefixes', 'apps', 'data', 'translations'])
// True when `candidate` sits under `root` and its deepest EXISTING parent is at // True when `candidate` sits under `root` and its deepest existing parent is
// least two components inside it. `files/data/fake-steam/appcache` passes even // at least two components inside it.
// before appcache is created, because files/data/fake-steam is already there;
// `files/bin/<x>` does not, because a single-component parent is no evidence
// that the link really belongs to the launcher.
function anchoredInside(root, candidate) { function anchoredInside(root, candidate) {
let p = candidate let p = candidate
while (p.length > root.length + 1) { while (p.length > root.length + 1) {
@ -69,12 +42,8 @@ function anchoredInside(root, candidate) {
return false return false
} }
// The new target for an absolute link: the same file, reached from the link's // The new target for an absolute link, relative to the link's own directory;
// own directory. `roots` are the trees the target may live in - the tree being // falls back to the longest suffix that exists locally.
// healed first, then (for a prefix linking into its build) the launcher's
// files/ root. When the target is not directly inside one of them, the longest
// suffix that exists here is used instead, which is what makes a link written
// on another machine work on this one.
function relativeTarget(link, target, roots) { function relativeTarget(link, target, roots) {
const dir = path.dirname(link) const dir = path.dirname(link)
for (const root of roots) { for (const root of roots) {
@ -84,9 +53,7 @@ function relativeTarget(link, target, roots) {
if (rel && !path.isAbsolute(rel)) return rel if (rel && !path.isAbsolute(rel)) return rel
} }
} }
// Second chance for links whose file is not here yet (or whose parent chain // second chance for links whose file is not here yet
// only partially exists): the relative path is still the right one, and
// writing it now beats leaving a link that can never resolve.
for (const requireLeaf of [true, false]) { for (const requireLeaf of [true, false]) {
for (const root of roots) { for (const root of roots) {
if (!root) continue if (!root) continue
@ -112,11 +79,8 @@ function relativeTarget(link, target, roots) {
return null return null
} }
// Rewire the symlinks under `root` that can be expressed relative to `roots` // Rewire rewritable symlinks under `root`; returns how many changed. Repeats
// (usually [root, DIRS.FILES]): absolute ones always, relative ones only when // until nothing changes (links can chain).
// 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.
async function rewireSymlinks(root, roots = []) { async function rewireSymlinks(root, roots = []) {
if (!root) return 0 if (!root) return 0
const candidates = [root, ...roots.filter(r => r && r !== root)] const candidates = [root, ...roots.filter(r => r && r !== root)]
@ -133,9 +97,7 @@ async function rewireSymlinks(root, roots = []) {
continue continue
} }
const absolute = path.isAbsolute(target) const absolute = path.isAbsolute(target)
// a relative link that still resolves is already relocatable - only a // a relative link that still resolves is already relocatable
// broken one is worth touching, and the suffix search in relativeTarget
// finds the file it was pointing at before the layout changed
if (!absolute && exists(path.resolve(path.dirname(link), target))) continue if (!absolute && exists(path.resolve(path.dirname(link), target))) continue
const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target) const from = absolute ? path.normalize(target) : path.resolve(path.dirname(link), target)
const next = relativeTarget(link, from, candidates) const next = relativeTarget(link, from, candidates)
@ -152,15 +114,11 @@ async function rewireSymlinks(root, roots = []) {
return fixed return fixed
} }
// Binaries a wine/proton build has to be able to execute. A copy that pipes // Binaries a wine/proton build must be able to execute; content copies drop
// file contents (the LAN sync, fs.copyFile) drops the bit, and without it // the bit and spawn fails with EACCES. Windows .exe files need no bit.
// `spawn` fails with EACCES and the build can never run. Windows .exe files
// are NOT in here on purpose: wine starts those itself, they need no bit.
const EXEC_NAMES = new Set(['proton', 'wine', 'wine64', 'wine-preloader', 'wine64-preloader', 'wineserver', 'winetricks', 'wineboot']) 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). // Give the exec bit back to a build's binaries and bin/ dirs.
// Used after a sync or an import - and to repair a build that was synced
// before the sync knew about permissions, without re-copying it.
async function restoreExecutableBits(root) { async function restoreExecutableBits(root) {
if (!root) return 0 if (!root) return 0
let fixed = 0 let fixed = 0

View file

@ -103,12 +103,8 @@ async function refreshWines() {
} }
const prev = readJson(REGISTRY, []).map(wineAbs) const prev = readJson(REGISTRY, []).map(wineAbs)
const prevById = new Map(prev.map(w => [w.id, w])) const prevById = new Map(prev.map(w => [w.id, w]))
// A build whose path changed since last scan was moved (or the launcher // heal moved builds and builds missing their exec bit; only a broken build
// itself was) - rewire its absolute symlinks to the current location. A // pays for the walk
// build that cannot be executed (no exec bit on proton/wine - exactly what
// a sync from a PLWL that predates permission transfer leaves behind) is
// healed here instead: two access() calls per build, and only a broken build
// pays for the walk. Without it such a build stays dead weight forever.
for (const w of found) { for (const w of found) {
const old = prevById.get(w.id) const old = prevById.get(w.id)
const moved = !old || old.path !== w.path 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)) 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 // display name and disabled flag live in the registry and survive rescans
// like the disabled flag (both live in the registry, not in the folder)
const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName])) const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName]))
for (const w of found) { for (const w of found) {
if (disabled.has(w.id)) w.disabled = true if (disabled.has(w.id)) w.disabled = true
@ -131,8 +126,7 @@ async function refreshWines() {
return found return found
} }
// Stored relative to FILES (portable), resolved to absolute for use - same // Stored FILES-relative (portable); `id`/`name`/`label`/`version`/`type` pass through.
// scheme as the game registry. `id`, `name`, `label`, `version`, `type` pass through.
function wineAbs(w) { function wineAbs(w) {
return applyDisplayName({ ...w, path: resolvePortablePath(w.path), wineBin: resolvePortablePath(w.wineBin) }) 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 return getWines().find(w => w.id === id) || null
} }
// What the user typed wins over the folder-derived label everywhere the // The user's label wins over the folder-derived one, everywhere.
// build is shown (library config, settings, Open with popup). Every registry
// read goes through here, so a stale stored label can never leak out.
function applyDisplayName(w) { function applyDisplayName(w) {
if (w && w.displayName) w.label = w.displayName if (w && w.displayName) w.label = w.displayName
return w return w
} }
// A build keeps its folder and id (apps point at the id, and prefix symlinks // "Renaming" only changes the shown label; folder and id stay.
// point inside the folder), so "renaming" only changes the shown label.
function setWineDisplayName(id, name) { function setWineDisplayName(id, name) {
const clean = normalizeLabel(name) const clean = normalizeLabel(name)
if (!clean) return { ok: false, error: 'invalidName' } if (!clean) return { ok: false, error: 'invalidName' }
return writeWineLabel(id, clean) return writeWineLabel(id, clean)
} }
// Clears a custom label back to the folder-derived one: what the LAN sync // Clears a custom label back to the folder-derived one (used by LAN sync).
// calls when the sender never renamed the build. The rename dialog never
// clears - an empty name there is an error (setWineDisplayName above).
function clearWineDisplayName(id) { function clearWineDisplayName(id) {
return writeWineLabel(id, '') return writeWineLabel(id, '')
} }
@ -203,9 +192,7 @@ function getDefaultWine() {
return require('./library').loadSettings().defaultWine || 'system' return require('./library').loadSettings().defaultWine || 'system'
} }
// Resolve the wine build a run should use: 'default'/'system'/unknown all // 'default'/'system'/unknown all fall back to System Wine.
// fall back to System Wine. Shared by the games service and the ad-hoc
// "open with" runner.
function resolveWine(id) { function resolveWine(id) {
const system = { id: 'system', type: 'system', label: 'System Wine' } const system = { id: 'system', type: 'system', label: 'System Wine' }
const wanted = id === 'default' ? getDefaultWine() : id const wanted = id === 'default' ? getDefaultWine() : id
@ -238,22 +225,15 @@ async function deleteWine(id, onProgress) {
} }
// --- symlink healing ------------------------------------------------------- // --- symlink healing -------------------------------------------------------
// Wine/Proton builds contain symlinks (e.g. inside the bundled default prefix) // Builds contain absolute symlinks that dangle after a move and crash Proton
// that point to absolute paths from wherever the build lived before (zip // during prefix setup; the rewiring lives in symlinks.js.
// 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.
async function fixBuildSymlinks(root) { async function fixBuildSymlinks(root) {
return rewireSymlinks(root, [DIRS.FILES]) return rewireSymlinks(root, [DIRS.FILES])
} }
// Everything a freshly arrived build needs before it can run: relative links // Make a freshly arrived build runnable: relative links, exec bits.
// and executable binaries. Returns what was changed, for the callers' reports.
async function healBuild(root) { async function healBuild(root) {
// a build folder that IS a symlink (kept on another disk) is left alone: // a symlinked build folder is left alone: its target may be outside
// the walk would follow the link and edit its target, which is not
// necessarily part of this launcher's trees
let st = null let st = null
try { try {
st = fs.lstatSync(root) st = fs.lstatSync(root)
@ -275,16 +255,11 @@ function isExec(p) {
} }
} }
// Where a wine/proton build keeps the programs wine itself spawns (wine, // Where a build keeps the programs wine spawns (wine, wineserver, ...).
// wineserver, wineboot, ...) and the loaders next to the shared libraries.
const AUDIT_DIRS = ['files/bin', 'bin', 'dist/bin', 'files/lib/wine/x86_64-unix', 'files/lib/wine/i386-unix'] 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 // Cheap audit for a build missing its exec bits (invisible until Play fails
// PLWL that predates permission transfer has no executable bit anywhere, and // with EACCES); only a failing build pays for the full walk.
// that stays invisible until the user presses Play (spawn fails with EACCES).
// Auditing is a handful of directory listings - only a build that FAILS it
// pays for the full walk, and the audit says nothing about a healthy build on
// every later start.
function buildAuditFailed(root, wineBin) { function buildAuditFailed(root, wineBin) {
if (!root) return false if (!root) return false
if (wineBin && !isExec(wineBin)) return true if (wineBin && !isExec(wineBin)) return true
@ -319,8 +294,7 @@ async function importWineFolder(srcDir, onProgress) {
if (build) copySrc = path.join(srcDir, build.name) if (build) copySrc = path.join(srcDir, build.name)
} }
const dst = dedupeDir(WINES_DIR, path.basename(copySrc)) const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
// a wine/proton build is big enough that the copy needs a real bar: count it // count first (staging), then report bytes as they land
// first (staging), then report bytes as they land while it copies
const emit = (phase, detail) => { const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: path.basename(dst), ...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 } 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 // Pseudo entry for the distro's wine; version probe is best-effort.
// builds; version probe is best-effort (a PATH without wine still lists it).
function systemWine() { function systemWine() {
let version = '' let version = ''
try { try {