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